mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-22 08:38:13 +02:00
feat: refactor telephony to support multiple telephony configurations (#251)
Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
parent
2f860e7f6d
commit
e16f6438bd
101 changed files with 10906 additions and 5420 deletions
|
|
@ -47,6 +47,18 @@ api/
|
|||
|
||||
When an API endpoint updates in-memory state (e.g. cached credentials, config objects), that change only affects the worker process that handled the request. With multiple FastAPI workers, **use `WorkerSyncManager`** (`services/worker_sync/`) to propagate changes to all workers via Redis pub/sub instead of updating local state directly.
|
||||
|
||||
## Organization Scoping (Security)
|
||||
|
||||
Most resources in this codebase are scoped to an organization. **Whenever you read or write an organization-scoped field, you must filter or validate by `organization_id`.** This is a tenant-isolation requirement, not a stylistic one — skipping the check lets a user in one org touch resources owned by another.
|
||||
|
||||
Concretely:
|
||||
|
||||
- **Reading** an org-scoped row by id: pass `organization_id=user.selected_organization_id` to the DB client (or query through an org-scoped helper). Never trust an id from the request body to imply ownership.
|
||||
- **Writing** a foreign key that points at another org-scoped resource (e.g. attaching `inbound_workflow_id` to a phone number, setting `telephony_configuration_id` on a campaign): fetch the referenced row with the user's `organization_id` and reject with 404 if it doesn't belong. The FK constraint only proves the row exists — it doesn't prove the caller is allowed to reference it.
|
||||
- **Listing** org-scoped resources: filter by `organization_id` at the query level, not in Python after the fact.
|
||||
|
||||
If a route's handler does not have access to an `organization_id` (e.g. webhook callbacks), derive it from the request payload and validate that derivation explicitly — don't assume.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
"""Drop workflow_run_mode Postgres enum, store mode as VARCHAR.
|
||||
|
||||
The Postgres enum required a migration every time a new telephony provider
|
||||
was added. With the column stored as VARCHAR, new providers can be added
|
||||
purely in application code (registry registration in the provider package).
|
||||
The Python ``WorkflowRunMode`` enum stays as a constant set used for
|
||||
comparisons; only the database column type changes.
|
||||
|
||||
Revision ID: 4d8e9b2a3c5f
|
||||
Revises: cdcf9f65913b, f2e1d0c9b8a7
|
||||
Create Date: 2026-04-25 21:30:00.000000
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4d8e9b2a3c5f"
|
||||
down_revision: Union[str, Sequence[str], None] = (
|
||||
"cdcf9f65913b",
|
||||
"f2e1d0c9b8a7",
|
||||
)
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# Mode values that existed when the enum was dropped, used to recreate the
|
||||
# enum on downgrade. New values added after this migration won't appear here
|
||||
# — that's the point of the refactor.
|
||||
_LEGACY_MODE_VALUES = (
|
||||
"ari",
|
||||
"plivo",
|
||||
"twilio",
|
||||
"vonage",
|
||||
"vobiz",
|
||||
"cloudonix",
|
||||
"telnyx",
|
||||
"webrtc",
|
||||
"smallwebrtc",
|
||||
"stasis",
|
||||
"VOICE",
|
||||
"CHAT",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Convert the mode column from the workflow_run_mode enum to VARCHAR(64).
|
||||
# Postgres requires a USING expression to cast the enum to text safely.
|
||||
op.execute(
|
||||
"ALTER TABLE workflow_runs ALTER COLUMN mode TYPE VARCHAR(64) USING mode::text"
|
||||
)
|
||||
# Drop the now-unused enum type.
|
||||
op.execute("DROP TYPE workflow_run_mode")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Recreate the enum with the values that existed at the time this
|
||||
# migration ran. Any values added afterwards (e.g. a future provider
|
||||
# registered in code only) will fail to cast back; operators on those
|
||||
# rows must clean them up before downgrading.
|
||||
enum_values = ", ".join(f"'{v}'" for v in _LEGACY_MODE_VALUES)
|
||||
op.execute(f"CREATE TYPE workflow_run_mode AS ENUM ({enum_values})")
|
||||
op.execute(
|
||||
"ALTER TABLE workflow_runs "
|
||||
"ALTER COLUMN mode TYPE workflow_run_mode USING mode::workflow_run_mode"
|
||||
)
|
||||
|
|
@ -0,0 +1,563 @@
|
|||
"""add multi telephony config tables
|
||||
|
||||
Revision ID: a2355fc6bdc1
|
||||
Revises: 4d8e9b2a3c5f
|
||||
Create Date: 2026-04-26 15:07:07.644855
|
||||
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "a2355fc6bdc1"
|
||||
down_revision: Union[str, None] = "4d8e9b2a3c5f"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# Credential keys that are NOT provider credentials and must be stripped before
|
||||
# the legacy TELEPHONY_CONFIGURATION JSON is copied into the new credentials column.
|
||||
_NON_CREDENTIAL_KEYS = {"provider", "from_numbers"}
|
||||
|
||||
|
||||
# Self-contained address normalizer for the legacy ``from_numbers`` backfill.
|
||||
# Migrations are snapshots — we deliberately avoid importing the live
|
||||
# ``api.utils.telephony_address`` here so this migration keeps working even if
|
||||
# that module later moves or its signature changes. The legacy data only ever
|
||||
# reached this column without country hints, so the country-hint branch of the
|
||||
# real normalizer is intentionally omitted.
|
||||
_PSTN_DIGITS_RE = re.compile(r"^\d{8,15}$")
|
||||
_PSTN_STRIP_RE = re.compile(r"[\s\-\(\)]")
|
||||
_SIP_URI_RE = re.compile(
|
||||
r"^(?P<scheme>sips?):(?:(?P<user>[^@;?]+)@)?(?P<host>[^:;?]+)"
|
||||
r"(?::(?P<port>\d+))?(?P<rest>[;?].*)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _NormalizedAddress:
|
||||
canonical: str
|
||||
address_type: str
|
||||
country_code: Optional[str] = None
|
||||
|
||||
|
||||
def normalize_telephony_address(raw: str) -> _NormalizedAddress:
|
||||
if raw is None:
|
||||
raise ValueError("address must not be None")
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
raise ValueError("address must not be empty")
|
||||
|
||||
if raw.lower().startswith(("sip:", "sips:")):
|
||||
m = _SIP_URI_RE.match(raw)
|
||||
if not m:
|
||||
return _NormalizedAddress(canonical=raw.lower(), address_type="sip_uri")
|
||||
scheme = m.group("scheme").lower()
|
||||
user = m.group("user")
|
||||
host = m.group("host").lower()
|
||||
port = m.group("port")
|
||||
rest = m.group("rest") or ""
|
||||
if (scheme == "sip" and port == "5060") or (
|
||||
scheme == "sips" and port == "5061"
|
||||
):
|
||||
port = None
|
||||
canonical = f"{scheme}:"
|
||||
if user:
|
||||
canonical += f"{user}@"
|
||||
canonical += host
|
||||
if port:
|
||||
canonical += f":{port}"
|
||||
if rest:
|
||||
canonical += rest.lower()
|
||||
return _NormalizedAddress(canonical=canonical, address_type="sip_uri")
|
||||
|
||||
digits = _PSTN_STRIP_RE.sub("", raw)
|
||||
if digits.startswith("+"):
|
||||
digits = digits[1:]
|
||||
if _PSTN_DIGITS_RE.fullmatch(digits):
|
||||
return _NormalizedAddress(canonical=f"+{digits}", address_type="pstn")
|
||||
|
||||
return _NormalizedAddress(canonical=raw.lower(), address_type="sip_extension")
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"telephony_configurations",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("organization_id", sa.Integer(), nullable=False),
|
||||
sa.Column("name", sa.String(length=64), nullable=False),
|
||||
sa.Column("provider", sa.String(length=32), nullable=False),
|
||||
sa.Column("credentials", sa.JSON(), nullable=False),
|
||||
sa.Column(
|
||||
"is_default_outbound",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"], ["organizations.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"organization_id", "name", name="uq_telephony_configurations_org_name"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_telephony_configurations_org",
|
||||
"telephony_configurations",
|
||||
["organization_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"uq_telephony_configurations_default",
|
||||
"telephony_configurations",
|
||||
["organization_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_default_outbound = true"),
|
||||
)
|
||||
op.create_table(
|
||||
"telephony_phone_numbers",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("organization_id", sa.Integer(), nullable=False),
|
||||
sa.Column("telephony_configuration_id", sa.Integer(), nullable=False),
|
||||
sa.Column("address", sa.String(length=255), nullable=False),
|
||||
sa.Column("address_normalized", sa.String(length=255), nullable=False),
|
||||
sa.Column("address_type", sa.String(length=16), nullable=False),
|
||||
sa.Column("country_code", sa.String(length=2), nullable=True),
|
||||
sa.Column("label", sa.String(length=64), nullable=True),
|
||||
sa.Column("inbound_workflow_id", sa.Integer(), nullable=True),
|
||||
sa.Column(
|
||||
"is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False
|
||||
),
|
||||
sa.Column(
|
||||
"is_default_caller_id",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("false"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"extra_metadata",
|
||||
sa.JSON(),
|
||||
server_default=sa.text("'{}'::json"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["inbound_workflow_id"], ["workflows.id"], ondelete="SET NULL"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["organization_id"], ["organizations.id"], ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["telephony_configuration_id"],
|
||||
["telephony_configurations.id"],
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint(
|
||||
"organization_id", "address_normalized", name="uq_phone_numbers_org_address"
|
||||
),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_phone_numbers_config",
|
||||
"telephony_phone_numbers",
|
||||
["telephony_configuration_id"],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
"ix_phone_numbers_inbound_lookup",
|
||||
"telephony_phone_numbers",
|
||||
["address_normalized", "organization_id"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("is_active = true"),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_phone_numbers_workflow",
|
||||
"telephony_phone_numbers",
|
||||
["inbound_workflow_id"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("inbound_workflow_id IS NOT NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_phone_numbers_default_caller",
|
||||
"telephony_phone_numbers",
|
||||
["telephony_configuration_id"],
|
||||
unique=True,
|
||||
postgresql_where=sa.text("is_default_caller_id = true"),
|
||||
)
|
||||
op.add_column(
|
||||
"campaigns",
|
||||
sa.Column("telephony_configuration_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_campaigns_telephony_config",
|
||||
"campaigns",
|
||||
["telephony_configuration_id"],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("telephony_configuration_id IS NOT NULL"),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_campaigns_telephony_configuration_id",
|
||||
"campaigns",
|
||||
"telephony_configurations",
|
||||
["telephony_configuration_id"],
|
||||
["id"],
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
_backfill_from_legacy_telephony_configuration()
|
||||
|
||||
|
||||
def _backfill_from_legacy_telephony_configuration() -> None:
|
||||
"""Copy legacy ``OrganizationConfigurationModel(TELEPHONY_CONFIGURATION)`` rows
|
||||
into the new tables and back-link campaigns to each org's default config.
|
||||
|
||||
For every legacy row we create:
|
||||
- one ``telephony_configurations`` row (name='Default', is_default_outbound=true),
|
||||
- one ``telephony_phone_numbers`` row per entry in ``value['from_numbers']``.
|
||||
|
||||
Then ``campaigns.telephony_configuration_id`` is set to that org's default config
|
||||
so the dispatcher's lookup keeps working without a code-side fallback.
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
legacy_rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT organization_id, value FROM organization_configurations "
|
||||
"WHERE key = 'TELEPHONY_CONFIGURATION'"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
for org_id, raw_value in legacy_rows:
|
||||
value = (
|
||||
raw_value if isinstance(raw_value, dict) else json.loads(raw_value or "{}")
|
||||
)
|
||||
if not value:
|
||||
continue
|
||||
|
||||
provider = value.get("provider")
|
||||
if not provider:
|
||||
continue
|
||||
|
||||
credentials = {k: v for k, v in value.items() if k not in _NON_CREDENTIAL_KEYS}
|
||||
|
||||
# Set created_at/updated_at explicitly: the SQLAlchemy model's
|
||||
# ``default=lambda: datetime.now(UTC)`` only fires on ORM-driven inserts,
|
||||
# not on raw SQL like this. Without these, both columns are NULL and the
|
||||
# ORM read path fails Pydantic validation.
|
||||
cfg_id = bind.execute(
|
||||
sa.text(
|
||||
"INSERT INTO telephony_configurations "
|
||||
"(organization_id, name, provider, credentials, is_default_outbound, "
|
||||
" created_at, updated_at) "
|
||||
"VALUES (:org_id, 'Default', :provider, CAST(:creds AS JSON), TRUE, "
|
||||
" NOW(), NOW()) "
|
||||
"RETURNING id"
|
||||
),
|
||||
{
|
||||
"org_id": org_id,
|
||||
"provider": provider,
|
||||
"creds": json.dumps(credentials),
|
||||
},
|
||||
).scalar_one()
|
||||
|
||||
for raw_address in value.get("from_numbers") or []:
|
||||
if raw_address is None:
|
||||
continue
|
||||
try:
|
||||
normalized = normalize_telephony_address(str(raw_address))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Skip duplicates within the same org. The unique constraint would also
|
||||
# catch this; we pre-filter to avoid breaking the whole transaction on
|
||||
# legacy data with the same number listed twice.
|
||||
existing = bind.execute(
|
||||
sa.text(
|
||||
"SELECT 1 FROM telephony_phone_numbers "
|
||||
"WHERE organization_id = :org_id AND address_normalized = :addr"
|
||||
),
|
||||
{"org_id": org_id, "addr": normalized.canonical},
|
||||
).first()
|
||||
if existing:
|
||||
continue
|
||||
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"INSERT INTO telephony_phone_numbers "
|
||||
"(organization_id, telephony_configuration_id, address, "
|
||||
" address_normalized, address_type, country_code, "
|
||||
" created_at, updated_at) "
|
||||
"VALUES (:org_id, :cfg_id, :addr, :norm, :type, :cc, "
|
||||
" NOW(), NOW())"
|
||||
),
|
||||
{
|
||||
"org_id": org_id,
|
||||
"cfg_id": cfg_id,
|
||||
"addr": str(raw_address),
|
||||
"norm": normalized.canonical,
|
||||
"type": normalized.address_type,
|
||||
"cc": normalized.country_code,
|
||||
},
|
||||
)
|
||||
|
||||
# Backfill campaigns to point at their org's default config.
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"UPDATE campaigns c "
|
||||
"SET telephony_configuration_id = tc.id "
|
||||
"FROM telephony_configurations tc "
|
||||
"WHERE tc.organization_id = c.organization_id "
|
||||
" AND tc.is_default_outbound = TRUE "
|
||||
" AND c.telephony_configuration_id IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
_move_ari_inbound_workflow_to_phone_numbers()
|
||||
|
||||
_validate_migrated_configurations()
|
||||
|
||||
|
||||
def _move_ari_inbound_workflow_to_phone_numbers() -> None:
|
||||
"""ARI's legacy single-config-per-org model stored the inbound workflow as
|
||||
``credentials.inbound_workflow_id`` — one workflow for the whole connection
|
||||
regardless of extension. The multi-config schema puts inbound routing on
|
||||
``telephony_phone_numbers`` (one workflow per extension), matching every
|
||||
other provider, so the ARI request/response models no longer declare the
|
||||
field. Copy it onto each linked phone number, then strip it from
|
||||
credentials so the data and the schema agree before validation runs.
|
||||
"""
|
||||
# 1. Backfill telephony_phone_numbers.inbound_workflow_id from each ARI
|
||||
# config's credentials.inbound_workflow_id. Only fill NULLs — the
|
||||
# inserts above leave this column NULL for every newly-created row,
|
||||
# but the guard keeps the step idempotent if rerun.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE telephony_phone_numbers tpn
|
||||
SET inbound_workflow_id = ((tc.credentials::jsonb)->>'inbound_workflow_id')::integer,
|
||||
updated_at = NOW()
|
||||
FROM telephony_configurations tc
|
||||
WHERE tpn.telephony_configuration_id = tc.id
|
||||
AND tc.provider = 'ari'
|
||||
AND (tc.credentials::jsonb) ? 'inbound_workflow_id'
|
||||
AND ((tc.credentials::jsonb)->>'inbound_workflow_id') ~ '^[0-9]+$'
|
||||
AND tpn.inbound_workflow_id IS NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# 2. Strip the legacy key from ARI configs' credentials so the schema and
|
||||
# the data agree (the ARI provider's request/response models no longer
|
||||
# declare inbound_workflow_id, which would otherwise trip the
|
||||
# extra-fields check in _validate_migrated_configurations).
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE telephony_configurations
|
||||
SET credentials = ((credentials::jsonb) - 'inbound_workflow_id')::json,
|
||||
updated_at = NOW()
|
||||
WHERE provider = 'ari'
|
||||
AND (credentials::jsonb) ? 'inbound_workflow_id'
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
_PLACEHOLDER_VALUE = "CHANGE_ME"
|
||||
|
||||
|
||||
def _validate_migrated_configurations() -> None:
|
||||
"""Round-trip every migrated row through its provider's Pydantic
|
||||
request schema so legacy data that the live code can no longer parse
|
||||
fails the migration loudly instead of silently breaking at runtime.
|
||||
|
||||
Migrations normally avoid importing live application modules to stay
|
||||
replay-safe, but the whole point of this step is to compare the
|
||||
just-written rows against the *current* schemas — so the import is
|
||||
intentional. If a provider has been removed or renamed since the
|
||||
legacy data was saved, that surfaces here as a missing-spec failure.
|
||||
|
||||
Validation mirrors what the runtime sees: ``provider`` + the JSONB
|
||||
``credentials`` columns + ``from_numbers`` joined from
|
||||
``telephony_phone_numbers``. We additionally reject credential keys
|
||||
that aren't declared on the request model — Pydantic ignores extras
|
||||
by default, so a stray legacy field (e.g. a renamed credential) would
|
||||
otherwise slip through and only show up later as a confused operator
|
||||
wondering why a value they entered does nothing.
|
||||
|
||||
Required string fields that are *missing* from legacy credentials
|
||||
(e.g. Plivo's ``application_id`` or Cloudonix's ``application_name``,
|
||||
which post-date some legacy rows) are filled with the
|
||||
``"CHANGE_ME"`` placeholder and the row is rewritten in place. Without
|
||||
this, the next ORM read fails Pydantic validation and the operator's
|
||||
config form refuses to render — leaving them with no UI path to enter
|
||||
the missing value.
|
||||
"""
|
||||
import importlib
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.services.telephony import registry
|
||||
|
||||
# Triggers each provider package's ``register()`` side effect.
|
||||
importlib.import_module("api.services.telephony.providers")
|
||||
|
||||
bind = op.get_bind()
|
||||
|
||||
cfg_rows = bind.execute(
|
||||
sa.text(
|
||||
"SELECT id, organization_id, name, provider, credentials "
|
||||
"FROM telephony_configurations"
|
||||
)
|
||||
).fetchall()
|
||||
|
||||
failures = []
|
||||
patched = []
|
||||
|
||||
for cfg_id, org_id, cfg_name, provider, raw_credentials in cfg_rows:
|
||||
credentials = (
|
||||
raw_credentials
|
||||
if isinstance(raw_credentials, dict)
|
||||
else json.loads(raw_credentials or "{}")
|
||||
)
|
||||
|
||||
spec = registry.get_optional(provider)
|
||||
if spec is None:
|
||||
failures.append(
|
||||
f"id={cfg_id} org={org_id} name={cfg_name!r}: provider "
|
||||
f"{provider!r} is not registered (registered: "
|
||||
f"{sorted(registry.names())})"
|
||||
)
|
||||
continue
|
||||
|
||||
added_placeholders = []
|
||||
for field_name, field_info in spec.config_request_cls.model_fields.items():
|
||||
if field_name in {"provider", "from_numbers"}:
|
||||
continue
|
||||
if field_name in credentials:
|
||||
continue
|
||||
if not field_info.is_required():
|
||||
continue
|
||||
# Only string fields get the sentinel — a non-string required field
|
||||
# (e.g. an int port) would still fail validation with "CHANGE_ME"
|
||||
# and the placeholder would mislead the operator. Surface it as a
|
||||
# failure instead of silently writing a wrong-type value.
|
||||
if field_info.annotation is not str:
|
||||
continue
|
||||
credentials[field_name] = _PLACEHOLDER_VALUE
|
||||
added_placeholders.append(field_name)
|
||||
|
||||
if added_placeholders:
|
||||
bind.execute(
|
||||
sa.text(
|
||||
"UPDATE telephony_configurations "
|
||||
"SET credentials = CAST(:creds AS JSON), updated_at = NOW() "
|
||||
"WHERE id = :cfg_id"
|
||||
),
|
||||
{"creds": json.dumps(credentials), "cfg_id": cfg_id},
|
||||
)
|
||||
patched.append(
|
||||
f"id={cfg_id} org={org_id} name={cfg_name!r} provider={provider!r}: "
|
||||
f"set placeholder {_PLACEHOLDER_VALUE!r} for missing required "
|
||||
f"field(s) {added_placeholders}"
|
||||
)
|
||||
|
||||
from_numbers = [
|
||||
row[0]
|
||||
for row in bind.execute(
|
||||
sa.text(
|
||||
"SELECT address FROM telephony_phone_numbers "
|
||||
"WHERE telephony_configuration_id = :cfg_id"
|
||||
),
|
||||
{"cfg_id": cfg_id},
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
# Explicit keys win over anything in credentials so a stray "provider"
|
||||
# or "from_numbers" left in the JSONB can't shadow the canonical values.
|
||||
payload = {**credentials, "provider": provider, "from_numbers": from_numbers}
|
||||
|
||||
known_fields = set(spec.config_request_cls.model_fields)
|
||||
extras = sorted(set(credentials) - known_fields - {"provider", "from_numbers"})
|
||||
if extras:
|
||||
failures.append(
|
||||
f"id={cfg_id} org={org_id} name={cfg_name!r} provider={provider!r}: "
|
||||
f"credentials contain unknown field(s) {extras} not declared on "
|
||||
f"{spec.config_request_cls.__name__}"
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
spec.config_request_cls.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
failures.append(
|
||||
f"id={cfg_id} org={org_id} name={cfg_name!r} provider={provider!r} "
|
||||
f"failed {spec.config_request_cls.__name__} validation: {exc}"
|
||||
)
|
||||
|
||||
if patched or failures:
|
||||
from loguru import logger
|
||||
|
||||
if patched:
|
||||
logger.warning(
|
||||
"Migrated telephony configurations had missing required fields "
|
||||
f"filled with the {_PLACEHOLDER_VALUE!r} placeholder. Update "
|
||||
"these in the UI before relying on the affected providers:\n - "
|
||||
+ "\n - ".join(patched)
|
||||
)
|
||||
|
||||
if failures:
|
||||
logger.warning(
|
||||
"Migrated telephony configurations did not pass live Pydantic "
|
||||
"validation. The migration will continue, but these rows will "
|
||||
"fail at runtime until fixed in the new tables:\n - "
|
||||
+ "\n - ".join(failures)
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(
|
||||
"fk_campaigns_telephony_configuration_id", "campaigns", type_="foreignkey"
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_campaigns_telephony_config",
|
||||
table_name="campaigns",
|
||||
postgresql_where=sa.text("telephony_configuration_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_column("campaigns", "telephony_configuration_id")
|
||||
op.drop_index(
|
||||
"uq_phone_numbers_default_caller",
|
||||
table_name="telephony_phone_numbers",
|
||||
postgresql_where=sa.text("is_default_caller_id = true"),
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_phone_numbers_workflow",
|
||||
table_name="telephony_phone_numbers",
|
||||
postgresql_where=sa.text("inbound_workflow_id IS NOT NULL"),
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_phone_numbers_inbound_lookup",
|
||||
table_name="telephony_phone_numbers",
|
||||
postgresql_where=sa.text("is_active = true"),
|
||||
)
|
||||
op.drop_index("ix_phone_numbers_config", table_name="telephony_phone_numbers")
|
||||
op.drop_table("telephony_phone_numbers")
|
||||
op.drop_index(
|
||||
"uq_telephony_configurations_default",
|
||||
table_name="telephony_configurations",
|
||||
postgresql_where=sa.text("is_default_outbound = true"),
|
||||
)
|
||||
op.drop_index(
|
||||
"ix_telephony_configurations_org", table_name="telephony_configurations"
|
||||
)
|
||||
op.drop_table("telephony_configurations")
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -23,6 +23,7 @@ class CampaignClient(BaseDBClient):
|
|||
max_concurrency: Optional[int] = None,
|
||||
schedule_config: Optional[dict] = None,
|
||||
circuit_breaker: Optional[dict] = None,
|
||||
telephony_configuration_id: Optional[int] = None,
|
||||
) -> CampaignModel:
|
||||
"""Create a new campaign"""
|
||||
async with self.async_session() as session:
|
||||
|
|
@ -46,6 +47,7 @@ class CampaignClient(BaseDBClient):
|
|||
if retry_config
|
||||
else CampaignModel.retry_config.default.arg,
|
||||
orchestrator_metadata=orchestrator_metadata,
|
||||
telephony_configuration_id=telephony_configuration_id,
|
||||
)
|
||||
session.add(campaign)
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ from api.db.organization_client import OrganizationClient
|
|||
from api.db.organization_configuration_client import OrganizationConfigurationClient
|
||||
from api.db.organization_usage_client import OrganizationUsageClient
|
||||
from api.db.reports_client import ReportsClient
|
||||
from api.db.telephony_configuration_client import TelephonyConfigurationClient
|
||||
from api.db.telephony_phone_number_client import TelephonyPhoneNumberClient
|
||||
from api.db.tool_client import ToolClient
|
||||
from api.db.user_client import UserClient
|
||||
from api.db.webhook_credential_client import WebhookCredentialClient
|
||||
|
|
@ -37,6 +39,8 @@ class DBClient(
|
|||
ToolClient,
|
||||
KnowledgeBaseClient,
|
||||
WorkflowRecordingClient,
|
||||
TelephonyConfigurationClient,
|
||||
TelephonyPhoneNumberClient,
|
||||
):
|
||||
"""
|
||||
Unified database client that combines all specialized database operations.
|
||||
|
|
|
|||
130
api/db/models.py
130
api/db/models.py
|
|
@ -30,7 +30,6 @@ from ..enums import (
|
|||
ToolStatus,
|
||||
TriggerState,
|
||||
WebhookCredentialType,
|
||||
WorkflowRunMode,
|
||||
WorkflowRunState,
|
||||
WorkflowStatus,
|
||||
)
|
||||
|
|
@ -178,6 +177,117 @@ class OrganizationConfigurationModel(Base):
|
|||
)
|
||||
|
||||
|
||||
class TelephonyConfigurationModel(Base):
|
||||
__tablename__ = "telephony_configurations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(
|
||||
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
name = Column(String(64), nullable=False)
|
||||
provider = Column(String(32), nullable=False)
|
||||
credentials = Column(JSON, nullable=False, default=dict)
|
||||
is_default_outbound = Column(
|
||||
Boolean, nullable=False, default=False, server_default=text("false")
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
organization = relationship("OrganizationModel")
|
||||
phone_numbers = relationship(
|
||||
"TelephonyPhoneNumberModel",
|
||||
back_populates="configuration",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"organization_id", "name", name="uq_telephony_configurations_org_name"
|
||||
),
|
||||
Index("ix_telephony_configurations_org", "organization_id"),
|
||||
Index(
|
||||
"uq_telephony_configurations_default",
|
||||
"organization_id",
|
||||
unique=True,
|
||||
postgresql_where=text("is_default_outbound = true"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TelephonyPhoneNumberModel(Base):
|
||||
__tablename__ = "telephony_phone_numbers"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
organization_id = Column(
|
||||
Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
telephony_configuration_id = Column(
|
||||
Integer,
|
||||
ForeignKey("telephony_configurations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
address = Column(String(255), nullable=False)
|
||||
address_normalized = Column(String(255), nullable=False)
|
||||
address_type = Column(String(16), nullable=False)
|
||||
country_code = Column(String(2), nullable=True)
|
||||
label = Column(String(64), nullable=True)
|
||||
inbound_workflow_id = Column(
|
||||
Integer,
|
||||
ForeignKey("workflows.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
is_active = Column(
|
||||
Boolean, nullable=False, default=True, server_default=text("true")
|
||||
)
|
||||
is_default_caller_id = Column(
|
||||
Boolean, nullable=False, default=False, server_default=text("false")
|
||||
)
|
||||
extra_metadata = Column(
|
||||
JSON, nullable=False, default=dict, server_default=text("'{}'::json")
|
||||
)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC))
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(UTC),
|
||||
onupdate=lambda: datetime.now(UTC),
|
||||
)
|
||||
|
||||
configuration = relationship(
|
||||
"TelephonyConfigurationModel", back_populates="phone_numbers"
|
||||
)
|
||||
inbound_workflow = relationship("WorkflowModel")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"organization_id",
|
||||
"address_normalized",
|
||||
name="uq_phone_numbers_org_address",
|
||||
),
|
||||
Index("ix_phone_numbers_config", "telephony_configuration_id"),
|
||||
Index(
|
||||
"ix_phone_numbers_workflow",
|
||||
"inbound_workflow_id",
|
||||
postgresql_where=text("inbound_workflow_id IS NOT NULL"),
|
||||
),
|
||||
Index(
|
||||
"ix_phone_numbers_inbound_lookup",
|
||||
"address_normalized",
|
||||
"organization_id",
|
||||
postgresql_where=text("is_active = true"),
|
||||
),
|
||||
Index(
|
||||
"uq_phone_numbers_default_caller",
|
||||
"telephony_configuration_id",
|
||||
unique=True,
|
||||
postgresql_where=text("is_default_caller_id = true"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class IntegrationModel(Base):
|
||||
__tablename__ = "integrations"
|
||||
|
||||
|
|
@ -334,10 +444,10 @@ class WorkflowRunModel(Base):
|
|||
Integer, ForeignKey("workflow_definitions.id"), nullable=True
|
||||
)
|
||||
definition = relationship("WorkflowDefinitionModel", back_populates="workflow_runs")
|
||||
mode = Column(
|
||||
Enum(*[mode.value for mode in WorkflowRunMode], name="workflow_run_mode"),
|
||||
nullable=False,
|
||||
)
|
||||
# Stored as VARCHAR (not a Postgres ENUM) so new telephony providers can
|
||||
# be added purely in application code without a database migration.
|
||||
# See WorkflowRunMode in api/enums.py for the canonical value set.
|
||||
mode = Column(String(64), nullable=False)
|
||||
call_type = Column(
|
||||
Enum(*[call_type.value for call_type in CallType], name="workflow_call_type"),
|
||||
nullable=False,
|
||||
|
|
@ -519,6 +629,11 @@ class CampaignModel(Base):
|
|||
organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False)
|
||||
workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False)
|
||||
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
# Nullable during the legacy → multi-config migration window. Backfilled to the
|
||||
# org's default config by the migration; will become NOT NULL in a follow-up.
|
||||
telephony_configuration_id = Column(
|
||||
Integer, ForeignKey("telephony_configurations.id"), nullable=True
|
||||
)
|
||||
|
||||
# Source configuration
|
||||
source_type = Column(String, nullable=False, default="google-sheet")
|
||||
|
|
@ -588,6 +703,11 @@ class CampaignModel(Base):
|
|||
Index("ix_campaigns_org_id", "organization_id"),
|
||||
Index("ix_campaigns_state", "state"),
|
||||
Index("ix_campaigns_workflow_id", "workflow_id"),
|
||||
Index(
|
||||
"ix_campaigns_telephony_config",
|
||||
"telephony_configuration_id",
|
||||
postgresql_where=text("telephony_configuration_id IS NOT NULL"),
|
||||
),
|
||||
# Index for efficient querying of active campaigns
|
||||
Index(
|
||||
"idx_campaigns_active_status",
|
||||
|
|
|
|||
270
api/db/telephony_configuration_client.py
Normal file
270
api/db/telephony_configuration_client.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""Database access for telephony configurations.
|
||||
|
||||
Each row represents one provider account that an organization has connected
|
||||
(e.g. "Twilio US prod", "Vobiz IN sandbox"). Replaces the single-row-per-org
|
||||
``OrganizationConfiguration(TELEPHONY_CONFIGURATION)`` storage.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import CampaignModel, TelephonyConfigurationModel
|
||||
|
||||
|
||||
class TelephonyConfigurationDuplicateAccountError(Exception):
|
||||
"""Raised when saving a config whose account_id collides with an existing
|
||||
config of the same provider in the same organization."""
|
||||
|
||||
|
||||
class TelephonyConfigurationInUseError(Exception):
|
||||
"""Raised when deleting a config that is still referenced by a campaign."""
|
||||
|
||||
|
||||
class TelephonyConfigurationClient(BaseDBClient):
|
||||
async def list_telephony_configurations(
|
||||
self, organization_id: int
|
||||
) -> List[TelephonyConfigurationModel]:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel)
|
||||
.where(TelephonyConfigurationModel.organization_id == organization_id)
|
||||
.order_by(TelephonyConfigurationModel.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_telephony_configuration(
|
||||
self, config_id: int
|
||||
) -> Optional[TelephonyConfigurationModel]:
|
||||
async with self.async_session() as session:
|
||||
return await session.get(TelephonyConfigurationModel, config_id)
|
||||
|
||||
async def get_telephony_configuration_for_org(
|
||||
self, config_id: int, organization_id: int
|
||||
) -> Optional[TelephonyConfigurationModel]:
|
||||
"""Lookup scoped to an org — used to authorize per-org access."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel).where(
|
||||
TelephonyConfigurationModel.id == config_id,
|
||||
TelephonyConfigurationModel.organization_id == organization_id,
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def get_default_telephony_configuration(
|
||||
self, organization_id: int
|
||||
) -> Optional[TelephonyConfigurationModel]:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel).where(
|
||||
TelephonyConfigurationModel.organization_id == organization_id,
|
||||
TelephonyConfigurationModel.is_default_outbound.is_(True),
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def find_telephony_config_by_account(
|
||||
self, provider: str, account_id_field: str, account_id: str
|
||||
) -> Optional[TelephonyConfigurationModel]:
|
||||
"""Global lookup used by the workflow-agnostic inbound dispatcher.
|
||||
|
||||
Returns the single config whose stored credentials contain
|
||||
``credentials[account_id_field] == account_id``. Filters in Python
|
||||
over the per-provider candidate set since credentials is JSON.
|
||||
"""
|
||||
if not account_id_field or not account_id:
|
||||
return None
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel).where(
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
)
|
||||
)
|
||||
for cand in result.scalars().all():
|
||||
stored = (cand.credentials or {}).get(account_id_field)
|
||||
if stored and stored == account_id:
|
||||
return cand
|
||||
return None
|
||||
|
||||
async def list_telephony_configurations_by_provider(
|
||||
self, organization_id: int, provider: str
|
||||
) -> List[TelephonyConfigurationModel]:
|
||||
"""Used by inbound matching to enumerate candidates of a given provider."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel).where(
|
||||
TelephonyConfigurationModel.organization_id == organization_id,
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_all_telephony_configurations_by_provider(
|
||||
self, provider: str
|
||||
) -> List[TelephonyConfigurationModel]:
|
||||
"""List configs of a given provider across every organization.
|
||||
|
||||
Used by background workers like the ARI manager that maintain
|
||||
long-lived connections per config row, independent of any one org.
|
||||
"""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel).where(
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create_telephony_configuration(
|
||||
self,
|
||||
organization_id: int,
|
||||
name: str,
|
||||
provider: str,
|
||||
credentials: Dict[str, Any],
|
||||
is_default_outbound: bool = False,
|
||||
account_id_credential_field: Optional[str] = None,
|
||||
) -> TelephonyConfigurationModel:
|
||||
"""Create a new config. Raises ``TelephonyConfigurationDuplicateAccountError``
|
||||
if the same provider+account_id is already configured for the org."""
|
||||
if account_id_credential_field:
|
||||
await self._guard_duplicate_account(
|
||||
organization_id,
|
||||
provider,
|
||||
credentials.get(account_id_credential_field),
|
||||
account_id_credential_field,
|
||||
exclude_id=None,
|
||||
)
|
||||
|
||||
async with self.async_session() as session:
|
||||
if is_default_outbound:
|
||||
await self._clear_default_outbound(session, organization_id)
|
||||
|
||||
row = TelephonyConfigurationModel(
|
||||
organization_id=organization_id,
|
||||
name=name,
|
||||
provider=provider,
|
||||
credentials=credentials,
|
||||
is_default_outbound=is_default_outbound,
|
||||
)
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
async def update_telephony_configuration(
|
||||
self,
|
||||
config_id: int,
|
||||
organization_id: int,
|
||||
name: Optional[str] = None,
|
||||
credentials: Optional[Dict[str, Any]] = None,
|
||||
account_id_credential_field: Optional[str] = None,
|
||||
) -> Optional[TelephonyConfigurationModel]:
|
||||
async with self.async_session() as session:
|
||||
row = await session.get(TelephonyConfigurationModel, config_id)
|
||||
if not row or row.organization_id != organization_id:
|
||||
return None
|
||||
|
||||
if credentials is not None and account_id_credential_field:
|
||||
await self._guard_duplicate_account(
|
||||
organization_id,
|
||||
row.provider,
|
||||
credentials.get(account_id_credential_field),
|
||||
account_id_credential_field,
|
||||
exclude_id=config_id,
|
||||
)
|
||||
|
||||
if name is not None:
|
||||
row.name = name
|
||||
if credentials is not None:
|
||||
row.credentials = credentials
|
||||
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
async def set_default_telephony_configuration(
|
||||
self, config_id: int, organization_id: int
|
||||
) -> Optional[TelephonyConfigurationModel]:
|
||||
"""Mark this config as the org's default outbound, clearing any other default."""
|
||||
async with self.async_session() as session:
|
||||
row = await session.get(TelephonyConfigurationModel, config_id)
|
||||
if not row or row.organization_id != organization_id:
|
||||
return None
|
||||
await self._clear_default_outbound(session, organization_id)
|
||||
row.is_default_outbound = True
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
async def delete_telephony_configuration(
|
||||
self, config_id: int, organization_id: int
|
||||
) -> bool:
|
||||
async with self.async_session() as session:
|
||||
row = await session.get(TelephonyConfigurationModel, config_id)
|
||||
if not row or row.organization_id != organization_id:
|
||||
return False
|
||||
|
||||
campaign_ref = await session.execute(
|
||||
select(CampaignModel.id)
|
||||
.where(CampaignModel.telephony_configuration_id == config_id)
|
||||
.limit(1)
|
||||
)
|
||||
if campaign_ref.first():
|
||||
raise TelephonyConfigurationInUseError(
|
||||
f"Telephony configuration {config_id} is referenced by one or "
|
||||
f"more campaigns and cannot be deleted."
|
||||
)
|
||||
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def _guard_duplicate_account(
|
||||
self,
|
||||
organization_id: int,
|
||||
provider: str,
|
||||
account_id: Optional[str],
|
||||
credential_field: str,
|
||||
exclude_id: Optional[int],
|
||||
) -> None:
|
||||
if not account_id:
|
||||
return
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyConfigurationModel).where(
|
||||
TelephonyConfigurationModel.organization_id == organization_id,
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
)
|
||||
)
|
||||
for row in result.scalars().all():
|
||||
if exclude_id is not None and row.id == exclude_id:
|
||||
continue
|
||||
stored = (row.credentials or {}).get(credential_field)
|
||||
if stored and stored == account_id:
|
||||
raise TelephonyConfigurationDuplicateAccountError(
|
||||
f"A {provider} configuration with this account is already "
|
||||
f"registered (config id {row.id})."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def _clear_default_outbound(session, organization_id: int) -> None:
|
||||
await session.execute(
|
||||
update(TelephonyConfigurationModel)
|
||||
.where(
|
||||
TelephonyConfigurationModel.organization_id == organization_id,
|
||||
TelephonyConfigurationModel.is_default_outbound.is_(True),
|
||||
)
|
||||
.values(is_default_outbound=False)
|
||||
)
|
||||
250
api/db/telephony_phone_number_client.py
Normal file
250
api/db/telephony_phone_number_client.py
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
"""Database access for telephony phone numbers.
|
||||
|
||||
Phone numbers are first-class entities (PSTN, SIP URI, or SIP extension)
|
||||
owned by a telephony configuration. They power both outbound caller-ID
|
||||
selection and inbound call routing.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from api.db.base_client import BaseDBClient
|
||||
from api.db.models import (
|
||||
TelephonyConfigurationModel,
|
||||
TelephonyPhoneNumberModel,
|
||||
WorkflowModel,
|
||||
)
|
||||
from api.utils.telephony_address import normalize_telephony_address
|
||||
|
||||
|
||||
class TelephonyPhoneNumberClient(BaseDBClient):
|
||||
async def list_phone_numbers_for_config(
|
||||
self, telephony_configuration_id: int
|
||||
) -> List[TelephonyPhoneNumberModel]:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyPhoneNumberModel)
|
||||
.where(
|
||||
TelephonyPhoneNumberModel.telephony_configuration_id
|
||||
== telephony_configuration_id
|
||||
)
|
||||
.order_by(TelephonyPhoneNumberModel.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_phone_numbers_with_workflow_name_for_config(
|
||||
self, telephony_configuration_id: int
|
||||
) -> List[Tuple[TelephonyPhoneNumberModel, Optional[str]]]:
|
||||
"""Same as :meth:`list_phone_numbers_for_config` but also returns the
|
||||
inbound workflow's display name (or None) for each row, fetched via a
|
||||
single LEFT JOIN so we don't load entire workflow rows."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyPhoneNumberModel, WorkflowModel.name)
|
||||
.join(
|
||||
WorkflowModel,
|
||||
WorkflowModel.id == TelephonyPhoneNumberModel.inbound_workflow_id,
|
||||
isouter=True,
|
||||
)
|
||||
.where(
|
||||
TelephonyPhoneNumberModel.telephony_configuration_id
|
||||
== telephony_configuration_id
|
||||
)
|
||||
.order_by(TelephonyPhoneNumberModel.created_at)
|
||||
)
|
||||
return [(row, name) for row, name in result.all()]
|
||||
|
||||
async def list_active_normalized_addresses_for_config(
|
||||
self, telephony_configuration_id: int
|
||||
) -> List[str]:
|
||||
"""Active phone numbers as canonical address strings (E.164 for PSTN,
|
||||
normalized SIP otherwise) — the shape providers want in their
|
||||
``from_numbers`` list for caller-ID and rate-limit pool keys."""
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyPhoneNumberModel.address_normalized)
|
||||
.where(
|
||||
TelephonyPhoneNumberModel.telephony_configuration_id
|
||||
== telephony_configuration_id,
|
||||
TelephonyPhoneNumberModel.is_active.is_(True),
|
||||
)
|
||||
.order_by(TelephonyPhoneNumberModel.created_at)
|
||||
)
|
||||
return [row[0] for row in result.all()]
|
||||
|
||||
async def get_phone_number(
|
||||
self, phone_number_id: int
|
||||
) -> Optional[TelephonyPhoneNumberModel]:
|
||||
async with self.async_session() as session:
|
||||
return await session.get(TelephonyPhoneNumberModel, phone_number_id)
|
||||
|
||||
async def get_phone_number_for_config(
|
||||
self, phone_number_id: int, telephony_configuration_id: int
|
||||
) -> Optional[TelephonyPhoneNumberModel]:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyPhoneNumberModel).where(
|
||||
TelephonyPhoneNumberModel.id == phone_number_id,
|
||||
TelephonyPhoneNumberModel.telephony_configuration_id
|
||||
== telephony_configuration_id,
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def find_active_phone_number_for_inbound(
|
||||
self,
|
||||
organization_id: int,
|
||||
address: str,
|
||||
provider: str,
|
||||
country_hint: Optional[str] = None,
|
||||
) -> Optional[TelephonyPhoneNumberModel]:
|
||||
"""Inbound routing primary lookup: normalize the called address and find
|
||||
the matching active row whose config is for the detected provider."""
|
||||
normalized = normalize_telephony_address(address, country_hint=country_hint)
|
||||
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyPhoneNumberModel)
|
||||
.join(
|
||||
TelephonyConfigurationModel,
|
||||
TelephonyConfigurationModel.id
|
||||
== TelephonyPhoneNumberModel.telephony_configuration_id,
|
||||
)
|
||||
.where(
|
||||
TelephonyPhoneNumberModel.organization_id == organization_id,
|
||||
TelephonyPhoneNumberModel.address_normalized
|
||||
== normalized.canonical,
|
||||
TelephonyPhoneNumberModel.is_active.is_(True),
|
||||
TelephonyConfigurationModel.provider == provider,
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def create_phone_number(
|
||||
self,
|
||||
organization_id: int,
|
||||
telephony_configuration_id: int,
|
||||
address: str,
|
||||
country_code: Optional[str] = None,
|
||||
label: Optional[str] = None,
|
||||
inbound_workflow_id: Optional[int] = None,
|
||||
is_active: bool = True,
|
||||
is_default_caller_id: bool = False,
|
||||
extra_metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> TelephonyPhoneNumberModel:
|
||||
normalized = normalize_telephony_address(address, country_hint=country_code)
|
||||
|
||||
async with self.async_session() as session:
|
||||
if is_default_caller_id:
|
||||
await self._clear_default_caller_id(session, telephony_configuration_id)
|
||||
|
||||
row = TelephonyPhoneNumberModel(
|
||||
organization_id=organization_id,
|
||||
telephony_configuration_id=telephony_configuration_id,
|
||||
address=address,
|
||||
address_normalized=normalized.canonical,
|
||||
address_type=normalized.address_type,
|
||||
country_code=country_code or normalized.country_code,
|
||||
label=label,
|
||||
inbound_workflow_id=inbound_workflow_id,
|
||||
is_active=is_active,
|
||||
is_default_caller_id=is_default_caller_id,
|
||||
extra_metadata=extra_metadata or {},
|
||||
)
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except IntegrityError as e:
|
||||
await session.rollback()
|
||||
raise e
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
async def update_phone_number(
|
||||
self,
|
||||
phone_number_id: int,
|
||||
telephony_configuration_id: int,
|
||||
label: Optional[str] = None,
|
||||
inbound_workflow_id: Optional[int] = None,
|
||||
is_active: Optional[bool] = None,
|
||||
country_code: Optional[str] = None,
|
||||
extra_metadata: Optional[Dict[str, Any]] = None,
|
||||
clear_inbound_workflow: bool = False,
|
||||
) -> Optional[TelephonyPhoneNumberModel]:
|
||||
"""Partial update. ``address`` is intentionally immutable — create a new
|
||||
row instead. Set ``clear_inbound_workflow=True`` to null out the FK."""
|
||||
async with self.async_session() as session:
|
||||
row = await session.get(TelephonyPhoneNumberModel, phone_number_id)
|
||||
if not row or row.telephony_configuration_id != telephony_configuration_id:
|
||||
return None
|
||||
|
||||
if label is not None:
|
||||
row.label = label
|
||||
if inbound_workflow_id is not None:
|
||||
row.inbound_workflow_id = inbound_workflow_id
|
||||
elif clear_inbound_workflow:
|
||||
row.inbound_workflow_id = None
|
||||
if is_active is not None:
|
||||
row.is_active = is_active
|
||||
if country_code is not None:
|
||||
row.country_code = country_code
|
||||
if extra_metadata is not None:
|
||||
row.extra_metadata = extra_metadata
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
async def set_default_caller_id(
|
||||
self, phone_number_id: int, telephony_configuration_id: int
|
||||
) -> Optional[TelephonyPhoneNumberModel]:
|
||||
async with self.async_session() as session:
|
||||
row = await session.get(TelephonyPhoneNumberModel, phone_number_id)
|
||||
if not row or row.telephony_configuration_id != telephony_configuration_id:
|
||||
return None
|
||||
await self._clear_default_caller_id(session, telephony_configuration_id)
|
||||
row.is_default_caller_id = True
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
async def get_default_caller_id(
|
||||
self, telephony_configuration_id: int
|
||||
) -> Optional[TelephonyPhoneNumberModel]:
|
||||
async with self.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(TelephonyPhoneNumberModel).where(
|
||||
TelephonyPhoneNumberModel.telephony_configuration_id
|
||||
== telephony_configuration_id,
|
||||
TelephonyPhoneNumberModel.is_default_caller_id.is_(True),
|
||||
)
|
||||
)
|
||||
return result.scalars().first()
|
||||
|
||||
async def delete_phone_number(
|
||||
self, phone_number_id: int, telephony_configuration_id: int
|
||||
) -> bool:
|
||||
async with self.async_session() as session:
|
||||
row = await session.get(TelephonyPhoneNumberModel, phone_number_id)
|
||||
if not row or row.telephony_configuration_id != telephony_configuration_id:
|
||||
return False
|
||||
await session.delete(row)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def _clear_default_caller_id(
|
||||
session, telephony_configuration_id: int
|
||||
) -> None:
|
||||
await session.execute(
|
||||
update(TelephonyPhoneNumberModel)
|
||||
.where(
|
||||
TelephonyPhoneNumberModel.telephony_configuration_id
|
||||
== telephony_configuration_id,
|
||||
TelephonyPhoneNumberModel.is_default_caller_id.is_(True),
|
||||
)
|
||||
.values(is_default_caller_id=False)
|
||||
)
|
||||
|
|
@ -40,14 +40,17 @@ async def _get_org_concurrent_limit(organization_id: int) -> int:
|
|||
|
||||
|
||||
async def _get_from_numbers_count(organization_id: int) -> int:
|
||||
"""Get the number of configured from_numbers for an organization."""
|
||||
"""Active phone-number count from the org's default telephony config.
|
||||
Used to validate ``max_concurrency`` against caller-id supply."""
|
||||
try:
|
||||
config = await db_client.get_configuration(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
default_cfg = await db_client.get_default_telephony_configuration(
|
||||
organization_id
|
||||
)
|
||||
if config and config.value:
|
||||
return len(config.value.get("from_numbers", []))
|
||||
if default_cfg:
|
||||
addresses = await db_client.list_active_normalized_addresses_for_config(
|
||||
default_cfg.id
|
||||
)
|
||||
return len(addresses)
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
|
@ -151,6 +154,10 @@ class CreateCampaignRequest(BaseModel):
|
|||
workflow_id: int
|
||||
source_type: str = Field(..., pattern="^(google-sheet|csv)$")
|
||||
source_id: str # Google Sheet URL or CSV file key
|
||||
# Optional during the legacy → multi-config migration window. Required in
|
||||
# a follow-up. When omitted, the dispatcher falls back to the org's
|
||||
# default config.
|
||||
telephony_configuration_id: Optional[int] = None
|
||||
retry_config: Optional[RetryConfigRequest] = None
|
||||
max_concurrency: Optional[int] = Field(default=None, ge=1, le=100)
|
||||
schedule_config: Optional[ScheduleConfigRequest] = None
|
||||
|
|
@ -316,7 +323,9 @@ async def create_campaign(
|
|||
raise HTTPException(status_code=400, detail=validation_result.error.message)
|
||||
|
||||
# Validate template variables against source data columns
|
||||
workflow = await db_client.get_workflow_by_id(request.workflow_id)
|
||||
workflow = await db_client.get_workflow(
|
||||
request.workflow_id, organization_id=user.selected_organization_id
|
||||
)
|
||||
if workflow:
|
||||
from api.services.workflow.dto import ReactFlowDTO
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
|
|
@ -355,6 +364,25 @@ async def create_campaign(
|
|||
request.max_concurrency, user.selected_organization_id
|
||||
)
|
||||
|
||||
# Resolve which telephony config the campaign is pinned to. Explicit value
|
||||
# wins; otherwise default to the org's default config so legacy clients keep
|
||||
# working through the migration window.
|
||||
telephony_configuration_id = request.telephony_configuration_id
|
||||
if telephony_configuration_id:
|
||||
cfg = await db_client.get_telephony_configuration_for_org(
|
||||
telephony_configuration_id, user.selected_organization_id
|
||||
)
|
||||
if not cfg:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="telephony_configuration_not_found"
|
||||
)
|
||||
else:
|
||||
default_cfg = await db_client.get_default_telephony_configuration(
|
||||
user.selected_organization_id
|
||||
)
|
||||
if default_cfg:
|
||||
telephony_configuration_id = default_cfg.id
|
||||
|
||||
# Build retry_config dict if provided
|
||||
retry_config = None
|
||||
if request.retry_config:
|
||||
|
|
@ -381,6 +409,7 @@ async def create_campaign(
|
|||
max_concurrency=request.max_concurrency,
|
||||
schedule_config=schedule_config,
|
||||
circuit_breaker=circuit_breaker_config,
|
||||
telephony_configuration_id=telephony_configuration_id,
|
||||
)
|
||||
|
||||
return _build_campaign_response(campaign, workflow_name)
|
||||
|
|
@ -441,28 +470,27 @@ async def start_campaign(
|
|||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Start campaign execution"""
|
||||
# Check if organization has TELEPHONY_CONFIGURATION configured
|
||||
twilio_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
# Block start if the org has no telephony configuration at all.
|
||||
configs = await db_client.list_telephony_configurations(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
if not twilio_config or not twilio_config.value:
|
||||
if not configs:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="You must configure telephony first by going to APP_URL/configure-telephony",
|
||||
)
|
||||
|
||||
# Check Dograh quota before starting campaign
|
||||
quota_result = await check_dograh_quota(user)
|
||||
if not quota_result.has_quota:
|
||||
raise HTTPException(status_code=402, detail=quota_result.error_message)
|
||||
|
||||
# Verify campaign exists and belongs to organization
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Check Dograh quota before starting campaign (apply per-workflow
|
||||
# model_overrides so we evaluate the keys this campaign will use).
|
||||
quota_result = await check_dograh_quota(user, workflow_id=campaign.workflow_id)
|
||||
if not quota_result.has_quota:
|
||||
raise HTTPException(status_code=402, detail=quota_result.error_message)
|
||||
|
||||
# Start the campaign using the runner service
|
||||
try:
|
||||
await campaign_runner_service.start_campaign(campaign_id)
|
||||
|
|
@ -734,28 +762,27 @@ async def resume_campaign(
|
|||
user: UserModel = Depends(get_user),
|
||||
) -> CampaignResponse:
|
||||
"""Resume a paused campaign"""
|
||||
# Check if organization has TELEPHONY_CONFIGURATION configured
|
||||
twilio_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
# Block resume if the org has no telephony configuration at all.
|
||||
configs = await db_client.list_telephony_configurations(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
if not twilio_config or not twilio_config.value:
|
||||
if not configs:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="You must configure telephony first by going to APP_URL/configure-telephony",
|
||||
)
|
||||
|
||||
# Check Dograh quota before resuming campaign
|
||||
quota_result = await check_dograh_quota(user)
|
||||
if not quota_result.has_quota:
|
||||
raise HTTPException(status_code=402, detail=quota_result.error_message)
|
||||
|
||||
# Verify campaign exists and belongs to organization
|
||||
campaign = await db_client.get_campaign(campaign_id, user.selected_organization_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Check Dograh quota before resuming campaign (apply per-workflow
|
||||
# model_overrides so we evaluate the keys this campaign will use).
|
||||
quota_result = await check_dograh_quota(user, workflow_id=campaign.workflow_id)
|
||||
if not quota_result.has_quota:
|
||||
raise HTTPException(status_code=402, detail=quota_result.error_message)
|
||||
|
||||
# Resume the campaign using the runner service
|
||||
try:
|
||||
await campaign_runner_service.resume_campaign(campaign_id)
|
||||
|
|
|
|||
|
|
@ -1,303 +1,672 @@
|
|||
from typing import List, Optional, Union
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from api.constants import DEFAULT_CAMPAIGN_RETRY_CONFIG, DEFAULT_ORG_CONCURRENCY_LIMIT
|
||||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.db.telephony_configuration_client import (
|
||||
TelephonyConfigurationDuplicateAccountError,
|
||||
TelephonyConfigurationInUseError,
|
||||
)
|
||||
from api.enums import OrganizationConfigurationKey, PostHogEvent
|
||||
from api.schemas.telephony_config import (
|
||||
ARIConfigurationRequest,
|
||||
ARIConfigurationResponse,
|
||||
CloudonixConfigurationRequest,
|
||||
CloudonixConfigurationResponse,
|
||||
PlivoConfigurationRequest,
|
||||
PlivoConfigurationResponse,
|
||||
TelephonyConfigRequest,
|
||||
TelephonyConfigurationCreateRequest,
|
||||
TelephonyConfigurationDetail,
|
||||
TelephonyConfigurationListItem,
|
||||
TelephonyConfigurationListResponse,
|
||||
TelephonyConfigurationResponse,
|
||||
TelnyxConfigurationRequest,
|
||||
TelnyxConfigurationResponse,
|
||||
TwilioConfigurationRequest,
|
||||
TwilioConfigurationResponse,
|
||||
VobizConfigurationRequest,
|
||||
VobizConfigurationResponse,
|
||||
VonageConfigurationRequest,
|
||||
VonageConfigurationResponse,
|
||||
TelephonyConfigurationUpdateRequest,
|
||||
)
|
||||
from api.schemas.telephony_phone_number import (
|
||||
PhoneNumberCreateRequest,
|
||||
PhoneNumberListResponse,
|
||||
PhoneNumberResponse,
|
||||
PhoneNumberUpdateRequest,
|
||||
ProviderSyncStatus,
|
||||
)
|
||||
from api.services.auth.depends import get_user
|
||||
from api.services.configuration.masking import is_mask_of, mask_key
|
||||
from api.services.posthog_client import capture_event
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
from api.services.telephony.factory import get_telephony_provider_by_id
|
||||
from api.services.worker_sync.manager import get_worker_sync_manager
|
||||
from api.services.worker_sync.protocol import WorkerSyncEventType
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
||||
router = APIRouter(prefix="/organizations", tags=["organizations"])
|
||||
|
||||
# Provider configuration constants
|
||||
PROVIDER_MASKED_FIELDS = {
|
||||
"twilio": ["account_sid", "auth_token"],
|
||||
"plivo": ["auth_id", "auth_token"],
|
||||
"vonage": ["private_key", "api_key", "api_secret"],
|
||||
"vobiz": ["auth_id", "auth_token"],
|
||||
"cloudonix": ["bearer_token"],
|
||||
"ari": ["app_password"],
|
||||
"telnyx": ["api_key"],
|
||||
}
|
||||
|
||||
def _sensitive_fields(provider_name: str) -> List[str]:
|
||||
"""Field names that should be masked when displaying stored config.
|
||||
|
||||
Sourced from ProviderUIField.sensitive in the registry — the same source
|
||||
of truth that drives the form-rendering UI.
|
||||
"""
|
||||
spec = telephony_registry.get_optional(provider_name)
|
||||
if spec is None or spec.ui_metadata is None:
|
||||
return []
|
||||
return [f.name for f in spec.ui_metadata.fields if f.sensitive]
|
||||
|
||||
|
||||
# TODO: Make endpoints provider-agnostic
|
||||
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
|
||||
async def get_telephony_configuration(user: UserModel = Depends(get_user)):
|
||||
"""Get telephony configuration for the user's organization with masked sensitive fields."""
|
||||
def _mask_sensitive(provider_name: str, value: dict) -> dict:
|
||||
"""Return a copy of ``value`` with sensitive fields masked for display."""
|
||||
out = dict(value)
|
||||
for field_name in _sensitive_fields(provider_name):
|
||||
v = out.get(field_name)
|
||||
if v:
|
||||
out[field_name] = mask_key(v)
|
||||
return out
|
||||
|
||||
|
||||
class TelephonyProviderUIField(BaseModel):
|
||||
"""One form field on a telephony provider's configuration UI."""
|
||||
|
||||
name: str
|
||||
label: str
|
||||
type: str
|
||||
required: bool
|
||||
sensitive: bool
|
||||
description: Optional[str] = None
|
||||
placeholder: Optional[str] = None
|
||||
|
||||
|
||||
class TelephonyProviderMetadata(BaseModel):
|
||||
"""UI form metadata for a single telephony provider."""
|
||||
|
||||
provider: str
|
||||
display_name: str
|
||||
fields: List[TelephonyProviderUIField]
|
||||
docs_url: Optional[str] = None
|
||||
|
||||
|
||||
class TelephonyProvidersMetadataResponse(BaseModel):
|
||||
"""List of UI form definitions used by the telephony-config screen."""
|
||||
|
||||
providers: List[TelephonyProviderMetadata]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-providers/metadata",
|
||||
response_model=TelephonyProvidersMetadataResponse,
|
||||
)
|
||||
async def get_telephony_providers_metadata(user: UserModel = Depends(get_user)):
|
||||
"""Return the list of available telephony providers and their form schemas.
|
||||
|
||||
The UI uses this to render the configuration form generically instead of
|
||||
hard-coding fields per provider. Adding a new provider only requires
|
||||
declaring its ui_metadata in providers/<name>/__init__.py.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
providers = []
|
||||
for spec in telephony_registry.all_specs():
|
||||
if spec.ui_metadata is None:
|
||||
continue
|
||||
providers.append(
|
||||
TelephonyProviderMetadata(
|
||||
provider=spec.name,
|
||||
display_name=spec.ui_metadata.display_name,
|
||||
fields=[
|
||||
TelephonyProviderUIField(
|
||||
name=f.name,
|
||||
label=f.label,
|
||||
type=f.type,
|
||||
required=f.required,
|
||||
sensitive=f.sensitive,
|
||||
description=f.description,
|
||||
placeholder=f.placeholder,
|
||||
)
|
||||
for f in spec.ui_metadata.fields
|
||||
],
|
||||
docs_url=spec.ui_metadata.docs_url,
|
||||
)
|
||||
)
|
||||
return TelephonyProvidersMetadataResponse(providers=providers)
|
||||
|
||||
|
||||
def _account_id_field(provider: str) -> str:
|
||||
"""The credential field that uniquely identifies the provider account.
|
||||
|
||||
Empty string for providers without an account-id concept (e.g. ARI).
|
||||
Drives the duplicate-account guard at save time and account-id matching
|
||||
at inbound webhook time.
|
||||
"""
|
||||
spec = telephony_registry.get_optional(provider)
|
||||
return spec.account_id_credential_field if spec else ""
|
||||
|
||||
|
||||
def preserve_masked_fields(provider: str, request_dict: dict, existing: dict):
|
||||
"""If the client re-submitted a masked sensitive field, restore the original."""
|
||||
for field_name in _sensitive_fields(provider):
|
||||
v = request_dict.get(field_name)
|
||||
if v and is_mask_of(v, existing.get(field_name, "")):
|
||||
request_dict[field_name] = existing[field_name]
|
||||
|
||||
|
||||
def _credentials_from_payload(config: TelephonyConfigRequest) -> dict:
|
||||
"""Provider credentials only — strip provider/from_numbers from the payload."""
|
||||
payload = config.model_dump()
|
||||
payload.pop("provider", None)
|
||||
payload.pop("from_numbers", None)
|
||||
return payload
|
||||
|
||||
|
||||
def _phone_number_to_response(
|
||||
row, inbound_workflow_name: Optional[str] = None
|
||||
) -> PhoneNumberResponse:
|
||||
response = PhoneNumberResponse.model_validate(row)
|
||||
response.inbound_workflow_name = inbound_workflow_name
|
||||
return response
|
||||
|
||||
|
||||
async def _sync_inbound_for_phone_number(
|
||||
config_id: int, address: str
|
||||
) -> ProviderSyncStatus:
|
||||
"""Push inbound webhook configuration to the provider.
|
||||
|
||||
``attach=True``: ask the provider to route this number's inbound calls
|
||||
to our workflow-agnostic dispatcher (``/api/v1/telephony/inbound/run``).
|
||||
``attach=False``: ask the provider to detach. The dispatcher resolves
|
||||
the workflow from the called number's ``inbound_workflow_id``, so the
|
||||
webhook URL is the same for every assignment — providers only need to
|
||||
bind/unbind the number, not rewrite per-workflow URLs.
|
||||
"""
|
||||
try:
|
||||
provider = await get_telephony_provider_by_id(config_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load telephony provider for config {config_id}: {e}")
|
||||
return ProviderSyncStatus(ok=False, message=f"Provider load failed: {e}")
|
||||
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/run"
|
||||
|
||||
try:
|
||||
result = await provider.configure_inbound(address, webhook_url)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Provider configure_inbound raised for config {config_id} "
|
||||
f"address {address}: {e}"
|
||||
)
|
||||
return ProviderSyncStatus(ok=False, message=f"Provider sync failed: {e}")
|
||||
|
||||
return ProviderSyncStatus(ok=result.ok, message=result.message)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-config CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/telephony-configs", response_model=TelephonyConfigurationListResponse)
|
||||
async def list_telephony_configurations(user: UserModel = Depends(get_user)):
|
||||
"""List the org's telephony configurations with phone-number counts."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
rows = await db_client.list_telephony_configurations(user.selected_organization_id)
|
||||
items: List[TelephonyConfigurationListItem] = []
|
||||
for row in rows:
|
||||
numbers = await db_client.list_phone_numbers_for_config(row.id)
|
||||
items.append(
|
||||
TelephonyConfigurationListItem(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
provider=row.provider,
|
||||
is_default_outbound=row.is_default_outbound,
|
||||
phone_number_count=len([n for n in numbers if n.is_active]),
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
)
|
||||
return TelephonyConfigurationListResponse(configurations=items)
|
||||
|
||||
|
||||
@router.post("/telephony-configs", response_model=TelephonyConfigurationDetail)
|
||||
async def create_telephony_configuration(
|
||||
request: TelephonyConfigurationCreateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Create a new telephony configuration for the org."""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
credentials = _credentials_from_payload(request.config)
|
||||
|
||||
try:
|
||||
row = await db_client.create_telephony_configuration(
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
provider=request.config.provider,
|
||||
credentials=credentials,
|
||||
is_default_outbound=request.is_default_outbound,
|
||||
account_id_credential_field=_account_id_field(request.config.provider),
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
except IntegrityError as e:
|
||||
raise HTTPException(status_code=409, detail=f"Duplicate name: {e}")
|
||||
|
||||
capture_event(
|
||||
distinct_id=str(user.provider_id),
|
||||
event=PostHogEvent.TELEPHONY_CONFIGURED,
|
||||
properties={
|
||||
"provider": request.config.provider,
|
||||
"organization_id": user.selected_organization_id,
|
||||
"config_id": row.id,
|
||||
},
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
return _detail_response(row)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-configs/{config_id}", response_model=TelephonyConfigurationDetail
|
||||
)
|
||||
async def get_telephony_configuration_by_id(
|
||||
config_id: int, user: UserModel = Depends(get_user)
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
row = await db_client.get_telephony_configuration_for_org(
|
||||
config_id, user.selected_organization_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
return _detail_response(row)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/telephony-configs/{config_id}", response_model=TelephonyConfigurationDetail
|
||||
)
|
||||
async def update_telephony_configuration(
|
||||
config_id: int,
|
||||
request: TelephonyConfigurationUpdateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
existing = await db_client.get_telephony_configuration_for_org(
|
||||
config_id, user.selected_organization_id
|
||||
)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
|
||||
credentials = None
|
||||
if request.config is not None:
|
||||
if request.config.provider != existing.provider:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provider cannot be changed; create a new configuration instead.",
|
||||
)
|
||||
credentials = _credentials_from_payload(request.config)
|
||||
preserve_masked_fields(
|
||||
existing.provider, credentials, existing.credentials or {}
|
||||
)
|
||||
|
||||
try:
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=config_id,
|
||||
organization_id=user.selected_organization_id,
|
||||
name=request.name,
|
||||
credentials=credentials,
|
||||
account_id_credential_field=_account_id_field(existing.provider),
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
return _detail_response(row)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telephony-configs/{config_id}/set-default-outbound",
|
||||
response_model=TelephonyConfigurationDetail,
|
||||
)
|
||||
async def set_default_outbound(config_id: int, user: UserModel = Depends(get_user)):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
row = await db_client.set_default_telephony_configuration(
|
||||
config_id, user.selected_organization_id
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
return _detail_response(row)
|
||||
|
||||
|
||||
@router.delete("/telephony-configs/{config_id}")
|
||||
async def delete_telephony_configuration(
|
||||
config_id: int, user: UserModel = Depends(get_user)
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
try:
|
||||
deleted = await db_client.delete_telephony_configuration(
|
||||
config_id, user.selected_organization_id
|
||||
)
|
||||
except TelephonyConfigurationInUseError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
return {"message": "Telephony configuration deleted"}
|
||||
|
||||
|
||||
def _detail_response(row) -> TelephonyConfigurationDetail:
|
||||
masked = _mask_sensitive(row.provider, row.credentials or {})
|
||||
return TelephonyConfigurationDetail(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
provider=row.provider,
|
||||
is_default_outbound=row.is_default_outbound,
|
||||
credentials=masked,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phone numbers (nested under a config)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _ensure_config_belongs_to_org(config_id: int, organization_id: int):
|
||||
cfg = await db_client.get_telephony_configuration_for_org(
|
||||
config_id, organization_id
|
||||
)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="Telephony configuration not found")
|
||||
return cfg
|
||||
|
||||
|
||||
async def _ensure_workflow_belongs_to_org(workflow_id: int, organization_id: int):
|
||||
workflow = await db_client.get_workflow(
|
||||
workflow_id, organization_id=organization_id
|
||||
)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail="Workflow not found")
|
||||
return workflow
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-configs/{config_id}/phone-numbers",
|
||||
response_model=PhoneNumberListResponse,
|
||||
)
|
||||
async def list_phone_numbers(config_id: int, user: UserModel = Depends(get_user)):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
rows = await db_client.list_phone_numbers_with_workflow_name_for_config(config_id)
|
||||
return PhoneNumberListResponse(
|
||||
phone_numbers=[_phone_number_to_response(r, name) for r, name in rows]
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telephony-configs/{config_id}/phone-numbers",
|
||||
response_model=PhoneNumberResponse,
|
||||
)
|
||||
async def create_phone_number(
|
||||
config_id: int,
|
||||
request: PhoneNumberCreateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
if request.inbound_workflow_id is not None:
|
||||
await _ensure_workflow_belongs_to_org(
|
||||
request.inbound_workflow_id, user.selected_organization_id
|
||||
)
|
||||
|
||||
try:
|
||||
row = await db_client.create_phone_number(
|
||||
organization_id=user.selected_organization_id,
|
||||
telephony_configuration_id=config_id,
|
||||
address=request.address,
|
||||
country_code=request.country_code,
|
||||
label=request.label,
|
||||
inbound_workflow_id=request.inbound_workflow_id,
|
||||
is_active=request.is_active,
|
||||
is_default_caller_id=request.is_default_caller_id,
|
||||
extra_metadata=request.extra_metadata,
|
||||
)
|
||||
except IntegrityError:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A phone number with this address already exists in the org.",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
response = _phone_number_to_response(row)
|
||||
if request.inbound_workflow_id is not None:
|
||||
response.provider_sync = await _sync_inbound_for_phone_number(
|
||||
config_id, row.address
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.get(
|
||||
"/telephony-configs/{config_id}/phone-numbers/{phone_number_id}",
|
||||
response_model=PhoneNumberResponse,
|
||||
)
|
||||
async def get_phone_number(
|
||||
config_id: int,
|
||||
phone_number_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
row = await db_client.get_phone_number_for_config(phone_number_id, config_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
return _phone_number_to_response(row)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/telephony-configs/{config_id}/phone-numbers/{phone_number_id}",
|
||||
response_model=PhoneNumberResponse,
|
||||
)
|
||||
async def update_phone_number(
|
||||
config_id: int,
|
||||
phone_number_id: int,
|
||||
request: PhoneNumberUpdateRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
existing = await db_client.get_phone_number_for_config(phone_number_id, config_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
|
||||
if request.inbound_workflow_id is not None:
|
||||
await _ensure_workflow_belongs_to_org(
|
||||
request.inbound_workflow_id, user.selected_organization_id
|
||||
)
|
||||
|
||||
row = await db_client.update_phone_number(
|
||||
phone_number_id=phone_number_id,
|
||||
telephony_configuration_id=config_id,
|
||||
label=request.label,
|
||||
inbound_workflow_id=request.inbound_workflow_id,
|
||||
is_active=request.is_active,
|
||||
country_code=request.country_code,
|
||||
extra_metadata=request.extra_metadata,
|
||||
clear_inbound_workflow=request.clear_inbound_workflow,
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
|
||||
response = _phone_number_to_response(row)
|
||||
|
||||
# Sync the provider application or address with the inbound
|
||||
# calling webhook address
|
||||
response.provider_sync = await _sync_inbound_for_phone_number(
|
||||
config_id, row.address
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.post(
|
||||
"/telephony-configs/{config_id}/phone-numbers/{phone_number_id}/set-default-caller",
|
||||
response_model=PhoneNumberResponse,
|
||||
)
|
||||
async def set_default_caller_id(
|
||||
config_id: int,
|
||||
phone_number_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
row = await db_client.set_default_caller_id(phone_number_id, config_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
return _phone_number_to_response(row)
|
||||
|
||||
|
||||
@router.delete("/telephony-configs/{config_id}/phone-numbers/{phone_number_id}")
|
||||
async def delete_phone_number(
|
||||
config_id: int,
|
||||
phone_number_id: int,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
await _ensure_config_belongs_to_org(config_id, user.selected_organization_id)
|
||||
|
||||
existing = await db_client.get_phone_number_for_config(phone_number_id, config_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
|
||||
deleted = await db_client.delete_phone_number(phone_number_id, config_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Phone number not found")
|
||||
|
||||
return {"message": "Phone number deleted"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy single-config shim
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/telephony-config", response_model=TelephonyConfigurationResponse)
|
||||
async def get_telephony_configuration(user: UserModel = Depends(get_user)):
|
||||
"""Legacy: returns the org's default config in the original per-provider
|
||||
response shape so the existing single-form UI keeps working. Prefer the
|
||||
multi-config endpoints (``/telephony-configs``) for new clients.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
cfg = await db_client.get_default_telephony_configuration(
|
||||
user.selected_organization_id
|
||||
)
|
||||
if not cfg:
|
||||
return TelephonyConfigurationResponse()
|
||||
|
||||
stored_provider = config.value.get("provider", "twilio")
|
||||
|
||||
if stored_provider == "twilio":
|
||||
account_sid = config.value.get("account_sid", "")
|
||||
auth_token = config.value.get("auth_token", "")
|
||||
from_numbers = (
|
||||
config.value.get("from_numbers", []) if account_sid and auth_token else []
|
||||
)
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=TwilioConfigurationResponse(
|
||||
provider="twilio",
|
||||
account_sid=mask_key(account_sid) if account_sid else "",
|
||||
auth_token=mask_key(auth_token) if auth_token else "",
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
plivo=None,
|
||||
vonage=None,
|
||||
vobiz=None,
|
||||
cloudonix=None,
|
||||
)
|
||||
elif stored_provider == "plivo":
|
||||
auth_id = config.value.get("auth_id", "")
|
||||
auth_token = config.value.get("auth_token", "")
|
||||
from_numbers = (
|
||||
config.value.get("from_numbers", []) if auth_id and auth_token else []
|
||||
)
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=None,
|
||||
plivo=PlivoConfigurationResponse(
|
||||
provider="plivo",
|
||||
auth_id=mask_key(auth_id) if auth_id else "",
|
||||
auth_token=mask_key(auth_token) if auth_token else "",
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
vonage=None,
|
||||
vobiz=None,
|
||||
cloudonix=None,
|
||||
)
|
||||
elif stored_provider == "vonage":
|
||||
application_id = config.value.get("application_id", "")
|
||||
private_key = config.value.get("private_key", "")
|
||||
api_key = config.value.get("api_key", "")
|
||||
api_secret = config.value.get("api_secret", "")
|
||||
from_numbers = (
|
||||
config.value.get("from_numbers", [])
|
||||
if application_id and private_key
|
||||
else []
|
||||
)
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=None,
|
||||
plivo=None,
|
||||
vonage=VonageConfigurationResponse(
|
||||
provider="vonage",
|
||||
application_id=application_id,
|
||||
private_key=mask_key(private_key) if private_key else "",
|
||||
api_key=mask_key(api_key) if api_key else None,
|
||||
api_secret=mask_key(api_secret) if api_secret else None,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
vobiz=None,
|
||||
cloudonix=None,
|
||||
)
|
||||
elif stored_provider == "vobiz":
|
||||
auth_id = config.value.get("auth_id", "")
|
||||
auth_token = config.value.get("auth_token", "")
|
||||
from_numbers = (
|
||||
config.value.get("from_numbers", []) if auth_id and auth_token else []
|
||||
)
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=None,
|
||||
plivo=None,
|
||||
vonage=None,
|
||||
vobiz=VobizConfigurationResponse(
|
||||
provider="vobiz",
|
||||
auth_id=mask_key(auth_id) if auth_id else "",
|
||||
auth_token=mask_key(auth_token) if auth_token else "",
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
cloudonix=None,
|
||||
)
|
||||
elif stored_provider == "cloudonix":
|
||||
bearer_token = config.value.get("bearer_token", "")
|
||||
domain_id = config.value.get("domain_id", "")
|
||||
from_numbers = config.value.get("from_numbers", [])
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
twilio=None,
|
||||
plivo=None,
|
||||
vonage=None,
|
||||
cloudonix=CloudonixConfigurationResponse(
|
||||
provider="cloudonix",
|
||||
bearer_token=mask_key(bearer_token) if bearer_token else "",
|
||||
domain_id=domain_id,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
vobiz=None,
|
||||
)
|
||||
elif stored_provider == "ari":
|
||||
ari_endpoint = config.value.get("ari_endpoint", "")
|
||||
app_name = config.value.get("app_name", "")
|
||||
app_password = config.value.get("app_password", "")
|
||||
ws_client_name = config.value.get("ws_client_name", "")
|
||||
from_numbers = config.value.get("from_numbers", [])
|
||||
|
||||
inbound_workflow_id = config.value.get("inbound_workflow_id")
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
ari=ARIConfigurationResponse(
|
||||
provider="ari",
|
||||
ari_endpoint=ari_endpoint,
|
||||
app_name=app_name,
|
||||
app_password=mask_key(app_password) if app_password else "",
|
||||
ws_client_name=ws_client_name,
|
||||
inbound_workflow_id=inbound_workflow_id,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
)
|
||||
elif stored_provider == "telnyx":
|
||||
api_key = config.value.get("api_key", "")
|
||||
connection_id = config.value.get("connection_id", "")
|
||||
from_numbers = config.value.get("from_numbers", [])
|
||||
|
||||
return TelephonyConfigurationResponse(
|
||||
telnyx=TelnyxConfigurationResponse(
|
||||
provider="telnyx",
|
||||
api_key=mask_key(api_key) if api_key else "",
|
||||
connection_id=connection_id,
|
||||
from_numbers=from_numbers,
|
||||
),
|
||||
)
|
||||
else:
|
||||
spec = telephony_registry.get_optional(cfg.provider)
|
||||
if spec is None:
|
||||
return TelephonyConfigurationResponse()
|
||||
|
||||
addresses = await db_client.list_active_normalized_addresses_for_config(cfg.id)
|
||||
masked = _mask_sensitive(cfg.provider, cfg.credentials or {})
|
||||
payload = {**masked, "provider": cfg.provider, "from_numbers": addresses}
|
||||
response_obj = spec.config_response_cls.model_validate(payload)
|
||||
return TelephonyConfigurationResponse(**{cfg.provider: response_obj})
|
||||
|
||||
|
||||
@router.post("/telephony-config")
|
||||
async def save_telephony_configuration(
|
||||
request: Union[
|
||||
TwilioConfigurationRequest,
|
||||
PlivoConfigurationRequest,
|
||||
VonageConfigurationRequest,
|
||||
VobizConfigurationRequest,
|
||||
CloudonixConfigurationRequest,
|
||||
ARIConfigurationRequest,
|
||||
TelnyxConfigurationRequest,
|
||||
],
|
||||
request: TelephonyConfigRequest,
|
||||
user: UserModel = Depends(get_user),
|
||||
):
|
||||
"""Save telephony configuration for the user's organization."""
|
||||
"""Legacy: upserts the org's default config (and its phone numbers) in the
|
||||
original payload shape so existing UI clients keep working. Prefer the
|
||||
multi-config + phone-number endpoints for new clients.
|
||||
"""
|
||||
if not user.selected_organization_id:
|
||||
raise HTTPException(status_code=400, detail="No organization selected")
|
||||
|
||||
# Fetch existing configuration to handle masked values
|
||||
existing_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
payload = request.model_dump()
|
||||
new_addresses = payload.pop("from_numbers", []) or []
|
||||
payload.pop("provider", None)
|
||||
field = _account_id_field(request.provider)
|
||||
|
||||
default = await db_client.get_default_telephony_configuration(
|
||||
user.selected_organization_id
|
||||
)
|
||||
|
||||
# Build single-provider configuration
|
||||
if request.provider == "twilio":
|
||||
config_value = {
|
||||
"provider": "twilio",
|
||||
"account_sid": request.account_sid,
|
||||
"auth_token": request.auth_token,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "plivo":
|
||||
config_value = {
|
||||
"provider": "plivo",
|
||||
"auth_id": request.auth_id,
|
||||
"auth_token": request.auth_token,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "vonage":
|
||||
config_value = {
|
||||
"provider": "vonage",
|
||||
"application_id": request.application_id,
|
||||
"private_key": request.private_key,
|
||||
"api_key": getattr(request, "api_key", None),
|
||||
"api_secret": getattr(request, "api_secret", None),
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "vobiz":
|
||||
config_value = {
|
||||
"provider": "vobiz",
|
||||
"auth_id": request.auth_id,
|
||||
"auth_token": request.auth_token,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "cloudonix":
|
||||
config_value = {
|
||||
"provider": "cloudonix",
|
||||
"bearer_token": request.bearer_token,
|
||||
"domain_id": request.domain_id,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "telnyx":
|
||||
config_value = {
|
||||
"provider": "telnyx",
|
||||
"api_key": request.api_key,
|
||||
"connection_id": request.connection_id,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
elif request.provider == "ari":
|
||||
config_value = {
|
||||
"provider": "ari",
|
||||
"ari_endpoint": request.ari_endpoint,
|
||||
"app_name": request.app_name,
|
||||
"app_password": request.app_password,
|
||||
"ws_client_name": request.ws_client_name,
|
||||
"inbound_workflow_id": request.inbound_workflow_id,
|
||||
"from_numbers": request.from_numbers,
|
||||
}
|
||||
if default and default.provider == request.provider:
|
||||
preserve_masked_fields(request.provider, payload, default.credentials or {})
|
||||
try:
|
||||
row = await db_client.update_telephony_configuration(
|
||||
config_id=default.id,
|
||||
organization_id=user.selected_organization_id,
|
||||
credentials=payload,
|
||||
account_id_credential_field=field,
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Unsupported provider: {request.provider}"
|
||||
)
|
||||
try:
|
||||
row = await db_client.create_telephony_configuration(
|
||||
organization_id=user.selected_organization_id,
|
||||
name=f"{request.provider.title()} Default",
|
||||
provider=request.provider,
|
||||
credentials=payload,
|
||||
is_default_outbound=True,
|
||||
account_id_credential_field=field,
|
||||
)
|
||||
except TelephonyConfigurationDuplicateAccountError as e:
|
||||
raise HTTPException(status_code=409, detail=str(e))
|
||||
|
||||
if existing_config and existing_config.value:
|
||||
existing_provider = existing_config.value.get("provider")
|
||||
|
||||
if existing_provider == request.provider:
|
||||
preserve_masked_fields(request, existing_config, config_value)
|
||||
|
||||
await db_client.upsert_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
config_value,
|
||||
)
|
||||
# Replace the phone-number set with the inline payload.
|
||||
existing_numbers = await db_client.list_phone_numbers_for_config(row.id)
|
||||
existing_by_address = {n.address: n for n in existing_numbers}
|
||||
incoming_set = set(new_addresses)
|
||||
for addr in new_addresses:
|
||||
if addr in existing_by_address:
|
||||
continue
|
||||
try:
|
||||
await db_client.create_phone_number(
|
||||
organization_id=user.selected_organization_id,
|
||||
telephony_configuration_id=row.id,
|
||||
address=addr,
|
||||
)
|
||||
except IntegrityError:
|
||||
logger.warning(
|
||||
f"Skipping duplicate phone number {addr!r} for config {row.id}"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Skipping invalid phone number {addr!r}: {e}")
|
||||
for n in existing_numbers:
|
||||
if n.address not in incoming_set:
|
||||
await db_client.delete_phone_number(n.id, row.id)
|
||||
|
||||
capture_event(
|
||||
distinct_id=str(user.provider_id),
|
||||
event=PostHogEvent.TELEPHONY_CONFIGURED,
|
||||
properties={
|
||||
"provider": request.provider,
|
||||
"phone_number_count": len(request.from_numbers),
|
||||
"phone_number_count": len(new_addresses),
|
||||
"organization_id": user.selected_organization_id,
|
||||
},
|
||||
)
|
||||
|
|
@ -305,20 +674,6 @@ async def save_telephony_configuration(
|
|||
return {"message": "Telephony configuration saved successfully"}
|
||||
|
||||
|
||||
def preserve_masked_fields(request, existing_config, config_value):
|
||||
provider = request.provider
|
||||
masked_fields = PROVIDER_MASKED_FIELDS.get(provider, [])
|
||||
|
||||
for field_name in masked_fields:
|
||||
if hasattr(request, field_name):
|
||||
field_value = getattr(request, field_name)
|
||||
# Check if field has a value and is a masked version of the existing value
|
||||
if field_value and is_mask_of(
|
||||
field_value, existing_config.value.get(field_name, "")
|
||||
):
|
||||
config_value[field_name] = existing_config.value[field_name]
|
||||
|
||||
|
||||
class LangfuseCredentialsRequest(BaseModel):
|
||||
host: str
|
||||
public_key: str
|
||||
|
|
@ -486,16 +841,18 @@ async def get_campaign_defaults(user: UserModel = Depends(get_user)):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Get from_numbers count from telephony configuration
|
||||
# Phone-number count from the org's default telephony config (used by the
|
||||
# campaign UI to validate max_concurrency against caller-id supply).
|
||||
from_numbers_count = 0
|
||||
try:
|
||||
telephony_config = await db_client.get_configuration(
|
||||
user.selected_organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
default_cfg = await db_client.get_default_telephony_configuration(
|
||||
user.selected_organization_id
|
||||
)
|
||||
if telephony_config and telephony_config.value:
|
||||
from_numbers = telephony_config.value.get("from_numbers", [])
|
||||
from_numbers_count = len(from_numbers)
|
||||
if default_cfg:
|
||||
addresses = await db_client.list_active_normalized_addresses_for_config(
|
||||
default_cfg.id
|
||||
)
|
||||
from_numbers_count = len(addresses)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ from pydantic import BaseModel
|
|||
from api.db import db_client
|
||||
from api.enums import TriggerState
|
||||
from api.services.quota_service import check_dograh_quota_by_user_id
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.services.telephony.factory import get_default_telephony_provider
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
||||
router = APIRouter(prefix="/public/agent")
|
||||
|
|
@ -83,8 +83,11 @@ async def _initiate_call(
|
|||
if trigger.state != TriggerState.ACTIVE.value:
|
||||
raise HTTPException(status_code=404, detail="Agent trigger is not active")
|
||||
|
||||
# 4.5 Check Dograh quota before initiating the call
|
||||
quota_result = await check_dograh_quota_by_user_id(api_key.created_by)
|
||||
# 4.5 Check Dograh quota before initiating the call (apply the trigger's
|
||||
# workflow's model_overrides so we evaluate the keys this run will use).
|
||||
quota_result = await check_dograh_quota_by_user_id(
|
||||
api_key.created_by, workflow_id=trigger.workflow_id
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
raise HTTPException(status_code=402, detail=quota_result.error_message)
|
||||
|
||||
|
|
@ -111,8 +114,14 @@ async def _initiate_call(
|
|||
detail="Trigger not found in the published Agent",
|
||||
)
|
||||
|
||||
# 6. Get telephony provider for the organization
|
||||
provider = await get_telephony_provider(trigger.organization_id)
|
||||
# 6. Get telephony provider for the organization (using its default config).
|
||||
try:
|
||||
provider = await get_default_telephony_provider(trigger.organization_id)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Telephony provider not configured for this organization",
|
||||
)
|
||||
|
||||
# Validate provider is configured
|
||||
if not provider.validate_config():
|
||||
|
|
@ -121,6 +130,10 @@ async def _initiate_call(
|
|||
detail="Telephony provider not configured for this organization",
|
||||
)
|
||||
|
||||
default_cfg = await db_client.get_default_telephony_configuration(
|
||||
trigger.organization_id
|
||||
)
|
||||
|
||||
# 7. Determine the workflow run mode based on provider type
|
||||
workflow_run_mode = provider.PROVIDER_NAME
|
||||
|
||||
|
|
@ -136,6 +149,7 @@ async def _initiate_call(
|
|||
"phone_number": request.phone_number,
|
||||
"agent_uuid": uuid,
|
||||
"trigger_mode": "test" if use_draft else "production",
|
||||
"telephony_configuration_id": default_cfg.id if default_cfg else None,
|
||||
**(request.initial_context or {}),
|
||||
},
|
||||
user_id=api_key.created_by,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -220,8 +220,9 @@ class SignalingManager:
|
|||
if org_id:
|
||||
set_current_org_id(org_id)
|
||||
|
||||
# Check Dograh quota before initiating the call
|
||||
quota_result = await check_dograh_quota(user)
|
||||
# Check Dograh quota before initiating the call (apply per-workflow
|
||||
# model_overrides so we evaluate the keys this workflow will use).
|
||||
quota_result = await check_dograh_quota(user, workflow_id=workflow_id)
|
||||
if not quota_result.has_quota:
|
||||
# Send error response for quota issues
|
||||
await ws.send_json(
|
||||
|
|
|
|||
|
|
@ -814,10 +814,15 @@ async def create_workflow_draft(
|
|||
@router.get("/summary")
|
||||
async def get_workflows_summary(
|
||||
user: UserModel = Depends(get_user),
|
||||
status: Optional[str] = Query(
|
||||
None,
|
||||
description="Filter by status (e.g. 'active' or 'archived'). Omit to return all.",
|
||||
),
|
||||
) -> List[WorkflowSummaryResponse]:
|
||||
"""Get minimal workflow information (id and name only) for all workflows"""
|
||||
workflows = await db_client.get_all_workflows(
|
||||
organization_id=user.selected_organization_id
|
||||
organization_id=user.selected_organization_id,
|
||||
status=status,
|
||||
)
|
||||
return [
|
||||
WorkflowSummaryResponse(id=workflow.id, name=workflow.name)
|
||||
|
|
|
|||
|
|
@ -1,174 +1,70 @@
|
|||
from typing import List, Optional
|
||||
"""Telephony configuration schemas.
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
Per-provider request/response classes live next to their providers in
|
||||
``api/services/telephony/providers/<name>/config.py``. This module re-exports
|
||||
them and assembles the discriminated union used by API routes.
|
||||
|
||||
Adding a new provider requires adding one import here.
|
||||
"""
|
||||
|
||||
class TwilioConfigurationRequest(BaseModel):
|
||||
"""Request schema for Twilio configuration."""
|
||||
from datetime import datetime
|
||||
from typing import Annotated, List, Optional, Union
|
||||
|
||||
provider: str = Field(default="twilio")
|
||||
account_sid: str = Field(..., description="Twilio Account SID")
|
||||
auth_token: str = Field(..., description="Twilio Auth Token")
|
||||
from_numbers: List[str] = Field(
|
||||
..., min_length=1, description="List of Twilio phone numbers"
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from api.services.telephony.providers.ari.config import (
|
||||
ARIConfigurationRequest,
|
||||
ARIConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony.providers.cloudonix.config import (
|
||||
CloudonixConfigurationRequest,
|
||||
CloudonixConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony.providers.plivo.config import (
|
||||
PlivoConfigurationRequest,
|
||||
PlivoConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony.providers.telnyx.config import (
|
||||
TelnyxConfigurationRequest,
|
||||
TelnyxConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony.providers.twilio.config import (
|
||||
TwilioConfigurationRequest,
|
||||
TwilioConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony.providers.vobiz.config import (
|
||||
VobizConfigurationRequest,
|
||||
VobizConfigurationResponse,
|
||||
)
|
||||
from api.services.telephony.providers.vonage.config import (
|
||||
VonageConfigurationRequest,
|
||||
VonageConfigurationResponse,
|
||||
)
|
||||
|
||||
class TwilioConfigurationResponse(BaseModel):
|
||||
"""Response schema for Twilio configuration with masked sensitive fields."""
|
||||
|
||||
provider: str
|
||||
account_sid: str # Masked (e.g., "****************def0")
|
||||
auth_token: str # Masked (e.g., "****************abc1")
|
||||
from_numbers: List[str]
|
||||
|
||||
|
||||
class PlivoConfigurationRequest(BaseModel):
|
||||
"""Request schema for Plivo configuration."""
|
||||
|
||||
provider: str = Field(default="plivo")
|
||||
auth_id: str = Field(..., description="Plivo Auth ID")
|
||||
auth_token: str = Field(..., description="Plivo Auth Token")
|
||||
from_numbers: List[str] = Field(
|
||||
..., min_length=1, description="List of Plivo phone numbers"
|
||||
)
|
||||
|
||||
|
||||
class PlivoConfigurationResponse(BaseModel):
|
||||
"""Response schema for Plivo configuration with masked sensitive fields."""
|
||||
|
||||
provider: str
|
||||
auth_id: str # Masked
|
||||
auth_token: str # Masked
|
||||
from_numbers: List[str]
|
||||
|
||||
|
||||
class VonageConfigurationRequest(BaseModel):
|
||||
"""Request schema for Vonage configuration."""
|
||||
|
||||
provider: str = Field(default="vonage")
|
||||
api_key: Optional[str] = Field(None, description="Vonage API Key")
|
||||
api_secret: Optional[str] = Field(None, description="Vonage API Secret")
|
||||
application_id: str = Field(..., description="Vonage Application ID")
|
||||
private_key: str = Field(..., description="Private key for JWT generation")
|
||||
from_numbers: List[str] = Field(
|
||||
..., min_length=1, description="List of Vonage phone numbers (without + prefix)"
|
||||
)
|
||||
|
||||
|
||||
class VonageConfigurationResponse(BaseModel):
|
||||
"""Response schema for Vonage configuration with masked sensitive fields."""
|
||||
|
||||
provider: str
|
||||
application_id: str # Not sensitive, can show full
|
||||
api_key: Optional[str] # Masked if present
|
||||
api_secret: Optional[str] # Masked if present
|
||||
private_key: str # Masked (shows only if configured)
|
||||
from_numbers: List[str]
|
||||
|
||||
|
||||
class VobizConfigurationRequest(BaseModel):
|
||||
"""Request schema for Vobiz configuration."""
|
||||
|
||||
provider: str = Field(default="vobiz")
|
||||
auth_id: str = Field(..., description="Vobiz Account ID (e.g., MA_SYQRLN1K)")
|
||||
auth_token: str = Field(..., description="Vobiz Auth Token")
|
||||
from_numbers: List[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="List of Vobiz phone numbers (E.164 without + prefix)",
|
||||
)
|
||||
|
||||
|
||||
class VobizConfigurationResponse(BaseModel):
|
||||
"""Response schema for Vobiz configuration with masked sensitive fields."""
|
||||
|
||||
provider: str
|
||||
auth_id: str # Masked (e.g., "****************L1NK")
|
||||
auth_token: str # Masked (e.g., "****************KEFO")
|
||||
from_numbers: List[str]
|
||||
|
||||
|
||||
class CloudonixConfigurationRequest(BaseModel):
|
||||
"""Request schema for Cloudonix configuration."""
|
||||
|
||||
provider: str = Field(default="cloudonix")
|
||||
bearer_token: str = Field(..., description="Cloudonix API Bearer Token")
|
||||
domain_id: str = Field(..., description="Cloudonix Domain ID")
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list, description="List of Cloudonix phone numbers (optional)"
|
||||
)
|
||||
|
||||
|
||||
class CloudonixConfigurationResponse(BaseModel):
|
||||
"""Response schema for Cloudonix configuration with masked sensitive fields."""
|
||||
|
||||
provider: str
|
||||
bearer_token: str # Masked (e.g., "****************abc1")
|
||||
domain_id: str # Not sensitive, can show full
|
||||
from_numbers: List[str]
|
||||
|
||||
|
||||
class ARIConfigurationRequest(BaseModel):
|
||||
"""Request schema for Asterisk ARI configuration."""
|
||||
|
||||
provider: str = Field(default="ari")
|
||||
ari_endpoint: str = Field(
|
||||
..., description="ARI base URL (e.g., http://asterisk.example.com:8088)"
|
||||
)
|
||||
app_name: str = Field(
|
||||
..., description="Stasis application name registered in Asterisk"
|
||||
)
|
||||
app_password: str = Field(..., description="ARI user password")
|
||||
ws_client_name: str = Field(
|
||||
default="",
|
||||
description="websocket_client.conf connection name for externalMedia (e.g., dograh_staging)",
|
||||
)
|
||||
inbound_workflow_id: Optional[int] = Field(
|
||||
default=None, description="Workflow ID for inbound calls"
|
||||
)
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of SIP extensions/numbers for outbound calls (optional)",
|
||||
)
|
||||
|
||||
|
||||
class ARIConfigurationResponse(BaseModel):
|
||||
"""Response schema for ARI configuration with masked sensitive fields."""
|
||||
|
||||
provider: str
|
||||
ari_endpoint: str
|
||||
app_name: str
|
||||
app_password: str # Masked
|
||||
ws_client_name: str = ""
|
||||
inbound_workflow_id: Optional[int] = None
|
||||
from_numbers: List[str]
|
||||
|
||||
|
||||
class TelnyxConfigurationRequest(BaseModel):
|
||||
"""Request schema for Telnyx configuration."""
|
||||
|
||||
provider: str = Field(default="telnyx")
|
||||
api_key: str = Field(..., description="Telnyx API Key")
|
||||
connection_id: str = Field(
|
||||
..., description="Telnyx Call Control Application ID (connection_id)"
|
||||
)
|
||||
from_numbers: List[str] = Field(
|
||||
..., min_length=1, description="List of Telnyx phone numbers (E.164 format)"
|
||||
)
|
||||
|
||||
|
||||
class TelnyxConfigurationResponse(BaseModel):
|
||||
"""Response schema for Telnyx configuration with masked sensitive fields."""
|
||||
|
||||
provider: str
|
||||
api_key: str # Masked
|
||||
connection_id: str
|
||||
from_numbers: List[str]
|
||||
# Discriminated union for incoming save requests. Pydantic dispatches on the
|
||||
# ``provider`` Literal field of each request class. Replaces the manual
|
||||
# if/elif chains that used to live in routes/organization.py.
|
||||
TelephonyConfigRequest = Annotated[
|
||||
Union[
|
||||
ARIConfigurationRequest,
|
||||
CloudonixConfigurationRequest,
|
||||
PlivoConfigurationRequest,
|
||||
TelnyxConfigurationRequest,
|
||||
TwilioConfigurationRequest,
|
||||
VobizConfigurationRequest,
|
||||
VonageConfigurationRequest,
|
||||
],
|
||||
Field(discriminator="provider"),
|
||||
]
|
||||
|
||||
|
||||
class TelephonyConfigurationResponse(BaseModel):
|
||||
"""Top-level telephony configuration response."""
|
||||
"""Top-level telephony configuration response.
|
||||
|
||||
Keeps the per-provider field shape that the UI client depends on. When
|
||||
the UI moves to metadata-driven forms, this can be replaced with a
|
||||
flat discriminated union.
|
||||
"""
|
||||
|
||||
twilio: Optional[TwilioConfigurationResponse] = None
|
||||
plivo: Optional[PlivoConfigurationResponse] = None
|
||||
|
|
@ -177,3 +73,79 @@ class TelephonyConfigurationResponse(BaseModel):
|
|||
cloudonix: Optional[CloudonixConfigurationResponse] = None
|
||||
ari: Optional[ARIConfigurationResponse] = None
|
||||
telnyx: Optional[TelnyxConfigurationResponse] = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Multi-config CRUD schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TelephonyConfigurationCreateRequest(BaseModel):
|
||||
"""Body for ``POST /telephony-configs``.
|
||||
|
||||
``config`` carries the provider-specific credential fields (the same
|
||||
discriminated union used by the legacy single-config endpoint). Any
|
||||
``from_numbers`` on the inner config are ignored — phone numbers are
|
||||
managed via the dedicated phone-numbers endpoints.
|
||||
"""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=64)
|
||||
is_default_outbound: bool = False
|
||||
config: TelephonyConfigRequest
|
||||
|
||||
|
||||
class TelephonyConfigurationUpdateRequest(BaseModel):
|
||||
"""Body for ``PUT /telephony-configs/{id}``. Partial update."""
|
||||
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=64)
|
||||
config: Optional[TelephonyConfigRequest] = None
|
||||
|
||||
|
||||
class TelephonyConfigurationListItem(BaseModel):
|
||||
"""One row in ``GET /telephony-configs``."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
provider: str
|
||||
is_default_outbound: bool
|
||||
phone_number_count: int = 0
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TelephonyConfigurationDetail(BaseModel):
|
||||
"""Body of ``GET /telephony-configs/{id}`` — credentials are masked."""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
provider: str
|
||||
is_default_outbound: bool
|
||||
credentials: dict
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TelephonyConfigurationListResponse(BaseModel):
|
||||
configurations: List[TelephonyConfigurationListItem]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ARIConfigurationRequest",
|
||||
"ARIConfigurationResponse",
|
||||
"CloudonixConfigurationRequest",
|
||||
"CloudonixConfigurationResponse",
|
||||
"PlivoConfigurationRequest",
|
||||
"PlivoConfigurationResponse",
|
||||
"TelephonyConfigRequest",
|
||||
"TelephonyConfigurationResponse",
|
||||
"TelnyxConfigurationRequest",
|
||||
"TelnyxConfigurationResponse",
|
||||
"TwilioConfigurationRequest",
|
||||
"TwilioConfigurationResponse",
|
||||
"VobizConfigurationRequest",
|
||||
"VobizConfigurationResponse",
|
||||
"VonageConfigurationRequest",
|
||||
"VonageConfigurationResponse",
|
||||
]
|
||||
|
|
|
|||
75
api/schemas/telephony_phone_number.py
Normal file
75
api/schemas/telephony_phone_number.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Request/response schemas for the phone-number CRUD endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PhoneNumberCreateRequest(BaseModel):
|
||||
"""Create a new phone number under a telephony configuration.
|
||||
|
||||
``address_normalized`` and ``address_type`` are computed server-side from
|
||||
``address`` (and ``country_code`` if PSTN). ``address`` itself is stored
|
||||
verbatim for display.
|
||||
"""
|
||||
|
||||
address: str = Field(..., min_length=1, max_length=255)
|
||||
country_code: Optional[str] = Field(default=None, min_length=2, max_length=2)
|
||||
label: Optional[str] = Field(default=None, max_length=64)
|
||||
inbound_workflow_id: Optional[int] = None
|
||||
is_active: bool = True
|
||||
is_default_caller_id: bool = False
|
||||
extra_metadata: Dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class PhoneNumberUpdateRequest(BaseModel):
|
||||
"""Partial update. ``address`` is intentionally immutable — to change a
|
||||
number, delete the row and create a new one."""
|
||||
|
||||
label: Optional[str] = Field(default=None, max_length=64)
|
||||
inbound_workflow_id: Optional[int] = None
|
||||
# Set to true to clear inbound_workflow_id (FK is otherwise non-nullable
|
||||
# via the partial-update pattern).
|
||||
clear_inbound_workflow: bool = False
|
||||
is_active: Optional[bool] = None
|
||||
country_code: Optional[str] = Field(default=None, min_length=2, max_length=2)
|
||||
extra_metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class ProviderSyncStatus(BaseModel):
|
||||
"""Result of pushing a phone-number change to the upstream provider.
|
||||
|
||||
Returned alongside create/update responses when the route attempted to
|
||||
sync inbound webhook configuration. ``ok=False`` is a warning, not a
|
||||
fatal error — the DB write succeeded.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class PhoneNumberResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
telephony_configuration_id: int
|
||||
address: str
|
||||
address_normalized: str
|
||||
address_type: str
|
||||
country_code: Optional[str] = None
|
||||
label: Optional[str] = None
|
||||
inbound_workflow_id: Optional[int] = None
|
||||
inbound_workflow_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_default_caller_id: bool
|
||||
extra_metadata: Dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
# Only set on create/update responses when the route attempted a
|
||||
# provider-side sync (e.g. setting Twilio's VoiceUrl). Omitted on reads.
|
||||
provider_sync: Optional[ProviderSyncStatus] = None
|
||||
|
||||
|
||||
class PhoneNumberListResponse(BaseModel):
|
||||
phone_numbers: list[PhoneNumberResponse]
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import asyncio
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
|
|
@ -15,10 +15,15 @@ from api.services.campaign.errors import (
|
|||
PhoneNumberPoolExhaustedError,
|
||||
)
|
||||
from api.services.campaign.rate_limiter import rate_limiter
|
||||
from api.services.telephony.base import TelephonyProvider
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Type-only — importing api.services.telephony eagerly triggers the
|
||||
# provider package init, which can pull in this module via the routes
|
||||
# chain and create a circular import. Runtime calls below go through
|
||||
# ``factory.get_telephony_provider`` (lazy import inside the method).
|
||||
from api.services.telephony.base import TelephonyProvider
|
||||
|
||||
|
||||
class CampaignCallDispatcher:
|
||||
"""Manages rate-limited and concurrent-limited call dispatching"""
|
||||
|
|
@ -26,9 +31,30 @@ class CampaignCallDispatcher:
|
|||
def __init__(self):
|
||||
self.default_concurrent_limit = int(DEFAULT_ORG_CONCURRENCY_LIMIT)
|
||||
|
||||
async def get_telephony_provider(self, organization_id: int) -> TelephonyProvider:
|
||||
"""Get telephony provider instance for specific organization"""
|
||||
return await get_telephony_provider(organization_id)
|
||||
async def get_telephony_provider(self, organization_id: int) -> "TelephonyProvider":
|
||||
"""Get telephony provider instance for specific organization (default config)."""
|
||||
from api.services.telephony.factory import get_default_telephony_provider
|
||||
|
||||
return await get_default_telephony_provider(organization_id)
|
||||
|
||||
async def get_provider_for_campaign(self, campaign) -> "TelephonyProvider":
|
||||
"""Get the telephony provider pinned to this campaign's config. Falls back
|
||||
to the org's default config for legacy campaigns whose
|
||||
``telephony_configuration_id`` was never backfilled."""
|
||||
from api.services.telephony.factory import (
|
||||
get_default_telephony_provider,
|
||||
get_telephony_provider_by_id,
|
||||
)
|
||||
|
||||
if campaign.telephony_configuration_id:
|
||||
return await get_telephony_provider_by_id(
|
||||
campaign.telephony_configuration_id
|
||||
)
|
||||
logger.warning(
|
||||
f"Campaign {campaign.id} has no telephony_configuration_id; "
|
||||
f"falling back to org default for {campaign.organization_id}"
|
||||
)
|
||||
return await get_default_telephony_provider(campaign.organization_id)
|
||||
|
||||
async def get_org_concurrent_limit(self, organization_id: int) -> int:
|
||||
"""Get the concurrent call limit for an organization."""
|
||||
|
|
@ -75,9 +101,9 @@ class CampaignCallDispatcher:
|
|||
logger.info(f"No more queued runs for campaign {campaign_id}")
|
||||
return 0
|
||||
|
||||
# Initialize from_number pool for this org's provider
|
||||
# Initialize from_number pool for this campaign's telephony config.
|
||||
try:
|
||||
provider = await self.get_telephony_provider(campaign.organization_id)
|
||||
provider = await self.get_provider_for_campaign(campaign)
|
||||
if provider.from_numbers:
|
||||
await rate_limiter.initialize_from_number_pool(
|
||||
campaign.organization_id, provider.from_numbers
|
||||
|
|
@ -180,8 +206,8 @@ class CampaignCallDispatcher:
|
|||
)
|
||||
raise ValueError(f"No phone number in queued run {queued_run.id}")
|
||||
|
||||
# Get provider first to determine the mode
|
||||
provider = await self.get_telephony_provider(campaign.organization_id)
|
||||
# Get provider for this campaign's pinned telephony config.
|
||||
provider = await self.get_provider_for_campaign(campaign)
|
||||
workflow_run_mode = provider.PROVIDER_NAME
|
||||
|
||||
# Acquire a unique from_number from the pool
|
||||
|
|
@ -206,6 +232,7 @@ class CampaignCallDispatcher:
|
|||
"source_uuid": queued_run.source_uuid,
|
||||
"caller_number": from_number,
|
||||
"called_number": phone_number,
|
||||
"telephony_configuration_id": campaign.telephony_configuration_id,
|
||||
}
|
||||
|
||||
logger.info(f"Final initial_context: {initial_context}")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
"""
|
||||
Audio configuration for pipeline components.
|
||||
"""Audio configuration for pipeline components.
|
||||
|
||||
This module provides centralized audio configuration to ensure consistent
|
||||
sample rates across all pipeline components and proper coordination between
|
||||
|
|
@ -11,8 +10,6 @@ from typing import Optional
|
|||
|
||||
from loguru import logger
|
||||
|
||||
from api.enums import WorkflowRunMode
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioConfig:
|
||||
|
|
@ -84,61 +81,35 @@ class AudioConfig:
|
|||
|
||||
|
||||
def create_audio_config(transport_type: str) -> AudioConfig:
|
||||
"""Create audio configuration based on transport type.
|
||||
"""Create audio configuration for a given transport.
|
||||
|
||||
Args:
|
||||
transport_type: Type of transport ("webrtc", "twilio", "plivo", "vonage", "vobiz", "cloudonix")
|
||||
|
||||
Returns:
|
||||
AudioConfig instance with appropriate settings
|
||||
Telephony providers contribute their wire-format sample rate through the
|
||||
provider registry (``ProviderSpec.transport_sample_rate``); WebRTC modes
|
||||
use 16 kHz (transports handle resampling from/to 24 kHz). The remaining
|
||||
AudioConfig fields are derived from the chosen rate.
|
||||
"""
|
||||
if transport_type in (
|
||||
WorkflowRunMode.TWILIO.value,
|
||||
WorkflowRunMode.PLIVO.value,
|
||||
WorkflowRunMode.VOBIZ.value,
|
||||
WorkflowRunMode.CLOUDONIX.value,
|
||||
WorkflowRunMode.ARI.value,
|
||||
WorkflowRunMode.TELNYX.value,
|
||||
):
|
||||
# Twilio, Plivo, Cloudonix, Vobiz, Telnyx, and ARI use MULAW at 8kHz
|
||||
return AudioConfig(
|
||||
transport_in_sample_rate=8000,
|
||||
transport_out_sample_rate=8000,
|
||||
vad_sample_rate=8000, # Use matching VAD rate
|
||||
pipeline_sample_rate=8000, # Keep at 8kHz to avoid resampling
|
||||
buffer_size_seconds=5.0,
|
||||
)
|
||||
elif transport_type == WorkflowRunMode.VONAGE.value:
|
||||
# Vonage uses 16kHz Linear PCM
|
||||
return AudioConfig(
|
||||
transport_in_sample_rate=16000,
|
||||
transport_out_sample_rate=16000,
|
||||
vad_sample_rate=16000, # Use matching VAD rate
|
||||
pipeline_sample_rate=16000, # Keep at 16kHz to avoid resampling
|
||||
buffer_size_seconds=5.0,
|
||||
)
|
||||
elif transport_type in [
|
||||
# Defer registry import to avoid an import cycle: the registry is imported
|
||||
# by every telephony provider package at startup.
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.telephony import registry
|
||||
|
||||
telephony_spec = registry.get_optional(transport_type)
|
||||
if telephony_spec is not None:
|
||||
rate = telephony_spec.transport_sample_rate
|
||||
elif transport_type in (
|
||||
WorkflowRunMode.WEBRTC.value,
|
||||
WorkflowRunMode.SMALLWEBRTC.value,
|
||||
]:
|
||||
# WebRTC typically uses 24kHz or 48kHz, but we limit pipeline to 16kHz
|
||||
# The transport will handle resampling between 24kHz and 16kHz
|
||||
return AudioConfig(
|
||||
transport_in_sample_rate=16000, # Transport will resample from 24kHz
|
||||
transport_out_sample_rate=16000, # Transport will resample to 24kHz
|
||||
vad_sample_rate=16000, # VAD native rate
|
||||
pipeline_sample_rate=16000, # Keep pipeline at 16kHz
|
||||
buffer_size_seconds=5.0,
|
||||
)
|
||||
):
|
||||
rate = 16000
|
||||
else:
|
||||
# Default configuration
|
||||
logger.warning(
|
||||
f"Unknown transport type: {transport_type}, using default config"
|
||||
)
|
||||
return AudioConfig(
|
||||
transport_in_sample_rate=16000,
|
||||
transport_out_sample_rate=16000,
|
||||
vad_sample_rate=16000,
|
||||
pipeline_sample_rate=16000,
|
||||
buffer_size_seconds=5.0,
|
||||
)
|
||||
rate = 16000
|
||||
|
||||
return AudioConfig(
|
||||
transport_in_sample_rate=rate,
|
||||
transport_out_sample_rate=rate,
|
||||
vad_sample_rate=rate,
|
||||
pipeline_sample_rate=rate,
|
||||
)
|
||||
|
|
|
|||
55
api/services/pipecat/audio_mixer.py
Normal file
55
api/services/pipecat/audio_mixer.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Shared helper for building audio output mixers used by telephony transports."""
|
||||
|
||||
import os
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from api.constants import APP_ROOT_DIR
|
||||
from api.services.pipecat.audio_file_cache import get_cached_ambient_noise_path
|
||||
from pipecat.audio.mixers.silence_mixer import SilenceAudioMixer
|
||||
from pipecat.audio.mixers.soundfile_mixer import SoundfileMixer
|
||||
|
||||
librnnoise_path = os.path.normpath(
|
||||
str(APP_ROOT_DIR / "native" / "rnnoise" / "librnnoise.so")
|
||||
)
|
||||
|
||||
|
||||
async def build_audio_out_mixer(
|
||||
audio_out_sample_rate: int,
|
||||
ambient_noise_config: dict | None,
|
||||
):
|
||||
"""Build the audio output mixer based on the ambient noise configuration.
|
||||
|
||||
Returns a ``SoundfileMixer`` when ambient noise is enabled, or a
|
||||
``SilenceAudioMixer`` otherwise. Supports custom user-uploaded audio
|
||||
files via the ``storage_key`` / ``storage_backend`` fields in the config.
|
||||
"""
|
||||
if not ambient_noise_config or not ambient_noise_config.get("enabled", False):
|
||||
return SilenceAudioMixer()
|
||||
|
||||
volume = ambient_noise_config.get("volume", 0.3)
|
||||
|
||||
storage_key = ambient_noise_config.get("storage_key")
|
||||
storage_backend = ambient_noise_config.get("storage_backend")
|
||||
|
||||
if storage_key and storage_backend:
|
||||
cached_path = await get_cached_ambient_noise_path(
|
||||
storage_key, storage_backend, audio_out_sample_rate
|
||||
)
|
||||
if cached_path:
|
||||
return SoundfileMixer(
|
||||
sound_files={"custom": cached_path},
|
||||
default_sound="custom",
|
||||
volume=volume,
|
||||
)
|
||||
logger.warning("Custom ambient noise file unavailable, falling back to default")
|
||||
|
||||
return SoundfileMixer(
|
||||
sound_files={
|
||||
"office": APP_ROOT_DIR
|
||||
/ "assets"
|
||||
/ f"office-ambience-{audio_out_sample_rate}-mono.wav"
|
||||
},
|
||||
default_sound="office",
|
||||
volume=volume,
|
||||
)
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import asyncio
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import HTTPException, WebSocket
|
||||
from fastapi import HTTPException
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.db.models import WorkflowModel
|
||||
from api.enums import WorkflowRunMode
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.pipecat.audio_config import AudioConfig, create_audio_config
|
||||
|
|
@ -44,17 +43,9 @@ from api.services.pipecat.service_factory import (
|
|||
from api.services.pipecat.tracing_config import (
|
||||
ensure_tracing,
|
||||
)
|
||||
from api.services.pipecat.transport_setup import (
|
||||
create_ari_transport,
|
||||
create_cloudonix_transport,
|
||||
create_plivo_transport,
|
||||
create_telnyx_transport,
|
||||
create_twilio_transport,
|
||||
create_vobiz_transport,
|
||||
create_vonage_transport,
|
||||
create_webrtc_transport,
|
||||
)
|
||||
from api.services.pipecat.transport_setup import create_webrtc_transport
|
||||
from api.services.pipecat.ws_sender_registry import get_ws_sender
|
||||
from api.services.telephony import registry as telephony_registry
|
||||
from api.services.workflow.dto import ReactFlowDTO
|
||||
from api.services.workflow.pipecat_engine import PipecatEngine
|
||||
from api.services.workflow.workflow import WorkflowGraph
|
||||
|
|
@ -95,110 +86,75 @@ from pipecat.utils.run_context import set_current_org_id, set_current_run_id
|
|||
ensure_tracing()
|
||||
|
||||
|
||||
async def run_pipeline_twilio(
|
||||
websocket_client: WebSocket,
|
||||
stream_sid: str,
|
||||
call_sid: str,
|
||||
async def run_pipeline_telephony(
|
||||
websocket,
|
||||
*,
|
||||
provider_name: str,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
call_id: str,
|
||||
transport_kwargs: dict,
|
||||
) -> None:
|
||||
"""Run pipeline for Twilio connections"""
|
||||
logger.debug(
|
||||
f"Running pipeline for Twilio connection with workflow_id: {workflow_id} and workflow_run_id: {workflow_run_id}"
|
||||
)
|
||||
"""Run a pipeline for any telephony provider.
|
||||
|
||||
Replaces the previous per-provider run_pipeline_<x> functions. The
|
||||
provider's transport factory and audio config are looked up from the
|
||||
registry, so adding a new provider requires no changes here.
|
||||
|
||||
Args:
|
||||
websocket: The accepted WebSocket from the provider.
|
||||
provider_name: Stable identifier of the provider (registry key).
|
||||
workflow_id: Workflow being executed.
|
||||
workflow_run_id: Workflow run row.
|
||||
user_id: Owner of the workflow.
|
||||
call_id: Provider call identifier (stored in cost_info for billing).
|
||||
transport_kwargs: Provider-specific kwargs forwarded to the transport
|
||||
factory (e.g. stream_sid + call_sid for Twilio).
|
||||
"""
|
||||
logger.debug(f"Running {provider_name} pipeline for workflow_run {workflow_run_id}")
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
# Store call ID in cost_info for later cost calculation (provider-agnostic)
|
||||
cost_info = {"call_id": call_sid}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info={"call_id": call_id})
|
||||
|
||||
# Get workflow to extract all pipeline configurations
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
if "vad_configuration" in workflow.workflow_configurations:
|
||||
vad_config = workflow.workflow_configurations["vad_configuration"]
|
||||
if "ambient_noise_configuration" in workflow.workflow_configurations:
|
||||
ambient_noise_config = workflow.workflow_configurations[
|
||||
"ambient_noise_configuration"
|
||||
]
|
||||
vad_config = workflow.workflow_configurations.get("vad_configuration")
|
||||
ambient_noise_config = workflow.workflow_configurations.get(
|
||||
"ambient_noise_configuration"
|
||||
)
|
||||
|
||||
# Create audio configuration for Twilio
|
||||
audio_config = create_audio_config(WorkflowRunMode.TWILIO.value)
|
||||
# The telephony config id is stamped on the workflow run when it's created
|
||||
# (test call, campaign dispatch, inbound). Transports use it to load creds
|
||||
# from the right config row. Falls back to None for legacy runs (transports
|
||||
# then resolve the org's default config).
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id)
|
||||
telephony_configuration_id = None
|
||||
if workflow_run and workflow_run.initial_context:
|
||||
telephony_configuration_id = workflow_run.initial_context.get(
|
||||
"telephony_configuration_id"
|
||||
)
|
||||
|
||||
transport = await create_twilio_transport(
|
||||
websocket_client,
|
||||
stream_sid,
|
||||
call_sid,
|
||||
spec = telephony_registry.get(provider_name)
|
||||
audio_config = create_audio_config(provider_name)
|
||||
|
||||
transport = await spec.transport_factory(
|
||||
websocket,
|
||||
workflow_run_id,
|
||||
audio_config,
|
||||
workflow.organization_id,
|
||||
vad_config,
|
||||
ambient_noise_config,
|
||||
vad_config=vad_config,
|
||||
ambient_noise_config=ambient_noise_config,
|
||||
telephony_configuration_id=telephony_configuration_id,
|
||||
**transport_kwargs,
|
||||
)
|
||||
await _run_pipeline(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
audio_config=audio_config,
|
||||
)
|
||||
|
||||
|
||||
async def run_pipeline_plivo(
|
||||
websocket_client: WebSocket,
|
||||
stream_id: str,
|
||||
call_id: str,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Run pipeline for Plivo WebSocket connections."""
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Starting Plivo pipeline - "
|
||||
f"stream_id={stream_id}, call_id={call_id}, workflow_id={workflow_id}"
|
||||
)
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
cost_info = {"call_id": call_id}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
if "vad_configuration" in workflow.workflow_configurations:
|
||||
vad_config = workflow.workflow_configurations["vad_configuration"]
|
||||
if "ambient_noise_configuration" in workflow.workflow_configurations:
|
||||
ambient_noise_config = workflow.workflow_configurations[
|
||||
"ambient_noise_configuration"
|
||||
]
|
||||
|
||||
try:
|
||||
audio_config = create_audio_config(WorkflowRunMode.PLIVO.value)
|
||||
|
||||
transport = await create_plivo_transport(
|
||||
websocket_client,
|
||||
stream_id,
|
||||
call_id,
|
||||
workflow_run_id,
|
||||
audio_config,
|
||||
workflow.organization_id,
|
||||
vad_config,
|
||||
ambient_noise_config,
|
||||
)
|
||||
|
||||
await _run_pipeline(
|
||||
transport,
|
||||
workflow_id,
|
||||
|
|
@ -206,341 +162,14 @@ async def run_pipeline_plivo(
|
|||
user_id,
|
||||
audio_config=audio_config,
|
||||
)
|
||||
logger.info(f"[run {workflow_run_id}] Plivo pipeline completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[run {workflow_run_id}] Error in Plivo pipeline: {e}", exc_info=True
|
||||
f"[run {workflow_run_id}] Error in {provider_name} pipeline: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def run_pipeline_vonage(
|
||||
websocket_client,
|
||||
call_uuid: str,
|
||||
workflow: WorkflowModel,
|
||||
organization_id: int,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
):
|
||||
"""Run pipeline for Vonage WebSocket connections.
|
||||
|
||||
Vonage uses raw PCM audio over WebSocket instead of base64-encoded μ-law.
|
||||
The audio is transmitted as binary frames at 16kHz by default.
|
||||
"""
|
||||
logger.info(f"Starting Vonage pipeline for workflow run {workflow_run_id}")
|
||||
set_current_run_id(workflow_run_id)
|
||||
set_current_org_id(organization_id)
|
||||
|
||||
# Store call ID in cost_info for later cost calculation (provider-agnostic)
|
||||
cost_info = {"call_id": call_uuid}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
# Extract VAD and ambient noise config from workflow
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
if "vad_configuration" in workflow.workflow_configurations:
|
||||
vad_config = workflow.workflow_configurations["vad_configuration"]
|
||||
if "ambient_noise_configuration" in workflow.workflow_configurations:
|
||||
ambient_noise_config = workflow.workflow_configurations[
|
||||
"ambient_noise_configuration"
|
||||
]
|
||||
|
||||
try:
|
||||
# Setup audio config for Vonage using the centralized config
|
||||
audio_config = create_audio_config(WorkflowRunMode.VONAGE.value)
|
||||
|
||||
# Create Vonage transport
|
||||
transport = await create_vonage_transport(
|
||||
websocket_client,
|
||||
call_uuid,
|
||||
workflow_run_id,
|
||||
audio_config,
|
||||
organization_id,
|
||||
vad_config,
|
||||
ambient_noise_config,
|
||||
)
|
||||
|
||||
# No special handshake needed for Vonage
|
||||
# Audio streaming starts immediately
|
||||
|
||||
# Run the pipeline (same as Twilio/WebRTC)
|
||||
await _run_pipeline(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
call_context_vars={},
|
||||
audio_config=audio_config,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in Vonage pipeline: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def run_pipeline_ari(
|
||||
websocket_client: WebSocket,
|
||||
channel_id: str,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Run pipeline for Asterisk ARI WebSocket connections.
|
||||
|
||||
ARI uses raw 16-bit signed linear PCM (SLIN16) at 16kHz
|
||||
transmitted as binary WebSocket frames via chan_websocket.
|
||||
"""
|
||||
logger.info(f"Starting ARI pipeline for workflow run {workflow_run_id}")
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
# Store call ID (channel_id) in cost_info
|
||||
cost_info = {"call_id": channel_id}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
# Get workflow to extract configurations
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
if "vad_configuration" in workflow.workflow_configurations:
|
||||
vad_config = workflow.workflow_configurations["vad_configuration"]
|
||||
if "ambient_noise_configuration" in workflow.workflow_configurations:
|
||||
ambient_noise_config = workflow.workflow_configurations[
|
||||
"ambient_noise_configuration"
|
||||
]
|
||||
|
||||
try:
|
||||
audio_config = create_audio_config(WorkflowRunMode.ARI.value)
|
||||
|
||||
transport = await create_ari_transport(
|
||||
websocket_client,
|
||||
channel_id,
|
||||
workflow_run_id,
|
||||
audio_config,
|
||||
workflow.organization_id,
|
||||
vad_config,
|
||||
ambient_noise_config,
|
||||
)
|
||||
|
||||
await _run_pipeline(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
audio_config=audio_config,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in ARI pipeline: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def run_pipeline_vobiz(
|
||||
websocket_client: WebSocket,
|
||||
stream_id: str,
|
||||
call_id: str,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Run pipeline for Vobiz using Plivo-compatible WebSocket protocol."""
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Starting Vobiz pipeline - "
|
||||
f"stream_id={stream_id}, call_id={call_id}, workflow_id={workflow_id}"
|
||||
)
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
cost_info = {"call_id": call_id}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
if "vad_configuration" in workflow.workflow_configurations:
|
||||
vad_config = workflow.workflow_configurations["vad_configuration"]
|
||||
if "ambient_noise_configuration" in workflow.workflow_configurations:
|
||||
ambient_noise_config = workflow.workflow_configurations[
|
||||
"ambient_noise_configuration"
|
||||
]
|
||||
|
||||
try:
|
||||
audio_config = create_audio_config(WorkflowRunMode.VOBIZ.value)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz audio config: "
|
||||
f"sample_rate={audio_config.transport_in_sample_rate}Hz, format=MULAW"
|
||||
)
|
||||
|
||||
transport = await create_vobiz_transport(
|
||||
websocket_client,
|
||||
stream_id,
|
||||
call_id,
|
||||
workflow_run_id,
|
||||
audio_config,
|
||||
workflow.organization_id,
|
||||
vad_config,
|
||||
ambient_noise_config,
|
||||
)
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Starting Vobiz pipeline execution")
|
||||
await _run_pipeline(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
audio_config=audio_config,
|
||||
)
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz pipeline completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[run {workflow_run_id}] Error in Vobiz pipeline: {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def run_pipeline_telnyx(
|
||||
websocket_client: WebSocket,
|
||||
stream_id: str,
|
||||
call_control_id: str,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Run pipeline for Telnyx Call Control WebSocket connections.
|
||||
|
||||
Telnyx uses PCMU at 8kHz over WebSocket with base64-encoded media events,
|
||||
similar to Twilio's protocol.
|
||||
"""
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Starting Telnyx pipeline - "
|
||||
f"stream_id={stream_id}, call_control_id={call_control_id}, "
|
||||
f"workflow_id={workflow_id}"
|
||||
)
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
cost_info = {"call_id": call_control_id}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
if "vad_configuration" in workflow.workflow_configurations:
|
||||
vad_config = workflow.workflow_configurations["vad_configuration"]
|
||||
if "ambient_noise_configuration" in workflow.workflow_configurations:
|
||||
ambient_noise_config = workflow.workflow_configurations[
|
||||
"ambient_noise_configuration"
|
||||
]
|
||||
|
||||
try:
|
||||
audio_config = create_audio_config(WorkflowRunMode.TELNYX.value)
|
||||
|
||||
transport = await create_telnyx_transport(
|
||||
websocket_client,
|
||||
stream_id,
|
||||
call_control_id,
|
||||
workflow_run_id,
|
||||
audio_config,
|
||||
workflow.organization_id,
|
||||
vad_config,
|
||||
ambient_noise_config,
|
||||
)
|
||||
|
||||
await _run_pipeline(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
audio_config=audio_config,
|
||||
)
|
||||
logger.info(f"[run {workflow_run_id}] Telnyx pipeline completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[run {workflow_run_id}] Error in Telnyx pipeline: {e}", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
async def run_pipeline_cloudonix(
|
||||
websocket_client: WebSocket,
|
||||
stream_sid: str,
|
||||
workflow_id: int,
|
||||
workflow_run_id: int,
|
||||
user_id: int,
|
||||
) -> None:
|
||||
"""Run pipeline for Cloudonix connections"""
|
||||
logger.debug(
|
||||
f"Running pipeline for Cloudonix connection with workflow_id: {workflow_id} and workflow_run_id: {workflow_run_id}"
|
||||
)
|
||||
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
call_id = workflow_run.gathered_context.get("call_id")
|
||||
if not call_id:
|
||||
logger.warning("call_id not found in gathered_context")
|
||||
raise Exception()
|
||||
|
||||
# Store call ID in cost_info for later cost calculation (provider-agnostic)
|
||||
cost_info = {"call_id": call_id}
|
||||
await db_client.update_workflow_run(workflow_run_id, cost_info=cost_info)
|
||||
|
||||
# Get workflow to extract all pipeline configurations
|
||||
workflow = await db_client.get_workflow(workflow_id, user_id)
|
||||
|
||||
# Set org context early so tasks created by the transport inherit it
|
||||
if workflow:
|
||||
set_current_org_id(workflow.organization_id)
|
||||
|
||||
vad_config = None
|
||||
ambient_noise_config = None
|
||||
if workflow and workflow.workflow_configurations:
|
||||
if "vad_configuration" in workflow.workflow_configurations:
|
||||
vad_config = workflow.workflow_configurations["vad_configuration"]
|
||||
if "ambient_noise_configuration" in workflow.workflow_configurations:
|
||||
ambient_noise_config = workflow.workflow_configurations[
|
||||
"ambient_noise_configuration"
|
||||
]
|
||||
|
||||
# Create audio configuration for Cloudonix
|
||||
audio_config = create_audio_config(WorkflowRunMode.CLOUDONIX.value)
|
||||
|
||||
transport = await create_cloudonix_transport(
|
||||
websocket_client,
|
||||
call_id,
|
||||
stream_sid,
|
||||
workflow_run_id,
|
||||
audio_config,
|
||||
workflow.organization_id,
|
||||
vad_config,
|
||||
ambient_noise_config,
|
||||
)
|
||||
await _run_pipeline(
|
||||
transport,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
audio_config=audio_config,
|
||||
)
|
||||
|
||||
|
||||
async def run_pipeline_smallwebrtc(
|
||||
webrtc_connection: SmallWebRTCConnection,
|
||||
workflow_id: int,
|
||||
|
|
|
|||
|
|
@ -1,514 +1,14 @@
|
|||
import os
|
||||
"""Transport factories for non-telephony pipelines.
|
||||
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
Telephony transports live in their respective ``api.services.telephony.providers/<name>/transport.py``.
|
||||
This module hosts only the shared, non-telephony transports (WebRTC, internal/LoopTalk).
|
||||
"""
|
||||
|
||||
from api.constants import APP_ROOT_DIR
|
||||
from api.db import db_client
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_file_cache import get_cached_ambient_noise_path
|
||||
from api.services.telephony.providers.ari_call_strategies import (
|
||||
ARIBridgeSwapStrategy,
|
||||
ARIHangupStrategy,
|
||||
)
|
||||
from api.services.telephony.providers.cloudonix_call_strategies import (
|
||||
CloudonixHangupStrategy,
|
||||
)
|
||||
from api.services.telephony.providers.twilio_call_strategies import (
|
||||
TwilioConferenceStrategy,
|
||||
TwilioHangupStrategy,
|
||||
)
|
||||
from pipecat.serializers.plivo import PlivoFrameSerializer
|
||||
from pipecat.audio.mixers.silence_mixer import SilenceAudioMixer
|
||||
from pipecat.audio.mixers.soundfile_mixer import SoundfileMixer
|
||||
from pipecat.serializers.asterisk import AsteriskFrameSerializer
|
||||
from pipecat.serializers.telnyx import TelnyxFrameSerializer
|
||||
from pipecat.serializers.twilio import TwilioFrameSerializer
|
||||
from pipecat.serializers.vobiz import VobizFrameSerializer
|
||||
from pipecat.serializers.vonage import VonageFrameSerializer
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from pipecat.transports.base_transport import TransportParams
|
||||
from pipecat.transports.smallwebrtc.connection import SmallWebRTCConnection
|
||||
from pipecat.transports.smallwebrtc.transport import SmallWebRTCTransport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
librnnoise_path = os.path.normpath(
|
||||
str(APP_ROOT_DIR / "native" / "rnnoise" / "librnnoise.so")
|
||||
)
|
||||
|
||||
|
||||
async def _build_audio_out_mixer(
|
||||
audio_out_sample_rate: int,
|
||||
ambient_noise_config: dict | None,
|
||||
):
|
||||
"""Build the audio output mixer based on the ambient noise configuration.
|
||||
|
||||
Returns a ``SoundfileMixer`` when ambient noise is enabled, or a
|
||||
``SilenceAudioMixer`` otherwise. Supports custom user-uploaded audio
|
||||
files via the ``storage_key`` / ``storage_backend`` fields in the config.
|
||||
"""
|
||||
if not ambient_noise_config or not ambient_noise_config.get("enabled", False):
|
||||
return SilenceAudioMixer()
|
||||
|
||||
volume = ambient_noise_config.get("volume", 0.3)
|
||||
|
||||
# Check for a custom uploaded ambient noise file
|
||||
storage_key = ambient_noise_config.get("storage_key")
|
||||
storage_backend = ambient_noise_config.get("storage_backend")
|
||||
|
||||
if storage_key and storage_backend:
|
||||
cached_path = await get_cached_ambient_noise_path(
|
||||
storage_key, storage_backend, audio_out_sample_rate
|
||||
)
|
||||
if cached_path:
|
||||
return SoundfileMixer(
|
||||
sound_files={"custom": cached_path},
|
||||
default_sound="custom",
|
||||
volume=volume,
|
||||
)
|
||||
logger.warning("Custom ambient noise file unavailable, falling back to default")
|
||||
|
||||
# Default built-in office ambience
|
||||
return SoundfileMixer(
|
||||
sound_files={
|
||||
"office": APP_ROOT_DIR
|
||||
/ "assets"
|
||||
/ f"office-ambience-{audio_out_sample_rate}-mono.wav"
|
||||
},
|
||||
default_sound="office",
|
||||
volume=volume,
|
||||
)
|
||||
|
||||
|
||||
async def create_twilio_transport(
|
||||
websocket_client: WebSocket,
|
||||
stream_sid: str,
|
||||
call_sid: str,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for Twilio connections"""
|
||||
|
||||
# Fetch Twilio credentials from organization config
|
||||
config = await db_client.get_configuration(
|
||||
organization_id, OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
raise ValueError(
|
||||
f"Twilio credentials not configured for organization {organization_id}"
|
||||
)
|
||||
|
||||
account_sid = config.value.get("account_sid")
|
||||
auth_token = config.value.get("auth_token")
|
||||
|
||||
if not account_sid or not auth_token:
|
||||
raise ValueError(
|
||||
f"Incomplete Twilio configuration for organization {organization_id}"
|
||||
)
|
||||
# Create strategy instances
|
||||
transfer_strategy = TwilioConferenceStrategy()
|
||||
hangup_strategy = TwilioHangupStrategy()
|
||||
|
||||
serializer = TwilioFrameSerializer(
|
||||
stream_sid=stream_sid,
|
||||
call_sid=call_sid,
|
||||
account_sid=account_sid,
|
||||
auth_token=auth_token,
|
||||
transfer_strategy=transfer_strategy,
|
||||
hangup_strategy=hangup_strategy,
|
||||
)
|
||||
|
||||
mixer = await _build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket_client,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def create_plivo_transport(
|
||||
websocket_client: WebSocket,
|
||||
stream_id: str,
|
||||
call_id: str,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for Plivo connections."""
|
||||
from api.services.telephony.factory import load_telephony_config
|
||||
|
||||
config = await load_telephony_config(organization_id)
|
||||
|
||||
if config.get("provider") != "plivo":
|
||||
raise ValueError(f"Expected Plivo provider, got {config.get('provider')}")
|
||||
|
||||
auth_id = config.get("auth_id")
|
||||
auth_token = config.get("auth_token")
|
||||
|
||||
if not auth_id or not auth_token:
|
||||
raise ValueError(
|
||||
f"Incomplete Plivo configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
serializer = PlivoFrameSerializer(
|
||||
stream_id=stream_id,
|
||||
call_id=call_id,
|
||||
auth_id=auth_id,
|
||||
auth_token=auth_token,
|
||||
params=PlivoFrameSerializer.InputParams(
|
||||
plivo_sample_rate=8000,
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
mixer = await _build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket_client,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def create_cloudonix_transport(
|
||||
websocket_client: WebSocket,
|
||||
call_id: str,
|
||||
stream_sid: str,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for Cloudonix connections"""
|
||||
|
||||
# Load Cloudonix configuration from database
|
||||
from api.services.telephony.factory import load_telephony_config
|
||||
|
||||
config = await load_telephony_config(organization_id)
|
||||
|
||||
if config.get("provider") != "cloudonix":
|
||||
raise ValueError(f"Expected Cloudonix provider, got {config.get('provider')}")
|
||||
|
||||
bearer_token = config.get("bearer_token")
|
||||
domain_id = config.get("domain_id")
|
||||
|
||||
if not bearer_token or not domain_id:
|
||||
raise ValueError(
|
||||
f"Incomplete Cloudonix configuration for organization {organization_id}. "
|
||||
f"Required: bearer_token, domain_id"
|
||||
)
|
||||
|
||||
from pipecat.serializers.cloudonix import CloudonixFrameSerializer
|
||||
|
||||
hangup_strategy = CloudonixHangupStrategy()
|
||||
serializer = CloudonixFrameSerializer(
|
||||
call_id=call_id,
|
||||
stream_sid=stream_sid,
|
||||
domain_id=domain_id,
|
||||
bearer_token=bearer_token,
|
||||
hangup_strategy=hangup_strategy,
|
||||
)
|
||||
|
||||
mixer = await _build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket_client,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
audio_out_10ms_chunks=2,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def create_telnyx_transport(
|
||||
websocket_client: WebSocket,
|
||||
stream_id: str,
|
||||
call_control_id: str,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for Telnyx connections."""
|
||||
config = await db_client.get_configuration(
|
||||
organization_id, OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value
|
||||
)
|
||||
|
||||
if not config or not config.value:
|
||||
raise ValueError(
|
||||
f"Telnyx credentials not configured for organization {organization_id}"
|
||||
)
|
||||
|
||||
if config.value.get("provider") != "telnyx":
|
||||
raise ValueError(
|
||||
f"Expected Telnyx provider, got {config.value.get('provider')}"
|
||||
)
|
||||
|
||||
api_key = config.value.get("api_key")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Incomplete Telnyx configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
serializer = TelnyxFrameSerializer(
|
||||
stream_id=stream_id,
|
||||
call_control_id=call_control_id,
|
||||
api_key=api_key,
|
||||
outbound_encoding="PCMU",
|
||||
inbound_encoding="PCMU",
|
||||
)
|
||||
|
||||
mixer = await _build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket_client,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def create_ari_transport(
|
||||
websocket_client: WebSocket,
|
||||
channel_id: str,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for Asterisk ARI connections"""
|
||||
|
||||
from api.services.telephony.factory import load_telephony_config
|
||||
|
||||
config = await load_telephony_config(organization_id)
|
||||
|
||||
if config.get("provider") != "ari":
|
||||
raise ValueError(f"Expected ARI provider, got {config.get('provider')}")
|
||||
|
||||
ari_endpoint = config.get("ari_endpoint")
|
||||
app_name = config.get("app_name")
|
||||
app_password = config.get("app_password")
|
||||
|
||||
if not ari_endpoint or not app_name or not app_password:
|
||||
raise ValueError(
|
||||
f"Incomplete ARI configuration for organization {organization_id}. "
|
||||
f"Required: ari_endpoint, app_name, app_password"
|
||||
)
|
||||
# Create strategy instances
|
||||
transfer_strategy = ARIBridgeSwapStrategy()
|
||||
hangup_strategy = ARIHangupStrategy()
|
||||
|
||||
serializer = AsteriskFrameSerializer(
|
||||
channel_id=channel_id,
|
||||
ari_endpoint=ari_endpoint,
|
||||
app_name=app_name,
|
||||
app_password=app_password,
|
||||
transfer_strategy=transfer_strategy,
|
||||
hangup_strategy=hangup_strategy,
|
||||
params=AsteriskFrameSerializer.InputParams(
|
||||
asterisk_sample_rate=audio_config.transport_in_sample_rate,
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
mixer = await _build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket_client,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def create_vonage_transport(
|
||||
websocket_client,
|
||||
call_uuid: str,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for Vonage connections"""
|
||||
|
||||
# Use the factory to load config from database
|
||||
from api.services.telephony.factory import load_telephony_config
|
||||
|
||||
config = await load_telephony_config(organization_id)
|
||||
|
||||
if config.get("provider") != "vonage":
|
||||
raise ValueError(f"Expected Vonage provider, got {config.get('provider')}")
|
||||
|
||||
application_id = config.get("application_id")
|
||||
private_key = config.get("private_key")
|
||||
|
||||
if not application_id or not private_key:
|
||||
raise ValueError(
|
||||
f"Incomplete Vonage configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
serializer = VonageFrameSerializer(
|
||||
call_uuid=call_uuid,
|
||||
application_id=application_id,
|
||||
private_key=private_key,
|
||||
params=VonageFrameSerializer.InputParams(
|
||||
vonage_sample_rate=audio_config.transport_in_sample_rate,
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
mixer = await _build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
# Important: Vonage uses binary WebSocket mode, not text
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket_client,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def create_vobiz_transport(
|
||||
websocket_client: WebSocket,
|
||||
stream_id: str,
|
||||
call_id: str,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for Vobiz connections.
|
||||
|
||||
Vobiz uses Plivo-compatible WebSocket protocol:
|
||||
- MULAW audio at 8kHz (same as Twilio)
|
||||
- Base64-encoded audio in JSON messages
|
||||
- PlivoFrameSerializer handles the protocol
|
||||
"""
|
||||
from loguru import logger
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Creating Vobiz transport - "
|
||||
f"stream_id={stream_id}, call_id={call_id}"
|
||||
)
|
||||
|
||||
# Load Vobiz configuration from database
|
||||
from api.services.telephony.factory import load_telephony_config
|
||||
|
||||
config = await load_telephony_config(organization_id)
|
||||
|
||||
if config.get("provider") != "vobiz":
|
||||
raise ValueError(f"Expected Vobiz provider, got {config.get('provider')}")
|
||||
|
||||
auth_id = config.get("auth_id")
|
||||
auth_token = config.get("auth_token")
|
||||
|
||||
if not auth_id or not auth_token:
|
||||
raise ValueError(
|
||||
f"Incomplete Vobiz configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[run {workflow_run_id}] Vobiz config loaded - auth_id={auth_id}, "
|
||||
f"from_numbers={len(config.get('from_numbers', []))} numbers"
|
||||
)
|
||||
|
||||
# Use VobizFrameSerializer for Vobiz WebSocket protocol
|
||||
serializer = VobizFrameSerializer(
|
||||
stream_id=stream_id,
|
||||
call_id=call_id,
|
||||
auth_id=auth_id,
|
||||
auth_token=auth_token,
|
||||
params=VobizFrameSerializer.InputParams(
|
||||
vobiz_sample_rate=8000, # Vobiz uses MULAW at 8kHz
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[run {workflow_run_id}] VobizFrameSerializer created for Vobiz - "
|
||||
f"transport_rate=8000Hz, pipeline_rate={audio_config.pipeline_sample_rate}Hz"
|
||||
)
|
||||
|
||||
mixer = await _build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
# Create WebSocket transport (same structure as Twilio/Vonage)
|
||||
transport = FastAPIWebsocketTransport(
|
||||
websocket=websocket_client,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz transport created successfully (VAD enabled)"
|
||||
)
|
||||
return transport
|
||||
|
||||
|
||||
async def create_webrtc_transport(
|
||||
|
|
@ -518,9 +18,8 @@ async def create_webrtc_transport(
|
|||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
):
|
||||
"""Create a transport for WebRTC connections"""
|
||||
|
||||
mixer = await _build_audio_out_mixer(
|
||||
"""Create a transport for WebRTC connections."""
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
|
|
@ -556,29 +55,3 @@ def create_internal_transport(
|
|||
pass
|
||||
# Commented out because looptalk coming in the regular import flow
|
||||
# was causing issue. May be move this to looptalk/orchestrator.py
|
||||
|
||||
# Create and return the internal transport with latency
|
||||
# return InternalTransport(
|
||||
# params=TransportParams(
|
||||
# audio_out_enabled=True,
|
||||
# audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
# audio_out_channels=1,
|
||||
# audio_in_enabled=True,
|
||||
# audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
# audio_in_channels=1,
|
||||
# audio_out_mixer=(
|
||||
# SoundfileMixer(
|
||||
# sound_files={
|
||||
# "office": APP_ROOT_DIR
|
||||
# / "assets"
|
||||
# / f"office-ambience-{audio_config.transport_out_sample_rate}-mono.wav"
|
||||
# },
|
||||
# default_sound="office",
|
||||
# volume=ambient_noise_config.get("volume", 0.3),
|
||||
# )
|
||||
# if ambient_noise_config and ambient_noise_config.get("enabled", False)
|
||||
# else SilenceAudioMixer()
|
||||
# ),
|
||||
# ),
|
||||
# latency_seconds=latency_seconds,
|
||||
# )
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from loguru import logger
|
|||
from api.db import db_client
|
||||
from api.db.models import UserModel
|
||||
from api.services.configuration.registry import ServiceProviders
|
||||
from api.services.configuration.resolve import resolve_effective_config
|
||||
from api.services.mps_service_key_client import mps_service_key_client
|
||||
|
||||
|
||||
|
|
@ -23,14 +24,23 @@ class QuotaCheckResult:
|
|||
error_code: str = ""
|
||||
|
||||
|
||||
async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
|
||||
async def check_dograh_quota(
|
||||
user: UserModel, workflow_id: int | None = None
|
||||
) -> QuotaCheckResult:
|
||||
"""Check if user has sufficient Dograh quota for making a call.
|
||||
|
||||
This function checks if the user is using any Dograh services (LLM, STT, TTS)
|
||||
and validates that they have sufficient credits remaining.
|
||||
|
||||
When ``workflow_id`` is provided, the workflow's per-workflow
|
||||
``model_overrides`` are merged onto the user's global config so the quota
|
||||
check runs against the credentials that will actually be used for the call
|
||||
(rather than always falling back to the user's defaults).
|
||||
|
||||
Args:
|
||||
user: The user to check quota for
|
||||
workflow_id: Optional workflow whose ``model_overrides`` should be
|
||||
applied when resolving the effective service config.
|
||||
|
||||
Returns:
|
||||
QuotaCheckResult with has_quota=True if user has sufficient quota or
|
||||
|
|
@ -41,6 +51,15 @@ async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
|
|||
# Get user configurations
|
||||
user_config = await db_client.get_user_configurations(user.id)
|
||||
|
||||
if workflow_id is not None:
|
||||
workflow = await db_client.get_workflow_by_id(workflow_id)
|
||||
if workflow:
|
||||
model_overrides = (workflow.workflow_configurations or {}).get(
|
||||
"model_overrides"
|
||||
)
|
||||
if model_overrides:
|
||||
user_config = resolve_effective_config(user_config, model_overrides)
|
||||
|
||||
# Check if user is using any Dograh service
|
||||
using_dograh = False
|
||||
dograh_api_keys = set()
|
||||
|
|
@ -112,13 +131,20 @@ async def check_dograh_quota(user: UserModel) -> QuotaCheckResult:
|
|||
return QuotaCheckResult(has_quota=True)
|
||||
|
||||
|
||||
async def check_dograh_quota_by_user_id(user_id: int) -> QuotaCheckResult:
|
||||
async def check_dograh_quota_by_user_id(
|
||||
user_id: int, workflow_id: int | None = None
|
||||
) -> QuotaCheckResult:
|
||||
"""Check Dograh quota by user ID.
|
||||
|
||||
Convenience function that fetches the user and then checks quota.
|
||||
Convenience function that fetches the user and then checks quota. When
|
||||
``workflow_id`` is provided, the workflow's ``model_overrides`` are
|
||||
applied so the quota check evaluates the credentials that will actually
|
||||
be used for the call.
|
||||
|
||||
Args:
|
||||
user_id: The ID of the user to check quota for
|
||||
workflow_id: Optional workflow whose per-workflow overrides should
|
||||
be applied to the user's config before checking quota.
|
||||
|
||||
Returns:
|
||||
QuotaCheckResult with quota status
|
||||
|
|
@ -129,4 +155,4 @@ async def check_dograh_quota_by_user_id(user_id: int) -> QuotaCheckResult:
|
|||
has_quota=False,
|
||||
error_message="User not found",
|
||||
)
|
||||
return await check_dograh_quota(user)
|
||||
return await check_dograh_quota(user, workflow_id=workflow_id)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
"""Telephony package.
|
||||
|
||||
Importing this package eagerly loads every provider in
|
||||
``api/services/telephony/providers/`` so each one self-registers with the
|
||||
registry before any consumer (factory, routes, schemas) runs. Python
|
||||
guarantees this ``__init__.py`` runs before any submodule of the package,
|
||||
so submodules like ``factory`` and ``registry`` can stay free of provider
|
||||
imports — no lazy flags, no cycle.
|
||||
"""
|
||||
|
||||
from . import (
|
||||
providers as _providers, # noqa: F401 -- import for side effects (registration)
|
||||
)
|
||||
|
|
@ -24,7 +24,7 @@ from loguru import logger
|
|||
|
||||
from api.constants import REDIS_URL
|
||||
from api.db import db_client
|
||||
from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunMode
|
||||
from api.enums import CallType, WorkflowRunMode
|
||||
from api.services.quota_service import check_dograh_quota_by_user_id
|
||||
from api.services.telephony.call_transfer_manager import get_call_transfer_manager
|
||||
from api.services.telephony.transfer_event_protocol import (
|
||||
|
|
@ -44,18 +44,18 @@ class ARIConnection:
|
|||
def __init__(
|
||||
self,
|
||||
organization_id: int,
|
||||
telephony_configuration_id: int,
|
||||
ari_endpoint: str,
|
||||
app_name: str,
|
||||
app_password: str,
|
||||
ws_client_name: str = "",
|
||||
inbound_workflow_id: int = None,
|
||||
):
|
||||
self.organization_id = organization_id
|
||||
self.telephony_configuration_id = telephony_configuration_id
|
||||
self.ari_endpoint = ari_endpoint.rstrip("/")
|
||||
self.app_name = app_name
|
||||
self.app_password = app_password
|
||||
self.ws_client_name = ws_client_name
|
||||
self.inbound_workflow_id = inbound_workflow_id
|
||||
|
||||
self._ws: Optional[websockets.ClientConnection] = None
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
|
|
@ -135,8 +135,8 @@ class ARIConnection:
|
|||
|
||||
@property
|
||||
def connection_key(self) -> str:
|
||||
"""Unique key for this connection based on config."""
|
||||
return f"{self.organization_id}:{self.ari_endpoint}:{self.app_name}"
|
||||
"""Unique key for this connection — one per ARI config row."""
|
||||
return f"config:{self.telephony_configuration_id}"
|
||||
|
||||
async def start(self):
|
||||
"""Start the WebSocket connection in a background task."""
|
||||
|
|
@ -468,22 +468,43 @@ class ARIConnection:
|
|||
called_number = channel.get("dialplan", {}).get("exten", "unknown")
|
||||
|
||||
try:
|
||||
# 1. Check inbound_workflow_id is configured
|
||||
if not self.inbound_workflow_id:
|
||||
# 1. Resolve the workflow from the called extension via the
|
||||
# telephony_phone_numbers row scoped to this connection's config.
|
||||
phone_row = await db_client.find_active_phone_number_for_inbound(
|
||||
self.organization_id, called_number, "ari"
|
||||
)
|
||||
if (
|
||||
not phone_row
|
||||
or phone_row.telephony_configuration_id
|
||||
!= self.telephony_configuration_id
|
||||
):
|
||||
logger.warning(
|
||||
f"[ARI org={self.organization_id}] Inbound call on channel {channel_id} "
|
||||
f"but no inbound_workflow_id configured — hanging up"
|
||||
f"[ARI org={self.organization_id}] Inbound call to extension "
|
||||
f"{called_number} on channel {channel_id} — no matching phone "
|
||||
f"number registered for config {self.telephony_configuration_id}, "
|
||||
f"hanging up"
|
||||
)
|
||||
await self._delete_channel(channel_id)
|
||||
return
|
||||
|
||||
inbound_workflow_id = phone_row.inbound_workflow_id
|
||||
if not inbound_workflow_id:
|
||||
logger.warning(
|
||||
f"[ARI org={self.organization_id}] Inbound call to extension "
|
||||
f"{called_number} on channel {channel_id} — phone number "
|
||||
f"{phone_row.address} has no inbound_workflow_id assigned, "
|
||||
f"hanging up"
|
||||
)
|
||||
await self._delete_channel(channel_id)
|
||||
return
|
||||
|
||||
# 2. Load workflow to get user_id and verify organization
|
||||
workflow = await db_client.get_workflow(
|
||||
self.inbound_workflow_id, organization_id=self.organization_id
|
||||
inbound_workflow_id, organization_id=self.organization_id
|
||||
)
|
||||
if not workflow:
|
||||
logger.warning(
|
||||
f"[ARI org={self.organization_id}] Workflow {self.inbound_workflow_id} "
|
||||
f"[ARI org={self.organization_id}] Workflow {inbound_workflow_id} "
|
||||
f"not found or doesn't belong to this organization — hanging up"
|
||||
)
|
||||
await self._delete_channel(channel_id)
|
||||
|
|
@ -491,8 +512,10 @@ class ARIConnection:
|
|||
|
||||
user_id = workflow.user_id
|
||||
|
||||
# 3. Check quota
|
||||
quota_result = await check_dograh_quota_by_user_id(user_id)
|
||||
# 3. Check quota (apply per-workflow model_overrides).
|
||||
quota_result = await check_dograh_quota_by_user_id(
|
||||
user_id, workflow_id=inbound_workflow_id
|
||||
)
|
||||
if not quota_result.has_quota:
|
||||
logger.warning(
|
||||
f"[ARI org={self.organization_id}] Quota exceeded for user {user_id} "
|
||||
|
|
@ -505,7 +528,7 @@ class ARIConnection:
|
|||
call_id = channel_id
|
||||
workflow_run = await db_client.create_workflow_run(
|
||||
name=f"ARI Inbound {caller_number}",
|
||||
workflow_id=self.inbound_workflow_id,
|
||||
workflow_id=inbound_workflow_id,
|
||||
mode=WorkflowRunMode.ARI.value,
|
||||
user_id=user_id,
|
||||
call_type=CallType.INBOUND,
|
||||
|
|
@ -534,7 +557,7 @@ class ARIConnection:
|
|||
channel_id,
|
||||
channel_state,
|
||||
str(workflow_run.id),
|
||||
str(self.inbound_workflow_id),
|
||||
str(inbound_workflow_id),
|
||||
str(user_id),
|
||||
)
|
||||
except Exception as e:
|
||||
|
|
@ -902,19 +925,19 @@ class ARIManager:
|
|||
|
||||
for config in active_configs:
|
||||
org_id = config["organization_id"]
|
||||
telephony_configuration_id = config["telephony_configuration_id"]
|
||||
ari_endpoint = config["ari_endpoint"]
|
||||
app_name = config["app_name"]
|
||||
app_password = config["app_password"]
|
||||
ws_client_name = config["ws_client_name"]
|
||||
inbound_workflow_id = config.get("inbound_workflow_id")
|
||||
|
||||
conn = ARIConnection(
|
||||
org_id,
|
||||
telephony_configuration_id,
|
||||
ari_endpoint,
|
||||
app_name,
|
||||
app_password,
|
||||
ws_client_name,
|
||||
inbound_workflow_id=inbound_workflow_id,
|
||||
)
|
||||
key = conn.connection_key
|
||||
|
||||
|
|
@ -923,19 +946,26 @@ class ARIManager:
|
|||
if key not in self._connections:
|
||||
# New configuration - start connection
|
||||
logger.info(
|
||||
f"[ARI Manager] New ARI config for org {org_id}: {ari_endpoint}"
|
||||
f"[ARI Manager] New ARI config {telephony_configuration_id} "
|
||||
f"for org {org_id}: {ari_endpoint}"
|
||||
)
|
||||
self._connections[key] = conn
|
||||
await conn.start()
|
||||
else:
|
||||
# Existing configuration - check if password or inbound_workflow_id changed
|
||||
# Existing configuration — reconnect if connection-level fields
|
||||
# (endpoint, app, password, ws client) changed. Workflow IDs are
|
||||
# resolved per-call via telephony_phone_numbers, so changes to
|
||||
# them don't require a reconnect.
|
||||
existing = self._connections[key]
|
||||
if (
|
||||
existing.app_password != app_password
|
||||
or existing.inbound_workflow_id != inbound_workflow_id
|
||||
existing.ari_endpoint != conn.ari_endpoint
|
||||
or existing.app_name != app_name
|
||||
or existing.app_password != app_password
|
||||
or existing.ws_client_name != ws_client_name
|
||||
):
|
||||
logger.info(
|
||||
f"[ARI Manager] Config changed for org {org_id}, reconnecting..."
|
||||
f"[ARI Manager] Config {telephony_configuration_id} "
|
||||
f"changed for org {org_id}, reconnecting..."
|
||||
)
|
||||
await existing.stop()
|
||||
self._connections[key] = conn
|
||||
|
|
@ -953,47 +983,44 @@ class ARIManager:
|
|||
if active_configs:
|
||||
logger.info(
|
||||
f"[ARI Manager] Active connections: {len(self._connections)} "
|
||||
f"(orgs: {[c['organization_id'] for c in active_configs]})"
|
||||
f"(configs: {[c['telephony_configuration_id'] for c in active_configs]})"
|
||||
)
|
||||
else:
|
||||
logger.debug("[ARI Manager] No ARI configurations found")
|
||||
|
||||
async def _load_ari_configs(self) -> list:
|
||||
"""Load all ARI telephony configurations from the database."""
|
||||
rows = await db_client.get_configurations_by_provider(
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value, "ari"
|
||||
)
|
||||
"""Load all ARI telephony configurations from the multi-config tables."""
|
||||
rows = await db_client.list_all_telephony_configurations_by_provider("ari")
|
||||
|
||||
configs = []
|
||||
for row in rows:
|
||||
org_id = row["organization_id"]
|
||||
value = row["value"]
|
||||
|
||||
ari_endpoint = value.get("ari_endpoint")
|
||||
app_name = value.get("app_name")
|
||||
app_password = value.get("app_password")
|
||||
ws_client_name = value.get("ws_client_name", "")
|
||||
credentials = row.credentials or {}
|
||||
ari_endpoint = credentials.get("ari_endpoint")
|
||||
app_name = credentials.get("app_name")
|
||||
app_password = credentials.get("app_password")
|
||||
ws_client_name = credentials.get("ws_client_name", "")
|
||||
|
||||
if not all([ari_endpoint, app_name, app_password]):
|
||||
logger.warning(
|
||||
f"[ARI Manager] Incomplete ARI config for org {org_id}, skipping"
|
||||
f"[ARI Manager] Incomplete ARI config {row.id} "
|
||||
f"for org {row.organization_id}, skipping"
|
||||
)
|
||||
continue
|
||||
|
||||
if not ws_client_name:
|
||||
logger.warning(
|
||||
f"[ARI Manager] Missing ws_client_name for org {org_id}, "
|
||||
f"externalMedia WebSocket won't work"
|
||||
f"[ARI Manager] Missing ws_client_name for config {row.id} "
|
||||
f"(org {row.organization_id}), externalMedia WebSocket won't work"
|
||||
)
|
||||
|
||||
configs.append(
|
||||
{
|
||||
"organization_id": org_id,
|
||||
"organization_id": row.organization_id,
|
||||
"telephony_configuration_id": row.id,
|
||||
"ari_endpoint": ari_endpoint,
|
||||
"app_name": app_name,
|
||||
"app_password": app_password,
|
||||
"ws_client_name": ws_client_name,
|
||||
"inbound_workflow_id": value.get("inbound_workflow_id"),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,19 @@ class CallInitiationResult:
|
|||
) # Full provider response for debugging
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderSyncResult:
|
||||
"""Result of pushing a configuration change to the upstream provider.
|
||||
|
||||
Used by ``configure_inbound`` (and similar provider-side syncs) so callers
|
||||
can surface a non-fatal warning to the user when the DB write succeeded
|
||||
but the provider API rejected the change.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
message: Optional[str] = None # human-readable detail when ok=False
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedInboundData:
|
||||
"""Standardized inbound call data across all providers."""
|
||||
|
|
@ -264,38 +277,76 @@ class TelephonyProvider(ABC):
|
|||
|
||||
@abstractmethod
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound webhook for security.
|
||||
|
||||
Each provider extracts its own signature/timestamp/nonce headers.
|
||||
Returning True when no signature is present means "no verification
|
||||
attempted" — providers should return False if a signature *is*
|
||||
present but invalid.
|
||||
|
||||
Args:
|
||||
url: The full webhook URL
|
||||
webhook_data: The webhook payload
|
||||
signature: The signature header from the provider
|
||||
url: The full webhook URL the provider POSTed to
|
||||
webhook_data: Parsed webhook payload (form fields or JSON)
|
||||
headers: HTTP headers from the request (case-insensitive lookup
|
||||
is the provider's responsibility)
|
||||
body: Raw request body — only used by providers that sign over
|
||||
the body bytes (e.g. Vobiz)
|
||||
|
||||
Returns:
|
||||
True if signature is valid, False otherwise
|
||||
True if signature is valid (or none required), False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data: "NormalizedInboundData",
|
||||
backend_endpoint: str,
|
||||
) -> Any:
|
||||
"""
|
||||
Generate the appropriate response for an inbound webhook.
|
||||
Bring up the inbound media stream for this provider and return the
|
||||
HTTP response body the webhook caller expects.
|
||||
|
||||
Markup-response providers (Twilio, Plivo, Vobiz, ...) build and
|
||||
return their TwiML/XML/NCCO directly. Call-control providers
|
||||
(Telnyx) issue the REST calls needed to answer the call and start
|
||||
streaming, then return a simple acknowledgement.
|
||||
|
||||
Args:
|
||||
websocket_url: WebSocket URL for audio streaming
|
||||
workflow_run_id: Optional workflow run ID for tracking
|
||||
workflow_run_id: Workflow run ID for tracking
|
||||
normalized_data: Parsed inbound webhook payload (provides
|
||||
``call_id`` for providers that need it)
|
||||
backend_endpoint: Public HTTPS base URL of this backend
|
||||
(already resolved by the caller); providers that need to
|
||||
build status / events URLs use this instead of re-fetching
|
||||
|
||||
Returns:
|
||||
FastAPI Response object
|
||||
FastAPI Response object (or dict/JSON-serializable value)
|
||||
"""
|
||||
pass
|
||||
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
) -> ProviderSyncResult:
|
||||
"""Sync inbound routing for ``address`` to the provider.
|
||||
|
||||
``webhook_url`` set: point the provider's resource for this number at
|
||||
the URL. ``None``: clear it. Default is a no-op for providers that
|
||||
don't support programmatic webhook configuration (e.g. ARI).
|
||||
"""
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
|
|
|
|||
|
|
@ -1,173 +1,215 @@
|
|||
"""
|
||||
Factory for creating telephony providers.
|
||||
Handles configuration loading from environment (OSS) or database (SaaS).
|
||||
The providers themselves don't know or care where config comes from.
|
||||
"""Factory for creating telephony providers.
|
||||
|
||||
Resolves a provider instance from a stored telephony configuration. Three
|
||||
resolution paths exist:
|
||||
|
||||
* by config id — the canonical path used by outbound (test calls, campaigns,
|
||||
API triggers) and by the websocket transport once a workflow run has
|
||||
``initial_context.telephony_configuration_id`` stamped on it.
|
||||
* by org default — used as a fallback when no specific config is requested
|
||||
(e.g. the legacy ``/telephony-config`` endpoint, the back-compat
|
||||
``get_telephony_provider(organization_id)`` shim).
|
||||
* for inbound — given a detected provider and an account-id from the webhook,
|
||||
iterate the org's configs of that provider and return the one whose stored
|
||||
account-id credential matches.
|
||||
|
||||
Provider classes don't need to know about the new storage shape. They still
|
||||
receive a normalized config dict containing credentials plus a
|
||||
``from_numbers`` list of address strings, which the factory assembles by
|
||||
joining ``telephony_phone_numbers``.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Type
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import OrganizationConfigurationKey
|
||||
from api.db.models import TelephonyConfigurationModel
|
||||
from api.services.telephony import registry
|
||||
from api.services.telephony.base import TelephonyProvider
|
||||
from api.services.telephony.providers.ari_provider import ARIProvider
|
||||
from api.services.telephony.providers.cloudonix_provider import CloudonixProvider
|
||||
from api.services.telephony.providers.plivo_provider import PlivoProvider
|
||||
from api.services.telephony.providers.telnyx_provider import TelnyxProvider
|
||||
from api.services.telephony.providers.twilio_provider import TwilioProvider
|
||||
from api.services.telephony.providers.vobiz_provider import VobizProvider
|
||||
from api.services.telephony.providers.vonage_provider import VonageProvider
|
||||
|
||||
|
||||
async def load_telephony_config_by_id(
|
||||
telephony_configuration_id: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Load and normalize the config row by primary key.
|
||||
|
||||
Returns a dict in the shape each provider class expects in its constructor
|
||||
(provider name + provider-specific credentials + ``from_numbers`` list of
|
||||
raw address strings).
|
||||
"""
|
||||
if not telephony_configuration_id:
|
||||
raise ValueError("telephony_configuration_id is required")
|
||||
|
||||
row = await db_client.get_telephony_configuration(telephony_configuration_id)
|
||||
if not row:
|
||||
raise ValueError(
|
||||
f"Telephony configuration {telephony_configuration_id} not found"
|
||||
)
|
||||
return await _normalize_with_phone_numbers(row)
|
||||
|
||||
|
||||
async def load_default_telephony_config(organization_id: int) -> Dict[str, Any]:
|
||||
"""Load the org's default outbound config."""
|
||||
if not organization_id:
|
||||
raise ValueError("organization_id is required")
|
||||
|
||||
row = await db_client.get_default_telephony_configuration(organization_id)
|
||||
if not row:
|
||||
raise ValueError(
|
||||
f"No default telephony configuration found for organization "
|
||||
f"{organization_id}"
|
||||
)
|
||||
return await _normalize_with_phone_numbers(row)
|
||||
|
||||
|
||||
async def find_telephony_config_for_inbound(
|
||||
organization_id: int, provider_name: str, account_id: Optional[str]
|
||||
) -> Optional[Tuple[int, Dict[str, Any]]]:
|
||||
"""Match an inbound webhook to one of the org's configs of the detected
|
||||
provider. Returns ``(config_id, normalized_config)`` or None.
|
||||
"""
|
||||
spec = registry.get_optional(provider_name)
|
||||
if not spec:
|
||||
return None
|
||||
|
||||
candidates = await db_client.list_telephony_configurations_by_provider(
|
||||
organization_id, provider_name
|
||||
)
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
field = spec.account_id_credential_field
|
||||
matched: Optional[TelephonyConfigurationModel] = None
|
||||
|
||||
if not field:
|
||||
# Provider has no account-id concept (e.g. ARI); only one config of this
|
||||
# provider is meaningful per org.
|
||||
if len(candidates) == 1:
|
||||
matched = candidates[0]
|
||||
else:
|
||||
logger.warning(
|
||||
f"Provider {provider_name} has multiple configs in org "
|
||||
f"{organization_id} but no account_id field to disambiguate; "
|
||||
f"picking the default outbound (or first)."
|
||||
)
|
||||
matched = next(
|
||||
(c for c in candidates if c.is_default_outbound), candidates[0]
|
||||
)
|
||||
else:
|
||||
for cand in candidates:
|
||||
stored = (cand.credentials or {}).get(field)
|
||||
if stored and account_id and stored == account_id:
|
||||
matched = cand
|
||||
break
|
||||
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
normalized = await _normalize_with_phone_numbers(matched)
|
||||
return matched.id, normalized
|
||||
|
||||
|
||||
async def get_telephony_provider_by_id(
|
||||
telephony_configuration_id: int,
|
||||
) -> TelephonyProvider:
|
||||
config = await load_telephony_config_by_id(telephony_configuration_id)
|
||||
return _instantiate(config)
|
||||
|
||||
|
||||
async def get_default_telephony_provider(organization_id: int) -> TelephonyProvider:
|
||||
config = await load_default_telephony_config(organization_id)
|
||||
return _instantiate(config)
|
||||
|
||||
|
||||
async def get_telephony_provider_for_inbound(
|
||||
organization_id: int, provider_name: str, account_id: Optional[str]
|
||||
) -> Optional[Tuple[int, TelephonyProvider]]:
|
||||
"""Returns ``(config_id, provider_instance)`` or None when no config matches."""
|
||||
match = await find_telephony_config_for_inbound(
|
||||
organization_id, provider_name, account_id
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
config_id, config = match
|
||||
return config_id, _instantiate(config)
|
||||
|
||||
|
||||
async def load_credentials_for_transport(
|
||||
organization_id: int,
|
||||
telephony_configuration_id: Optional[int],
|
||||
expected_provider: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Helper for per-provider transport modules.
|
||||
|
||||
Resolves the right credentials for a websocket transport given what's
|
||||
available on the workflow run. Uses ``telephony_configuration_id`` when
|
||||
stamped (the new path), otherwise falls back to the org's default config
|
||||
so legacy runs created before the multi-config migration still work.
|
||||
Raises ValueError when the resolved config is for a different provider.
|
||||
"""
|
||||
if telephony_configuration_id:
|
||||
config = await load_telephony_config_by_id(telephony_configuration_id)
|
||||
else:
|
||||
config = await load_default_telephony_config(organization_id)
|
||||
|
||||
actual = config.get("provider")
|
||||
if actual != expected_provider:
|
||||
raise ValueError(
|
||||
f"Expected {expected_provider} provider, got {actual} "
|
||||
f"(config_id={telephony_configuration_id}, org={organization_id})"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Back-compat shims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def load_telephony_config(organization_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Load telephony configuration from database.
|
||||
"""Deprecated: returns the org's default config.
|
||||
|
||||
Args:
|
||||
organization_id: Organization ID for database config
|
||||
|
||||
Returns:
|
||||
Configuration dictionary with provider type and credentials
|
||||
|
||||
Raises:
|
||||
ValueError: If no configuration found for the organization
|
||||
"""
|
||||
if not organization_id:
|
||||
raise ValueError("Organization ID is required to load telephony configuration")
|
||||
|
||||
logger.debug(f"Loading telephony config from database for org {organization_id}")
|
||||
|
||||
config = await db_client.get_configuration(
|
||||
organization_id,
|
||||
OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value,
|
||||
)
|
||||
|
||||
if config and config.value:
|
||||
# Simple single-provider format
|
||||
provider = config.value.get("provider", "twilio")
|
||||
|
||||
if provider == "twilio":
|
||||
return {
|
||||
"provider": "twilio",
|
||||
"account_sid": config.value.get("account_sid"),
|
||||
"auth_token": config.value.get("auth_token"),
|
||||
"from_numbers": config.value.get("from_numbers", []),
|
||||
}
|
||||
elif provider == "plivo":
|
||||
return {
|
||||
"provider": "plivo",
|
||||
"auth_id": config.value.get("auth_id"),
|
||||
"auth_token": config.value.get("auth_token"),
|
||||
"from_numbers": config.value.get("from_numbers", []),
|
||||
}
|
||||
elif provider == "vonage":
|
||||
return {
|
||||
"provider": "vonage",
|
||||
"application_id": config.value.get("application_id"),
|
||||
"private_key": config.value.get("private_key"),
|
||||
"api_key": config.value.get("api_key"),
|
||||
"api_secret": config.value.get("api_secret"),
|
||||
"from_numbers": config.value.get("from_numbers", []),
|
||||
}
|
||||
elif provider == "vobiz":
|
||||
return {
|
||||
"provider": "vobiz",
|
||||
"auth_id": config.value.get("auth_id"),
|
||||
"auth_token": config.value.get("auth_token"),
|
||||
"from_numbers": config.value.get("from_numbers", []),
|
||||
}
|
||||
elif provider == "cloudonix":
|
||||
return {
|
||||
"provider": "cloudonix",
|
||||
"bearer_token": config.value.get("bearer_token"),
|
||||
"api_key": config.value.get("api_key"), # For x-cx-apikey validation
|
||||
"domain_id": config.value.get("domain_id"),
|
||||
"from_numbers": config.value.get("from_numbers", []),
|
||||
}
|
||||
elif provider == "telnyx":
|
||||
return {
|
||||
"provider": "telnyx",
|
||||
"api_key": config.value.get("api_key"),
|
||||
"connection_id": config.value.get("connection_id"),
|
||||
"from_numbers": config.value.get("from_numbers", []),
|
||||
}
|
||||
elif provider == "ari":
|
||||
return {
|
||||
"provider": "ari",
|
||||
"ari_endpoint": config.value.get("ari_endpoint"),
|
||||
"app_name": config.value.get("app_name"),
|
||||
"app_password": config.value.get("app_password"),
|
||||
"inbound_workflow_id": config.value.get("inbound_workflow_id"),
|
||||
"from_numbers": config.value.get("from_numbers", []),
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"Unknown provider in config: {provider}")
|
||||
|
||||
raise ValueError(
|
||||
f"No telephony configuration found for organization {organization_id}"
|
||||
)
|
||||
Existing callers that don't carry a config id continue to work via this
|
||||
shim. New code should pass an explicit telephony_configuration_id."""
|
||||
return await load_default_telephony_config(organization_id)
|
||||
|
||||
|
||||
async def get_telephony_provider(organization_id: int) -> TelephonyProvider:
|
||||
"""Deprecated: returns a provider for the org's default config.
|
||||
|
||||
See ``load_telephony_config`` above. New code should call
|
||||
``get_telephony_provider_by_id`` with the resolved config id.
|
||||
"""
|
||||
Factory function to create telephony providers.
|
||||
|
||||
Args:
|
||||
organization_id: Organization ID (required)
|
||||
|
||||
Returns:
|
||||
Configured telephony provider instance
|
||||
|
||||
Raises:
|
||||
ValueError: If provider type is unknown or configuration is invalid
|
||||
"""
|
||||
# Load configuration
|
||||
config = await load_telephony_config(organization_id)
|
||||
|
||||
provider_type = config.get("provider", "twilio")
|
||||
logger.info(f"Creating {provider_type} telephony provider")
|
||||
|
||||
# Create provider instance with configuration
|
||||
if provider_type == "twilio":
|
||||
return TwilioProvider(config)
|
||||
|
||||
elif provider_type == "plivo":
|
||||
return PlivoProvider(config)
|
||||
|
||||
elif provider_type == "vonage":
|
||||
return VonageProvider(config)
|
||||
|
||||
elif provider_type == "vobiz":
|
||||
return VobizProvider(config)
|
||||
|
||||
elif provider_type == "cloudonix":
|
||||
return CloudonixProvider(config)
|
||||
|
||||
elif provider_type == "telnyx":
|
||||
return TelnyxProvider(config)
|
||||
|
||||
elif provider_type == "ari":
|
||||
return ARIProvider(config)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown telephony provider: {provider_type}")
|
||||
return await get_default_telephony_provider(organization_id)
|
||||
|
||||
|
||||
async def get_all_telephony_providers() -> List[Type[TelephonyProvider]]:
|
||||
"""
|
||||
Get all available telephony provider classes for webhook detection.
|
||||
"""All registered provider classes — used by inbound webhook detection."""
|
||||
return [spec.provider_cls for spec in registry.all_specs()]
|
||||
|
||||
|
||||
Returns:
|
||||
List of provider classes that can be used for webhook detection
|
||||
"""
|
||||
return [
|
||||
ARIProvider,
|
||||
CloudonixProvider,
|
||||
PlivoProvider,
|
||||
TelnyxProvider,
|
||||
TwilioProvider,
|
||||
VobizProvider,
|
||||
VonageProvider,
|
||||
]
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _normalize_with_phone_numbers(
|
||||
row: TelephonyConfigurationModel,
|
||||
) -> Dict[str, Any]:
|
||||
"""Run the provider's config_loader over the credentials, then attach the
|
||||
active phone numbers as a ``from_numbers`` list (raw address strings)."""
|
||||
spec = registry.get(row.provider)
|
||||
raw = dict(row.credentials or {})
|
||||
raw["provider"] = row.provider
|
||||
base = spec.config_loader(raw)
|
||||
|
||||
addresses = await db_client.list_active_normalized_addresses_for_config(row.id)
|
||||
base["from_numbers"] = addresses
|
||||
return base
|
||||
|
||||
|
||||
def _instantiate(config: Dict[str, Any]) -> TelephonyProvider:
|
||||
spec = registry.get(config["provider"])
|
||||
logger.info(f"Creating {spec.name} telephony provider")
|
||||
return spec.provider_cls(config)
|
||||
|
|
|
|||
123
api/services/telephony/providers/CLAUDE.md
Normal file
123
api/services/telephony/providers/CLAUDE.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Telephony Providers
|
||||
|
||||
Each subdirectory here is a self-registering telephony provider. Adding a new one should touch this folder plus **exactly two lines** outside it. If a change you're making requires editing `factory.py`, `audio_config.py`, `run_pipeline.py`, `routes/telephony.py`, or any frontend file, stop — that's a smell. Push the variation through the registry instead.
|
||||
|
||||
## Anatomy of a provider package
|
||||
|
||||
```
|
||||
providers/<name>/
|
||||
├── __init__.py # Required. Builds + register()s ProviderSpec
|
||||
├── config.py # Required. Pydantic Request + Response, both with `provider: Literal["<name>"]`
|
||||
├── provider.py # Required. TelephonyProvider subclass
|
||||
├── transport.py # Required. async create_transport(...) -> FastAPIWebsocketTransport
|
||||
├── serializers.py # Optional but conventional. Re-export from pipecat
|
||||
├── routes.py # Optional. APIRouter mounted lazily under /api/v1/telephony
|
||||
└── strategies.py # Optional. Transfer/Hangup strategies for the frame serializer
|
||||
```
|
||||
|
||||
Every file is provider-local. Nothing here imports another provider package.
|
||||
|
||||
## The two edits outside this folder
|
||||
|
||||
After creating `providers/<name>/`:
|
||||
|
||||
1. `providers/__init__.py` — add `<name>` to the import-for-side-effects list. Registration runs at import time.
|
||||
2. `api/schemas/telephony_config.py` — import `<Name>ConfigurationRequest`/`Response` and add the request to the `TelephonyConfigRequest` `Union[...]` and the response as an optional field on `TelephonyConfigurationResponse`.
|
||||
|
||||
If you find yourself editing anything else, re-read the registry plumbing first:
|
||||
|
||||
| Want to change... | Source of truth |
|
||||
| --- | --- |
|
||||
| Outbound provider lookup | `factory.get_telephony_provider*` reads `registry.get(name).provider_cls` |
|
||||
| Stored credentials → constructor dict | `ProviderSpec.config_loader` |
|
||||
| Audio sample rate / VAD rate | `ProviderSpec.transport_sample_rate` (full `AudioConfig` is built in `pipecat/audio_config.py::create_audio_config`) |
|
||||
| Which transport runs in `run_pipeline_telephony` | `ProviderSpec.transport_factory` |
|
||||
| Save-request validation + masked response shape | `ProviderSpec.config_request_cls` / `config_response_cls` |
|
||||
| Form rendered by the telephony-config UI | `ProviderSpec.ui_metadata` (`ProviderUIField` list) |
|
||||
| Which credential masks on read | `ui_metadata.fields[*].sensitive=True` (no separate list) |
|
||||
| Inbound webhook → config row matching | `ProviderSpec.account_id_credential_field` |
|
||||
| HTTP routes (answer URL, status callbacks) | `providers/<name>/routes.py` (auto-mounted via `importlib`) |
|
||||
|
||||
## ProviderSpec — minimum viable shape
|
||||
|
||||
```python
|
||||
SPEC = ProviderSpec(
|
||||
name="<name>", # registry key, WorkflowRunMode value, stored discriminator
|
||||
provider_cls=YourProvider,
|
||||
config_loader=_config_loader, # raw dict from DB → constructor dict
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=8000, # wire-format rate; pipecat derives the full AudioConfig
|
||||
config_request_cls=YourProviderConfigurationRequest,
|
||||
config_response_cls=YourProviderConfigurationResponse,
|
||||
ui_metadata=ProviderUIMetadata(...), # drives the form UI
|
||||
account_id_credential_field="api_key", # "" if provider has no account-id concept
|
||||
)
|
||||
register(SPEC)
|
||||
```
|
||||
|
||||
`ProviderSpec` is frozen — immutable post-registration. Re-registration with the same instance is a no-op; re-registration with a different instance raises.
|
||||
|
||||
## Registration is import-driven, not config-driven
|
||||
|
||||
`api/services/telephony/__init__.py` imports `providers/` for side effects. Don't add a registration call elsewhere — by the time `factory`, `audio_config`, or `run_pipeline_telephony` look the spec up, the package init has already executed.
|
||||
|
||||
The package init **does not import `routes.py`** — `api/routes/telephony.py::_mount_provider_routers()` walks `registry.all_specs()` and uses `importlib.import_module(f"...providers.{spec.name}.routes")`, treating `ModuleNotFoundError` as "no routes for this provider." This is what keeps `from api.services.telephony.base import TelephonyProvider` from fanning out to every route handler in the app. Don't undo it by importing `.routes` from `__init__.py`.
|
||||
|
||||
## Conventions
|
||||
|
||||
### `provider: Literal["<name>"]` on both Request and Response
|
||||
|
||||
Pydantic's discriminated union dispatches on this field. Forgetting `Literal` makes the union accept any provider's payload as yours. Default it to the literal so save calls don't have to send it explicitly.
|
||||
|
||||
### Transports load credentials lazily
|
||||
|
||||
Always:
|
||||
|
||||
```python
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="<name>",
|
||||
)
|
||||
```
|
||||
|
||||
Never read the org's default config from `transport.py`. The workflow run carries `telephony_configuration_id` in `initial_context` for multi-config orgs; `load_credentials_for_transport` resolves the right row and validates the provider matches.
|
||||
|
||||
### `_config_loader` is a pure dict reshape
|
||||
|
||||
It runs over `TelephonyConfigurationModel.credentials` (the JSONB column). Don't do I/O in it. Don't pull `from_numbers` from credentials — the factory attaches active phone numbers from `telephony_phone_numbers` after the loader runs, by joining and normalizing addresses.
|
||||
|
||||
### Sensitive fields
|
||||
|
||||
Mark every credential field `sensitive=True` in `ProviderUIMetadata`. The org routes derive masking from `ui_metadata`, not from a separate hardcoded list. If you re-submit a masked value, `preserve_masked_fields` restores the original — relying on this means you should never write `sensitive=False` on a real secret to "make the form simpler."
|
||||
|
||||
### Inbound webhook routing
|
||||
|
||||
When multiple configs of the same provider live in one org (e.g. two Twilio sub-accounts), the inbound dispatcher matches the webhook to a config by `credentials[<account_id_credential_field>]`. Set this to whatever your provider stamps on inbound payloads (`account_sid` for Twilio, `auth_id` for Plivo, etc.). Set `""` only when the provider truly has no account-id concept (e.g. ARI — there's at most one config per org).
|
||||
|
||||
### `configure_inbound` defaults to no-op
|
||||
|
||||
Override only when the provider supports programmatic webhook binding (Plivo `application_id`, Telnyx app config). Markup-response providers that learn the webhook URL from console-side configuration leave the default. Returning `ProviderSyncResult(ok=False, message="...")` surfaces a non-fatal warning to the user without aborting the DB write.
|
||||
|
||||
## Reference implementations
|
||||
|
||||
Pick the closest shape and copy from it.
|
||||
|
||||
| Provider | Pick when... |
|
||||
| --- | --- |
|
||||
| `twilio/` | Markup-response (TwiML), HMAC-signed webhooks, conference-style transfers, status callbacks. The most full-featured reference. |
|
||||
| `plivo/` | Markup-response with multi-callback signature schemes, programmatic answer-URL sync via Application API. |
|
||||
| `vonage/` | JWT auth, 16 kHz Linear PCM wire format, NCCO JSON responses. |
|
||||
| `cloudonix/` | SIP-trunk-style with custom transfer/hangup strategies. |
|
||||
| `telnyx/` | Call-control style — REST calls to answer/stream rather than markup response. |
|
||||
| `vobiz/` | Body-signed webhooks (signature covers raw bytes). |
|
||||
| `ari/` | Smallest viable: no `routes.py`, no `verify_inbound_signature`, WebSocket-only, no account-id. |
|
||||
|
||||
## What NOT to do
|
||||
|
||||
- **Don't import another provider's `provider.py` or `transport.py`.** Cross-provider behavior belongs in `services/telephony/` (e.g. `status_processor`, `ari_manager`, `call_transfer_manager`), not in another provider's package.
|
||||
- **Don't add a hardcoded provider list anywhere.** If you need to iterate, use `registry.all_specs()` / `registry.names()`.
|
||||
- **Don't add a route under `routes/telephony.py` for a single provider.** Provider-specific handlers go in `providers/<name>/routes.py`. Cross-provider handlers (`/inbound/run`, `/twiml`) stay in `routes/telephony.py`.
|
||||
- **Don't import `.routes` from a provider's `__init__.py`.** That's the cycle we deliberately broke — see "Registration is import-driven."
|
||||
- **Don't write a frontend form for a new provider.** The UI consumes `GET /api/v1/organizations/telephony-providers/metadata` and renders generically from `ProviderUIField`. If a `field.type` you need doesn't exist (`text`/`password`/`textarea`/`string-array`/`number`), extend the renderer in `ui/src/app/(authenticated)/telephony-configurations/` once — not per provider.
|
||||
- **Don't run a database migration to add a provider.** The discriminator lives in JSONB credentials and a `VARCHAR(64)` `mode` column; nothing in the DB schema knows the set of provider names.
|
||||
|
|
@ -1 +1,17 @@
|
|||
# Telephony provider implementations
|
||||
"""Telephony provider implementations.
|
||||
|
||||
Importing this module triggers each provider package to register itself
|
||||
with ``api.services.telephony.registry``. Adding a new provider requires
|
||||
exactly one new line below — no edits to factory, audio_config, schemas,
|
||||
or run_pipeline.
|
||||
"""
|
||||
|
||||
from api.services.telephony.providers import ( # noqa: F401 -- import for side effects (registration)
|
||||
ari,
|
||||
cloudonix,
|
||||
plivo,
|
||||
telnyx,
|
||||
twilio,
|
||||
vobiz,
|
||||
vonage,
|
||||
)
|
||||
|
|
|
|||
86
api/services/telephony/providers/ari/__init__.py
Normal file
86
api/services/telephony/providers/ari/__init__.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
"""ARI (Asterisk REST Interface) telephony provider package."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.services.telephony.registry import (
|
||||
ProviderSpec,
|
||||
ProviderUIField,
|
||||
ProviderUIMetadata,
|
||||
register,
|
||||
)
|
||||
|
||||
from .config import ARIConfigurationRequest, ARIConfigurationResponse
|
||||
from .provider import ARIProvider
|
||||
from .transport import create_transport
|
||||
|
||||
|
||||
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"provider": "ari",
|
||||
"ari_endpoint": value.get("ari_endpoint"),
|
||||
"app_name": value.get("app_name"),
|
||||
"app_password": value.get("app_password"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
||||
_UI_METADATA = ProviderUIMetadata(
|
||||
display_name="Asterisk ARI",
|
||||
docs_url="https://docs.dograh.com/integrations/telephony/asterisk-ari",
|
||||
fields=[
|
||||
ProviderUIField(
|
||||
name="ari_endpoint",
|
||||
label="ARI Endpoint",
|
||||
type="text",
|
||||
description="ARI base URL (e.g., http://asterisk.example.com:8088)",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="app_name",
|
||||
label="Stasis App Name",
|
||||
type="text",
|
||||
description="Stasis application name registered in Asterisk",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="app_password",
|
||||
label="ARI Password",
|
||||
type="password",
|
||||
sensitive=True,
|
||||
),
|
||||
ProviderUIField(
|
||||
name="ws_client_name",
|
||||
label="websocket_client.conf Name",
|
||||
type="text",
|
||||
description="websocket_client.conf connection name for externalMedia",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="From Extensions",
|
||||
type="string-array",
|
||||
description="SIP extensions/numbers for outbound calls",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
SPEC = ProviderSpec(
|
||||
name="ari",
|
||||
provider_cls=ARIProvider,
|
||||
config_loader=_config_loader,
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=8000,
|
||||
config_request_cls=ARIConfigurationRequest,
|
||||
ui_metadata=_UI_METADATA,
|
||||
config_response_cls=ARIConfigurationResponse,
|
||||
)
|
||||
|
||||
|
||||
register(SPEC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC",
|
||||
"ARIConfigurationRequest",
|
||||
"ARIConfigurationResponse",
|
||||
"ARIProvider",
|
||||
"create_transport",
|
||||
]
|
||||
37
api/services/telephony/providers/ari/config.py
Normal file
37
api/services/telephony/providers/ari/config.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
"""ARI (Asterisk REST Interface) telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ARIConfigurationRequest(BaseModel):
|
||||
"""Request schema for Asterisk ARI configuration."""
|
||||
|
||||
provider: Literal["ari"] = Field(default="ari")
|
||||
ari_endpoint: str = Field(
|
||||
..., description="ARI base URL (e.g., http://asterisk.example.com:8088)"
|
||||
)
|
||||
app_name: str = Field(
|
||||
..., description="Stasis application name registered in Asterisk"
|
||||
)
|
||||
app_password: str = Field(..., description="ARI user password")
|
||||
ws_client_name: str = Field(
|
||||
default="",
|
||||
description="websocket_client.conf connection name for externalMedia (e.g., dograh_staging)",
|
||||
)
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of SIP extensions/numbers for outbound calls (optional)",
|
||||
)
|
||||
|
||||
|
||||
class ARIConfigurationResponse(BaseModel):
|
||||
"""Response schema for ARI configuration with masked sensitive fields."""
|
||||
|
||||
provider: Literal["ari"] = Field(default="ari")
|
||||
ari_endpoint: str
|
||||
app_name: str
|
||||
app_password: str # Masked
|
||||
ws_client_name: str = ""
|
||||
from_numbers: List[str]
|
||||
|
|
@ -50,7 +50,6 @@ class ARIProvider(TelephonyProvider):
|
|||
self.ari_endpoint = config.get("ari_endpoint", "").rstrip("/")
|
||||
self.app_name = config.get("app_name", "")
|
||||
self.app_password = config.get("app_password", "")
|
||||
self.inbound_workflow_id = config.get("inbound_workflow_id")
|
||||
self.from_numbers = config.get("from_numbers", [])
|
||||
|
||||
if isinstance(self.from_numbers, str):
|
||||
|
|
@ -251,7 +250,7 @@ class ARIProvider(TelephonyProvider):
|
|||
Unlike Twilio (which sends "connected" and "start" JSON messages),
|
||||
Asterisk chan_websocket starts streaming audio immediately.
|
||||
"""
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_ari
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
# Get channel_id from workflow run context
|
||||
workflow_run = await db_client.get_workflow_run(workflow_run_id, user_id)
|
||||
|
|
@ -263,8 +262,14 @@ class ARIProvider(TelephonyProvider):
|
|||
f"[ARI] Starting pipeline for workflow_run {workflow_run_id}, channel={channel_id}"
|
||||
)
|
||||
|
||||
await run_pipeline_ari(
|
||||
websocket, channel_id, workflow_id, workflow_run_id, user_id
|
||||
await run_pipeline_telephony(
|
||||
websocket,
|
||||
provider_name=self.PROVIDER_NAME,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=channel_id,
|
||||
transport_kwargs={"channel_id": channel_id},
|
||||
)
|
||||
|
||||
# ======== INBOUND CALL METHODS ========
|
||||
|
|
@ -307,15 +312,23 @@ class ARIProvider(TelephonyProvider):
|
|||
return phone_number or ""
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""ARI authenticates via WebSocket connection credentials, not signatures."""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data,
|
||||
backend_endpoint: str,
|
||||
):
|
||||
"""ARI does not generate HTTP responses for inbound calls."""
|
||||
from fastapi import Response
|
||||
|
||||
5
api/services/telephony/providers/ari/serializers.py
Normal file
5
api/services/telephony/providers/ari/serializers.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Asterisk frame serializer (re-exported from pipecat)."""
|
||||
|
||||
from pipecat.serializers.asterisk import AsteriskFrameSerializer
|
||||
|
||||
__all__ = ["AsteriskFrameSerializer"]
|
||||
70
api/services/telephony/providers/ari/transport.py
Normal file
70
api/services/telephony/providers/ari/transport.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
"""ARI (Asterisk) transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import AsteriskFrameSerializer
|
||||
from .strategies import ARIBridgeSwapStrategy, ARIHangupStrategy
|
||||
|
||||
|
||||
async def create_transport(
|
||||
websocket: WebSocket,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
*,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
telephony_configuration_id: int | None = None,
|
||||
channel_id: str,
|
||||
):
|
||||
"""Create a transport for Asterisk ARI connections."""
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="ari"
|
||||
)
|
||||
|
||||
ari_endpoint = config.get("ari_endpoint")
|
||||
app_name = config.get("app_name")
|
||||
app_password = config.get("app_password")
|
||||
|
||||
if not ari_endpoint or not app_name or not app_password:
|
||||
raise ValueError(
|
||||
f"Incomplete ARI configuration for organization {organization_id}. "
|
||||
f"Required: ari_endpoint, app_name, app_password"
|
||||
)
|
||||
|
||||
serializer = AsteriskFrameSerializer(
|
||||
channel_id=channel_id,
|
||||
ari_endpoint=ari_endpoint,
|
||||
app_name=app_name,
|
||||
app_password=app_password,
|
||||
transfer_strategy=ARIBridgeSwapStrategy(),
|
||||
hangup_strategy=ARIHangupStrategy(),
|
||||
params=AsteriskFrameSerializer.InputParams(
|
||||
asterisk_sample_rate=audio_config.transport_in_sample_rate,
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
80
api/services/telephony/providers/cloudonix/__init__.py
Normal file
80
api/services/telephony/providers/cloudonix/__init__.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Cloudonix telephony provider package."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.services.telephony.registry import (
|
||||
ProviderSpec,
|
||||
ProviderUIField,
|
||||
ProviderUIMetadata,
|
||||
register,
|
||||
)
|
||||
|
||||
from .config import CloudonixConfigurationRequest, CloudonixConfigurationResponse
|
||||
from .provider import CloudonixProvider
|
||||
from .transport import create_transport
|
||||
|
||||
|
||||
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"provider": "cloudonix",
|
||||
"bearer_token": value.get("bearer_token"),
|
||||
"api_key": value.get("api_key"), # For x-cx-apikey validation
|
||||
"domain_id": value.get("domain_id"),
|
||||
"application_name": value.get("application_name"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
||||
_UI_METADATA = ProviderUIMetadata(
|
||||
display_name="Cloudonix",
|
||||
docs_url="https://docs.dograh.com/integrations/telephony/cloudonix",
|
||||
fields=[
|
||||
ProviderUIField(
|
||||
name="bearer_token",
|
||||
label="Bearer Token",
|
||||
type="password",
|
||||
sensitive=True,
|
||||
description="Cloudonix API Bearer Token",
|
||||
),
|
||||
ProviderUIField(name="domain_id", label="Domain ID", type="text"),
|
||||
ProviderUIField(
|
||||
name="application_name",
|
||||
label="Application Name",
|
||||
type="text",
|
||||
description=(
|
||||
"Cloudonix Voice Application name whose url is updated when "
|
||||
"inbound workflows are attached to numbers on this domain"
|
||||
),
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="Phone Numbers",
|
||||
type="string-array",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
SPEC = ProviderSpec(
|
||||
name="cloudonix",
|
||||
provider_cls=CloudonixProvider,
|
||||
config_loader=_config_loader,
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=8000,
|
||||
config_request_cls=CloudonixConfigurationRequest,
|
||||
ui_metadata=_UI_METADATA,
|
||||
config_response_cls=CloudonixConfigurationResponse,
|
||||
account_id_credential_field="domain_id",
|
||||
)
|
||||
|
||||
|
||||
register(SPEC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC",
|
||||
"CloudonixConfigurationRequest",
|
||||
"CloudonixConfigurationResponse",
|
||||
"CloudonixProvider",
|
||||
"create_transport",
|
||||
]
|
||||
34
api/services/telephony/providers/cloudonix/config.py
Normal file
34
api/services/telephony/providers/cloudonix/config.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Cloudonix telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CloudonixConfigurationRequest(BaseModel):
|
||||
"""Request schema for Cloudonix configuration."""
|
||||
|
||||
provider: Literal["cloudonix"] = Field(default="cloudonix")
|
||||
bearer_token: str = Field(..., description="Cloudonix API Bearer Token")
|
||||
domain_id: str = Field(..., description="Cloudonix Domain ID")
|
||||
application_name: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Cloudonix Voice Application name. The application's url is "
|
||||
"updated when inbound workflows are attached to numbers on "
|
||||
"this domain."
|
||||
),
|
||||
)
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list, description="List of Cloudonix phone numbers (optional)"
|
||||
)
|
||||
|
||||
|
||||
class CloudonixConfigurationResponse(BaseModel):
|
||||
"""Response schema for Cloudonix configuration with masked sensitive fields."""
|
||||
|
||||
provider: Literal["cloudonix"] = Field(default="cloudonix")
|
||||
bearer_token: str # Masked
|
||||
domain_id: str
|
||||
application_name: str
|
||||
from_numbers: List[str]
|
||||
|
|
@ -14,6 +14,7 @@ from api.enums import WorkflowRunMode
|
|||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
ProviderSyncResult,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
|
@ -39,10 +40,13 @@ class CloudonixProvider(TelephonyProvider):
|
|||
config: Dictionary containing:
|
||||
- bearer_token: Cloudonix API Bearer Token
|
||||
- domain_id: Cloudonix Domain ID
|
||||
- application_name: Cloudonix Voice Application name whose
|
||||
url is updated by ``configure_inbound``
|
||||
- from_numbers: List of phone numbers to use (optional, fetched from API if not provided)
|
||||
"""
|
||||
self.bearer_token = config.get("bearer_token")
|
||||
self.domain_id = config.get("domain_id")
|
||||
self.application_name = config.get("application_name")
|
||||
self.from_numbers = config.get("from_numbers", [])
|
||||
|
||||
# Handle both single number (string) and multiple numbers (list)
|
||||
|
|
@ -384,7 +388,8 @@ class CloudonixProvider(TelephonyProvider):
|
|||
2. "start" event with streamSid and callSid
|
||||
3. Then audio messages
|
||||
"""
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_cloudonix
|
||||
from api.db import db_client
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
try:
|
||||
# Wait for "connected" event
|
||||
|
|
@ -421,9 +426,27 @@ class CloudonixProvider(TelephonyProvider):
|
|||
f"stream_sid: {stream_sid} call_sid: {call_sid}"
|
||||
)
|
||||
|
||||
# Run the Cloudonix pipeline
|
||||
await run_pipeline_cloudonix(
|
||||
websocket, stream_sid, workflow_id, workflow_run_id, user_id
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
call_id = (
|
||||
workflow_run.gathered_context.get("call_id")
|
||||
if workflow_run and workflow_run.gathered_context
|
||||
else None
|
||||
)
|
||||
if not call_id:
|
||||
logger.error(
|
||||
f"call_id not found in gathered_context for workflow run {workflow_run_id}"
|
||||
)
|
||||
await websocket.close(code=4400, reason="Missing call_id")
|
||||
return
|
||||
|
||||
await run_pipeline_telephony(
|
||||
websocket,
|
||||
provider_name=self.PROVIDER_NAME,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=call_id,
|
||||
transport_kwargs={"call_id": call_id, "stream_sid": stream_sid},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -562,14 +585,20 @@ class CloudonixProvider(TelephonyProvider):
|
|||
return clean_number
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], api_key: str
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the API key of an inbound Cloudonix webhook for security.
|
||||
|
||||
Cloudonix uses x-cx-apikey header validation instead of signature verification.
|
||||
The API key from the webhook should match the bearer_token in our configuration.
|
||||
Cloudonix uses ``x-cx-apikey`` header validation instead of signature
|
||||
verification. The API key from the webhook should match the
|
||||
bearer_token in our configuration.
|
||||
"""
|
||||
api_key = headers.get("x-cx-apikey", "")
|
||||
if not api_key:
|
||||
logger.warning("No x-cx-apikey provided in Cloudonix webhook")
|
||||
return False
|
||||
|
|
@ -591,10 +620,86 @@ class CloudonixProvider(TelephonyProvider):
|
|||
|
||||
return True # TODO: update this post clarification from cloudonix
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
) -> ProviderSyncResult:
|
||||
"""Update the ``url`` on the Cloudonix Voice Application.
|
||||
|
||||
PATCH is partial, so we send only ``url`` and ``method=POST`` (our
|
||||
``/inbound/run`` is POST-only); ``type``, ``active``, and ``profile``
|
||||
are preserved as configured in the cockpit. The URL is shared across
|
||||
every DNID on the application — clearing is a no-op to avoid
|
||||
silently breaking inbound for sibling numbers.
|
||||
"""
|
||||
if webhook_url is None:
|
||||
logger.info(
|
||||
f"Cloudonix configure_inbound clear for {address}: skipping "
|
||||
f"application update (url is shared across all DNIDs on Voice "
|
||||
f"Application {self.application_name})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
if not self.validate_config():
|
||||
return ProviderSyncResult(
|
||||
ok=False, message="Cloudonix provider not properly configured"
|
||||
)
|
||||
|
||||
if not self.application_name:
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"Cloudonix application_name is not configured. Set it in "
|
||||
"the telephony configuration so inbound webhooks can be "
|
||||
"synced to the right Voice Application."
|
||||
),
|
||||
)
|
||||
|
||||
app_endpoint = (
|
||||
f"{self.base_url}/customers/self/domains/{self.domain_id}/"
|
||||
f"applications/{self.application_name}"
|
||||
)
|
||||
data = {
|
||||
"url": webhook_url,
|
||||
"method": "POST",
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.patch(
|
||||
app_endpoint, json=data, headers=self._get_auth_headers()
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
body = await response.text()
|
||||
logger.error(
|
||||
f"Cloudonix Voice Application update failed for "
|
||||
f"{self.application_name} on domain "
|
||||
f"{self.domain_id}: {response.status} {body}"
|
||||
)
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=f"Cloudonix API {response.status}: {body}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Exception updating Cloudonix Voice Application "
|
||||
f"{self.application_name}: {e}"
|
||||
)
|
||||
return ProviderSyncResult(ok=False, message=f"Cloudonix update failed: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Cloudonix url set on Voice Application {self.application_name} "
|
||||
f"(domain={self.domain_id}, triggered by address {address})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data,
|
||||
backend_endpoint: str,
|
||||
):
|
||||
"""
|
||||
Generate the appropriate CXML response for an inbound Cloudonix webhook.
|
||||
|
||||
131
api/services/telephony/providers/cloudonix/routes.py
Normal file
131
api/services/telephony/providers/cloudonix/routes.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
"""Cloudonix telephony routes (webhooks, status callbacks, answer URLs).
|
||||
|
||||
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
|
||||
provider registry — see ProviderSpec.router.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/cloudonix/status-callback/{workflow_run_id}")
|
||||
async def handle_cloudonix_status_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
):
|
||||
"""Handle Cloudonix-specific status callbacks.
|
||||
|
||||
Cloudonix sends call status updates to the callback URL specified during call initiation.
|
||||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
# Parse callback data - determine if JSON or form data
|
||||
content_type = request.headers.get("content-type", "")
|
||||
|
||||
if "application/json" in content_type:
|
||||
callback_data = await request.json()
|
||||
else:
|
||||
# Assume form data (like Twilio)
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Cloudonix status callback: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Get workflow run to find organization
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"Workflow run {workflow_run_id} not found for status callback")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
# Get workflow and provider
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
|
||||
return {"status": "ignored", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
# Parse the callback data into generic format
|
||||
parsed_data = provider.parse_status_callback(callback_data)
|
||||
|
||||
# Create StatusCallbackRequest from parsed data
|
||||
status_update = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
from_number=parsed_data.get("from_number"),
|
||||
to_number=parsed_data.get("to_number"),
|
||||
direction=parsed_data.get("direction"),
|
||||
duration=parsed_data.get("duration"),
|
||||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/cloudonix/cdr")
|
||||
async def handle_cloudonix_cdr(request: Request):
|
||||
"""Handle Cloudonix CDR (Call Detail Record) webhooks.
|
||||
|
||||
Cloudonix sends CDR records when calls complete. The CDR contains:
|
||||
- domain: Used to identify the organization
|
||||
- call_id: Used to find the workflow run
|
||||
- disposition: Call termination status (ANSWER, BUSY, CANCEL, FAILED, CONGESTION, NOANSWER)
|
||||
- duration/billsec: Call duration information
|
||||
"""
|
||||
try:
|
||||
cdr_data = await request.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse Cloudonix CDR JSON: {e}")
|
||||
return {"status": "error", "message": "Invalid JSON payload"}
|
||||
|
||||
# Extract domain to find organization
|
||||
domain = cdr_data.get("domain")
|
||||
if not domain:
|
||||
logger.warning("Cloudonix CDR missing domain field")
|
||||
return {"status": "error", "message": "Missing domain field"}
|
||||
|
||||
# Extract call_id to find workflow run
|
||||
call_id = cdr_data.get("session").get("token")
|
||||
logger.info(f"Cloudonix CDR data for call id {call_id} - {cdr_data}")
|
||||
if not call_id:
|
||||
logger.warning("Cloudonix CDR missing call_id field")
|
||||
return {"status": "error", "message": "Missing call_id field"}
|
||||
|
||||
# Find workflow run by call_id in gathered_context
|
||||
workflow_run = await db_client.get_workflow_run_by_call_id(call_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"No workflow run found for Cloudonix call_id: {call_id}")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow_run_id = workflow_run.id
|
||||
set_current_run_id(workflow_run_id)
|
||||
logger.info(f"[run {workflow_run_id}] Processing Cloudonix CDR for call {call_id}")
|
||||
|
||||
# Convert CDR to status update using StatusCallbackRequest
|
||||
status_update = StatusCallbackRequest.from_cloudonix_cdr(cdr_data)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Cloudonix CDR processed successfully - "
|
||||
f"disposition: {cdr_data.get('disposition')}, status: {status_update.status}"
|
||||
)
|
||||
|
||||
return {"status": "success"}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
"""Cloudonix frame serializer (re-exported from pipecat)."""
|
||||
|
||||
from pipecat.serializers.cloudonix import CloudonixFrameSerializer
|
||||
|
||||
__all__ = ["CloudonixFrameSerializer"]
|
||||
66
api/services/telephony/providers/cloudonix/transport.py
Normal file
66
api/services/telephony/providers/cloudonix/transport.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Cloudonix transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import CloudonixFrameSerializer
|
||||
from .strategies import CloudonixHangupStrategy
|
||||
|
||||
|
||||
async def create_transport(
|
||||
websocket: WebSocket,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
*,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
telephony_configuration_id: int | None = None,
|
||||
call_id: str,
|
||||
stream_sid: str,
|
||||
):
|
||||
"""Create a transport for Cloudonix connections."""
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="cloudonix"
|
||||
)
|
||||
|
||||
bearer_token = config.get("bearer_token")
|
||||
domain_id = config.get("domain_id")
|
||||
|
||||
if not bearer_token or not domain_id:
|
||||
raise ValueError(
|
||||
f"Incomplete Cloudonix configuration for organization {organization_id}. "
|
||||
f"Required: bearer_token, domain_id"
|
||||
)
|
||||
|
||||
serializer = CloudonixFrameSerializer(
|
||||
call_id=call_id,
|
||||
stream_sid=stream_sid,
|
||||
domain_id=domain_id,
|
||||
bearer_token=bearer_token,
|
||||
hangup_strategy=CloudonixHangupStrategy(),
|
||||
)
|
||||
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
audio_out_10ms_chunks=2,
|
||||
),
|
||||
)
|
||||
76
api/services/telephony/providers/plivo/__init__.py
Normal file
76
api/services/telephony/providers/plivo/__init__.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Plivo telephony provider package."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.services.telephony.registry import (
|
||||
ProviderSpec,
|
||||
ProviderUIField,
|
||||
ProviderUIMetadata,
|
||||
register,
|
||||
)
|
||||
|
||||
from .config import PlivoConfigurationRequest, PlivoConfigurationResponse
|
||||
from .provider import PlivoProvider
|
||||
from .transport import create_transport
|
||||
|
||||
|
||||
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"provider": "plivo",
|
||||
"auth_id": value.get("auth_id"),
|
||||
"auth_token": value.get("auth_token"),
|
||||
"application_id": value.get("application_id"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
||||
_UI_METADATA = ProviderUIMetadata(
|
||||
display_name="Plivo",
|
||||
docs_url="https://docs.dograh.com/integrations/telephony/plivo",
|
||||
fields=[
|
||||
ProviderUIField(name="auth_id", label="Auth ID", type="text", sensitive=True),
|
||||
ProviderUIField(
|
||||
name="auth_token", label="Auth Token", type="password", sensitive=True
|
||||
),
|
||||
ProviderUIField(
|
||||
name="application_id",
|
||||
label="Application ID",
|
||||
type="text",
|
||||
description=(
|
||||
"Plivo Application ID whose answer_url is updated when inbound "
|
||||
"workflows are attached to numbers on this account"
|
||||
),
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="Phone Numbers",
|
||||
type="string-array",
|
||||
description="E.164-formatted Plivo phone numbers used for outbound calls",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
SPEC = ProviderSpec(
|
||||
name="plivo",
|
||||
provider_cls=PlivoProvider,
|
||||
config_loader=_config_loader,
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=8000,
|
||||
config_request_cls=PlivoConfigurationRequest,
|
||||
ui_metadata=_UI_METADATA,
|
||||
config_response_cls=PlivoConfigurationResponse,
|
||||
account_id_credential_field="auth_id",
|
||||
)
|
||||
|
||||
|
||||
register(SPEC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC",
|
||||
"PlivoConfigurationRequest",
|
||||
"PlivoConfigurationResponse",
|
||||
"PlivoProvider",
|
||||
"create_transport",
|
||||
]
|
||||
33
api/services/telephony/providers/plivo/config.py
Normal file
33
api/services/telephony/providers/plivo/config.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Plivo telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PlivoConfigurationRequest(BaseModel):
|
||||
"""Request schema for Plivo configuration."""
|
||||
|
||||
provider: Literal["plivo"] = Field(default="plivo")
|
||||
auth_id: str = Field(..., description="Plivo Auth ID")
|
||||
auth_token: str = Field(..., description="Plivo Auth Token")
|
||||
application_id: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Plivo Application ID. The application's answer_url is updated "
|
||||
"when inbound workflows are attached to numbers on this account."
|
||||
),
|
||||
)
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list, description="List of Plivo phone numbers"
|
||||
)
|
||||
|
||||
|
||||
class PlivoConfigurationResponse(BaseModel):
|
||||
"""Response schema for Plivo configuration with masked sensitive fields."""
|
||||
|
||||
provider: Literal["plivo"] = Field(default="plivo")
|
||||
auth_id: str # Masked
|
||||
auth_token: str # Masked
|
||||
application_id: str
|
||||
from_numbers: List[str]
|
||||
|
|
@ -6,7 +6,6 @@ import base64
|
|||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
|
@ -20,6 +19,7 @@ from api.enums import WorkflowRunMode
|
|||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
ProviderSyncResult,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
|
@ -39,6 +39,7 @@ class PlivoProvider(TelephonyProvider):
|
|||
def __init__(self, config: Dict[str, Any]):
|
||||
self.auth_id = config.get("auth_id")
|
||||
self.auth_token = config.get("auth_token")
|
||||
self.application_id = config.get("application_id")
|
||||
self.from_numbers = config.get("from_numbers", [])
|
||||
|
||||
if isinstance(self.from_numbers, str):
|
||||
|
|
@ -147,7 +148,9 @@ class PlivoProvider(TelephonyProvider):
|
|||
@staticmethod
|
||||
def _query_map(query: str) -> Dict[str, Any]:
|
||||
return {
|
||||
PlivoProvider._stringify_signature_value(key): PlivoProvider._stringify_signature_value(value)
|
||||
PlivoProvider._stringify_signature_value(
|
||||
key
|
||||
): PlivoProvider._stringify_signature_value(value)
|
||||
for key, value in parse_qs(query, keep_blank_values=True).items()
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +160,9 @@ class PlivoProvider(TelephonyProvider):
|
|||
for key in sorted(params.keys()):
|
||||
value = params[key]
|
||||
if isinstance(value, list):
|
||||
normalized_values = sorted(PlivoProvider._stringify_signature_value(value))
|
||||
normalized_values = sorted(
|
||||
PlivoProvider._stringify_signature_value(value)
|
||||
)
|
||||
parts.append("&".join(f"{key}={item}" for item in normalized_values))
|
||||
else:
|
||||
parts.append(f"{key}={PlivoProvider._stringify_signature_value(value)}")
|
||||
|
|
@ -169,7 +174,9 @@ class PlivoProvider(TelephonyProvider):
|
|||
for key in sorted(params.keys()):
|
||||
value = params[key]
|
||||
if isinstance(value, list):
|
||||
normalized_values = sorted(PlivoProvider._stringify_signature_value(value))
|
||||
normalized_values = sorted(
|
||||
PlivoProvider._stringify_signature_value(value)
|
||||
)
|
||||
parts.append("".join(f"{key}{item}" for item in normalized_values))
|
||||
elif isinstance(value, dict):
|
||||
parts.append(f"{key}{PlivoProvider._sorted_params_string(value)}")
|
||||
|
|
@ -178,9 +185,13 @@ class PlivoProvider(TelephonyProvider):
|
|||
return "".join(parts)
|
||||
|
||||
@staticmethod
|
||||
def _construct_get_url(uri: str, params: Dict[str, Any], empty_post_params: bool = True) -> str:
|
||||
def _construct_get_url(
|
||||
uri: str, params: Dict[str, Any], empty_post_params: bool = True
|
||||
) -> str:
|
||||
parsed_uri = urlparse(uri)
|
||||
base_url = urlunparse((parsed_uri.scheme, parsed_uri.netloc, parsed_uri.path, "", "", ""))
|
||||
base_url = urlunparse(
|
||||
(parsed_uri.scheme, parsed_uri.netloc, parsed_uri.path, "", "", "")
|
||||
)
|
||||
|
||||
combined_params = dict(params)
|
||||
combined_params.update(PlivoProvider._query_map(parsed_uri.query))
|
||||
|
|
@ -220,7 +231,9 @@ class PlivoProvider(TelephonyProvider):
|
|||
).digest()
|
||||
).decode("utf-8")
|
||||
|
||||
candidates = [candidate.strip() for candidate in signature.split(",") if candidate]
|
||||
candidates = [
|
||||
candidate.strip() for candidate in signature.split(",") if candidate
|
||||
]
|
||||
return any(hmac.compare_digest(computed, candidate) for candidate in candidates)
|
||||
|
||||
async def get_webhook_response(
|
||||
|
|
@ -298,7 +311,7 @@ class PlivoProvider(TelephonyProvider):
|
|||
user_id: int,
|
||||
workflow_run_id: int,
|
||||
) -> None:
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_plivo
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
first_msg = await websocket.receive_text()
|
||||
start_msg = json.loads(first_msg)
|
||||
|
|
@ -329,8 +342,14 @@ class PlivoProvider(TelephonyProvider):
|
|||
await websocket.close(code=4400, reason="Missing call ID")
|
||||
return
|
||||
|
||||
await run_pipeline_plivo(
|
||||
websocket, stream_id, call_id, workflow_id, workflow_run_id, user_id
|
||||
await run_pipeline_telephony(
|
||||
websocket,
|
||||
provider_name=self.PROVIDER_NAME,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=call_id,
|
||||
transport_kwargs={"stream_id": stream_id, "call_id": call_id},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -338,8 +357,7 @@ class PlivoProvider(TelephonyProvider):
|
|||
cls, webhook_data: Dict[str, Any], headers: Dict[str, str]
|
||||
) -> bool:
|
||||
has_plivo_signature = (
|
||||
"x-plivo-signature-v3" in headers
|
||||
or "x-plivo-signature-ma-v3" in headers
|
||||
"x-plivo-signature-v3" in headers or "x-plivo-signature-ma-v3" in headers
|
||||
)
|
||||
return has_plivo_signature and "CallUUID" in webhook_data
|
||||
|
||||
|
|
@ -347,8 +365,11 @@ class PlivoProvider(TelephonyProvider):
|
|||
def parse_inbound_webhook(webhook_data: Dict[str, Any]) -> NormalizedInboundData:
|
||||
return NormalizedInboundData(
|
||||
provider=PlivoProvider.PROVIDER_NAME,
|
||||
call_id=webhook_data.get("CallUUID", "") or webhook_data.get("RequestUUID", ""),
|
||||
from_number=PlivoProvider.normalize_phone_number(webhook_data.get("From", "")),
|
||||
call_id=webhook_data.get("CallUUID", "")
|
||||
or webhook_data.get("RequestUUID", ""),
|
||||
from_number=PlivoProvider.normalize_phone_number(
|
||||
webhook_data.get("From", "")
|
||||
),
|
||||
to_number=PlivoProvider.normalize_phone_number(webhook_data.get("To", "")),
|
||||
direction=webhook_data.get("Direction", ""),
|
||||
call_status=webhook_data.get("CallStatus", ""),
|
||||
|
|
@ -387,27 +408,111 @@ class PlivoProvider(TelephonyProvider):
|
|||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
signature: str,
|
||||
nonce: str = "",
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
if os.getenv("ENVIRONMENT") == "local":
|
||||
logger.warning(
|
||||
"Skipping Plivo inbound signature verification in local environment"
|
||||
)
|
||||
return True
|
||||
signature = headers.get("x-plivo-signature-v3") or headers.get(
|
||||
"x-plivo-signature-ma-v3", ""
|
||||
)
|
||||
nonce = headers.get("x-plivo-signature-v3-nonce", "")
|
||||
if not signature:
|
||||
# Plivo always signs its webhooks; missing header means the
|
||||
# request didn't come from Plivo (or was tampered with).
|
||||
logger.warning("Inbound Plivo webhook missing X-Plivo-Signature-V3")
|
||||
return False
|
||||
return await self.verify_webhook_signature(url, webhook_data, signature, nonce)
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
) -> ProviderSyncResult:
|
||||
"""Update the answer_url on the configured Plivo Application.
|
||||
|
||||
Plivo numbers don't carry an answer_url directly — the URL lives on a
|
||||
Plivo Application, and a number is linked to one app via ``app_id``.
|
||||
Every call to this method updates the answer_url on
|
||||
``self.application_id``, regardless of which ``address`` triggered the
|
||||
sync. ``address`` is informational. Linking the number to
|
||||
``self.application_id`` (in the Plivo console, or via the Account
|
||||
Phone Number API) is the operator's responsibility — we only update
|
||||
the application's webhook here.
|
||||
|
||||
Clearing (``webhook_url=None``) is a no-op on Plivo's side: the URL
|
||||
is shared across every number linked to this application, so
|
||||
unsetting it for one number would silently break inbound for the
|
||||
rest. The DB-level disconnect is sufficient — inbound calls without
|
||||
a matching workflow are rejected by the backend.
|
||||
"""
|
||||
if webhook_url is None:
|
||||
logger.info(
|
||||
f"Plivo configure_inbound clear for {address}: skipping "
|
||||
f"application update (answer_url is shared across all numbers "
|
||||
f"on application {self.application_id})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
if not self.validate_config():
|
||||
return ProviderSyncResult(
|
||||
ok=False, message="Plivo provider not properly configured"
|
||||
)
|
||||
|
||||
if not self.application_id:
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"Plivo application_id is not configured. Set it in the "
|
||||
"telephony configuration so inbound webhooks can be "
|
||||
"synced to the right Application."
|
||||
),
|
||||
)
|
||||
|
||||
app_endpoint = f"{self.base_url}/Application/{self.application_id}/"
|
||||
data = {
|
||||
"answer_url": webhook_url,
|
||||
"answer_method": "POST",
|
||||
}
|
||||
auth = aiohttp.BasicAuth(self.auth_id, self.auth_token)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(app_endpoint, json=data, auth=auth) as response:
|
||||
if response.status not in (200, 202):
|
||||
body = await response.text()
|
||||
logger.error(
|
||||
f"Plivo application update failed for "
|
||||
f"{self.application_id}: {response.status} {body}"
|
||||
)
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=f"Plivo API {response.status}: {body}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Exception updating Plivo application {self.application_id}: {e}"
|
||||
)
|
||||
return ProviderSyncResult(ok=False, message=f"Plivo update failed: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Plivo answer_url set on application {self.application_id} "
|
||||
f"(triggered by address {address})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data,
|
||||
backend_endpoint: str,
|
||||
):
|
||||
from fastapi import Response
|
||||
|
||||
hangup_callback_attr = ""
|
||||
if workflow_run_id:
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
hangup_url = f"{backend_endpoint}/api/v1/telephony/plivo/hangup-callback/{workflow_run_id}"
|
||||
hangup_callback_attr = f' statusCallbackUrl="{hangup_url}" statusCallbackMethod="POST"'
|
||||
hangup_callback_attr = (
|
||||
f' statusCallbackUrl="{hangup_url}" statusCallbackMethod="POST"'
|
||||
)
|
||||
|
||||
plivo_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
172
api/services/telephony/providers/plivo/routes.py
Normal file
172
api/services/telephony/providers/plivo/routes.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
"""Plivo telephony routes (webhooks, status callbacks, answer URLs).
|
||||
|
||||
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
|
||||
provider registry — see ProviderSpec.router.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Request
|
||||
from loguru import logger
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _handle_plivo_status_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_plivo_signature_v3: Optional[str],
|
||||
x_plivo_signature_ma_v3: Optional[str],
|
||||
x_plivo_signature_v3_nonce: Optional[str],
|
||||
):
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Plivo callback: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"Workflow run {workflow_run_id} not found for Plivo callback")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
|
||||
return {"status": "ignored", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3
|
||||
if signature:
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
callback_kind = request.url.path.split("/")[-2]
|
||||
full_url = f"{backend_endpoint}/api/v1/telephony/plivo/{callback_kind}/{workflow_run_id}"
|
||||
is_valid = await provider.verify_inbound_signature(
|
||||
full_url,
|
||||
callback_data,
|
||||
dict(request.headers),
|
||||
)
|
||||
if not is_valid:
|
||||
logger.warning(f"[run {workflow_run_id}] Invalid Plivo webhook signature")
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
parsed_data = provider.parse_status_callback(callback_data)
|
||||
status_update = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
from_number=parsed_data.get("from_number"),
|
||||
to_number=parsed_data.get("to_number"),
|
||||
direction=parsed_data.get("direction"),
|
||||
duration=parsed_data.get("duration"),
|
||||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/plivo-xml", include_in_schema=False)
|
||||
async def handle_plivo_xml_webhook(
|
||||
workflow_id: int,
|
||||
user_id: int,
|
||||
workflow_run_id: int,
|
||||
organization_id: int,
|
||||
request: Request,
|
||||
x_plivo_signature_v3: Optional[str] = Header(None),
|
||||
x_plivo_signature_ma_v3: Optional[str] = Header(None),
|
||||
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
|
||||
):
|
||||
"""
|
||||
Handle initial webhook from Plivo when an outbound call is answered.
|
||||
Returns Plivo XML response with Stream element.
|
||||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
provider = await get_telephony_provider(organization_id)
|
||||
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
signature = x_plivo_signature_v3 or x_plivo_signature_ma_v3
|
||||
if signature:
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
full_url = (
|
||||
f"{backend_endpoint}/api/v1/telephony/plivo-xml"
|
||||
f"?workflow_id={workflow_id}"
|
||||
f"&user_id={user_id}"
|
||||
f"&workflow_run_id={workflow_run_id}"
|
||||
f"&organization_id={organization_id}"
|
||||
)
|
||||
is_valid = await provider.verify_inbound_signature(
|
||||
full_url, callback_data, dict(request.headers)
|
||||
)
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Invalid Plivo signature on answer webhook"
|
||||
)
|
||||
return provider.generate_error_response(
|
||||
"invalid_signature", "Invalid webhook signature."
|
||||
)
|
||||
|
||||
call_id = callback_data.get("CallUUID") or callback_data.get("RequestUUID")
|
||||
if call_id:
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
gathered_context = dict(workflow_run.gathered_context or {})
|
||||
gathered_context["call_id"] = call_id
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id, gathered_context=gathered_context
|
||||
)
|
||||
|
||||
response_content = await provider.get_webhook_response(
|
||||
workflow_id, user_id, workflow_run_id
|
||||
)
|
||||
return HTMLResponse(content=response_content, media_type="application/xml")
|
||||
|
||||
|
||||
@router.post("/plivo/hangup-callback/{workflow_run_id}")
|
||||
async def handle_plivo_hangup_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_plivo_signature_v3: Optional[str] = Header(None),
|
||||
x_plivo_signature_ma_v3: Optional[str] = Header(None),
|
||||
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Plivo hangup callbacks."""
|
||||
return await _handle_plivo_status_callback(
|
||||
workflow_run_id,
|
||||
request,
|
||||
x_plivo_signature_v3,
|
||||
x_plivo_signature_ma_v3,
|
||||
x_plivo_signature_v3_nonce,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/plivo/ring-callback/{workflow_run_id}")
|
||||
async def handle_plivo_ring_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_plivo_signature_v3: Optional[str] = Header(None),
|
||||
x_plivo_signature_ma_v3: Optional[str] = Header(None),
|
||||
x_plivo_signature_v3_nonce: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Plivo ring callbacks."""
|
||||
return await _handle_plivo_status_callback(
|
||||
workflow_run_id,
|
||||
request,
|
||||
x_plivo_signature_v3,
|
||||
x_plivo_signature_ma_v3,
|
||||
x_plivo_signature_v3_nonce,
|
||||
)
|
||||
5
api/services/telephony/providers/plivo/serializers.py
Normal file
5
api/services/telephony/providers/plivo/serializers.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Plivo frame serializer (re-exported from pipecat)."""
|
||||
|
||||
from pipecat.serializers.plivo import PlivoFrameSerializer
|
||||
|
||||
__all__ = ["PlivoFrameSerializer"]
|
||||
66
api/services/telephony/providers/plivo/transport.py
Normal file
66
api/services/telephony/providers/plivo/transport.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Plivo transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import PlivoFrameSerializer
|
||||
|
||||
|
||||
async def create_transport(
|
||||
websocket: WebSocket,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
*,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
telephony_configuration_id: int | None = None,
|
||||
stream_id: str,
|
||||
call_id: str,
|
||||
):
|
||||
"""Create a transport for Plivo connections."""
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="plivo"
|
||||
)
|
||||
|
||||
auth_id = config.get("auth_id")
|
||||
auth_token = config.get("auth_token")
|
||||
|
||||
if not auth_id or not auth_token:
|
||||
raise ValueError(
|
||||
f"Incomplete Plivo configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
serializer = PlivoFrameSerializer(
|
||||
stream_id=stream_id,
|
||||
call_id=call_id,
|
||||
auth_id=auth_id,
|
||||
auth_token=auth_token,
|
||||
params=PlivoFrameSerializer.InputParams(
|
||||
plivo_sample_rate=8000,
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
71
api/services/telephony/providers/telnyx/__init__.py
Normal file
71
api/services/telephony/providers/telnyx/__init__.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"""Telnyx telephony provider package."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.services.telephony.registry import (
|
||||
ProviderSpec,
|
||||
ProviderUIField,
|
||||
ProviderUIMetadata,
|
||||
register,
|
||||
)
|
||||
|
||||
from .config import TelnyxConfigurationRequest, TelnyxConfigurationResponse
|
||||
from .provider import TelnyxProvider
|
||||
from .transport import create_transport
|
||||
|
||||
|
||||
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"provider": "telnyx",
|
||||
"api_key": value.get("api_key"),
|
||||
"connection_id": value.get("connection_id"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
||||
_UI_METADATA = ProviderUIMetadata(
|
||||
display_name="Telnyx",
|
||||
docs_url="https://docs.dograh.com/integrations/telephony/telnyx",
|
||||
fields=[
|
||||
ProviderUIField(
|
||||
name="api_key", label="API Key", type="password", sensitive=True
|
||||
),
|
||||
ProviderUIField(
|
||||
name="connection_id",
|
||||
label="Call Control App ID",
|
||||
type="text",
|
||||
description="Telnyx Call Control Application ID (connection_id)",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="Phone Numbers",
|
||||
type="string-array",
|
||||
description="E.164-formatted Telnyx phone numbers",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
SPEC = ProviderSpec(
|
||||
name="telnyx",
|
||||
provider_cls=TelnyxProvider,
|
||||
config_loader=_config_loader,
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=8000,
|
||||
config_request_cls=TelnyxConfigurationRequest,
|
||||
ui_metadata=_UI_METADATA,
|
||||
config_response_cls=TelnyxConfigurationResponse,
|
||||
account_id_credential_field="connection_id",
|
||||
)
|
||||
|
||||
|
||||
register(SPEC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC",
|
||||
"TelnyxConfigurationRequest",
|
||||
"TelnyxConfigurationResponse",
|
||||
"TelnyxProvider",
|
||||
"create_transport",
|
||||
]
|
||||
29
api/services/telephony/providers/telnyx/config.py
Normal file
29
api/services/telephony/providers/telnyx/config.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""Telnyx telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TelnyxConfigurationRequest(BaseModel):
|
||||
"""Request schema for Telnyx configuration."""
|
||||
|
||||
provider: Literal["telnyx"] = Field(default="telnyx")
|
||||
api_key: str = Field(..., description="Telnyx API Key")
|
||||
connection_id: str = Field(
|
||||
..., description="Telnyx Call Control Application ID (connection_id)"
|
||||
)
|
||||
# Phone numbers are managed via the dedicated phone-numbers endpoints; the
|
||||
# legacy /telephony-config POST shim still accepts them inline.
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list, description="List of Telnyx phone numbers"
|
||||
)
|
||||
|
||||
|
||||
class TelnyxConfigurationResponse(BaseModel):
|
||||
"""Response schema for Telnyx configuration with masked sensitive fields."""
|
||||
|
||||
provider: Literal["telnyx"] = Field(default="telnyx")
|
||||
api_key: str # Masked
|
||||
connection_id: str
|
||||
from_numbers: List[str]
|
||||
|
|
@ -16,6 +16,7 @@ from api.enums import WorkflowRunMode
|
|||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
ProviderSyncResult,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
|
@ -241,7 +242,7 @@ class TelnyxProvider(TelephonyProvider):
|
|||
2. "start" event with stream_id, call_control_id, media_format
|
||||
3. "media" events with base64-encoded audio
|
||||
"""
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telnyx
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
try:
|
||||
# Wait for "connected" event
|
||||
|
|
@ -290,14 +291,17 @@ class TelnyxProvider(TelephonyProvider):
|
|||
f"call_control_id={call_control_id}"
|
||||
)
|
||||
|
||||
# Run the Telnyx pipeline
|
||||
await run_pipeline_telnyx(
|
||||
await run_pipeline_telephony(
|
||||
websocket,
|
||||
stream_id,
|
||||
call_control_id,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
provider_name=self.PROVIDER_NAME,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=call_control_id,
|
||||
transport_kwargs={
|
||||
"stream_id": stream_id,
|
||||
"call_control_id": call_control_id,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -409,23 +413,155 @@ class TelnyxProvider(TelephonyProvider):
|
|||
return phone_number or ""
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""Required by the abstract interface. Telnyx signature verification
|
||||
(Ed25519) is not yet implemented — accepts all inbound webhooks for now.
|
||||
(Ed25519 via ``telnyx-signature-ed25519``) is not yet implemented —
|
||||
accepts all inbound webhooks for now.
|
||||
"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
"""Telnyx inbound calls don't use a webhook response for streaming.
|
||||
The streaming is set up via Call Control commands.
|
||||
"""
|
||||
from fastapi import Response
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
) -> ProviderSyncResult:
|
||||
"""Update webhook_event_url on the Telnyx Call Control Application.
|
||||
|
||||
return Response(content="{}", media_type="application/json")
|
||||
PATCH requires application_name even on partial updates, so we GET
|
||||
first to preserve whatever name the user set in the cockpit. The URL
|
||||
is shared across every number on the application — clearing is a
|
||||
no-op to avoid silently breaking inbound for sibling numbers.
|
||||
"""
|
||||
if webhook_url is None:
|
||||
logger.info(
|
||||
f"Telnyx configure_inbound clear for {address}: skipping "
|
||||
f"application update (webhook_event_url is shared across all "
|
||||
f"numbers on Call Control Application {self.connection_id})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
if not self.validate_config():
|
||||
return ProviderSyncResult(
|
||||
ok=False, message="Telnyx provider not properly configured"
|
||||
)
|
||||
|
||||
if not self.connection_id:
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"Telnyx connection_id (Call Control Application ID) is "
|
||||
"not configured. Set it in the telephony configuration "
|
||||
"so inbound webhooks can be synced to the right "
|
||||
"application."
|
||||
),
|
||||
)
|
||||
|
||||
app_endpoint = (
|
||||
f"{self.TELNYX_API_BASE}/call_control_applications/{self.connection_id}"
|
||||
)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
app_endpoint, headers=self._headers()
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
body = await response.text()
|
||||
logger.error(
|
||||
f"Failed to fetch Telnyx Call Control Application "
|
||||
f"{self.connection_id}: {response.status} {body}"
|
||||
)
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=f"Telnyx API {response.status}: {body}",
|
||||
)
|
||||
app_data = await response.json()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Exception fetching Telnyx Call Control Application "
|
||||
f"{self.connection_id}: {e}"
|
||||
)
|
||||
return ProviderSyncResult(ok=False, message=f"Telnyx lookup failed: {e}")
|
||||
|
||||
application_name = (app_data.get("data") or {}).get("application_name")
|
||||
if not application_name:
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Telnyx Call Control Application {self.connection_id} "
|
||||
f"did not return an application_name; cannot PATCH "
|
||||
f"without it."
|
||||
),
|
||||
)
|
||||
|
||||
update_body = {
|
||||
"application_name": application_name,
|
||||
"webhook_event_url": webhook_url,
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.patch(
|
||||
app_endpoint, json=update_body, headers=self._headers()
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
body = await response.text()
|
||||
logger.error(
|
||||
f"Telnyx Call Control Application update failed "
|
||||
f"for {self.connection_id}: {response.status} "
|
||||
f"{body}"
|
||||
)
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=f"Telnyx API {response.status}: {body}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Exception updating Telnyx Call Control Application "
|
||||
f"{self.connection_id}: {e}"
|
||||
)
|
||||
return ProviderSyncResult(ok=False, message=f"Telnyx update failed: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Telnyx webhook_event_url set on Call Control Application "
|
||||
f"{self.connection_id} (triggered by address {address})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data,
|
||||
backend_endpoint: str,
|
||||
):
|
||||
"""Answer the inbound Telnyx call via Call Control and start streaming.
|
||||
|
||||
Unlike markup-response providers, Telnyx ignores webhook response
|
||||
bodies for call control — the call must be answered with a REST
|
||||
call back to Telnyx before media can flow. We do that here and
|
||||
return a simple acknowledgement; on failure, return the
|
||||
ANSWER_FAILED error response so the route stays provider-agnostic.
|
||||
"""
|
||||
events_url = (
|
||||
f"{backend_endpoint}/api/v1/telephony/telnyx/events/{workflow_run_id}"
|
||||
)
|
||||
try:
|
||||
await self.answer_and_stream(
|
||||
call_control_id=normalized_data.call_id,
|
||||
stream_url=websocket_url,
|
||||
webhook_url=events_url,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to answer Telnyx inbound call: {e}")
|
||||
return self.generate_error_response(
|
||||
"ANSWER_FAILED", "Failed to answer call"
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
@staticmethod
|
||||
def generate_error_response(error_type: str, message: str) -> tuple:
|
||||
77
api/services/telephony/providers/telnyx/routes.py
Normal file
77
api/services/telephony/providers/telnyx/routes.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Telnyx telephony routes (webhooks, status callbacks, answer URLs).
|
||||
|
||||
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
|
||||
provider registry — see ProviderSpec.router.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/telnyx/events/{workflow_run_id}")
|
||||
async def handle_telnyx_events(
|
||||
request: Request,
|
||||
workflow_run_id: int,
|
||||
):
|
||||
"""Handle Telnyx Call Control webhook events.
|
||||
|
||||
Telnyx sends all call lifecycle events (call.initiated, call.answered,
|
||||
call.hangup, streaming.started, streaming.stopped) as JSON POST requests.
|
||||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
event_data = await request.json()
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Telnyx event: {json.dumps(event_data)}"
|
||||
)
|
||||
|
||||
# Extract event type from Telnyx envelope
|
||||
data = event_data.get("data", {})
|
||||
event_type = data.get("event_type", "")
|
||||
|
||||
# Skip streaming events — they're informational only
|
||||
if event_type in ("streaming.started", "streaming.stopped"):
|
||||
logger.debug(f"[run {workflow_run_id}] Telnyx streaming event: {event_type}")
|
||||
return {"status": "success"}
|
||||
|
||||
# Get workflow run and provider
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"Workflow run {workflow_run_id} not found for Telnyx event")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
|
||||
return {"status": "ignored", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
# Parse the callback data into generic format
|
||||
parsed_data = provider.parse_status_callback(event_data)
|
||||
|
||||
status_update = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
from_number=parsed_data.get("from_number"),
|
||||
to_number=parsed_data.get("to_number"),
|
||||
direction=parsed_data.get("direction"),
|
||||
duration=parsed_data.get("duration"),
|
||||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
||||
return {"status": "success"}
|
||||
5
api/services/telephony/providers/telnyx/serializers.py
Normal file
5
api/services/telephony/providers/telnyx/serializers.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Telnyx frame serializer (re-exported from pipecat)."""
|
||||
|
||||
from pipecat.serializers.telnyx import TelnyxFrameSerializer
|
||||
|
||||
__all__ = ["TelnyxFrameSerializer"]
|
||||
61
api/services/telephony/providers/telnyx/transport.py
Normal file
61
api/services/telephony/providers/telnyx/transport.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"""Telnyx transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import TelnyxFrameSerializer
|
||||
|
||||
|
||||
async def create_transport(
|
||||
websocket: WebSocket,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
*,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
telephony_configuration_id: int | None = None,
|
||||
stream_id: str,
|
||||
call_control_id: str,
|
||||
):
|
||||
"""Create a transport for Telnyx connections."""
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="telnyx"
|
||||
)
|
||||
|
||||
api_key = config.get("api_key")
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"Incomplete Telnyx configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
serializer = TelnyxFrameSerializer(
|
||||
stream_id=stream_id,
|
||||
call_control_id=call_control_id,
|
||||
api_key=api_key,
|
||||
outbound_encoding="PCMU",
|
||||
inbound_encoding="PCMU",
|
||||
)
|
||||
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
76
api/services/telephony/providers/twilio/__init__.py
Normal file
76
api/services/telephony/providers/twilio/__init__.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Twilio telephony provider package."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.services.telephony.registry import (
|
||||
ProviderSpec,
|
||||
ProviderUIField,
|
||||
ProviderUIMetadata,
|
||||
register,
|
||||
)
|
||||
|
||||
from .config import TwilioConfigurationRequest, TwilioConfigurationResponse
|
||||
from .provider import TwilioProvider
|
||||
from .transport import create_transport
|
||||
|
||||
|
||||
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"provider": "twilio",
|
||||
"account_sid": value.get("account_sid"),
|
||||
"auth_token": value.get("auth_token"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
||||
_UI_METADATA = ProviderUIMetadata(
|
||||
display_name="Twilio",
|
||||
docs_url="https://docs.dograh.com/integrations/telephony/twilio",
|
||||
fields=[
|
||||
ProviderUIField(
|
||||
name="account_sid",
|
||||
label="Account SID",
|
||||
type="text",
|
||||
sensitive=True,
|
||||
description="Twilio Account SID (starts with AC)",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="auth_token",
|
||||
label="Auth Token",
|
||||
type="password",
|
||||
sensitive=True,
|
||||
description="Twilio Auth Token",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="Phone Numbers",
|
||||
type="string-array",
|
||||
description="E.164-formatted Twilio phone numbers used for outbound calls",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
SPEC = ProviderSpec(
|
||||
name="twilio",
|
||||
provider_cls=TwilioProvider,
|
||||
config_loader=_config_loader,
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=8000,
|
||||
config_request_cls=TwilioConfigurationRequest,
|
||||
ui_metadata=_UI_METADATA,
|
||||
config_response_cls=TwilioConfigurationResponse,
|
||||
account_id_credential_field="account_sid",
|
||||
)
|
||||
|
||||
|
||||
register(SPEC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC",
|
||||
"TwilioConfigurationRequest",
|
||||
"TwilioConfigurationResponse",
|
||||
"TwilioProvider",
|
||||
"create_transport",
|
||||
]
|
||||
27
api/services/telephony/providers/twilio/config.py
Normal file
27
api/services/telephony/providers/twilio/config.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Twilio telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TwilioConfigurationRequest(BaseModel):
|
||||
"""Request schema for Twilio configuration."""
|
||||
|
||||
provider: Literal["twilio"] = Field(default="twilio")
|
||||
account_sid: str = Field(..., description="Twilio Account SID")
|
||||
auth_token: str = Field(..., description="Twilio Auth Token")
|
||||
# Phone numbers are managed via the dedicated phone-numbers endpoints; the
|
||||
# legacy /telephony-config POST shim still accepts them inline.
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list, description="List of Twilio phone numbers"
|
||||
)
|
||||
|
||||
|
||||
class TwilioConfigurationResponse(BaseModel):
|
||||
"""Response schema for Twilio configuration with masked sensitive fields."""
|
||||
|
||||
provider: Literal["twilio"] = Field(default="twilio")
|
||||
account_sid: str # Masked (e.g., "****************def0")
|
||||
auth_token: str # Masked (e.g., "****************abc1")
|
||||
from_numbers: List[str]
|
||||
|
|
@ -15,9 +15,11 @@ from api.enums import WorkflowRunMode
|
|||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
ProviderSyncResult,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
from api.utils.telephony_address import normalize_telephony_address
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import WebSocket
|
||||
|
|
@ -253,7 +255,7 @@ class TwilioProvider(TelephonyProvider):
|
|||
2. "start" event with streamSid and callSid
|
||||
3. Then audio messages
|
||||
"""
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_twilio
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
try:
|
||||
# Wait for "connected" event
|
||||
|
|
@ -288,9 +290,14 @@ class TwilioProvider(TelephonyProvider):
|
|||
await websocket.close(code=4400, reason="Missing stream identifiers")
|
||||
return
|
||||
|
||||
# Run the Twilio pipeline
|
||||
await run_pipeline_twilio(
|
||||
websocket, stream_sid, call_sid, workflow_id, workflow_run_id, user_id
|
||||
await run_pipeline_telephony(
|
||||
websocket,
|
||||
provider_name=self.PROVIDER_NAME,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=call_sid,
|
||||
transport_kwargs={"stream_sid": stream_sid, "call_sid": call_sid},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -392,17 +399,117 @@ class TwilioProvider(TelephonyProvider):
|
|||
return stored_account_sid == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Twilio webhook for security.
|
||||
Twilio signs requests with the ``X-Twilio-Signature`` header.
|
||||
"""
|
||||
signature = headers.get("x-twilio-signature", "")
|
||||
if not signature:
|
||||
# Twilio always signs its webhooks; missing header means the
|
||||
# request didn't come from Twilio (or was tampered with).
|
||||
logger.warning("Inbound Twilio webhook missing X-Twilio-Signature")
|
||||
return False
|
||||
return await self.verify_webhook_signature(url, webhook_data, signature)
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
) -> ProviderSyncResult:
|
||||
"""Set (or clear) the VoiceUrl on Twilio's IncomingPhoneNumber for ``address``.
|
||||
|
||||
Looks up the number's SID by E.164 then POSTs the update. Non-PSTN
|
||||
addresses (SIP URIs, extensions) are skipped — Twilio's
|
||||
IncomingPhoneNumbers resource only covers PSTN numbers.
|
||||
"""
|
||||
if not self.validate_config():
|
||||
return ProviderSyncResult(
|
||||
ok=False, message="Twilio provider not properly configured"
|
||||
)
|
||||
|
||||
normalized = normalize_telephony_address(address)
|
||||
if normalized.address_type != "pstn":
|
||||
# Nothing to do on Twilio's side for SIP URIs/extensions.
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
e164 = normalized.canonical
|
||||
try:
|
||||
sid = await self._lookup_incoming_number_sid(e164)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to look up Twilio number {e164}: {e}")
|
||||
return ProviderSyncResult(ok=False, message=f"Twilio lookup failed: {e}")
|
||||
|
||||
if not sid:
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=(
|
||||
f"Phone number {e164} is not owned by this Twilio account "
|
||||
f"({self.account_sid}). Add it in the Twilio console first."
|
||||
),
|
||||
)
|
||||
|
||||
endpoint = f"{self.base_url}/IncomingPhoneNumbers/{sid}.json"
|
||||
if webhook_url:
|
||||
data = {
|
||||
"VoiceUrl": webhook_url,
|
||||
"VoiceMethod": "POST",
|
||||
}
|
||||
else:
|
||||
# Clearing — Twilio treats empty string as "unset".
|
||||
data = {
|
||||
"VoiceUrl": "",
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
|
||||
async with session.post(endpoint, data=data, auth=auth) as response:
|
||||
if response.status not in (200, 201):
|
||||
body = await response.text()
|
||||
logger.error(
|
||||
f"Twilio VoiceUrl update failed for {e164} "
|
||||
f"(sid={sid}): {response.status} {body}"
|
||||
)
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=f"Twilio API {response.status}: {body}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception updating Twilio VoiceUrl for {e164}: {e}")
|
||||
return ProviderSyncResult(ok=False, message=f"Twilio update failed: {e}")
|
||||
|
||||
action = "set" if webhook_url else "cleared"
|
||||
logger.info(f"Twilio VoiceUrl {action} for {e164} (sid={sid})")
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
async def _lookup_incoming_number_sid(self, e164: str) -> Optional[str]:
|
||||
"""Return the Twilio SID of the IncomingPhoneNumber matching ``e164``."""
|
||||
endpoint = f"{self.base_url}/IncomingPhoneNumbers.json"
|
||||
params = {"PhoneNumber": e164}
|
||||
async with aiohttp.ClientSession() as session:
|
||||
auth = aiohttp.BasicAuth(self.account_sid, self.auth_token)
|
||||
async with session.get(endpoint, params=params, auth=auth) as response:
|
||||
if response.status != 200:
|
||||
body = await response.text()
|
||||
raise Exception(f"Twilio API {response.status}: {body}")
|
||||
data = await response.json()
|
||||
numbers = data.get("incoming_phone_numbers") or []
|
||||
if not numbers:
|
||||
return None
|
||||
return numbers[0].get("sid")
|
||||
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data,
|
||||
backend_endpoint: str,
|
||||
):
|
||||
"""
|
||||
Generate TwiML response for an inbound Twilio webhook.
|
||||
|
||||
|
|
@ -413,7 +520,6 @@ class TwilioProvider(TelephonyProvider):
|
|||
# Generate StatusCallback URL using same pattern as outbound calls
|
||||
status_callback_attr = ""
|
||||
if workflow_run_id:
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
status_callback_url = f"{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
|
||||
status_callback_attr = f' statusCallback="{status_callback_url}"'
|
||||
|
||||
106
api/services/telephony/providers/twilio/routes.py
Normal file
106
api/services/telephony/providers/twilio/routes.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"""Twilio telephony routes (webhooks, status callbacks, answer URLs).
|
||||
|
||||
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
|
||||
provider registry — see ProviderSpec.router.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Request
|
||||
from loguru import logger
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/twiml", include_in_schema=False)
|
||||
async def handle_twiml_webhook(
|
||||
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
|
||||
):
|
||||
"""
|
||||
Handle initial webhook from telephony provider.
|
||||
Returns provider-specific response (e.g., TwiML for Twilio).
|
||||
"""
|
||||
|
||||
provider = await get_telephony_provider(organization_id)
|
||||
|
||||
response_content = await provider.get_webhook_response(
|
||||
workflow_id, user_id, workflow_run_id
|
||||
)
|
||||
|
||||
return HTMLResponse(content=response_content, media_type="application/xml")
|
||||
|
||||
|
||||
@router.post("/twilio/status-callback/{workflow_run_id}")
|
||||
async def handle_twilio_status_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_webhook_signature: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Twilio-specific status callbacks."""
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
# Parse form data
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received status callback: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Get workflow run to find organization
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"Workflow run {workflow_run_id} not found for status callback")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
# Get workflow and provider
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(f"Workflow {workflow_run.workflow_id} not found")
|
||||
return {"status": "ignored", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
if x_webhook_signature:
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
full_url = f"{backend_endpoint}/api/v1/telephony/twilio/status-callback/{workflow_run_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
full_url, callback_data, x_webhook_signature
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"Invalid webhook signature for workflow run {workflow_run_id}"
|
||||
)
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
# Parse the callback data into generic format
|
||||
parsed_data = provider.parse_status_callback(callback_data)
|
||||
|
||||
# Create StatusCallbackRequest from parsed data
|
||||
status_update = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
from_number=parsed_data.get("from_number"),
|
||||
to_number=parsed_data.get("to_number"),
|
||||
direction=parsed_data.get("direction"),
|
||||
duration=parsed_data.get("duration"),
|
||||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
||||
return {"status": "success"}
|
||||
10
api/services/telephony/providers/twilio/serializers.py
Normal file
10
api/services/telephony/providers/twilio/serializers.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
"""Twilio frame serializer.
|
||||
|
||||
Re-exported from pipecat. Kept local so transport.py imports from
|
||||
``.serializers`` and we have an obvious place to drop a custom subclass if
|
||||
pipecat upstream lags.
|
||||
"""
|
||||
|
||||
from pipecat.serializers.twilio import TwilioFrameSerializer
|
||||
|
||||
__all__ = ["TwilioFrameSerializer"]
|
||||
65
api/services/telephony/providers/twilio/transport.py
Normal file
65
api/services/telephony/providers/twilio/transport.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"""Twilio transport factory."""
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import TwilioFrameSerializer
|
||||
from .strategies import TwilioConferenceStrategy, TwilioHangupStrategy
|
||||
|
||||
|
||||
async def create_transport(
|
||||
websocket: WebSocket,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
*,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
telephony_configuration_id: int | None = None,
|
||||
stream_sid: str,
|
||||
call_sid: str,
|
||||
):
|
||||
"""Create a transport for Twilio connections."""
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="twilio"
|
||||
)
|
||||
|
||||
account_sid = config.get("account_sid")
|
||||
auth_token = config.get("auth_token")
|
||||
|
||||
if not account_sid or not auth_token:
|
||||
raise ValueError(
|
||||
f"Incomplete Twilio configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
serializer = TwilioFrameSerializer(
|
||||
stream_sid=stream_sid,
|
||||
call_sid=call_sid,
|
||||
account_sid=account_sid,
|
||||
auth_token=auth_token,
|
||||
transfer_strategy=TwilioConferenceStrategy(),
|
||||
hangup_strategy=TwilioHangupStrategy(),
|
||||
)
|
||||
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
82
api/services/telephony/providers/vobiz/__init__.py
Normal file
82
api/services/telephony/providers/vobiz/__init__.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""Vobiz telephony provider package."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.services.telephony.registry import (
|
||||
ProviderSpec,
|
||||
ProviderUIField,
|
||||
ProviderUIMetadata,
|
||||
register,
|
||||
)
|
||||
|
||||
from .config import VobizConfigurationRequest, VobizConfigurationResponse
|
||||
from .provider import VobizProvider
|
||||
from .transport import create_transport
|
||||
|
||||
|
||||
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"provider": "vobiz",
|
||||
"auth_id": value.get("auth_id"),
|
||||
"auth_token": value.get("auth_token"),
|
||||
"application_id": value.get("application_id"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
||||
_UI_METADATA = ProviderUIMetadata(
|
||||
display_name="Vobiz",
|
||||
docs_url="https://docs.dograh.com/integrations/telephony/vobiz",
|
||||
fields=[
|
||||
ProviderUIField(
|
||||
name="auth_id",
|
||||
label="Account ID",
|
||||
type="text",
|
||||
sensitive=True,
|
||||
description="Vobiz Account ID (e.g., MA_SYQRLN1K)",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="auth_token", label="Auth Token", type="password", sensitive=True
|
||||
),
|
||||
ProviderUIField(
|
||||
name="application_id",
|
||||
label="Application ID",
|
||||
type="text",
|
||||
description=(
|
||||
"Vobiz Application ID whose answer_url is updated when "
|
||||
"inbound workflows are attached to numbers on this account"
|
||||
),
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="Phone Numbers",
|
||||
type="string-array",
|
||||
description="E.164-formatted phone numbers without + prefix",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
SPEC = ProviderSpec(
|
||||
name="vobiz",
|
||||
provider_cls=VobizProvider,
|
||||
config_loader=_config_loader,
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=8000,
|
||||
config_request_cls=VobizConfigurationRequest,
|
||||
ui_metadata=_UI_METADATA,
|
||||
config_response_cls=VobizConfigurationResponse,
|
||||
account_id_credential_field="auth_id",
|
||||
)
|
||||
|
||||
|
||||
register(SPEC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC",
|
||||
"VobizConfigurationRequest",
|
||||
"VobizConfigurationResponse",
|
||||
"VobizProvider",
|
||||
"create_transport",
|
||||
]
|
||||
34
api/services/telephony/providers/vobiz/config.py
Normal file
34
api/services/telephony/providers/vobiz/config.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"""Vobiz telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VobizConfigurationRequest(BaseModel):
|
||||
"""Request schema for Vobiz configuration."""
|
||||
|
||||
provider: Literal["vobiz"] = Field(default="vobiz")
|
||||
auth_id: str = Field(..., description="Vobiz Account ID (e.g., MA_SYQRLN1K)")
|
||||
auth_token: str = Field(..., description="Vobiz Auth Token")
|
||||
application_id: str = Field(
|
||||
...,
|
||||
description=(
|
||||
"Vobiz Application ID. The application's answer_url is updated "
|
||||
"when inbound workflows are attached to numbers on this account."
|
||||
),
|
||||
)
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of Vobiz phone numbers (E.164 without + prefix)",
|
||||
)
|
||||
|
||||
|
||||
class VobizConfigurationResponse(BaseModel):
|
||||
"""Response schema for Vobiz configuration with masked sensitive fields."""
|
||||
|
||||
provider: Literal["vobiz"] = Field(default="vobiz")
|
||||
auth_id: str # Masked
|
||||
auth_token: str # Masked
|
||||
application_id: str
|
||||
from_numbers: List[str]
|
||||
|
|
@ -14,6 +14,7 @@ from api.enums import WorkflowRunMode
|
|||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
ProviderSyncResult,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
|
@ -39,10 +40,13 @@ class VobizProvider(TelephonyProvider):
|
|||
config: Dictionary containing:
|
||||
- auth_id: Vobiz Account ID (e.g., MA_SYQRLN1K)
|
||||
- auth_token: Vobiz Auth Token
|
||||
- application_id: Vobiz Application ID whose answer_url is
|
||||
updated by ``configure_inbound``
|
||||
- from_numbers: List of phone numbers to use (E.164 format without +)
|
||||
"""
|
||||
self.auth_id = config.get("auth_id")
|
||||
self.auth_token = config.get("auth_token")
|
||||
self.application_id = config.get("application_id")
|
||||
self.from_numbers = config.get("from_numbers", [])
|
||||
|
||||
# Handle both single number (string) and multiple numbers (list)
|
||||
|
|
@ -356,7 +360,7 @@ class VobizProvider(TelephonyProvider):
|
|||
Extracts stream_id and call_id from the start event and delegates
|
||||
message handling to VobizFrameSerializer.
|
||||
"""
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_vobiz
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
first_msg = await websocket.receive_text()
|
||||
start_msg = json.loads(first_msg)
|
||||
|
|
@ -386,8 +390,14 @@ class VobizProvider(TelephonyProvider):
|
|||
f"stream_id: {stream_id}, call_id: {call_id}"
|
||||
)
|
||||
|
||||
await run_pipeline_vobiz(
|
||||
websocket, stream_id, call_id, workflow_id, workflow_run_id, user_id
|
||||
await run_pipeline_telephony(
|
||||
websocket,
|
||||
provider_name=self.PROVIDER_NAME,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=call_id,
|
||||
transport_kwargs={"stream_id": stream_id, "call_id": call_id},
|
||||
)
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz pipeline completed")
|
||||
|
|
@ -467,22 +477,107 @@ class VobizProvider(TelephonyProvider):
|
|||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
signature: str,
|
||||
timestamp: str = None,
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Verify the signature of an inbound Vobiz webhook for security.
|
||||
Uses the same HMAC-SHA256 verification as other Vobiz webhooks.
|
||||
Uses HMAC-SHA256 over ``timestamp + '.' + body`` with the auth_token.
|
||||
"""
|
||||
signature = headers.get("x-vobiz-signature", "")
|
||||
timestamp = headers.get("x-vobiz-timestamp")
|
||||
if not signature:
|
||||
# Vobiz always signs its webhooks; missing header means the
|
||||
# request didn't come from Vobiz (or was tampered with).
|
||||
logger.warning("Inbound Vobiz webhook missing X-Vobiz-Signature")
|
||||
return False
|
||||
return await self.verify_webhook_signature(
|
||||
url, webhook_data, signature, timestamp, body
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
) -> ProviderSyncResult:
|
||||
"""Update answer_url on the Vobiz Application (Plivo-compatible model).
|
||||
|
||||
Vobiz's update is partial so we POST only ``answer_url`` and
|
||||
``answer_method`` — ``app_name``, ``hangup_url``, etc. stay as the
|
||||
user set them. The URL is shared across every number on the
|
||||
application — clearing is a no-op to avoid silently breaking
|
||||
inbound for sibling numbers.
|
||||
"""
|
||||
if webhook_url is None:
|
||||
logger.info(
|
||||
f"Vobiz configure_inbound clear for {address}: skipping "
|
||||
f"application update (answer_url is shared across all numbers "
|
||||
f"on application {self.application_id})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
if not self.validate_config():
|
||||
return ProviderSyncResult(
|
||||
ok=False, message="Vobiz provider not properly configured"
|
||||
)
|
||||
|
||||
if not self.application_id:
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"Vobiz application_id is not configured. Set it in the "
|
||||
"telephony configuration so inbound webhooks can be "
|
||||
"synced to the right Application."
|
||||
),
|
||||
)
|
||||
|
||||
app_endpoint = (
|
||||
f"{self.base_url}/v1/Account/{self.auth_id}/Application/"
|
||||
f"{self.application_id}/"
|
||||
)
|
||||
data = {
|
||||
"answer_url": webhook_url,
|
||||
"answer_method": "POST",
|
||||
}
|
||||
headers = {
|
||||
"X-Auth-ID": self.auth_id,
|
||||
"X-Auth-Token": self.auth_token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
app_endpoint, json=data, headers=headers
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
body = await response.text()
|
||||
logger.error(
|
||||
f"Vobiz application update failed for "
|
||||
f"{self.application_id}: {response.status} {body}"
|
||||
)
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=f"Vobiz API {response.status}: {body}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Exception updating Vobiz application {self.application_id}: {e}"
|
||||
)
|
||||
return ProviderSyncResult(ok=False, message=f"Vobiz update failed: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Vobiz answer_url set on application {self.application_id} "
|
||||
f"(triggered by address {address})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data,
|
||||
backend_endpoint: str,
|
||||
):
|
||||
"""
|
||||
Generate Vobiz XML response for an inbound webhook.
|
||||
|
||||
420
api/services/telephony/providers/vobiz/routes.py
Normal file
420
api/services/telephony/providers/vobiz/routes.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""Vobiz telephony routes (webhooks, status callbacks, answer URLs).
|
||||
|
||||
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
|
||||
provider registry — see ProviderSpec.router.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Header, Request
|
||||
from loguru import logger
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
from api.utils.telephony_helper import (
|
||||
parse_webhook_request,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/vobiz-xml", include_in_schema=False)
|
||||
async def handle_vobiz_xml_webhook(
|
||||
workflow_id: int, user_id: int, workflow_run_id: int, organization_id: int
|
||||
):
|
||||
"""
|
||||
Handle initial webhook from Vobiz when call is answered.
|
||||
Returns Vobiz XML response with Stream element.
|
||||
|
||||
Vobiz uses Plivo-compatible XML format similar to Twilio's TwiML.
|
||||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz XML webhook called - "
|
||||
f"workflow_id={workflow_id}, user_id={user_id}, org_id={organization_id}"
|
||||
)
|
||||
|
||||
provider = await get_telephony_provider(organization_id)
|
||||
|
||||
logger.debug(f"[run {workflow_run_id}] Using provider: {provider.PROVIDER_NAME}")
|
||||
|
||||
response_content = await provider.get_webhook_response(
|
||||
workflow_id, user_id, workflow_run_id
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"[run {workflow_run_id}] Vobiz XML response generated:\n{response_content}"
|
||||
)
|
||||
|
||||
return HTMLResponse(content=response_content, media_type="application/xml")
|
||||
|
||||
|
||||
@router.post("/vobiz/hangup-callback/{workflow_run_id}")
|
||||
async def handle_vobiz_hangup_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_vobiz_signature: Optional[str] = Header(None),
|
||||
x_vobiz_timestamp: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Vobiz hangup callback (sent when call ends).
|
||||
|
||||
Vobiz sends callbacks to hangup_url when the call terminates.
|
||||
This includes call duration, status, and billing information.
|
||||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
# Logging all headers and body to understand what Vobiz actually sends
|
||||
all_headers = dict(request.headers)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}"
|
||||
)
|
||||
|
||||
# Parse the callback data (Vobiz sends form data or JSON)
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz hangup callback - Body: {json.dumps(callback_data)}"
|
||||
)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz hangup callback {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Verify signature if provided
|
||||
if x_vobiz_signature:
|
||||
# We need the workflow run to get organization for provider credentials
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found for signature verification"
|
||||
)
|
||||
return {"status": "error", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow not found for signature verification"
|
||||
)
|
||||
return {"status": "error", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
# Get raw body for signature verification
|
||||
raw_body = await request.body()
|
||||
webhook_body = raw_body.decode("utf-8")
|
||||
|
||||
# Verify signature
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/{workflow_run_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
webhook_body,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Invalid Vobiz hangup callback signature"
|
||||
)
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz hangup callback signature verified")
|
||||
else:
|
||||
# Get workflow run for processing (signature verification already got it if needed)
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found for Vobiz hangup callback"
|
||||
)
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
# Get workflow and provider
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(f"[run {workflow_run_id}] Workflow not found")
|
||||
return {"status": "ignored", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
logger.debug(
|
||||
f"[run {workflow_run_id}] Processing Vobiz hangup with provider: {provider.PROVIDER_NAME}"
|
||||
)
|
||||
|
||||
# Parse the callback data into generic format
|
||||
parsed_data = provider.parse_status_callback(callback_data)
|
||||
|
||||
logger.debug(
|
||||
f"[run {workflow_run_id}] Parsed Vobiz callback data: {json.dumps(parsed_data)}"
|
||||
)
|
||||
|
||||
# Create StatusCallbackRequest from parsed data
|
||||
status_update = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
from_number=parsed_data.get("from_number"),
|
||||
to_number=parsed_data.get("to_number"),
|
||||
direction=parsed_data.get("direction"),
|
||||
duration=parsed_data.get("duration"),
|
||||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz hangup callback processed successfully")
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/vobiz/ring-callback/{workflow_run_id}")
|
||||
async def handle_vobiz_ring_callback(
|
||||
workflow_run_id: int,
|
||||
request: Request,
|
||||
x_vobiz_signature: Optional[str] = Header(None),
|
||||
x_vobiz_timestamp: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Vobiz ring callback (sent when call starts ringing).
|
||||
|
||||
Vobiz can send callbacks to ring_url when the call starts ringing.
|
||||
This is optional and used for tracking ringing status.
|
||||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
|
||||
# Logging all headers and body to understand what Vobiz actually sends
|
||||
all_headers = dict(request.headers)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz ring callback - Headers: {json.dumps(all_headers)}"
|
||||
)
|
||||
|
||||
# Parse the callback data
|
||||
form_data = await request.form()
|
||||
callback_data = dict(form_data)
|
||||
|
||||
# TODO: Remove this debug logging after Vobiz team clarifies webhook authentication
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz ring callback - Body: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Received Vobiz ring callback {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
# Verify signature if provided
|
||||
if x_vobiz_signature:
|
||||
# We need the workflow run to get organization for provider credentials
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found for signature verification"
|
||||
)
|
||||
return {"status": "error", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow not found for signature verification"
|
||||
)
|
||||
return {"status": "error", "reason": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
# Get raw body for signature verification
|
||||
raw_body = await request.body()
|
||||
webhook_body = raw_body.decode("utf-8")
|
||||
|
||||
# Verify signature
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = (
|
||||
f"{backend_endpoint}/api/v1/telephony/vobiz/ring-callback/{workflow_run_id}"
|
||||
)
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
webhook_body,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Invalid Vobiz ring callback signature"
|
||||
)
|
||||
return {"status": "error", "reason": "invalid_signature"}
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz ring callback signature verified")
|
||||
else:
|
||||
# Get workflow run for processing (signature verification already got it if needed)
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found for Vobiz ring callback"
|
||||
)
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
# Log the ringing event
|
||||
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
|
||||
ring_log = {
|
||||
"status": "ringing",
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"call_id": callback_data.get("call_uuid", callback_data.get("CallUUID", "")),
|
||||
"event_type": "ring",
|
||||
"raw_data": callback_data,
|
||||
}
|
||||
telephony_callback_logs.append(ring_log)
|
||||
|
||||
# Update workflow run logs
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
logs={"telephony_status_callbacks": telephony_callback_logs},
|
||||
)
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz ring callback logged")
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
@router.post("/vobiz/hangup-callback/workflow/{workflow_id}")
|
||||
async def handle_vobiz_hangup_callback_by_workflow(
|
||||
workflow_id: int,
|
||||
request: Request,
|
||||
x_vobiz_signature: Optional[str] = Header(None),
|
||||
x_vobiz_timestamp: Optional[str] = Header(None),
|
||||
):
|
||||
"""Handle Vobiz hangup callback with workflow_id - finds workflow run by call_id."""
|
||||
|
||||
all_headers = dict(request.headers)
|
||||
logger.info(
|
||||
f"[workflow {workflow_id}] Vobiz hangup callback - Headers: {json.dumps(all_headers)}"
|
||||
)
|
||||
|
||||
try:
|
||||
callback_data, _ = await parse_webhook_request(request)
|
||||
except ValueError:
|
||||
callback_data = {}
|
||||
|
||||
call_uuid = callback_data.get("CallUUID") or callback_data.get("call_uuid")
|
||||
logger.info(
|
||||
f"[workflow {workflow_id}] Received Vobiz hangup callback for call {call_uuid}: {json.dumps(callback_data)}"
|
||||
)
|
||||
|
||||
if not call_uuid:
|
||||
logger.warning(
|
||||
f"[workflow {workflow_id}] No call_uuid found in Vobiz hangup callback"
|
||||
)
|
||||
return {"status": "error", "message": "No call_uuid found"}
|
||||
|
||||
workflow_client = WorkflowClient()
|
||||
workflow = await workflow_client.get_workflow_by_id(workflow_id)
|
||||
if not workflow:
|
||||
logger.warning(f"[workflow {workflow_id}] Workflow not found")
|
||||
return {"status": "error", "message": "workflow_not_found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
if x_vobiz_signature:
|
||||
raw_body = await request.body()
|
||||
webhook_body = raw_body.decode("utf-8")
|
||||
backend_endpoint, _ = await get_backend_endpoints()
|
||||
webhook_url = f"{backend_endpoint}/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}"
|
||||
|
||||
is_valid = await provider.verify_webhook_signature(
|
||||
webhook_url,
|
||||
callback_data,
|
||||
x_vobiz_signature,
|
||||
x_vobiz_timestamp,
|
||||
webhook_body,
|
||||
)
|
||||
|
||||
if not is_valid:
|
||||
logger.warning(
|
||||
f"[workflow {workflow_id}] Invalid Vobiz hangup callback signature"
|
||||
)
|
||||
return {"status": "error", "message": "invalid_signature"}
|
||||
|
||||
logger.info(
|
||||
f"[workflow {workflow_id}] Vobiz hangup callback signature verified"
|
||||
)
|
||||
|
||||
try:
|
||||
db_client = WorkflowRunClient()
|
||||
async with db_client.async_session() as session:
|
||||
# Fetch workflow run with matching call_id in gathered_context
|
||||
query = text("""
|
||||
SELECT id FROM workflow_runs
|
||||
WHERE workflow_id = :workflow_id
|
||||
AND CAST(gathered_context AS jsonb) @> CAST(:call_id_json AS jsonb)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""")
|
||||
|
||||
result = await session.execute(
|
||||
query,
|
||||
{
|
||||
"workflow_id": workflow_id,
|
||||
"call_id_json": json.dumps({"call_id": call_uuid}),
|
||||
},
|
||||
)
|
||||
workflow_run_row = result.fetchone()
|
||||
|
||||
if not workflow_run_row:
|
||||
logger.warning(
|
||||
f"[workflow {workflow_id}] No workflow run found for call {call_uuid}"
|
||||
)
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
workflow_run_id = workflow_run_row[0]
|
||||
set_current_run_id(workflow_run_id)
|
||||
logger.info(
|
||||
f"[workflow {workflow_id}] Found workflow run {workflow_run_id} for call {call_uuid}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[workflow {workflow_id}] Error finding workflow run for call {call_uuid}: {e}"
|
||||
)
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
try:
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(f"[run {workflow_run_id}] Workflow run not found")
|
||||
return {"status": "ignored", "reason": "workflow_run_not_found"}
|
||||
|
||||
parsed_data = provider.parse_status_callback(callback_data)
|
||||
|
||||
status = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
from_number=parsed_data.get("from_number"),
|
||||
to_number=parsed_data.get("to_number"),
|
||||
direction=parsed_data.get("direction"),
|
||||
duration=parsed_data.get("duration"),
|
||||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
await _process_status_update(workflow_run_id, status)
|
||||
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Vobiz hangup callback processed successfully"
|
||||
)
|
||||
return {"status": "success"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[run {workflow_run_id}] Error processing Vobiz hangup callback: {e}"
|
||||
)
|
||||
return {"status": "error", "message": str(e)}
|
||||
5
api/services/telephony/providers/vobiz/serializers.py
Normal file
5
api/services/telephony/providers/vobiz/serializers.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Vobiz frame serializer (re-exported from pipecat)."""
|
||||
|
||||
from pipecat.serializers.vobiz import VobizFrameSerializer
|
||||
|
||||
__all__ = ["VobizFrameSerializer"]
|
||||
80
api/services/telephony/providers/vobiz/transport.py
Normal file
80
api/services/telephony/providers/vobiz/transport.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Vobiz transport factory.
|
||||
|
||||
Vobiz uses Plivo-compatible WebSocket protocol:
|
||||
- MULAW audio at 8kHz (same as Twilio)
|
||||
- Base64-encoded audio in JSON messages
|
||||
"""
|
||||
|
||||
from fastapi import WebSocket
|
||||
from loguru import logger
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import VobizFrameSerializer
|
||||
|
||||
|
||||
async def create_transport(
|
||||
websocket: WebSocket,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
*,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
telephony_configuration_id: int | None = None,
|
||||
stream_id: str,
|
||||
call_id: str,
|
||||
):
|
||||
"""Create a transport for Vobiz connections."""
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Creating Vobiz transport - "
|
||||
f"stream_id={stream_id}, call_id={call_id}"
|
||||
)
|
||||
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="vobiz"
|
||||
)
|
||||
|
||||
auth_id = config.get("auth_id")
|
||||
auth_token = config.get("auth_token")
|
||||
|
||||
if not auth_id or not auth_token:
|
||||
raise ValueError(
|
||||
f"Incomplete Vobiz configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
serializer = VobizFrameSerializer(
|
||||
stream_id=stream_id,
|
||||
call_id=call_id,
|
||||
auth_id=auth_id,
|
||||
auth_token=auth_token,
|
||||
params=VobizFrameSerializer.InputParams(
|
||||
vobiz_sample_rate=8000,
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
transport = FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
|
||||
logger.info(f"[run {workflow_run_id}] Vobiz transport created successfully")
|
||||
return transport
|
||||
84
api/services/telephony/providers/vonage/__init__.py
Normal file
84
api/services/telephony/providers/vonage/__init__.py
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
"""Vonage telephony provider package."""
|
||||
|
||||
from typing import Any, Dict
|
||||
|
||||
from api.services.telephony.registry import (
|
||||
ProviderSpec,
|
||||
ProviderUIField,
|
||||
ProviderUIMetadata,
|
||||
register,
|
||||
)
|
||||
|
||||
from .config import VonageConfigurationRequest, VonageConfigurationResponse
|
||||
from .provider import VonageProvider
|
||||
from .transport import create_transport
|
||||
|
||||
|
||||
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {
|
||||
"provider": "vonage",
|
||||
"application_id": value.get("application_id"),
|
||||
"private_key": value.get("private_key"),
|
||||
"api_key": value.get("api_key"),
|
||||
"api_secret": value.get("api_secret"),
|
||||
"from_numbers": value.get("from_numbers", []),
|
||||
}
|
||||
|
||||
|
||||
_UI_METADATA = ProviderUIMetadata(
|
||||
display_name="Vonage",
|
||||
docs_url="https://docs.dograh.com/integrations/telephony/vonage",
|
||||
fields=[
|
||||
ProviderUIField(name="application_id", label="Application ID", type="text"),
|
||||
ProviderUIField(
|
||||
name="private_key",
|
||||
label="Private Key",
|
||||
type="textarea",
|
||||
sensitive=True,
|
||||
description="Vonage RSA private key for JWT generation",
|
||||
),
|
||||
ProviderUIField(
|
||||
name="api_key",
|
||||
label="API Key",
|
||||
type="text",
|
||||
sensitive=True,
|
||||
),
|
||||
ProviderUIField(
|
||||
name="api_secret",
|
||||
label="API Secret",
|
||||
type="password",
|
||||
sensitive=True,
|
||||
),
|
||||
ProviderUIField(
|
||||
name="from_numbers",
|
||||
label="Phone Numbers",
|
||||
type="string-array",
|
||||
description="Vonage phone numbers without + prefix",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
SPEC = ProviderSpec(
|
||||
name="vonage",
|
||||
provider_cls=VonageProvider,
|
||||
config_loader=_config_loader,
|
||||
transport_factory=create_transport,
|
||||
transport_sample_rate=16000,
|
||||
config_request_cls=VonageConfigurationRequest,
|
||||
ui_metadata=_UI_METADATA,
|
||||
config_response_cls=VonageConfigurationResponse,
|
||||
account_id_credential_field="api_key",
|
||||
)
|
||||
|
||||
|
||||
register(SPEC)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SPEC",
|
||||
"VonageConfigurationRequest",
|
||||
"VonageConfigurationResponse",
|
||||
"VonageProvider",
|
||||
"create_transport",
|
||||
]
|
||||
30
api/services/telephony/providers/vonage/config.py
Normal file
30
api/services/telephony/providers/vonage/config.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
"""Vonage telephony configuration schemas."""
|
||||
|
||||
from typing import List, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class VonageConfigurationRequest(BaseModel):
|
||||
"""Request schema for Vonage configuration."""
|
||||
|
||||
provider: Literal["vonage"] = Field(default="vonage")
|
||||
api_key: str = Field(..., description="Vonage API Key")
|
||||
api_secret: str = Field(..., description="Vonage API Secret")
|
||||
application_id: str = Field(..., description="Vonage Application ID")
|
||||
private_key: str = Field(..., description="Private key for JWT generation")
|
||||
from_numbers: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of Vonage phone numbers (without + prefix)",
|
||||
)
|
||||
|
||||
|
||||
class VonageConfigurationResponse(BaseModel):
|
||||
"""Response schema for Vonage configuration with masked sensitive fields."""
|
||||
|
||||
provider: Literal["vonage"] = Field(default="vonage")
|
||||
application_id: str # Not sensitive, can show full
|
||||
api_key: str # Masked
|
||||
api_secret: str # Masked
|
||||
private_key: str # Masked
|
||||
from_numbers: List[str]
|
||||
|
|
@ -16,6 +16,7 @@ from api.enums import WorkflowRunMode
|
|||
from api.services.telephony.base import (
|
||||
CallInitiationResult,
|
||||
NormalizedInboundData,
|
||||
ProviderSyncResult,
|
||||
TelephonyProvider,
|
||||
)
|
||||
from api.utils.common import get_backend_endpoints
|
||||
|
|
@ -324,7 +325,7 @@ class VonageProvider(TelephonyProvider):
|
|||
2. Or directly start with binary audio
|
||||
"""
|
||||
from api.db import db_client
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_vonage
|
||||
from api.services.pipecat.run_pipeline import run_pipeline_telephony
|
||||
|
||||
try:
|
||||
# Get workflow run to extract call UUID
|
||||
|
|
@ -375,15 +376,14 @@ class VonageProvider(TelephonyProvider):
|
|||
logger.debug(f"Vonage started with binary audio for {workflow_run_id}")
|
||||
# The pipeline will handle this first audio chunk
|
||||
|
||||
# Run the Vonage pipeline
|
||||
await run_pipeline_vonage(
|
||||
await run_pipeline_telephony(
|
||||
websocket,
|
||||
call_uuid,
|
||||
workflow,
|
||||
workflow.organization_id,
|
||||
workflow_id,
|
||||
workflow_run_id,
|
||||
user_id,
|
||||
provider_name=self.PROVIDER_NAME,
|
||||
workflow_id=workflow_id,
|
||||
workflow_run_id=workflow_run_id,
|
||||
user_id=user_id,
|
||||
call_id=call_uuid,
|
||||
transport_kwargs={"call_uuid": call_uuid},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -442,17 +442,134 @@ class VonageProvider(TelephonyProvider):
|
|||
return stored_api_key == webhook_account_id
|
||||
|
||||
async def verify_inbound_signature(
|
||||
self, url: str, webhook_data: Dict[str, Any], signature: str
|
||||
self,
|
||||
url: str,
|
||||
webhook_data: Dict[str, Any],
|
||||
headers: Dict[str, str],
|
||||
body: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Vonage inbound signature verification - minimalist implementation.
|
||||
"""
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def generate_inbound_response(
|
||||
websocket_url: str, workflow_run_id: int = None
|
||||
) -> tuple:
|
||||
async def configure_inbound(
|
||||
self, address: str, webhook_url: Optional[str]
|
||||
) -> ProviderSyncResult:
|
||||
"""Update the answer_url on Vonage's Application for ``address``.
|
||||
|
||||
Vonage routes inbound calls per-application: a single ``answer_url`` on
|
||||
``self.application_id`` applies to every number attached to it. The
|
||||
``address`` argument is informational — every call to this method
|
||||
rewrites (or leaves alone) the application's webhook, regardless of
|
||||
which number triggered the sync.
|
||||
|
||||
Vonage's PUT /v2/applications/{id} is full-replacement, so we GET the
|
||||
current application, mutate ``capabilities.voice.webhooks.answer_url``,
|
||||
and PUT the result back. ``api_key`` and ``api_secret`` are used for
|
||||
Basic auth on the application API (the JWT auth used elsewhere is for
|
||||
the Voice API, not the Application API).
|
||||
|
||||
Clearing (``webhook_url=None``) is a no-op on the Vonage side: the URL
|
||||
is shared across all numbers on this application, so unsetting it for
|
||||
one number would silently break inbound for every other number still
|
||||
attached. The DB-level disconnect is sufficient — inbound calls
|
||||
without a matching workflow are rejected by the backend.
|
||||
"""
|
||||
if webhook_url is None:
|
||||
logger.info(
|
||||
f"Vonage configure_inbound clear for {address}: skipping "
|
||||
f"application update (answer_url is shared across all numbers "
|
||||
f"on application {self.application_id})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
if not self.validate_config():
|
||||
return ProviderSyncResult(
|
||||
ok=False, message="Vonage provider not properly configured"
|
||||
)
|
||||
|
||||
if not (self.api_key and self.api_secret):
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=(
|
||||
"Vonage api_key and api_secret are required to update the "
|
||||
"application's answer_url"
|
||||
),
|
||||
)
|
||||
|
||||
app_endpoint = f"{self.base_url}/v2/applications/{self.application_id}"
|
||||
auth = aiohttp.BasicAuth(self.api_key, self.api_secret)
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(app_endpoint, auth=auth) as response:
|
||||
if response.status != 200:
|
||||
body = await response.text()
|
||||
logger.error(
|
||||
f"Failed to fetch Vonage application "
|
||||
f"{self.application_id}: {response.status} {body}"
|
||||
)
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=f"Vonage API {response.status}: {body}",
|
||||
)
|
||||
app_data = await response.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Exception fetching Vonage application: {e}")
|
||||
return ProviderSyncResult(ok=False, message=f"Vonage lookup failed: {e}")
|
||||
|
||||
capabilities = app_data.get("capabilities") or {}
|
||||
voice = capabilities.get("voice") or {}
|
||||
webhooks = voice.get("webhooks") or {}
|
||||
|
||||
webhooks["answer_url"] = {
|
||||
"address": webhook_url,
|
||||
"http_method": "POST",
|
||||
}
|
||||
voice["webhooks"] = webhooks
|
||||
capabilities["voice"] = voice
|
||||
|
||||
update_body = {
|
||||
"name": app_data.get("name"),
|
||||
"capabilities": capabilities,
|
||||
}
|
||||
if "privacy" in app_data:
|
||||
update_body["privacy"] = app_data["privacy"]
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.put(
|
||||
app_endpoint, json=update_body, auth=auth
|
||||
) as response:
|
||||
if response.status not in (200, 201):
|
||||
body = await response.text()
|
||||
logger.error(
|
||||
f"Vonage application update failed for "
|
||||
f"{self.application_id}: {response.status} {body}"
|
||||
)
|
||||
return ProviderSyncResult(
|
||||
ok=False,
|
||||
message=f"Vonage API {response.status}: {body}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Exception updating Vonage application: {e}")
|
||||
return ProviderSyncResult(ok=False, message=f"Vonage update failed: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Vonage answer_url set on application {self.application_id} "
|
||||
f"(triggered by address {address})"
|
||||
)
|
||||
return ProviderSyncResult(ok=True)
|
||||
|
||||
async def start_inbound_stream(
|
||||
self,
|
||||
*,
|
||||
websocket_url: str,
|
||||
workflow_run_id: int,
|
||||
normalized_data,
|
||||
backend_endpoint: str,
|
||||
):
|
||||
"""
|
||||
Generate NCCO response for inbound Vonage webhook.
|
||||
"""
|
||||
120
api/services/telephony/providers/vonage/routes.py
Normal file
120
api/services/telephony/providers/vonage/routes.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Vonage telephony routes (webhooks, status callbacks, answer URLs).
|
||||
|
||||
Mounted under ``/api/v1/telephony`` by ``api.routes.telephony`` via the
|
||||
provider registry — see ProviderSpec.router.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from loguru import logger
|
||||
|
||||
from api.db import db_client
|
||||
from api.services.telephony.factory import get_telephony_provider
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
from pipecat.utils.run_context import set_current_run_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ncco", include_in_schema=False)
|
||||
async def handle_ncco_webhook(
|
||||
workflow_id: int,
|
||||
user_id: int,
|
||||
workflow_run_id: int,
|
||||
organization_id: Optional[int] = None,
|
||||
):
|
||||
"""Handle NCCO (Nexmo Call Control Objects) webhook for Vonage.
|
||||
|
||||
Returns JSON response instead of XML like TwiML.
|
||||
"""
|
||||
|
||||
provider = await get_telephony_provider(organization_id or user_id)
|
||||
|
||||
response_content = await provider.get_webhook_response(
|
||||
workflow_id, user_id, workflow_run_id
|
||||
)
|
||||
|
||||
return json.loads(response_content)
|
||||
|
||||
|
||||
@router.post("/vonage/events/{workflow_run_id}")
|
||||
async def handle_vonage_events(
|
||||
request: Request,
|
||||
workflow_run_id: int,
|
||||
):
|
||||
"""Handle Vonage-specific event webhooks.
|
||||
|
||||
Vonage sends all call events to a single endpoint.
|
||||
Events include: started, ringing, answered, complete, failed, etc.
|
||||
"""
|
||||
set_current_run_id(workflow_run_id)
|
||||
# Parse the event data
|
||||
event_data = await request.json()
|
||||
logger.info(f"[run {workflow_run_id}] Received Vonage event: {event_data}")
|
||||
|
||||
# Get workflow run for processing
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.error(f"[run {workflow_run_id}] Workflow run not found")
|
||||
return {"status": "error", "message": "Workflow run not found"}
|
||||
|
||||
# For a completed call that includes cost info, capture it immediately
|
||||
if event_data.get("status") == "completed":
|
||||
# Vonage sometimes includes price info in the webhook
|
||||
if "price" in event_data or "rate" in event_data:
|
||||
try:
|
||||
if workflow_run.cost_info:
|
||||
# Store immediate cost info if available
|
||||
cost_info = workflow_run.cost_info.copy()
|
||||
if "price" in event_data:
|
||||
cost_info["vonage_webhook_price"] = float(event_data["price"])
|
||||
if "rate" in event_data:
|
||||
cost_info["vonage_webhook_rate"] = float(event_data["rate"])
|
||||
if "duration" in event_data:
|
||||
cost_info["vonage_webhook_duration"] = int(
|
||||
event_data["duration"]
|
||||
)
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id, cost_info=cost_info
|
||||
)
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Captured Vonage cost info from webhook"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[run {workflow_run_id}] Failed to capture Vonage cost from webhook: {e}"
|
||||
)
|
||||
|
||||
# Get workflow and provider
|
||||
workflow = await db_client.get_workflow_by_id(workflow_run.workflow_id)
|
||||
if not workflow:
|
||||
logger.error(f"[run {workflow_run_id}] Workflow not found")
|
||||
return {"status": "error", "message": "Workflow not found"}
|
||||
|
||||
provider = await get_telephony_provider(workflow.organization_id)
|
||||
|
||||
# Parse the event data into generic format
|
||||
parsed_data = provider.parse_status_callback(event_data)
|
||||
|
||||
# Create StatusCallbackRequest from parsed data
|
||||
status_update = StatusCallbackRequest(
|
||||
call_id=parsed_data["call_id"],
|
||||
status=parsed_data["status"],
|
||||
from_number=parsed_data.get("from_number"),
|
||||
to_number=parsed_data.get("to_number"),
|
||||
direction=parsed_data.get("direction"),
|
||||
duration=parsed_data.get("duration"),
|
||||
extra=parsed_data.get("extra", {}),
|
||||
)
|
||||
|
||||
# Process the status update
|
||||
await _process_status_update(workflow_run_id, status_update)
|
||||
|
||||
# Return 204 No Content as expected by Vonage
|
||||
return {"status": "ok"}
|
||||
5
api/services/telephony/providers/vonage/serializers.py
Normal file
5
api/services/telephony/providers/vonage/serializers.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Vonage frame serializer (re-exported from pipecat)."""
|
||||
|
||||
from pipecat.serializers.vonage import VonageFrameSerializer
|
||||
|
||||
__all__ = ["VonageFrameSerializer"]
|
||||
63
api/services/telephony/providers/vonage/transport.py
Normal file
63
api/services/telephony/providers/vonage/transport.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Vonage transport factory."""
|
||||
|
||||
from api.services.pipecat.audio_config import AudioConfig
|
||||
from api.services.pipecat.audio_mixer import build_audio_out_mixer
|
||||
from api.services.telephony.factory import load_credentials_for_transport
|
||||
from pipecat.transports.websocket.fastapi import (
|
||||
FastAPIWebsocketParams,
|
||||
FastAPIWebsocketTransport,
|
||||
)
|
||||
|
||||
from .serializers import VonageFrameSerializer
|
||||
|
||||
|
||||
async def create_transport(
|
||||
websocket,
|
||||
workflow_run_id: int,
|
||||
audio_config: AudioConfig,
|
||||
organization_id: int,
|
||||
*,
|
||||
vad_config: dict | None = None,
|
||||
ambient_noise_config: dict | None = None,
|
||||
telephony_configuration_id: int | None = None,
|
||||
call_uuid: str,
|
||||
):
|
||||
"""Create a transport for Vonage connections."""
|
||||
config = await load_credentials_for_transport(
|
||||
organization_id, telephony_configuration_id, expected_provider="vonage"
|
||||
)
|
||||
|
||||
application_id = config.get("application_id")
|
||||
private_key = config.get("private_key")
|
||||
|
||||
if not application_id or not private_key:
|
||||
raise ValueError(
|
||||
f"Incomplete Vonage configuration for organization {organization_id}"
|
||||
)
|
||||
|
||||
serializer = VonageFrameSerializer(
|
||||
call_uuid=call_uuid,
|
||||
application_id=application_id,
|
||||
private_key=private_key,
|
||||
params=VonageFrameSerializer.InputParams(
|
||||
vonage_sample_rate=audio_config.transport_in_sample_rate,
|
||||
sample_rate=audio_config.pipeline_sample_rate,
|
||||
),
|
||||
)
|
||||
|
||||
mixer = await build_audio_out_mixer(
|
||||
audio_config.transport_out_sample_rate, ambient_noise_config
|
||||
)
|
||||
|
||||
# Vonage uses binary WebSocket mode, not text
|
||||
return FastAPIWebsocketTransport(
|
||||
websocket=websocket,
|
||||
params=FastAPIWebsocketParams(
|
||||
audio_in_enabled=True,
|
||||
audio_out_enabled=True,
|
||||
audio_in_sample_rate=audio_config.transport_in_sample_rate,
|
||||
audio_out_sample_rate=audio_config.transport_out_sample_rate,
|
||||
audio_out_mixer=mixer,
|
||||
serializer=serializer,
|
||||
),
|
||||
)
|
||||
148
api/services/telephony/registry.py
Normal file
148
api/services/telephony/registry.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"""Provider registry for telephony.
|
||||
|
||||
Each provider package registers itself by importing this module and calling
|
||||
``register(ProviderSpec(...))`` from its ``__init__.py``. Consumers (factory,
|
||||
audio config, run_pipeline, schemas) look up providers through ``get(name)``
|
||||
or iterate via ``all_specs()`` instead of branching on provider name.
|
||||
|
||||
Adding a new provider should not require any edit outside its own folder
|
||||
plus a single import line in ``providers/__init__.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Type,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from api.services.telephony.base import TelephonyProvider
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderUIField:
|
||||
"""One form field for the telephony configuration UI.
|
||||
|
||||
Used to generate provider-specific config forms without per-provider
|
||||
UI code. Field semantics mirror the Pydantic config_request_cls.
|
||||
"""
|
||||
|
||||
name: str # Must match the Pydantic field name on config_request_cls
|
||||
label: str
|
||||
type: str # "text" | "password" | "textarea" | "string-array" | "number"
|
||||
required: bool = True
|
||||
sensitive: bool = False # If true, mask when displaying stored value
|
||||
description: Optional[str] = None
|
||||
placeholder: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderUIMetadata:
|
||||
"""Display metadata for a provider's configuration form."""
|
||||
|
||||
display_name: str
|
||||
fields: List[ProviderUIField]
|
||||
docs_url: Optional[str] = None
|
||||
|
||||
|
||||
# Signature every provider's transport factory must satisfy.
|
||||
# Provider-specific args (stream_sid, call_sid, channel_id, ...) are passed via **kwargs.
|
||||
TransportFactory = Callable[..., Awaitable[Any]]
|
||||
|
||||
# Loader takes the raw config.value dict from the DB and returns a normalized
|
||||
# config dict that the provider class accepts in its constructor.
|
||||
ConfigLoader = Callable[[Dict[str, Any]], Dict[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProviderSpec:
|
||||
"""Everything needed to plug a telephony provider into the platform.
|
||||
|
||||
Attributes:
|
||||
name: Stable identifier (e.g., "twilio"). Used as the discriminator in
|
||||
stored config JSON and as the WorkflowRunMode value.
|
||||
provider_cls: The TelephonyProvider subclass.
|
||||
config_loader: Normalizes raw stored config into the dict shape the
|
||||
provider constructor expects. Replaces the if/elif chain in the
|
||||
old factory.load_telephony_config().
|
||||
transport_factory: Async callable that creates the pipecat transport
|
||||
for an accepted WebSocket. Provider-specific kwargs (stream_sid,
|
||||
call_sid, etc.) are forwarded as ``**kwargs``.
|
||||
transport_sample_rate: Wire-format audio sample rate this provider
|
||||
uses (e.g. 8000 for Twilio/Plivo, 16000 for Vonage). The pipecat
|
||||
layer derives the full ``AudioConfig`` from this.
|
||||
config_request_cls: Pydantic model for incoming save requests.
|
||||
config_response_cls: Pydantic model for outgoing (masked) responses.
|
||||
ui_metadata: Optional form metadata used by the telephony-config
|
||||
UI to render a provider-specific form. Surfaced via
|
||||
``GET /api/v1/telephony/providers/metadata``.
|
||||
|
||||
Note: provider routes (webhooks, status callbacks, answer URLs) are
|
||||
NOT carried on the spec. They live in
|
||||
``providers/<name>/routes.py`` and are loaded on-demand by
|
||||
``api.routes.telephony`` via ``importlib`` so route handlers (which
|
||||
can have deep dependency chains into campaign/db code) don't get
|
||||
pulled in just because someone imported a TelephonyProvider type.
|
||||
"""
|
||||
|
||||
name: str
|
||||
provider_cls: Type["TelephonyProvider"]
|
||||
config_loader: ConfigLoader
|
||||
transport_factory: TransportFactory
|
||||
transport_sample_rate: int
|
||||
config_request_cls: Type[BaseModel]
|
||||
config_response_cls: Type[BaseModel]
|
||||
ui_metadata: Optional[ProviderUIMetadata] = None
|
||||
# Credential field that uniquely identifies the provider account. Used to
|
||||
# (a) match an inbound webhook to the right org config when multiple configs
|
||||
# exist for the same provider, and (b) reject duplicate-account saves.
|
||||
# Empty string means the provider has no account-id concept (e.g. ARI).
|
||||
account_id_credential_field: str = ""
|
||||
|
||||
|
||||
_REGISTRY: Dict[str, ProviderSpec] = {}
|
||||
|
||||
|
||||
def register(spec: ProviderSpec) -> None:
|
||||
"""Register a provider. Called once per provider at import time."""
|
||||
if spec.name in _REGISTRY:
|
||||
# Re-registration is benign as long as the spec is the same instance.
|
||||
# Otherwise it indicates a duplicate provider name, which is a bug.
|
||||
if _REGISTRY[spec.name] is not spec:
|
||||
raise ValueError(f"Provider '{spec.name}' is already registered")
|
||||
return
|
||||
_REGISTRY[spec.name] = spec
|
||||
|
||||
|
||||
def get(name: str) -> ProviderSpec:
|
||||
"""Look up a registered provider by name."""
|
||||
try:
|
||||
return _REGISTRY[name]
|
||||
except KeyError:
|
||||
raise ValueError(f"Unknown telephony provider: {name}") from None
|
||||
|
||||
|
||||
def get_optional(name: str) -> Optional[ProviderSpec]:
|
||||
"""Look up a registered provider by name, returning None if not registered."""
|
||||
return _REGISTRY.get(name)
|
||||
|
||||
|
||||
def all_specs() -> List[ProviderSpec]:
|
||||
"""Return all registered providers in name-sorted order (stable iteration)."""
|
||||
return [_REGISTRY[k] for k in sorted(_REGISTRY)]
|
||||
|
||||
|
||||
def names() -> Iterable[str]:
|
||||
"""Return all registered provider names."""
|
||||
return sorted(_REGISTRY)
|
||||
215
api/services/telephony/status_processor.py
Normal file
215
api/services/telephony/status_processor.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""Provider-agnostic call status processing.
|
||||
|
||||
Extracted from ``api/routes/telephony.py`` so that per-provider route
|
||||
modules can import the processor and normalized request type without
|
||||
introducing a circular import on the routes module.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import BaseModel
|
||||
|
||||
from api.db import db_client
|
||||
from api.enums import WorkflowRunState
|
||||
from api.services.campaign.campaign_call_dispatcher import campaign_call_dispatcher
|
||||
from api.services.campaign.campaign_event_publisher import (
|
||||
get_campaign_event_publisher,
|
||||
)
|
||||
from api.services.campaign.circuit_breaker import circuit_breaker
|
||||
|
||||
|
||||
class StatusCallbackRequest(BaseModel):
|
||||
"""Normalized status callback shape used across all telephony providers.
|
||||
|
||||
Per-provider converters live as classmethods (``from_twilio``, ``from_plivo``,
|
||||
``from_vonage``, ``from_cloudonix_cdr``) so the route handler for each
|
||||
provider can map raw webhook payloads into this shape and hand off to
|
||||
:func:`_process_status_update`.
|
||||
"""
|
||||
|
||||
call_id: str
|
||||
status: str
|
||||
from_number: Optional[str] = None
|
||||
to_number: Optional[str] = None
|
||||
direction: Optional[str] = None
|
||||
duration: Optional[str] = None
|
||||
|
||||
extra: dict = {}
|
||||
|
||||
@classmethod
|
||||
def from_twilio(cls, data: dict):
|
||||
"""Convert Twilio callback to generic format."""
|
||||
return cls(
|
||||
call_id=data.get("CallSid", ""),
|
||||
status=data.get("CallStatus", ""),
|
||||
from_number=data.get("From"),
|
||||
to_number=data.get("To"),
|
||||
direction=data.get("Direction"),
|
||||
duration=data.get("CallDuration") or data.get("Duration"),
|
||||
extra=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_plivo(cls, data: dict):
|
||||
"""Convert Plivo callback to generic format."""
|
||||
status_map = {
|
||||
"in-progress": "answered",
|
||||
"ringing": "ringing",
|
||||
"ring": "ringing",
|
||||
"completed": "completed",
|
||||
"hangup": "completed",
|
||||
"stopstream": "completed",
|
||||
"busy": "busy",
|
||||
"no-answer": "no-answer",
|
||||
"cancel": "canceled",
|
||||
"cancelled": "canceled",
|
||||
"timeout": "no-answer",
|
||||
}
|
||||
call_status = (data.get("CallStatus") or data.get("Event") or "").lower()
|
||||
return cls(
|
||||
call_id=data.get("CallUUID", "") or data.get("RequestUUID", ""),
|
||||
status=status_map.get(call_status, call_status),
|
||||
from_number=data.get("From"),
|
||||
to_number=data.get("To"),
|
||||
direction=data.get("Direction"),
|
||||
duration=data.get("Duration"),
|
||||
extra=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_vonage(cls, data: dict):
|
||||
"""Convert Vonage event to generic format."""
|
||||
status_map = {
|
||||
"started": "initiated",
|
||||
"ringing": "ringing",
|
||||
"answered": "answered",
|
||||
"complete": "completed",
|
||||
"failed": "failed",
|
||||
"busy": "busy",
|
||||
"timeout": "no-answer",
|
||||
"rejected": "busy",
|
||||
}
|
||||
|
||||
return cls(
|
||||
call_id=data.get("uuid", ""),
|
||||
status=status_map.get(data.get("status", ""), data.get("status", "")),
|
||||
from_number=data.get("from"),
|
||||
to_number=data.get("to"),
|
||||
direction=data.get("direction"),
|
||||
duration=data.get("duration"),
|
||||
extra=data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_cloudonix_cdr(cls, data: dict):
|
||||
"""Convert Cloudonix CDR to generic format."""
|
||||
disposition_map = {
|
||||
"ANSWER": "completed",
|
||||
"BUSY": "busy",
|
||||
"CANCEL": "canceled",
|
||||
"FAILED": "failed",
|
||||
"CONGESTION": "failed",
|
||||
"NOANSWER": "no-answer",
|
||||
}
|
||||
|
||||
disposition = data.get("disposition", "")
|
||||
status = disposition_map.get(disposition.upper(), disposition.lower())
|
||||
|
||||
return cls(
|
||||
call_id=data.get("session").get("token"),
|
||||
status=status,
|
||||
from_number=data.get("from"),
|
||||
to_number=data.get("to"),
|
||||
duration=str(data.get("billsec") or data.get("duration") or 0),
|
||||
extra=data,
|
||||
)
|
||||
|
||||
|
||||
async def _process_status_update(workflow_run_id: int, status: StatusCallbackRequest):
|
||||
"""Process status updates from telephony providers.
|
||||
|
||||
Idempotent: handles repeated callbacks (e.g. from both webhook and CDR).
|
||||
"""
|
||||
workflow_run = await db_client.get_workflow_run_by_id(workflow_run_id)
|
||||
if not workflow_run:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Workflow run not found in status update"
|
||||
)
|
||||
return
|
||||
|
||||
telephony_callback_logs = workflow_run.logs.get("telephony_status_callbacks", [])
|
||||
telephony_callback_log = {
|
||||
"status": status.status,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"call_id": status.call_id,
|
||||
"duration": status.duration,
|
||||
**status.extra,
|
||||
}
|
||||
telephony_callback_logs.append(telephony_callback_log)
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
logs={"telephony_status_callbacks": telephony_callback_logs},
|
||||
)
|
||||
|
||||
if status.status == "completed":
|
||||
logger.info(
|
||||
f"[run {workflow_run_id}] Call completed with duration: {status.duration}s"
|
||||
)
|
||||
|
||||
if workflow_run.campaign_id:
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
await circuit_breaker.record_and_evaluate(
|
||||
workflow_run.campaign_id, is_failure=False
|
||||
)
|
||||
|
||||
if workflow_run.state != WorkflowRunState.COMPLETED.value:
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
is_completed=True,
|
||||
state=WorkflowRunState.COMPLETED.value,
|
||||
)
|
||||
|
||||
elif status.status in ["failed", "busy", "no-answer", "canceled", "error"]:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Call failed with status: {status.status}"
|
||||
)
|
||||
|
||||
if workflow_run.campaign_id:
|
||||
await campaign_call_dispatcher.release_call_slot(workflow_run_id)
|
||||
await circuit_breaker.record_and_evaluate(
|
||||
workflow_run.campaign_id,
|
||||
is_failure=status.status in ("error", "failed"),
|
||||
)
|
||||
|
||||
if status.status in ["busy", "no-answer"] and workflow_run.campaign_id:
|
||||
publisher = await get_campaign_event_publisher()
|
||||
await publisher.publish_retry_needed(
|
||||
workflow_run_id=workflow_run_id,
|
||||
reason=status.status.replace("-", "_"),
|
||||
campaign_id=workflow_run.campaign_id,
|
||||
queued_run_id=workflow_run.queued_run_id,
|
||||
)
|
||||
|
||||
call_tags = (
|
||||
workflow_run.gathered_context.get("call_tags", [])
|
||||
if workflow_run.gathered_context
|
||||
else []
|
||||
)
|
||||
call_tags.extend(["not_connected", f"telephony_{status.status.lower()}"])
|
||||
|
||||
await db_client.update_workflow_run(
|
||||
run_id=workflow_run_id,
|
||||
is_completed=True,
|
||||
state=WorkflowRunState.COMPLETED.value,
|
||||
gathered_context={"call_tags": call_tags},
|
||||
)
|
||||
elif status.status in ["in-progress", "initiated", "ringing"]:
|
||||
# No-op while the call is in flight.
|
||||
pass
|
||||
else:
|
||||
logger.warning(
|
||||
f"[run {workflow_run_id}] Unexpected status update: {status.status}"
|
||||
)
|
||||
|
|
@ -366,7 +366,10 @@ class TestProcessStatusUpdateCircuitBreaker:
|
|||
"""When a campaign call fails, record_and_evaluate should be called
|
||||
with is_failure=True."""
|
||||
|
||||
from api.routes.telephony import StatusCallbackRequest, _process_status_update
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
|
||||
mock_workflow_run = MagicMock()
|
||||
mock_workflow_run.id = 100
|
||||
|
|
@ -382,11 +385,13 @@ class TestProcessStatusUpdateCircuitBreaker:
|
|||
)
|
||||
|
||||
with (
|
||||
patch("api.routes.telephony.db_client") as mock_db,
|
||||
patch("api.routes.telephony.campaign_call_dispatcher") as mock_dispatcher,
|
||||
patch("api.routes.telephony.circuit_breaker") as mock_cb,
|
||||
patch("api.services.telephony.status_processor.db_client") as mock_db,
|
||||
patch(
|
||||
"api.routes.telephony.get_campaign_event_publisher"
|
||||
"api.services.telephony.status_processor.campaign_call_dispatcher"
|
||||
) as mock_dispatcher,
|
||||
patch("api.services.telephony.status_processor.circuit_breaker") as mock_cb,
|
||||
patch(
|
||||
"api.services.telephony.status_processor.get_campaign_event_publisher"
|
||||
) as mock_get_publisher,
|
||||
):
|
||||
mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run)
|
||||
|
|
@ -407,7 +412,10 @@ class TestProcessStatusUpdateCircuitBreaker:
|
|||
"""When a campaign call succeeds, record_and_evaluate should be called
|
||||
with is_failure=False."""
|
||||
|
||||
from api.routes.telephony import StatusCallbackRequest, _process_status_update
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
|
||||
mock_workflow_run = MagicMock()
|
||||
mock_workflow_run.id = 100
|
||||
|
|
@ -422,9 +430,11 @@ class TestProcessStatusUpdateCircuitBreaker:
|
|||
)
|
||||
|
||||
with (
|
||||
patch("api.routes.telephony.db_client") as mock_db,
|
||||
patch("api.routes.telephony.campaign_call_dispatcher") as mock_dispatcher,
|
||||
patch("api.routes.telephony.circuit_breaker") as mock_cb,
|
||||
patch("api.services.telephony.status_processor.db_client") as mock_db,
|
||||
patch(
|
||||
"api.services.telephony.status_processor.campaign_call_dispatcher"
|
||||
) as mock_dispatcher,
|
||||
patch("api.services.telephony.status_processor.circuit_breaker") as mock_cb,
|
||||
):
|
||||
mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run)
|
||||
mock_db.update_workflow_run = AsyncMock()
|
||||
|
|
@ -440,7 +450,10 @@ class TestProcessStatusUpdateCircuitBreaker:
|
|||
async def test_non_campaign_call_skips_circuit_breaker(self):
|
||||
"""Calls without campaign_id should not interact with circuit breaker."""
|
||||
|
||||
from api.routes.telephony import StatusCallbackRequest, _process_status_update
|
||||
from api.services.telephony.status_processor import (
|
||||
StatusCallbackRequest,
|
||||
_process_status_update,
|
||||
)
|
||||
|
||||
mock_workflow_run = MagicMock()
|
||||
mock_workflow_run.id = 100
|
||||
|
|
@ -455,8 +468,8 @@ class TestProcessStatusUpdateCircuitBreaker:
|
|||
)
|
||||
|
||||
with (
|
||||
patch("api.routes.telephony.db_client") as mock_db,
|
||||
patch("api.routes.telephony.circuit_breaker") as mock_cb,
|
||||
patch("api.services.telephony.status_processor.db_client") as mock_db,
|
||||
patch("api.services.telephony.status_processor.circuit_breaker") as mock_cb,
|
||||
):
|
||||
mock_db.get_workflow_run_by_id = AsyncMock(return_value=mock_workflow_run)
|
||||
mock_db.update_workflow_run = AsyncMock()
|
||||
|
|
|
|||
122
api/utils/telephony_address.py
Normal file
122
api/utils/telephony_address.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""Telephony address normalization.
|
||||
|
||||
Telephony "from" / "to" identifiers can be PSTN numbers (E.164 or local),
|
||||
SIP URIs, or bare SIP extensions. This module normalizes any input to a
|
||||
canonical form used both for storage in `telephony_phone_numbers.address_normalized`
|
||||
and for lookups against incoming webhooks.
|
||||
|
||||
The canonical form is deterministic and case-insensitive where the
|
||||
underlying protocol allows it.
|
||||
|
||||
Lives in ``api.utils`` (not ``api.services.telephony``) so it can be
|
||||
imported from migrations and DB clients without triggering provider
|
||||
registration in the telephony package's ``__init__.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Optional
|
||||
|
||||
from api.utils.telephony_helper import get_country_code
|
||||
|
||||
AddressType = Literal["pstn", "sip_uri", "sip_extension"]
|
||||
|
||||
_PSTN_DIGITS_RE = re.compile(r"^\d{8,15}$")
|
||||
_PSTN_STRIP_RE = re.compile(r"[\s\-\(\)]")
|
||||
# RFC 3261 SIP URI: sip:user@host[:port][;params][?headers]
|
||||
# We only normalize scheme, host, port, and the user part (preserving case).
|
||||
_SIP_URI_RE = re.compile(
|
||||
r"^(?P<scheme>sips?):(?:(?P<user>[^@;?]+)@)?(?P<host>[^:;?]+)"
|
||||
r"(?::(?P<port>\d+))?(?P<rest>[;?].*)?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NormalizedAddress:
|
||||
canonical: str
|
||||
address_type: AddressType
|
||||
country_code: Optional[str] = None # ISO-2; only set for PSTN when known
|
||||
|
||||
|
||||
def normalize_telephony_address(
|
||||
raw: str, country_hint: Optional[str] = None
|
||||
) -> NormalizedAddress:
|
||||
"""Normalize a telephony address into a canonical form for storage/lookup.
|
||||
|
||||
`country_hint` is an ISO-2 country code used to disambiguate non-E.164
|
||||
PSTN inputs (e.g. "08043071383" with hint "IN" → "+918043071383").
|
||||
"""
|
||||
if raw is None:
|
||||
raise ValueError("address must not be None")
|
||||
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
raise ValueError("address must not be empty")
|
||||
|
||||
lowered = raw.lower()
|
||||
if lowered.startswith(("sip:", "sips:")):
|
||||
return _normalize_sip_uri(raw)
|
||||
|
||||
digits = _PSTN_STRIP_RE.sub("", raw)
|
||||
if digits.startswith("+"):
|
||||
digits = digits[1:]
|
||||
if _PSTN_DIGITS_RE.fullmatch(digits):
|
||||
return _normalize_pstn(digits, country_hint)
|
||||
|
||||
# Anything else — short numeric extension, alphanumeric username, etc.
|
||||
return NormalizedAddress(canonical=raw.lower(), address_type="sip_extension")
|
||||
|
||||
|
||||
def _normalize_pstn(digits: str, country_hint: Optional[str]) -> NormalizedAddress:
|
||||
country_code: Optional[str] = None
|
||||
|
||||
# If a country hint is given and the digits don't already start with that
|
||||
# country's dial code, try to apply it. Local numbers may include a leading
|
||||
# zero that needs stripping (e.g. India "0xxxx" → "+91xxxx").
|
||||
if country_hint:
|
||||
dial = get_country_code(country_hint)
|
||||
if dial:
|
||||
country_code = country_hint.upper()
|
||||
if not digits.startswith(dial):
|
||||
stripped = digits.lstrip("0")
|
||||
# Only apply the hint if doing so yields a sane E.164 length.
|
||||
candidate = f"{dial}{stripped}"
|
||||
if 8 <= len(candidate) <= 15:
|
||||
digits = candidate
|
||||
|
||||
return NormalizedAddress(
|
||||
canonical=f"+{digits}",
|
||||
address_type="pstn",
|
||||
country_code=country_code,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_sip_uri(raw: str) -> NormalizedAddress:
|
||||
m = _SIP_URI_RE.match(raw)
|
||||
if not m:
|
||||
# Malformed URI — preserve as-is, lowercased, so equality still works.
|
||||
return NormalizedAddress(canonical=raw.lower(), address_type="sip_uri")
|
||||
|
||||
scheme = m.group("scheme").lower()
|
||||
user = m.group("user") # case-preserving per RFC 3261
|
||||
host = m.group("host").lower()
|
||||
port = m.group("port")
|
||||
rest = m.group("rest") or ""
|
||||
|
||||
# Drop default ports (5060 for sip, 5061 for sips).
|
||||
if (scheme == "sip" and port == "5060") or (scheme == "sips" and port == "5061"):
|
||||
port = None
|
||||
|
||||
canonical = f"{scheme}:"
|
||||
if user:
|
||||
canonical += f"{user}@"
|
||||
canonical += host
|
||||
if port:
|
||||
canonical += f":{port}"
|
||||
if rest:
|
||||
canonical += rest.lower()
|
||||
|
||||
return NormalizedAddress(canonical=canonical, address_type="sip_uri")
|
||||
|
|
@ -183,22 +183,24 @@ def generic_hangup_response():
|
|||
|
||||
|
||||
async def parse_webhook_request(request: Request) -> tuple[dict, str]:
|
||||
"""Parse webhook request data from either JSON or form"""
|
||||
"""Parse webhook request data from either JSON or form.
|
||||
|
||||
Returns ``(webhook_data, raw_body)`` where ``raw_body`` is the
|
||||
request body decoded as UTF-8 — kept around for providers (e.g.
|
||||
Vobiz) whose signature is computed over the raw bytes.
|
||||
"""
|
||||
raw_body = (await request.body()).decode("utf-8", errors="replace")
|
||||
try:
|
||||
# Try JSON first
|
||||
webhook_data = await request.json()
|
||||
data_source = "JSON"
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback to form data
|
||||
form_data = await request.form()
|
||||
webhook_data = dict(form_data)
|
||||
data_source = "FORM"
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to parse webhook data: {e}")
|
||||
raise ValueError("Unable to parse webhook data")
|
||||
|
||||
return webhook_data, data_source
|
||||
return webhook_data, raw_body
|
||||
|
||||
|
||||
def get_country_code(country_iso: str) -> str:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue