feat: refactor telephony to support multiple telephony configurations (#251)

Co-authored-by: Sabiha Khan <sabihak89@gmail.com>
This commit is contained in:
Abhishek 2026-04-29 11:39:57 +05:30 committed by GitHub
parent 2f860e7f6d
commit e16f6438bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 10906 additions and 5420 deletions

View file

@ -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

View file

@ -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"
)

View file

@ -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 ###

View file

@ -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:

View file

@ -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.

View file

@ -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",

View 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)
)

View 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)
)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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)

View file

@ -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",
]

View 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]

View file

@ -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}")

View file

@ -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,
)

View 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,
)

View file

@ -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,

View file

@ -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,
# )

View file

@ -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)

View file

@ -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)
)

View file

@ -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"),
}
)

View file

@ -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:

View file

@ -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)

View 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.

View file

@ -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,
)

View 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",
]

View 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]

View file

@ -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

View file

@ -0,0 +1,5 @@
"""Asterisk frame serializer (re-exported from pipecat)."""
from pipecat.serializers.asterisk import AsteriskFrameSerializer
__all__ = ["AsteriskFrameSerializer"]

View 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,
),
)

View 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",
]

View 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]

View file

@ -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.

View 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"}

View file

@ -0,0 +1,5 @@
"""Cloudonix frame serializer (re-exported from pipecat)."""
from pipecat.serializers.cloudonix import CloudonixFrameSerializer
__all__ = ["CloudonixFrameSerializer"]

View 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,
),
)

View 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",
]

View 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]

View file

@ -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>

View 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,
)

View file

@ -0,0 +1,5 @@
"""Plivo frame serializer (re-exported from pipecat)."""
from pipecat.serializers.plivo import PlivoFrameSerializer
__all__ = ["PlivoFrameSerializer"]

View 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,
),
)

View 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",
]

View 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]

View file

@ -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:

View 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"}

View file

@ -0,0 +1,5 @@
"""Telnyx frame serializer (re-exported from pipecat)."""
from pipecat.serializers.telnyx import TelnyxFrameSerializer
__all__ = ["TelnyxFrameSerializer"]

View 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,
),
)

View 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",
]

View 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]

View file

@ -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}"'

View 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"}

View 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"]

View 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,
),
)

View 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",
]

View 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]

View file

@ -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.

View 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)}

View file

@ -0,0 +1,5 @@
"""Vobiz frame serializer (re-exported from pipecat)."""
from pipecat.serializers.vobiz import VobizFrameSerializer
__all__ = ["VobizFrameSerializer"]

View 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

View 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",
]

View 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]

View file

@ -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.
"""

View 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"}

View file

@ -0,0 +1,5 @@
"""Vonage frame serializer (re-exported from pipecat)."""
from pipecat.serializers.vonage import VonageFrameSerializer
__all__ = ["VonageFrameSerializer"]

View 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,
),
)

View 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)

View 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}"
)

View file

@ -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()

View 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")

View file

@ -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: