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:

View file

@ -98,6 +98,8 @@
"integrations/telephony/inbound",
"integrations/telephony/twilio",
"integrations/telephony/vonage",
"integrations/telephony/plivo",
"integrations/telephony/telnyx",
"integrations/telephony/cloudonix",
"integrations/telephony/vobiz",
"integrations/telephony/asterisk-ari",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Before After
Before After

View file

@ -90,8 +90,8 @@ Refer to the [Asterisk WebSocket documentation](https://docs.asterisk.org/) for
### Step 1: Navigate to Telephony Settings
1. Go to **Workflow** → **Phone Call** → **Configure Telephony**
2. Select **Asterisk (ARI)** as your provider
1. Navigate to **/telephony-configurations** and click **Add configuration**
2. Select **Asterisk ARI** as your provider
### Step 2: Enter Your ARI Credentials
@ -103,47 +103,69 @@ Configure the following fields:
| **Stasis App Name** | The ARI username configured in `ari.conf` | `dograh` |
| **App Password** | The ARI password configured in `ari.conf` | `your_secure_password` |
| **WebSocket Client Name** | The connection name from `websocket_client.conf` | `dograh_staging` |
| **Inbound Workflow ID** | The workflow to activate for inbound calls (optional) | `42` |
| **SIP Extensions / Numbers** | Optional SIP extensions or trunk numbers for outbound calls | `PJSIP/6001` or `6001` |
| **From Extensions** | Optional SIP extensions or trunk numbers for outbound calls | `PJSIP/6001` or `6001` |
### Step 3: Save and Test
### Step 3: Save and Add Extensions
1. Click **Save Configuration**
2. Create a test workflow
3. Initiate a test call to verify the connection
2. Open the configuration you just created and add each SIP extension that should be reachable as a **phone number** (e.g. `8000`). For inbound, you'll assign a workflow to each extension separately — see [Inbound Calling](#inbound-calling) below.
3. Create a test workflow and initiate a test call to verify the connection.
## Inbound Calling
Unlike other telephony providers that use HTTP webhooks for inbound calls, ARI delivers inbound calls as **StasisStart events on the ARI WebSocket**. Dograh automatically detects these events and activates the configured workflow.
Unlike other telephony providers that use HTTP webhooks for inbound calls, ARI delivers inbound calls as **StasisStart events on the ARI WebSocket**. Dograh automatically detects these events and activates the workflow assigned to the called extension.
### How It Works
1. An external call arrives at Asterisk and the dialplan routes it to `Stasis(dograh)`
2. Asterisk fires a StasisStart event over the ARI WebSocket with the channel in `Ring` state
3. Dograh identifies this as an inbound call, validates your quota, and creates a workflow run
2. Asterisk fires a StasisStart event over the ARI WebSocket with the channel in `Ring` state and the dialed extension in the dialplan context
3. Dograh looks up the called extension in your telephony configuration's phone numbers, finds the assigned workflow, validates quota, and creates a workflow run
4. The call is answered, bridged to an external media channel, and your voice agent workflow begins
Workflow assignment is **per extension**, so different extensions on the same Asterisk can route to different agents.
### Setting Up Inbound Calls
**Step 1: Configure the Asterisk dialplan**
Ensure your dialplan routes inbound calls to the Stasis application as shown in the [dialplan configuration above](#configure-the-stasis-dialplan-extensionsconf).
Ensure your dialplan routes the extensions you care about into the Stasis application. Either route a specific extension:
**Step 2: Set the Inbound Workflow ID in Dograh**
```ini
[from-external]
exten => 8000,1,NoOp(Incoming call to 8000)
same => n,Stasis(dograh)
same => n,Hangup()
```
1. Go to **Workflow** → **Phone Call** → **Configure Telephony**
2. In the ARI configuration, enter the **Inbound Workflow ID** — this is the ID of the workflow you want to activate when an inbound call arrives
3. Click **Save Configuration**
…or use a pattern that catches every extension you'll register in Dograh:
You can find a workflow's ID in the URL when viewing it (e.g., `/workflows/42` means the ID is `42`).
```ini
[from-external]
exten => _X.,1,NoOp(Incoming call to ${EXTEN})
same => n,Stasis(dograh)
same => n,Hangup()
```
<Note>
If no Inbound Workflow ID is configured, inbound calls will be hung up immediately. You must set this field for inbound calling to work.
</Note>
Replace `dograh` with the app name you configured in `ari.conf` and in Dograh.
**Step 2: Add the extension as a phone number in Dograh**
1. Go to **/telephony-configurations** and open your Asterisk ARI configuration
2. In the **Phone numbers** section, add a phone number whose address is the SIP extension (e.g. `8000`)
3. Set its **Inbound workflow** to the agent that should answer
4. Save
<Note>
Adding the extension in Dograh doesn't change Asterisk's dialplan — that's
what Step 1 is for. The Dograh entry tells the StasisStart handler which
workflow to run when a call to that extension reaches the Stasis app.
</Note>
Repeat Step 2 for each extension that should reach a voice agent.
**Step 3: Test an inbound call**
Place a call to a number or extension routed to your Stasis application. You should see the workflow activate and the voice agent respond.
Place a call to one of the extensions you configured. You should see the assigned workflow activate and the voice agent respond.
### Inbound Call Context
@ -188,10 +210,14 @@ When an inbound call activates a workflow, the following context is available to
</Accordion>
<Accordion title="Inbound calls are immediately hung up">
- Verify the **Inbound Workflow ID** is set in your ARI telephony configuration
- Confirm the workflow ID exists and belongs to the same organization as the ARI config
- Verify the called extension is added as a phone number under your ARI
configuration in /telephony-configurations and has an **Inbound workflow**
assigned
- Confirm the workflow exists and belongs to the same organization as the
ARI config
- Check that your organization has available quota
- Review Dograh logs for warnings mentioning "no inbound_workflow_id configured"
- Review Dograh logs for warnings like "no matching phone number registered
for config" or "has no inbound_workflow_id assigned"
</Accordion>
<Accordion title="WebSocket client connection issues">

View file

@ -15,7 +15,8 @@ Before setting up Cloudonix integration, you'll need:
- A [Cloudonix account](https://cockpit.cloudonix.io/onboarding?affiliate=DOGRAH)
- A Cloudonix domain UUID (or the domain name)
- A Cloudonix domain API Key
- A Cloudonix domain API Key (Bearer Token)
- A Cloudonix **Voice Application** on that domain — Dograh will manage its `url`
- A Cloudonix outbound voice trunk service provider connection
- Dograh AI instance running and accessible
@ -36,38 +37,36 @@ Watch this step-by-step guide to set up Cloudonix with Dograh AI:
### Step 1: Get Cloudonix Credentials
1. Log in to your [Cloudonix Console](https://cockpit.cloudonix.io/onboarding?affiliate=DOGRAH)
2. Find your **Domain UUID** and **Domain API Key** on the dashboard
2. Find your **Domain ID** (UUID or domain name) and **Bearer Token** (Domain API Key) on the dashboard
3. Navigate to your domain's **Applications** and create (or open) the application you'll use with Dograh
4. Copy the **Application Name** — Dograh will manage this application's `url`
### Step 2: Configure in Dograh AI
1. Navigate to **Workflow** → **Phone Call** → **Configure Telephony**
1. Navigate to **/telephony-configurations** and click **Add configuration**
2. Watch the Cloudonix setup video tutorial above for detailed guidance
3. Select **Cloudonix** as your provider
4. Enter your credentials:
- Domain UUID
- Domain API Key
- Bearer Token
- Domain ID
- Application Name
5. Click **Save Configuration**
6. Open the configuration you just created and add at least one **phone number** (with country code in E.164 format, e.g. `+1234567890`). The default caller ID is used for outbound calls.
### Step 3: Test Your Configuration
1. Create a test workflow
2. Click "Test Call" to verify connection
2. Click "Call" to verify connection
3. Check call logs for successful connection
## Inbound Calling Setup
The Dograh AI configuration for inbound calling is **identical** to outbound calling - use the same credentials configured above. However, you need additional setup in your Cloudonix Console to route incoming calls to Dograh AI.
Cloudonix routes inbound calls per **Voice Application** — the webhook URL is set once on the application, and applies to every DNID bound to it. **When you save an inbound workflow on a phone number, Dograh automatically pushes the webhook URL to your Voice Application's `url`** (provided the credentials are correct), so you don't need to set the webhook by hand.
### Configure Inbound Trunk and Application
### Step 1: Set Up the Inbound Trunk
1. **Set Up Inbound Trunk**:
- Log in to your [Cloudonix Console](https://cockpit.cloudonix.io)
- Navigate to **Trunks** → **Create Inbound Trunk**
- Configure your inbound trunk with your voice service provider
- Ensure the trunk can receive calls to your desired phone numbers
2. **Create Application for Your Domain**:
- Select your domain in the Cloudonix Console
1. Log in to your [Cloudonix Console](https://cockpit.cloudonix.io)
2. Navigate to **Trunks** → **Create Inbound Trunk** and configure your inbound trunk with your voice service provider
<img
className="block dark:hidden"
@ -80,32 +79,79 @@ The Dograh AI configuration for inbound calling is **identical** to outbound cal
alt="Cloudonix console showing domain selection"
/>
- Create a new application for your domain
- Set the webhook URL in the application:
```
https://api.dograh.com/api/v1/telephony/inbound/{workflow_id}
```
### Step 2: Create the Voice Application and Link DNIDs
1. In the Cloudonix cockpit, select your domain and navigate to **Applications**
2. Create a new application (or open the existing one whose name you configured in Dograh) with these settings:
- **Application Resource Type**: `Remote Application Resource`
- **Application Runtime**: `Cloudonix (CXML)`
- **Application URL**: `https://api.dograh.com/api/v1/telephony/inbound/run`
- **HTTP Method**: `POST`
3. Under **DNID Numbers**, add each phone number (DNID) you want to route through this application
4. Save
<Note>
Replace `{workflow_id}` with your actual workflow ID. If using self-hosted Dograh, replace `api.dograh.com` with your domain.
The Application URL is what Dograh's auto-push updates in Step 3 — you
can leave it blank during creation and let the auto-push fill it in,
or paste the URL above so the application is usable immediately.
Either works. Self-hosted Dograh deployments use their backend domain
in place of `api.dograh.com`.
</Note>
<img
className="block dark:hidden"
src="/images/cloudonix-inbound-config-application.png"
alt="Cloudonix application form showing Remote Application Resource type, Cloudonix CXML runtime, the Dograh inbound URL, POST method, and a DNID number entry"
/>
<img
className="hidden dark:block"
src="/images/cloudonix-inbound-config-application.png"
alt="Cloudonix application form showing Remote Application Resource type, Cloudonix CXML runtime, the Dograh inbound URL, POST method, and a DNID number entry"
/>
### Step 3: Assign an Inbound Workflow to the Phone Number in Dograh
1. Go to **/telephony-configurations** and open your Cloudonix configuration
2. In the **Phone numbers** section, edit the number that should receive inbound calls
3. Set its **Inbound workflow** to the agent that should answer
4. Save
### Step 4: Verify the URL on the Voice Application
1. Open your Cloudonix cockpit and navigate to your domain's **Applications**
2. Open the application whose name you configured in Dograh
3. Confirm:
- **URL** is set to: `https://api.dograh.com/api/v1/telephony/inbound/run`
- **Method** is `POST`
<Note>
Dograh pushed this URL automatically when you saved the inbound workflow
in Step 3. The same URL is shared across every DNID bound to that
application — Dograh routes each inbound call to the right agent based
on the called number's inbound workflow assignment. If the field is
empty, shows a different URL, or Dograh surfaced a sync warning on
save, the auto-push failed — most often because the Bearer Token,
Domain ID, or Application Name in Dograh is incorrect. Paste the URL
into the field yourself, set the method to `POST`, and save. On
self-hosted Dograh, replace `api.dograh.com` with your backend domain.
</Note>
<img
className="block dark:hidden"
src="/images/cloudonix-inbound-config-2.png"
alt="Cloudonix application creation with webhook URL configuration"
alt="Cloudonix application showing webhook URL configuration"
/>
<img
className="hidden dark:block"
src="/images/cloudonix-inbound-config-2.png"
alt="Cloudonix application creation with webhook URL configuration"
alt="Cloudonix application showing webhook URL configuration"
/>
3. **Verify Configuration**:
- Ensure your Dograh AI instance is publicly accessible
- Test that webhook URL responds correctly
- Verify any firewalls allow Cloudonix's IP ranges
- Confirm your inbound trunk is active and receiving calls
### Step 5: Verify Setup
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Cloudonix's IP ranges
- Confirm your inbound trunk is active and receiving calls
### Test Inbound Calling
@ -122,34 +168,36 @@ The Dograh AI configuration for inbound calling is **identical** to outbound cal
</Accordion>
<Accordion title="Authentication failed">
- Verify Domain UUID and Domain API Key are correct
- Verify Domain ID, Bearer Token, and Application Name are correct
- Check for extra spaces in credentials
- Ensure credentials haven't been disabled or deleted in Cloudonix Console
</Accordion>
<Accordion title="No audio on calls">
- Verify WebSocket connection is established
- Check firewall rules for WebSocket traffic
- Ensure audio pipeline is configured correctly
</Accordion>
<Accordion title="Inbound calls not reaching voice agent">
- Verify inbound trunk is properly configured and active
- Check routing rules point to correct Dograh AI webhook endpoint
- Ensure webhook URLs are publicly accessible
- Confirm phone numbers are correctly routed to your trunk
- Verify the DNID is bound to the same Voice Application whose name you
configured in Dograh - Confirm the called number exists in your Dograh
telephony configuration and has an **Inbound workflow** assigned -
After assigning the inbound workflow, confirm Dograh successfully updated
the application's `url` (no warning shown on save) - Verify your inbound
trunk is active and receiving calls
</Accordion>
<Accordion title="Voice agent doesn't respond to inbound calls">
- Verify organization_id in webhook URLs matches your setup
- Check that voice agent workflow is properly configured
- Ensure SIP connection instructions are correctly returned
- Review webhook logs for error responses
- Confirm the phone number has an **Inbound workflow** assigned in
/telephony-configurations - Verify the Bearer Token in Dograh matches the
one in your Cloudonix Console - Verify WebSocket connection establishes
successfully - Review call logs for error messages
</Accordion>
</AccordionGroup>
## Best Practices
- Store credentials securely in the database
- Test your configuration with a single call before running campaigns
- Monitor Cloudonix Sessions for usage
- Use a dedicated Voice Application for Dograh so the shared `url` doesn't conflict with other systems

View file

@ -5,286 +5,394 @@ description: "Build your own telephony provider integration for Dograh AI"
## Overview
Dograh AI's telephony abstraction layer allows you to integrate any telephony service by implementing the `TelephonyProvider` interface.
A telephony provider is implemented as a **self-registering package** under `api/services/telephony/providers/<name>/`. The package contributes everything Dograh needs to wire the provider in — the provider class, transport factory, audio config, request/response schemas, optional HTTP routes, and the form metadata used to render its configuration UI — through a single `ProviderSpec` registered at import time.
## Provider Interface
Adding a new provider should not require touching the factory, the audio config, the API routes module, the run-pipeline module, or the frontend. The only edits outside the provider folder are:
All telephony providers must implement this abstract base class:
1. One import line in `api/services/telephony/providers/__init__.py`
2. One import line in `api/schemas/telephony_config.py` to add the request/response classes to the `TelephonyConfigRequest` discriminated union
## Provider Package Layout
```
api/services/telephony/providers/your_provider/
├── __init__.py # Builds and registers ProviderSpec
├── config.py # Pydantic Request/Response schemas
├── provider.py # TelephonyProvider subclass
├── transport.py # Pipecat WebSocket transport factory
├── serializers.py # Frame serializer (usually re-exports from pipecat)
├── routes.py # (optional) HTTP webhook/callback handlers
└── strategies.py # (optional) Transfer/hangup strategies
```
Three files are required (`__init__.py`, `config.py`, `provider.py`, `transport.py`). The rest are optional and are discovered automatically when present:
- **`routes.py`** — if the module exists and exports `router: APIRouter`, the routes module is imported lazily and mounted under `/api/v1/telephony` by `api.routes.telephony` via `importlib`. Providers that only stream over WebSocket (e.g. ARI) can omit it.
- **`strategies.py`** — used by transports that need provider-specific call transfer/hangup logic in the frame serializer (e.g. Twilio Conference transfers).
- **`serializers.py`** — typically a re-export from pipecat. Keep the file even when it's a one-line re-export so transport code imports from `.serializers`, giving you an obvious place to drop a custom subclass later.
## The `TelephonyProvider` Interface
Subclass `TelephonyProvider` in `provider.py`:
```python
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
from api.services.telephony.base import (
CallInitiationResult,
NormalizedInboundData,
ProviderSyncResult,
TelephonyProvider,
)
class TelephonyProvider(ABC):
"""Abstract base class for telephony providers."""
@abstractmethod
async def initiate_call(
self,
to_number: str,
webhook_url: str,
workflow_run_id: Optional[int] = None,
**kwargs: Any
) -> Dict[str, Any]:
"""Initiate an outbound call."""
pass
@abstractmethod
async def get_call_status(self, call_id: str) -> Dict[str, Any]:
"""Get current status of a call."""
pass
@abstractmethod
async def get_available_phone_numbers(self) -> List[str]:
"""Get list of available phone numbers."""
pass
@abstractmethod
def validate_config(self) -> bool:
"""Validate provider configuration."""
pass
@abstractmethod
async def verify_webhook_signature(
self, url: str, params: Dict[str, Any], signature: str
) -> bool:
"""Verify webhook signature for security."""
pass
@abstractmethod
async def get_webhook_response(
self, workflow_id: int, user_id: int, workflow_run_id: int
) -> str:
"""Generate initial webhook response."""
pass
async def get_call_cost(self, call_id: str) -> Dict[str, Any]:
"""Get cost information for a completed call."""
pass
class YourProvider(TelephonyProvider):
PROVIDER_NAME = "your_provider"
WEBHOOK_ENDPOINT = "your-provider-xml" # path under /api/v1/telephony
def __init__(self, config: dict):
self.api_key = config.get("api_key")
self.from_numbers = config.get("from_numbers", [])
# ---------- outbound ----------
async def initiate_call(self, to_number, webhook_url, workflow_run_id=None,
from_number=None, **kwargs) -> CallInitiationResult: ...
async def get_call_status(self, call_id) -> dict: ...
async def get_call_cost(self, call_id) -> dict: ...
async def get_available_phone_numbers(self) -> list[str]: ...
def validate_config(self) -> bool: ...
# ---------- webhooks ----------
async def verify_webhook_signature(self, url, params, signature) -> bool: ...
async def get_webhook_response(self, workflow_id, user_id, workflow_run_id) -> str: ...
def parse_status_callback(self, data: dict) -> dict: ...
# ---------- websocket ----------
async def handle_websocket(self, websocket, workflow_id, user_id, workflow_run_id): ...
# ---------- inbound ----------
@classmethod
def can_handle_webhook(cls, webhook_data, headers) -> bool: ...
@staticmethod
def parse_inbound_webhook(webhook_data) -> NormalizedInboundData: ...
@staticmethod
def validate_account_id(config_data, webhook_account_id) -> bool: ...
def normalize_phone_number(self, phone_number: str) -> str: ...
async def verify_inbound_signature(self, url, webhook_data, headers, body="") -> bool: ...
async def start_inbound_stream(self, *, websocket_url, workflow_run_id,
normalized_data, backend_endpoint): ...
@staticmethod
def generate_error_response(error_type, message) -> tuple: ...
# ---------- transfers ----------
async def transfer_call(self, destination, transfer_id, conference_name,
timeout=30, **kwargs) -> dict: ...
def supports_transfers(self) -> bool: ...
# ---------- optional ----------
async def configure_inbound(self, address, webhook_url) -> ProviderSyncResult:
# Default returns ok=True — implement only if your provider supports
# programmatic webhook configuration (e.g. binding a number to a URL
# via API). Used to point inbound numbers at /api/v1/telephony/inbound/run.
return ProviderSyncResult(ok=True)
```
See `api/services/telephony/base.py` for the full docstrings on each method.
## Implementation Guide
### 1. Create Your Provider
### 1. Configuration schemas
Create a new file in `api/services/telephony/providers/`:
Define Pydantic models for the credential payload. The `provider` `Literal` discriminator is what makes the schemas dispatch correctly through the registry's discriminated union.
```python
# api/services/telephony/providers/your_provider.py
# providers/your_provider/config.py
from typing import List, Literal
from pydantic import BaseModel, Field
from typing import Any, Dict, List, Optional
from api.services.telephony.base import TelephonyProvider
class YourProvider(TelephonyProvider):
"""Your custom telephony provider implementation."""
def __init__(self, config: Dict[str, Any]):
"""Initialize with configuration dictionary."""
# Extract your provider-specific configuration
self.api_key = config.get("api_key")
self.api_secret = config.get("api_secret")
self.from_number = config.get("from_numbers", [""])[0]
def validate_config(self) -> bool:
"""Check if all required configuration is present."""
return bool(self.api_key and self.api_secret and self.from_number)
async def initiate_call(
self,
to_number: str,
webhook_url: str,
workflow_run_id: Optional[int] = None,
**kwargs: Any
) -> Dict[str, Any]:
"""Start an outbound call using your provider's API."""
# Implement your provider's call initiation logic
pass
# Implement other required methods...
class YourProviderConfigurationRequest(BaseModel):
provider: Literal["your_provider"] = Field(default="your_provider")
api_key: str = Field(..., description="Your Provider API key")
api_secret: str = Field(..., description="Your Provider API secret")
from_numbers: List[str] = Field(default_factory=list)
class YourProviderConfigurationResponse(BaseModel):
provider: Literal["your_provider"] = Field(default="your_provider")
api_key: str # masked when returned
api_secret: str # masked when returned
from_numbers: List[str]
```
### 2. Register in Factory
### 2. Transport factory
Update `api/services/telephony/factory.py` to include your provider:
Build the Pipecat `FastAPIWebsocketTransport` for accepted WebSockets. Always load credentials through `load_credentials_for_transport` so the right config row is picked when the workflow run carries a `telephony_configuration_id` (multi-config orgs).
```python
from api.services.telephony.providers.your_provider import YourProvider
# providers/your_provider/transport.py
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 YourProviderFrameSerializer
async def get_telephony_provider(
organization_id: int
) -> TelephonyProvider:
"""Factory function to get appropriate telephony provider."""
config = await load_telephony_config(organization_id)
provider_type = config.get("provider", "twilio")
if provider_type == "twilio":
return TwilioProvider(config)
elif provider_type == "vonage":
return VonageProvider(config)
elif provider_type == "your_provider":
return YourProvider(config)
else:
raise ValueError(f"Unknown telephony provider: {provider_type}")
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,
# provider-specific kwargs (forwarded by run_pipeline_telephony as **transport_kwargs)
stream_id: str,
call_id: str,
):
config = await load_credentials_for_transport(
organization_id, telephony_configuration_id,
expected_provider="your_provider",
)
serializer = YourProviderFrameSerializer(
stream_id=stream_id,
call_id=call_id,
api_key=config["api_key"],
)
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,
),
)
```
### 3. Add Configuration Support
### 3. Routes (optional)
Update the configuration loader in `factory.py` to handle your provider's database configuration:
If your provider POSTs webhooks to Dograh (answer URL, status callbacks, hangup callbacks), expose them through a module-level `router`. The routes are auto-mounted under `/api/v1/telephony`.
```python
# In load_telephony_config function
if provider == "your_provider":
# providers/your_provider/routes.py
from fastapi import APIRouter, Request
from api.services.telephony.factory import get_telephony_provider
from api.services.telephony.status_processor import (
StatusCallbackRequest,
_process_status_update,
)
router = APIRouter()
@router.post("/your-provider/status-callback/{workflow_run_id}")
async def status_callback(workflow_run_id: int, request: Request):
...
```
Routes are loaded lazily via `importlib` from `api.routes.telephony._mount_provider_routers`, so your route module can freely import other backend services without creating import cycles at provider-class load time.
### 4. Register the `ProviderSpec`
The package's `__init__.py` is where everything comes together:
```python
# providers/your_provider/__init__.py
from typing import Any, Dict
from api.services.pipecat.audio_config import AudioConfig
from api.services.telephony.registry import (
ProviderSpec,
ProviderUIField,
ProviderUIMetadata,
register,
)
from .config import YourProviderConfigurationRequest, YourProviderConfigurationResponse
from .provider import YourProvider
from .transport import create_transport
def _config_loader(value: Dict[str, Any]) -> Dict[str, Any]:
"""Normalize the stored credentials dict into the constructor shape."""
return {
"provider": "your_provider",
"api_key": config.value.get("api_key"),
"api_secret": config.value.get("api_secret"),
"from_numbers": config.value.get("from_numbers", [])
"api_key": value.get("api_key"),
"api_secret": value.get("api_secret"),
"from_numbers": value.get("from_numbers", []),
}
_AUDIO_CONFIG = AudioConfig(
transport_in_sample_rate=8000,
transport_out_sample_rate=8000,
vad_sample_rate=8000,
pipeline_sample_rate=8000,
buffer_size_seconds=5.0,
)
_UI_METADATA = ProviderUIMetadata(
display_name="Your Provider",
docs_url="https://docs.your-provider.com",
fields=[
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="E.164-formatted phone numbers used for outbound calls",
),
],
)
SPEC = ProviderSpec(
name="your_provider",
provider_cls=YourProvider,
config_loader=_config_loader,
transport_factory=create_transport,
audio_config=_AUDIO_CONFIG,
config_request_cls=YourProviderConfigurationRequest,
config_response_cls=YourProviderConfigurationResponse,
ui_metadata=_UI_METADATA,
# Credential field that uniquely identifies the provider account.
# Used to disambiguate inbound webhooks across multiple configs of the
# same provider. Empty string for providers without an account-id concept.
account_id_credential_field="api_key",
)
register(SPEC)
```
The configuration will be stored in the database under the `TELEPHONY_CONFIGURATION` key in the `organization_configuration` table and managed through the web interface.
`ProviderSpec` covers everything downstream code needs:
| Field | Used by |
| --- | --- |
| `name` | Stored as the discriminator on every `TelephonyConfiguration` row and as the `WorkflowRunMode` value |
| `provider_cls` | `factory.get_telephony_provider*` |
| `config_loader` | `factory._normalize_with_phone_numbers` (replaces the old if/elif chain) |
| `transport_factory` | `run_pipeline_telephony` |
| `audio_config` | `create_audio_config()` and `run_pipeline_telephony` |
| `config_request_cls` / `config_response_cls` | `TelephonyConfigRequest` discriminated union |
| `ui_metadata` | `GET /api/v1/organizations/telephony-providers/metadata` (drives the form UI) and the `_sensitive_fields` masking helper |
| `account_id_credential_field` | Inbound webhook routing across multiple configs of the same provider |
### 5. Wire the package into the registry import chain
Add one import line to `api/services/telephony/providers/__init__.py`:
```python
from api.services.telephony.providers import ( # noqa: F401 -- side effects
ari,
cloudonix,
plivo,
telnyx,
twilio,
vobiz,
vonage,
your_provider, # ← add this
)
```
### 6. Add to the discriminated union
Add one import block to `api/schemas/telephony_config.py` so the request/response classes participate in the `TelephonyConfigRequest` union and the `TelephonyConfigurationResponse` shape:
```python
from api.services.telephony.providers.your_provider.config import (
YourProviderConfigurationRequest,
YourProviderConfigurationResponse,
)
TelephonyConfigRequest = Annotated[
Union[
# ...existing entries...
YourProviderConfigurationRequest,
],
Field(discriminator="provider"),
]
class TelephonyConfigurationResponse(BaseModel):
# ...existing entries...
your_provider: Optional[YourProviderConfigurationResponse] = None
```
That's it for backend wiring.
## Frontend
The configuration form is **metadata-driven**. The UI calls `GET /api/v1/organizations/telephony-providers/metadata`, gets back the list of providers and their `ProviderUIField` definitions, and renders each form generically. **No per-provider frontend code is needed** — your `ProviderUIMetadata` declaration is what drives the form.
If you add a new field type that the existing renderer doesn't support (e.g. a file upload), extend the renderer in `ui/src/app/(authenticated)/telephony-configurations/`. The supported `ProviderUIField.type` values today are `text`, `password`, `textarea`, `string-array`, and `number`.
## Audio Format Considerations
Different providers use different audio formats:
- **Twilio**: 8kHz μ-law (MULAW) encoded in Base64
- **Vonage**: 16kHz Linear PCM as binary frames
Each provider declares its wire format through its `AudioConfig`. Common shapes:
Your provider may differ, so ensure proper audio format conversion in your WebSocket handler and configure the audio pipeline accordingly.
- **Twilio / Plivo**: 8 kHz μ-law, base64-encoded JSON frames
- **Vonage**: 16 kHz Linear PCM as binary frames
- **Asterisk ARI**: 8 kHz Linear PCM via externalMedia
The pipeline sample rate is capped at 16 kHz to satisfy VAD; transports handle resampling between the wire format and the pipeline's internal rate.
## Testing
Create unit tests for your provider:
```python
# tests/test_your_provider.py
# api/tests/telephony/test_your_provider.py
import pytest
from api.services.telephony.providers.your_provider import YourProvider
@pytest.mark.asyncio
async def test_validate_config():
config = {
provider = YourProvider({
"api_key": "test_key",
"api_secret": "test_secret",
"from_numbers": ["+1234567890"]
}
provider = YourProvider(config)
"from_numbers": ["+1234567890"],
})
assert provider.validate_config() is True
```
For end-to-end testing, save your provider through the telephony-configurations UI and trigger a test call from a workflow.
## Best Practices
1. **Error Handling**: Implement robust error handling with meaningful messages
2. **Logging**: Use `loguru.logger` for consistent logging
3. **Async Operations**: All I/O operations should be async
4. **Configuration Validation**: Validate config on initialization
5. **Security**: Always verify webhook signatures
1. **Trust the registry** — never import another provider's class directly; resolve through `factory.get_telephony_provider*`.
2. **Sensitive fields** — mark every credential field `sensitive=True` in `ProviderUIMetadata`. The save endpoint masks these on read and preserves the original when the client re-submits a masked value.
3. **Inbound signature verification** — always validate inbound webhook signatures in `verify_inbound_signature`. Returning `True` when no signature header is present is acceptable; return `False` when a signature *is* present but invalid.
4. **Transports load credentials lazily** — call `load_credentials_for_transport` with the `telephony_configuration_id` from the workflow run. Don't read the org's default config from `transport.py`.
5. **Logging** — use `loguru.logger`.
## Reference Implementations
See these provider implementations for complete examples:
- **Twilio**: `api/services/telephony/providers/twilio_provider.py` - Basic authentication, XML (TwiML) responses
- **Vonage**: `api/services/telephony/providers/vonage_provider.py` - JWT authentication, JSON (NCCO) responses
| Provider | Notable for |
| --- | --- |
| `providers/twilio/` | Full-featured: outbound, inbound, conference transfers, status callbacks, custom strategies |
| `providers/plivo/` | Recently-added reference; mirrors Twilio's shape with multi-callback signatures |
| `providers/vonage/` | JWT auth, 16 kHz Linear PCM, NCCO responses |
| `providers/cloudonix/` | SIP-based, custom call strategies |
| `providers/telnyx/` | Call-control style: REST-driven inbound answer flow rather than markup response |
| `providers/ari/` | Minimal example — no `routes.py`, no inbound webhook verification, WebSocket-only |
<Note>
Other providers like Plivo, Telnyx, or custom SIP providers can be implemented following the same pattern.
These are not included out-of-the-box but can be easily added by implementing the TelephonyProvider interface.
Use ARI as the smallest viable example when your provider doesn't expose HTTP
webhooks, and Twilio as the reference when it does.
</Note>
## UI Implementation Guide
To integrate your new provider into the frontend, you'll need to update the configuration form and the workflow header.
### 1. Update Configuration Page
Modify `src/app/configure-telephony/page.tsx` to include your provider's form fields.
**A. Update Interface**
Add your provider's specific configuration fields to the `TelephonyConfigForm` interface:
```typescript
interface TelephonyConfigForm {
provider: string;
// ... existing fields
// Your Provider Fields
your_provider_api_key?: string;
your_provider_secret?: string;
}
```
**B. Add to Dropdown**
Add your provider to the `Select` component options:
```tsx
<SelectContent>
<SelectItem value="twilio">Twilio</SelectItem>
<SelectItem value="vonage">Vonage</SelectItem>
<SelectItem value="your_provider">Your Provider</SelectItem>
</SelectContent>
```
**C. Add Form Fields**
Render your provider's fields conditionally:
```tsx
{selectedProvider === "your_provider" && (
<>
<div className="space-y-2">
<Label htmlFor="your_provider_api_key">API Key</Label>
<Input
id="your_provider_api_key"
{...register("your_provider_api_key", {
required: selectedProvider === "your_provider"
})}
/>
</div>
{/* Add other fields similarly */}
</>
)}
```
**D. Handle Submission**
Update the `onSubmit` function to format the request correctly:
```typescript
// Inside onSubmit function
if (data.provider === "your_provider") {
requestBody = {
provider: "your_provider",
api_key: data.your_provider_api_key,
// ... other fields
};
}
```
### 2. Enable Call Button
Update `src/app/workflow/[workflowId]/components/WorkflowHeader.tsx` to enable the "Phone Call" button when your provider is configured.
```typescript
// In handlePhoneCallClick function
if (
configResponse.error ||
(!configResponse.data?.twilio &&
!configResponse.data?.vonage &&
!configResponse.data?.your_provider) // Add this check
) {
setConfigureDialogOpen(true);
return;
}
```
### 3. Update API Client
After updating the backend and frontend, regenerate the API client to ensure types are synced:
```bash
npm run generate-client
```

View file

@ -1,11 +1,11 @@
---
title: "Inbound Calling"
description: "Configure inbound calling for Twilio, Vonage, and Cloudonix providers in Dograh AI"
description: "Configure inbound calling for Twilio, Vonage, Plivo, Telnyx, Cloudonix, Vobiz, and Asterisk ARI providers in Dograh AI"
---
## Overview
Dograh AI supports inbound calling across all supported telephony providers. When someone calls your configured phone number, your voice agent automatically answers and handles the interaction. The inbound calling configuration in Dograh AI uses the same telephony settings as outbound calling.
Dograh AI supports inbound calling across all supported telephony providers. When someone calls your configured phone number, your voice agent automatically answers and handles the interaction. Inbound and outbound calls share the same telephony configuration.
## Supported Providers for Inbound Calls
@ -13,6 +13,15 @@ Dograh AI supports inbound calling across all supported telephony providers. Whe
<Card title="Twilio" href="/integrations/telephony/twilio">
Industry-leading platform with robust inbound call handling
</Card>
<Card title="Vonage" href="/integrations/telephony/vonage">
Application-level inbound routing with high-quality 16kHz audio
</Card>
<Card title="Plivo" href="/integrations/telephony/plivo">
Application-level inbound routing on Plivo's global voice cloud
</Card>
<Card title="Telnyx" href="/integrations/telephony/telnyx">
Application-level inbound via Telnyx Call Control API
</Card>
<Card title="Cloudonix" href="/integrations/telephony/cloudonix">
SIP-based inbound calling with flexible trunk configuration
</Card>
@ -24,59 +33,67 @@ Dograh AI supports inbound calling across all supported telephony providers. Whe
</Card>
</CardGroup>
<Note>
**Vonage**: Inbound calling support is coming soon. Currently supports outbound calling only.
</Note>
## How Inbound Calling Works
When someone calls your configured phone number:
Dograh exposes a **single inbound webhook URL** for the whole org:
1. **Call Received**: The telephony provider receives the incoming call
2. **Webhook Triggered**: The provider sends a webhook to Dograh AI
3. **Agent Activated**: Dograh AI activates your configured voice agent workflow
```
https://api.dograh.com/api/v1/telephony/inbound/run
```
When a call arrives:
1. **Call Received**: Your telephony provider receives the incoming call
2. **Webhook Triggered**: The provider sends a webhook to `/inbound/run`
3. **Org & Agent Resolved**: Dograh identifies the org from the webhook's account credentials, then looks up which agent should answer based on the called number's **Inbound workflow** assignment in your telephony configuration
4. **Audio Streaming**: Real-time audio streams between caller and agent via WebSocket
5. **Conversation Handled**: Your voice agent manages the entire conversation
You don't construct per-workflow URLs anymore — the routing is done by the called number's inbound workflow assignment in Dograh.
## Configuration in Dograh AI
Setting up inbound calling requires three simple steps:
Setting up inbound calling takes three steps:
### Step 1: Configure Telephony Provider
The telephony configuration for inbound calling is **identical** to outbound calling. Follow the **Configuration** section in your provider's documentation page:
The telephony configuration for inbound calling is **identical** to outbound calling. Add (or open) a configuration at **/telephony-configurations** following your provider's documentation:
- [Twilio Configuration](/integrations/telephony/twilio#configuration)
- [Vonage Configuration](/integrations/telephony/vonage#configuration)
- [Plivo Configuration](/integrations/telephony/plivo#configuration)
- [Telnyx Configuration](/integrations/telephony/telnyx#configuration)
- [Cloudonix Configuration](/integrations/telephony/cloudonix#configuration)
- [Vobiz Configuration](/integrations/telephony/vobiz#configuration)
- [Asterisk ARI Configuration](/integrations/telephony/asterisk-ari#configuration-in-dograh)
### Step 2: Get Your Workflow Webhook URL
### Step 2: Assign an Inbound Workflow to the Phone Number
1. **Find Your Workflow ID**:
- Navigate to your workflow page (e.g., `https://app.dograh.com/workflow/3`)
- Note the workflow ID from the URL (in this example: `3`)
1. Go to **/telephony-configurations** and open your configuration
2. In the **Phone numbers** section, edit the number that should receive inbound calls
3. Set its **Inbound workflow** to the agent that should answer
4. Save
2. **Construct Webhook URL**:
```
https://api.dograh.com/api/v1/telephony/inbound/{workflow_id}
```
For example, if your workflow ID is `3`:
```
https://api.dograh.com/api/v1/telephony/inbound/3
```
This is what tells Dograh which agent to run when a call comes in for that number.
<Note>
If you're using a self-hosted Dograh instance, replace `api.dograh.com` with your own domain.
</Note>
### Step 3: Point Your Provider at `/inbound/run`
### Step 3: Configure Provider-Specific Inbound Settings
Each provider needs its inbound webhook (Twilio Voice URL / Vonage Answer URL / etc.) set to the global Dograh endpoint:
Each telephony provider requires additional configuration to route incoming calls to your Dograh AI webhook. Follow the provider-specific inbound setup instructions:
```
https://api.dograh.com/api/v1/telephony/inbound/run
```
<Note>
If you're self-hosting Dograh, replace `api.dograh.com` with your own domain.
</Note>
The exact place to set this varies by provider — follow the provider-specific instructions:
- [Twilio Inbound Setup](/integrations/telephony/twilio#inbound-calling-setup)
- [Vonage Inbound Setup](/integrations/telephony/vonage#inbound-calling-setup)
- [Plivo Inbound Setup](/integrations/telephony/plivo#inbound-calling-setup)
- [Telnyx Inbound Setup](/integrations/telephony/telnyx#inbound-calling-setup)
- [Cloudonix Inbound Setup](/integrations/telephony/cloudonix#inbound-calling-setup)
- [Vobiz Inbound Setup](/integrations/telephony/vobiz#inbound-calling-setup)
- [Asterisk ARI Inbound Setup](/integrations/telephony/asterisk-ari#inbound-calling)
@ -114,8 +131,8 @@ After completing both the Dograh AI configuration and provider-specific setup:
<Accordion title="Workflow not found error">
- **Error**: "Workflow not found"
- **Solution**: Verify the workflow ID in your webhook URL is correct and the workflow exists in your dashboard
- Double-check the webhook URL format: `https://api.dograh.com/api/v1/telephony/inbound/{workflow_id}`
- **Solution**: Open the called number in **/telephony-configurations** and confirm an **Inbound workflow** is assigned. Without an assignment, Dograh has no agent to route the call to.
- Verify the assigned workflow still exists in your dashboard
</Accordion>
<Accordion title="Account validation failed">
@ -126,8 +143,8 @@ After completing both the Dograh AI configuration and provider-specific setup:
<Accordion title="Phone number not configured">
- **Error**: "Phone number not configured: This number is not set up for inbound calls"
- **Solution**: Add this phone number to your telephony configuration in Dograh AI
- Ensure the phone number is properly linked to your provider account
- **Solution**: Add this phone number under **Phone numbers** in your telephony configuration at **/telephony-configurations**
- Ensure the phone number is also linked to your provider account
</Accordion>
<Accordion title="Signature validation failed">
@ -162,12 +179,11 @@ After completing both the Dograh AI configuration and provider-specific setup:
- **Single Configuration**: Use the same telephony configuration for both inbound and outbound calls
- **Testing**: Always test inbound calling after configuration changes
- **Monitoring**: Monitor both Dograh AI logs and provider dashboards for call analytics
- **Backup Numbers**: Configure multiple phone numbers for redundancy
- **Security**: Ensure webhook signature verification is enabled for security
## Next Steps
1. Choose your telephony provider and complete the basic configuration
2. Follow the provider-specific setup instructions (to be detailed)
3. Test your inbound calling setup
4. Configure your voice agent workflow for optimal caller experience
2. Follow the provider-specific setup instructions
3. Assign an inbound workflow to each phone number that should accept calls
4. Test your inbound calling setup

View file

@ -5,7 +5,7 @@ description: "Connect voice agents with telephony providers for inbound and outb
## Overview
Dograh AI's telephony integration system provides a unified interface for connecting with various telephony providers. This abstraction layer allows you to easily switch between providers without changing your application logic.
Dograh AI's telephony integration system provides a unified interface for connecting with various telephony providers. The same configuration powers both outbound calls (initiated from Dograh) and inbound calls (received on a phone number you own).
## Supported Providers
@ -16,98 +16,70 @@ Dograh AI's telephony integration system provides a unified interface for connec
<Card title="Vonage" href="/integrations/telephony/vonage">
High-quality voice with 16kHz audio and excellent international coverage
</Card>
<Card title="Plivo" href="/integrations/telephony/plivo">
Cloud communications platform with programmable voice and global PSTN reach
</Card>
<Card title="Cloudonix" href="/integrations/telephony/cloudonix">
SIP-based telephony with flexible trunk configuration
</Card>
<Card title="Vobiz" href="/integrations/telephony/vobiz">
Cloud-based telephony with global reach and competitive pricing
</Card>
<Card title="Asterisk ARI" href="/integrations/telephony/asterisk-ari">
Connect to your own Asterisk PBX via the Asterisk REST Interface
</Card>
<Card title="Custom Provider" href="/integrations/telephony/custom">
Build your own telephony provider integration
</Card>
</CardGroup>
## Architecture
The telephony integration system uses a provider abstraction pattern that ensures consistency across different services:
```python
# All providers implement this interface
class TelephonyProvider(ABC):
async def initiate_call(to_number: str, webhook_url: str, workflow_run_id: Optional[int] = None, **kwargs) -> CallInitiationResult
async def get_call_status(call_id: str) -> Dict[str, Any]
async def get_available_phone_numbers() -> List[str]
def validate_config() -> bool
async def verify_webhook_signature(url: str, params: Dict, signature: str) -> bool
async def get_webhook_response(workflow_id: int, user_id: int, workflow_run_id: int) -> str
async def get_call_cost(call_id: str) -> Dict[str, Any]
def parse_status_callback(data: Dict[str, Any]) -> Dict[str, Any]
async def handle_websocket(websocket: WebSocket, workflow_id: int, user_id: int, workflow_run_id: int) -> None
```
## Configuration
Dograh AI uses database configuration for all telephony providers. Configure providers through the web interface:
All telephony providers are configured from a single page in Dograh:
1. Navigate to **Workflow** → **Phone Call** → **Configure Telephony**
2. Select your provider (Twilio or Vonage)
3. Watch the provider-specific video tutorial for setup guidance
4. Enter your credentials
5. Save configuration
6. Test with a phone call
1. Navigate to **/telephony-configurations** and click **Add configuration**
2. Select your provider
3. Enter your credentials and save
4. Open the new configuration and add at least one **phone number**
5. (Optional) Assign an **Inbound workflow** to a phone number to enable inbound calling
<iframe
className="w-full aspect-video rounded-xl"
src="https://www.tella.tv/video/setting-up-telephony-configurations-in-dobra-f6zj/embed"
title="Setting up telephony configurations in Dograh"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
A single org can hold multiple configurations (for example, separate Twilio sub-accounts) and multiple phone numbers per configuration. Mark one configuration as the **default outbound** to use it for test calls and campaigns by default.
## Common Features
The telephony integration in Dograh AI supports:
- **Outbound Calls**: Initiate calls to any phone number
- **Outbound Calls**: Initiate calls to any phone number from a workflow or campaign
- **Inbound Calls**: Route incoming calls to the right voice agent — see the [Inbound Calling guide](/integrations/telephony/inbound)
- **Call Transfer**: Transfer an in-progress call to a human or another number (provider-dependent)
- **Call Status Tracking**: Monitor call lifecycle events (initiated, ringing, answered, completed, failed)
- **WebSocket Streaming**: Real-time audio streaming for voice agents
- **Webhook Authentication**: Secure webhook signature verification
- **WebSocket Audio Streaming**: Real-time, bidirectional audio between caller and agent
- **Webhook Signature Verification**: Inbound webhooks are verified against the matched configuration's credentials
## Code Usage
## Inbound Calling
Here's how to use the telephony provider in your code:
Inbound calls use a **single org-wide webhook URL**:
```python
from api.services.telephony.factory import get_telephony_provider
# Get provider based on organization configuration
provider = await get_telephony_provider(organization_id)
# Initiate a call
result = await provider.initiate_call(
to_number="+1234567890",
webhook_url="https://your-domain.com/webhook",
workflow_run_id=123
)
# Check call status
status = await provider.get_call_status(result.call_id)
# Get call cost after completion
cost_info = await provider.get_call_cost(result.call_id)
```
https://api.dograh.com/api/v1/telephony/inbound/run
```
## API Endpoints
The telephony system exposes these unified endpoints:
| Endpoint | Method | Description |
|----------|---------|-------------|
| `/api/v1/telephony/initiate-call` | POST | Start an outbound call |
| `/api/v1/telephony/twilio/status-callback/{id}` | POST | Receive Twilio status updates |
| `/api/v1/telephony/vonage/events/{id}` | POST | Receive Vonage event updates |
| `/api/v1/telephony/twiml` | POST | Handle Twilio webhook (TwiML) |
| `/api/v1/telephony/ncco` | GET | Handle Vonage webhook (NCCO) |
| `/api/v1/telephony/ws/{workflow_id}/{user_id}/{workflow_run_id}` | WebSocket | Real-time audio streaming |
## Implementation Status
- **Twilio**: ✅ Fully implemented and tested
- **Vonage**: ✅ Fully implemented with 16kHz audio support
- **Custom Providers**: The abstraction layer supports adding new providers by implementing the `TelephonyProvider` interface
Dograh resolves the org from the webhook's account credentials and the agent from the called number's **Inbound workflow** assignment. See [Inbound Calling](/integrations/telephony/inbound) for the full setup.
## Troubleshooting
<AccordionGroup>
<Accordion title="Calls not connecting">
- Verify credentials are correctly configured
- Check phone number format (must include country code)
- Check phone number format (E.164 with country code, e.g. `+1234567890`)
- Ensure webhook URLs are publicly accessible
- Review provider-specific error logs
</Accordion>
@ -116,14 +88,11 @@ The telephony system exposes these unified endpoints:
- Check network bandwidth and latency
- Verify audio codec compatibility
- Review WebSocket connection stability
- Ensure proper audio format:
- Twilio: 8kHz μ-law (MULAW)
- Vonage: 16kHz Linear PCM
</Accordion>
<Accordion title="Webhook signature validation failing">
- Confirm auth tokens match between provider and configuration
- Verify webhook URL matches exactly (including parameters)
- Confirm auth tokens / API secrets match between provider and Dograh configuration
- Verify webhook URL matches exactly (including the `/inbound/run` path)
- Check for proxy or load balancer modifications
</Accordion>
</AccordionGroup>
@ -131,5 +100,5 @@ The telephony system exposes these unified endpoints:
## Next Steps
- [Set up your first telephony provider](/integrations/telephony/twilio)
- [Configure inbound calling](/integrations/telephony/inbound)
- [Build a custom provider integration](/integrations/telephony/custom)
- [Configure webhooks and callbacks](/integrations/telephony/webhooks)

View file

@ -0,0 +1,138 @@
---
title: "Plivo Integration"
description: "Configure Plivo for voice communication in Dograh AI"
---
## Overview
Plivo is a cloud communications platform that provides global voice and messaging APIs. Dograh AI's Plivo integration uses Plivo's XML and WebSocket streaming to power your voice agents.
## Prerequisites
Before setting up Plivo integration, you'll need:
- A [Plivo account](https://www.plivo.com/)
- Auth ID and Auth Token from your Plivo Console
- A Plivo **Application** with Voice capability (used for inbound webhook routing)
- At least one Plivo phone number
- Dograh AI instance running and accessible
## Configuration
### Step 1: Get Plivo Credentials
1. Log in to your [Plivo Console](https://console.plivo.com/)
2. Find your **Auth ID** and **Auth Token** on the dashboard
3. Navigate to **Voice** → **Applications** and create (or open) the application you'll use with Dograh
4. Copy the **Application ID** (a UUID) — you'll attach numbers to this app and Dograh will manage its `answer_url`
5. Navigate to **Phone Numbers** → **Your Numbers** and copy the numbers you plan to use
### Step 2: Configure in Dograh AI
1. Navigate to **/telephony-configurations** and click **Add configuration**
2. Select **Plivo** as your provider
3. Enter your credentials:
- Auth ID
- Auth Token
- Application ID
4. Click **Save Configuration**
5. Open the configuration you just created and add at least one **phone number** (with country code in E.164 format, e.g. `+1234567890`). The default caller ID is used for outbound calls.
### Step 3: Test Your Configuration
1. Create a test workflow
2. Click "Call" to verify connection
3. Check call logs for successful connection
## Inbound Calling Setup
Plivo numbers don't carry an `answer_url` directly — the URL lives on a Plivo **Application**, and each number is linked to one application. **When you save an inbound workflow on a phone number, Dograh automatically pushes the webhook URL to your Plivo Application's `answer_url`** (provided the credentials are correct). You don't need to set the webhook by hand.
### Step 1: Link the Phone Number to Your Plivo Application
1. Go to **Phone Numbers** → **Your Numbers** in the [Plivo Console](https://console.plivo.com/)
2. Open the number you want to use for inbound calls
3. Set its **Application** to the same application whose ID you configured in Dograh
4. Save
### Step 2: Assign an Inbound Workflow to the Phone Number in Dograh
1. Go to **/telephony-configurations** and open your Plivo configuration
2. In the **Phone numbers** section, edit the number that should receive inbound calls
3. Set its **Inbound workflow** to the agent that should answer
4. Save
### Step 3: Verify the Answer URL on the Plivo Application
1. Open your Plivo Application in the [Plivo Console](https://console.plivo.com/) under **Voice** → **Applications**
2. Confirm:
- **Answer URL** is set to: `https://api.dograh.com/api/v1/telephony/inbound/run`
- **Answer Method** is `POST`
<Note>
Dograh pushed this URL automatically when you saved the inbound workflow
in Step 2. The same URL is shared across every number linked to that
application — Dograh routes each inbound call to the right agent based
on the called number's inbound workflow assignment. If the field is
empty, shows a different URL, or Dograh surfaced a sync warning on
save, the auto-push failed — most often because the Auth ID/Token or
Application ID in Dograh is incorrect. Paste the URL into the field
yourself, set the method to `POST`, and save the application. On
self-hosted Dograh, replace `api.dograh.com` with your backend domain.
</Note>
### Step 4: Verify Setup
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Plivo's IP ranges
### Test Inbound Calling
1. Call your configured Plivo phone number from another phone
2. Verify your Dograh AI voice agent answers and responds
3. Check call logs in both Dograh AI dashboard and Plivo Console
## Troubleshooting
<AccordionGroup>
<Accordion title="Invalid phone number error">
Ensure phone numbers include country code in E.164 format: `+1234567890`
</Accordion>
<Accordion title="Authentication failed">
- Verify Auth ID and Auth Token are correct - Check for extra spaces in
credentials - Ensure credentials haven't been regenerated in Plivo Console
</Accordion>
<Accordion title="Webhook signature validation failing">
- Confirm your Auth Token matches exactly - Verify the webhook URL matches
what Plivo sends - Check if you're behind a proxy that modifies requests
</Accordion>
<Accordion title="No audio on calls">
- Verify WebSocket connection is established - Check firewall rules for
WebSocket traffic - Ensure audio pipeline is configured correctly
</Accordion>
<Accordion title="Inbound calls go to voicemail or aren't answered">
- Verify the phone number is linked to the same Plivo Application whose
ID you configured in Dograh - Confirm the called number exists in your
Dograh telephony configuration and has an **Inbound workflow** assigned
- After assigning the inbound workflow, confirm Dograh successfully
updated the application's `answer_url` (no warning shown on save) -
Verify Dograh AI instance is running and responding
</Accordion>
<Accordion title="Voice agent doesn't respond to inbound calls">
- Confirm the phone number has an **Inbound workflow** assigned in
/telephony-configurations - Check webhook signature validation is working
(Auth Token in Dograh matches Plivo Console) - Verify WebSocket connection
establishes successfully - Review call logs for error messages
</Accordion>
</AccordionGroup>
## Best Practices
- Test your configuration with a single call before running campaigns
- Monitor Plivo Console for usage and billing
- Use a dedicated Plivo Application for Dograh so the shared `answer_url` doesn't conflict with other systems

View file

@ -0,0 +1,134 @@
---
title: "Telnyx Integration"
description: "Configure Telnyx for voice communication in Dograh AI"
---
## Overview
Telnyx is a cloud communications platform that provides programmable voice via its Call Control API. Dograh AI's Telnyx integration uses Call Control plus WebSocket media streaming to power your voice agents.
## Prerequisites
Before setting up Telnyx integration, you'll need:
- A [Telnyx account](https://telnyx.com/)
- An **API Key** from the Telnyx Mission Control Portal
- A **Call Control Application** (its `connection_id` is what Dograh stores)
- At least one Telnyx phone number assigned to that Call Control Application
- Dograh AI instance running and accessible
## Configuration
### Step 1: Get Telnyx Credentials
1. Log in to the [Telnyx Mission Control Portal](https://portal.telnyx.com/)
2. Navigate to **API Keys** and create (or copy) an API Key
3. Navigate to **Call Control** → **Applications** and create (or open) the application you'll use with Dograh
4. Copy the application's **Connection ID** (Call Control App ID)
5. Navigate to **Numbers** → **My Numbers** and assign your phone numbers to that Call Control Application
### Step 2: Configure in Dograh AI
1. Navigate to **/telephony-configurations** and click **Add configuration**
2. Select **Telnyx** as your provider
3. Enter your credentials:
- API Key
- Call Control App ID (Connection ID)
4. Click **Save Configuration**
5. Open the configuration you just created and add at least one **phone number** (with country code in E.164 format, e.g. `+1234567890`). The default caller ID is used for outbound calls.
### Step 3: Test Your Configuration
1. Create a test workflow
2. Click "Call" to verify connection
3. Check call logs for successful connection
## Inbound Calling Setup
Telnyx delivers inbound webhooks at the **Call Control Application** level — the webhook URL is configured once on the application, and applies to every number assigned to it. **When you save an inbound workflow on a phone number, Dograh automatically pushes the webhook URL to your Call Control Application's `webhook_event_url`** (provided the credentials are correct).
### Step 1: Assign an Inbound Workflow to the Phone Number
1. Go to **/telephony-configurations** and open your Telnyx configuration
2. In the **Phone numbers** section, edit the number that should receive inbound calls
3. Set its **Inbound workflow** to the agent that should answer
4. Save
### Step 2: Verify the Webhook URL on the Call Control Application
1. Go to **Call Control** → **Applications** in the Telnyx Portal
2. Open the application whose Connection ID you configured in Dograh
3. In **Webhook Settings**, confirm:
- **Webhook URL** is set to: `https://api.dograh.com/api/v1/telephony/inbound/run`
- **HTTP Method** is `POST`
4. Make sure the phone numbers you want to use for inbound are assigned to this application
<Note>
Dograh pushed this URL automatically when you saved the inbound workflow
in Step 1. The same URL is shared across every number on the Call
Control Application — Dograh matches the inbound call to the right
agent using the called number's inbound workflow assignment. If the
field is empty, shows a different URL, or Dograh surfaced a sync
warning on save, the auto-push failed — most often because the API
Key or Connection ID in Dograh is incorrect. Paste the URL into the
field yourself, set the method to `POST`, and save. On self-hosted
Dograh, replace `api.dograh.com` with your backend domain.
</Note>
### Step 3: Verify Setup
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Telnyx's IP ranges
### Test Inbound Calling
1. Call your configured Telnyx phone number from another phone
2. Verify your Dograh AI voice agent answers and responds
3. Check call logs in both Dograh AI dashboard and Telnyx Portal
## Troubleshooting
<AccordionGroup>
<Accordion title="Invalid phone number error">
Ensure phone numbers include country code in E.164 format: `+1234567890`
</Accordion>
<Accordion title="Authentication failed">
- Verify the API Key is correct and active - Check for extra spaces in the
key - Ensure the key hasn't been revoked in the Telnyx Portal
</Accordion>
<Accordion title="Webhook signature validation failing">
- Telnyx signs webhooks with Ed25519 - confirm the public key on the
application hasn't changed - Verify the webhook URL matches what Telnyx
sends - Check if you're behind a proxy that modifies request bodies
</Accordion>
<Accordion title="No audio on calls">
- Verify WebSocket connection is established - Check firewall rules for
WebSocket traffic - Ensure audio pipeline is configured correctly
</Accordion>
<Accordion title="Inbound calls aren't answered">
- Verify the Call Control Application's webhook URL is set to
`https://api.dograh.com/api/v1/telephony/inbound/run` - Ensure the webhook
URL is publicly accessible from the internet - Confirm the called number
is assigned to the same Call Control Application whose Connection ID is
configured in Dograh - Confirm the called number exists in your Dograh
telephony configuration and has an **Inbound workflow** assigned - Verify
Dograh AI instance is running and responding
</Accordion>
<Accordion title="Voice agent doesn't respond to inbound calls">
- Confirm the phone number has an **Inbound workflow** assigned in
/telephony-configurations - Verify the API Key matches the one stored in
your Dograh telephony configuration - Verify WebSocket connection
establishes successfully - Review call logs for error messages
</Accordion>
</AccordionGroup>
## Best Practices
- Test your configuration with a single call before running campaigns
- Monitor the Telnyx Portal for usage and billing
- Use a dedicated Call Control Application for Dograh so the shared webhook URL doesn't conflict with other systems

View file

@ -16,18 +16,6 @@ Before setting up Twilio integration, you'll need:
- At least one Twilio phone number
- Dograh AI instance running and accessible
## Video Tutorial
Watch this step-by-step guide to set up Twilio with Dograh AI:
<iframe
className="w-full aspect-video rounded-xl"
src="https://www.youtube.com/embed/jlPD4CSJHHI"
title="Dograh Twilio Setup"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
## Configuration
### Step 1: Get Twilio Credentials
@ -39,40 +27,47 @@ Watch this step-by-step guide to set up Twilio with Dograh AI:
### Step 2: Configure in Dograh AI
1. Navigate to **Workflow** → **Phone Call** → **Configure Telephony**
2. Watch the Twilio setup video tutorial above for detailed guidance
3. Select **Twilio** as your provider
4. Enter your credentials:
1. Navigate to **/telephony-configurations** and click **Add configuration**
2. Select **Twilio** as your provider
3. Enter your credentials:
- Account SID
- Auth Token
- From Phone Number (with country code, e.g., +1234567890)
5. Click **Save Configuration**
4. Click **Save Configuration**
5. Open the configuration you just created and add at least one **phone number** (with country code in E.164 format, e.g. `+1234567890`). The default caller ID is used for outbound calls.
### Step 3: Test Your Configuration
1. Create a test workflow
2. Click "Test Call" to verify connection
2. Click "Call" to verify connection
3. Check call logs for successful connection
## Inbound Calling Setup
To enable inbound calling with Twilio:
Inbound routing is driven by the phone number itself — there is a **single webhook URL** for the entire org, and Dograh resolves which agent to run from the called number's assigned inbound workflow. **When you save an inbound workflow on a phone number, Dograh automatically pushes the webhook URL to that number's `VoiceUrl` in your Twilio account** (provided the credentials are correct and the number belongs to that account).
1. **Complete Telephony Configuration**: Use the same [Configuration](#configuration) steps above
2. **Get Your Workflow Webhook URL**: Find your workflow ID and construct the webhook URL as: `https://api.dograh.com/api/v1/telephony/inbound/{workflow_id}`
### Step 1: Assign an Inbound Workflow to the Phone Number
### Configure Webhook in Twilio Console
1. Go to **/telephony-configurations** and open your Twilio configuration
2. In the **Phone numbers** section, edit the number that should receive inbound calls
3. Set its **Inbound workflow** to the agent that should answer
4. Save
1. **Configure Phone Number Webhook**:
- Go to **Phone Numbers** → **Manage** → **Active Numbers** in Twilio Console
- Click on the phone number you want to use for inbound calls
- In the "Voice Configuration" section:
- Set **Webhook** to: `https://api.dograh.com/api/v1/telephony/inbound/{workflow_id}`
- Set **HTTP Method** to: `POST`
- Click **Save Configuration**
### Step 2: Verify the Webhook URL in Twilio Console
1. Go to **Phone Numbers** → **Manage** → **Active Numbers** in Twilio Console
2. Click the phone number you assigned an inbound workflow to in Step 1
3. In the **Voice Configuration** section, confirm:
- **Webhook** is set to: `https://api.dograh.com/api/v1/telephony/inbound/run`
- **HTTP Method** is `POST`
<Note>
Replace `{workflow_id}` with your actual workflow ID. If using self-hosted Dograh, replace `api.dograh.com` with your domain.
Dograh pushed this URL automatically when you saved the inbound workflow
in Step 1. If the field is empty, shows a different URL, or Dograh
surfaced a sync warning, the auto-push failed — most often because the
credentials are incorrect or the number isn't owned by this Twilio
account. Paste the URL into the field yourself, set the method to
`POST`, and click **Save Configuration**. On self-hosted Dograh,
replace `api.dograh.com` with your backend domain.
</Note>
<img
@ -86,9 +81,10 @@ To enable inbound calling with Twilio:
alt="Twilio phone number voice configuration showing webhook URL setup for inbound calls"
/>
2. **Verify Setup**:
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Twilio's IP ranges
### Step 3: Verify Setup
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Twilio's IP ranges
### Test Inbound Calling
@ -102,42 +98,39 @@ To enable inbound calling with Twilio:
<Accordion title="Invalid phone number error">
Ensure phone numbers include country code in E.164 format: `+1234567890`
</Accordion>
<Accordion title="Authentication failed">
- Verify Account SID and Auth Token are correct
- Check for extra spaces in credentials
- Ensure credentials haven't been regenerated in Twilio Console
- Verify Account SID and Auth Token are correct - Check for extra spaces in
credentials - Ensure credentials haven't been regenerated in Twilio Console
</Accordion>
<Accordion title="Webhook signature validation failing">
- Confirm your Auth Token matches exactly
- Verify the webhook URL matches what Twilio sends
- Check if you're behind a proxy that modifies requests
- Confirm your Auth Token matches exactly - Verify the webhook URL matches
what Twilio sends - Check if you're behind a proxy that modifies requests
</Accordion>
<Accordion title="No audio on calls">
- Verify WebSocket connection is established
- Check firewall rules for WebSocket traffic
- Ensure audio pipeline is configured correctly
- Verify WebSocket connection is established - Check firewall rules for
WebSocket traffic - Ensure audio pipeline is configured correctly
</Accordion>
<Accordion title="Inbound calls go to voicemail">
- Verify webhook URL is correctly configured in Twilio Console
- Ensure webhook URL is publicly accessible from internet
- Check that phone number is properly linked to webhook
- Verify Dograh AI instance is running and responding
- Verify the Twilio number's webhook is set to
`https://api.dograh.com/api/v1/telephony/inbound/run` - Ensure the webhook
URL is publicly accessible from the internet - Confirm the called number
exists in your Dograh telephony configuration and has an **Inbound
workflow** assigned - Verify Dograh AI instance is running and responding
</Accordion>
<Accordion title="Voice agent doesn't respond to inbound calls">
- Confirm voice agent workflow is properly configured
- Check webhook signature validation is working
- Verify WebSocket connection establishes successfully
- Review call logs for error messages
- Confirm the phone number has an **Inbound workflow** assigned in
/telephony-configurations - Check webhook signature validation is working
(Auth Token in Dograh matches Twilio Console) - Verify WebSocket connection
establishes successfully - Review call logs for error messages
</Accordion>
</AccordionGroup>
## Best Practices
- Store credentials securely in the database
- Test your configuration with a single call before running campaigns
- Monitor Twilio Console for usage and billing
- Monitor Twilio Console for usage and billing

View file

@ -12,109 +12,91 @@ Vobiz is a cloud-based telephony service provider that offers global reach with
Before setting up Vobiz integration, you'll need:
- A [Vobiz account](https://vobiz.com)
- Auth ID from your Vobiz dashboard
- Auth Token generated for your account
- At least one configured phone number in your Vobiz account
- Auth ID and Auth Token from your Vobiz dashboard
- A Vobiz **Application** (used for inbound webhook routing)
- At least one Vobiz phone number
- Dograh AI instance running and accessible
## Configuration
### Step 1: Get Vobiz Credentials
1. Sign up for a Vobiz account
2. Log in to your Vobiz dashboard
3. Navigate to your account settings to find your **Auth ID**
4. Generate an **Auth Token** for API access
5. Configure phone numbers in your Vobiz account for outbound calling
1. Log in to your Vobiz dashboard
2. Find your **Auth ID** and generate an **Auth Token** for API access
3. Navigate to the **Applications** section and create (or open) the application you'll use with Dograh
4. Copy the **Application ID** — Dograh will manage its `answer_url`
5. Navigate to **Phone Numbers** and copy the numbers you plan to use
### Step 2: Configure in Dograh AI
1. Navigate to **Workflow** → **Phone Call** → **Configure Telephony**
1. Navigate to **/telephony-configurations** and click **Add configuration**
2. Select **Vobiz** as your provider
3. Enter your credentials:
- Auth ID
- Auth Token
- From Phone Number (with country code, e.g., +1234567890)
- Application ID
4. Click **Save Configuration**
<Note>
Vobiz provides cloud-based telephony services with global reach and competitive pricing.
</Note>
5. Open the configuration you just created and add at least one **phone number** (with country code in E.164 format, e.g. `+1234567890`). The default caller ID is used for outbound calls.
### Step 3: Test Your Configuration
1. Create a test workflow
2. Click "Test Call" to verify connection
2. Click "Call" to verify connection
3. Check call logs for successful connection
## Inbound Calling Setup
To enable inbound calling with Vobiz:
Vobiz numbers don't carry an `answer_url` directly — the URL lives on a Vobiz **Application**, and each number is linked to one application. **When you save an inbound workflow on a phone number, Dograh automatically pushes the webhook URL to your Vobiz Application's `answer_url`** (provided the credentials are correct).
1. **Complete Telephony Configuration**: Use the same [Configuration](#configuration) steps above
2. **Get Your Workflow Webhook URL**: Find your workflow ID and construct the webhook URL as: `https://api.dograh.com/api/v1/telephony/inbound/{workflow_id}`
### Step 1: Link the Phone Number to Your Vobiz Application
### Configure Application in Vobiz Console
Vobiz requires creating an XML application to handle inbound calls. Follow these steps:
1. **Navigate to Applications**:
- Log in to your Vobiz Console
- Navigate to the **Applications** section
1. Log in to your Vobiz Console and open the **Applications** section
2. Edit the application whose ID you configured in Dograh
3. Attach the phone number you want to use for inbound calls
4. Save
<img
className="block dark:hidden"
src="/images/vobiz-inbound-config-1.png"
alt="Vobiz console showing Applications section navigation"
src="/images/vobiz-inbound-config-3.png"
alt="Vobiz application showing phone number attachment interface"
/>
<img
className="hidden dark:block"
src="/images/vobiz-inbound-config-1.png"
alt="Vobiz console showing Applications section navigation"
src="/images/vobiz-inbound-config-3.png"
alt="Vobiz application showing phone number attachment interface"
/>
2. **Create New Application**:
- Click **Create New Application**
- Configure the XML application with the following URLs:
- **Answer URL**: `https://api.dograh.com/api/v1/telephony/inbound/{workflow_id}`
- **Hangup URL**: `https://api.dograh.com/api/v1/telephony/vobiz/hangup-callback/workflow/{workflow_id}`
- **Fallback Answer URL**: `https://api.dograh.com/api/v1/telephony/inbound/fallback`
- Save the application
### Step 2: Assign an Inbound Workflow to the Phone Number in Dograh
1. Go to **/telephony-configurations** and open your Vobiz configuration
2. In the **Phone numbers** section, edit the number that should receive inbound calls
3. Set its **Inbound workflow** to the agent that should answer
4. Save
### Step 3: Verify the Answer URL on the Vobiz Application
1. Open your Vobiz Console and navigate to **Applications**
2. Open the application whose ID you configured in Dograh
3. Confirm:
- **Answer URL** is set to: `https://api.dograh.com/api/v1/telephony/inbound/run`
- **Answer Method** is `POST`
<Note>
Replace `{workflow_id}` with your actual workflow ID. If using self-hosted Dograh, replace `api.dograh.com` with your domain.
Dograh pushed this URL automatically when you saved the inbound workflow
in Step 2. The same URL is shared across every number linked to that
application — Dograh routes each inbound call to the right agent based
on the called number's inbound workflow assignment. If the field is
empty, shows a different URL, or Dograh surfaced a sync warning on
save, the auto-push failed — most often because the Auth ID/Token or
Application ID in Dograh is incorrect. Paste the URL into the field
yourself, set the method to `POST`, and save. On self-hosted Dograh,
replace `api.dograh.com` with your backend domain.
</Note>
<img
className="block dark:hidden"
src="/images/vobiz-inbound-config-2.png"
alt="Vobiz XML application configuration showing Answer URL, Hangup URL, and Fallback Answer URL"
/>
<img
className="hidden dark:block"
src="/images/vobiz-inbound-config-2.png"
alt="Vobiz XML application configuration showing Answer URL, Hangup URL, and Fallback Answer URL"
/>
### Step 4: Verify Setup
3. **Attach Phone Number**:
- After saving the application, edit it to attach a phone number
- Select the phone number you want to use for inbound calls
- Link it to your created application
<img
className="block dark:hidden"
src="/images/vobiz-inbound-config-3.png"
alt="Vobiz application showing phone number attachment interface"
/>
<img
className="hidden dark:block"
src="/images/vobiz-inbound-config-3.png"
alt="Vobiz application showing phone number attachment interface"
/>
2. **Verify Setup**:
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Vobiz's webhook requests
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Vobiz's webhook requests
### Test Inbound Calling
@ -147,24 +129,26 @@ Vobiz requires creating an XML application to handle inbound calls. Follow these
- Ensure audio pipeline is configured correctly
</Accordion>
<Accordion title="Inbound calls not connecting">
- Verify webhook URL is correctly configured in Vobiz dashboard
- Ensure webhook URL is publicly accessible from internet
- Check that phone number is properly linked to webhook
- Verify Dograh AI instance is running and responding
<Accordion title="Inbound calls go to voicemail or aren't answered">
- Verify the phone number is linked to the same Vobiz Application whose
ID you configured in Dograh - Confirm the called number exists in your
Dograh telephony configuration and has an **Inbound workflow** assigned
- After assigning the inbound workflow, confirm Dograh successfully
updated the application's `answer_url` (no warning shown on save) -
Verify Dograh AI instance is running and responding
</Accordion>
<Accordion title="Voice agent doesn't respond to inbound calls">
- Confirm voice agent workflow is properly configured
- Check webhook processing is working correctly
- Verify WebSocket connection establishes successfully
- Review call logs for error messages
- Confirm the phone number has an **Inbound workflow** assigned in
/telephony-configurations - Check webhook signature validation is working
(Auth Token in Dograh matches Vobiz dashboard) - Verify WebSocket connection
establishes successfully - Review call logs for error messages
</Accordion>
</AccordionGroup>
## Best Practices
- Store credentials securely in the database
- Test your configuration with a single call before running campaigns
- Monitor Vobiz dashboard for usage and billing
- Keep your Auth Token secure and rotate it periodically
- Keep your Auth Token secure and rotate it periodically
- Use a dedicated Vobiz Application for Dograh so the shared `answer_url` doesn't conflict with other systems

View file

@ -14,21 +14,10 @@ Before setting up Vonage integration, you'll need:
- A [Vonage account](https://www.vonage.com/communications-apis/)
- Vonage Application with Voice capability enabled
- Application ID and Private Key from your Vonage Dashboard
- At least one Vonage phone number
- API Key and API Secret from your Vonage Dashboard
- At least one Vonage phone number linked to the application
- Dograh AI instance running and accessible
## Video Tutorial
Watch this step-by-step guide to set up Vonage with Dograh AI:
<iframe
className="w-full aspect-video rounded-xl"
src="https://www.tella.tv/video/configuring-telephony-on-dograh-with-vonage-3wvo/embed?b=1&title=1&a=1&loop=0&t=0&muted=0&wt=1"
title="Dograh Vonage Setup"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
## Configuration
### Step 1: Create Vonage Application
@ -48,37 +37,65 @@ Watch this step-by-step guide to set up Vonage with Dograh AI:
### Step 3: Configure in Dograh AI
1. Navigate to **Workflow** → **Phone Call** → **Configure Telephony**
2. Watch the Vonage setup video tutorial above for detailed guidance
3. Select **Vonage** as your provider
4. Enter your credentials:
1. Navigate to **/telephony-configurations** and click **Add configuration**
2. Select **Vonage** as your provider
3. Enter your credentials:
- Application ID
- Private Key (entire key including BEGIN/END lines)
- API Key (Optional - for some operations)
- API Secret (Optional - for webhook verification)
- From Phone Number (without '+' prefix, e.g., 14155551234)
5. Click **Save Configuration**
- API Key
- API Secret
4. Click **Save Configuration**
5. Open the configuration you just created and add at least one **phone number** (without `+` prefix, e.g. `14155551234`). The default caller ID is used for outbound calls.
### Step 4: Test Your Configuration
1. Create a test workflow
2. Click "Test Call" to verify connection
2. Click "Call" to verify connection
3. Check call logs for successful connection
## Inbound Calling
## Inbound Calling Setup
<Note>
**Coming Soon**: Inbound calling support for Vonage is currently under development and will be available in a future release. For now, Vonage integration supports outbound calling only.
</Note>
Vonage configures inbound webhooks at the **application level**, not per phone number. A single **Answer URL** on the Vonage application applies to every number linked to it. Dograh routes the call to the right agent based on the called number's inbound workflow assignment inside Dograh. **When you save an inbound workflow on a phone number, Dograh automatically pushes the webhook URL to your Vonage Application's Answer URL** (provided the credentials are correct).
Vonage integration currently supports outbound calling. Inbound calling functionality is being developed and will include:
### Step 1: Link Phone Numbers to Your Vonage Application
- Webhook configuration for incoming calls
- NCCO response handling
- Event tracking for call lifecycle management
- WebSocket audio streaming for inbound calls
1. Open the [Vonage Dashboard](https://dashboard.nexmo.com/)
2. Under **Numbers** → **Your Numbers**, link each number you want to use for inbound to the same Vonage Application whose ID you configured in Dograh
For inbound calling needs, consider using [Twilio](/integrations/telephony/twilio), [Cloudonix](/integrations/telephony/cloudonix), or [Vobiz](/integrations/telephony/vobiz) which have full inbound support.
### Step 2: Assign an Inbound Workflow to the Phone Number in Dograh
1. Go to **/telephony-configurations** and open your Vonage configuration
2. In the **Phone numbers** section, edit the number that should receive inbound calls
3. Set its **Inbound workflow** to the agent that should answer
4. Save
### Step 3: Verify the Answer URL on the Vonage Application
1. Open your Vonage Application in the [Vonage Dashboard](https://dashboard.nexmo.com/)
2. Under **Capabilities** → **Voice**, confirm:
- **Answer URL** is set to: `https://api.dograh.com/api/v1/telephony/inbound/run`
- **HTTP Method** is `POST`
<Note>
Dograh pushed this URL automatically when you saved the inbound workflow
in Step 2. If the field is empty, shows a different URL, or Dograh
surfaced a sync warning on save, the auto-push failed — most often
because the API Key/Secret or Application ID in Dograh is incorrect.
Paste the URL into the field yourself, set the method to `POST`, and
save the application. On self-hosted Dograh, replace `api.dograh.com`
with your backend domain.
</Note>
### Step 4: Verify Setup
- Ensure your Dograh AI instance is publicly accessible
- Verify any firewalls allow Vonage's IP ranges
### Test Inbound Calling
1. Call your configured Vonage phone number from another phone
2. Verify your Dograh AI voice agent answers and responds
3. Check call logs in both Dograh AI dashboard and Vonage Dashboard
## Audio Quality Optimization
@ -124,17 +141,17 @@ Vonage uses higher quality audio (16kHz) which provides:
</Accordion>
<Accordion title="Inbound calls not reaching voice agent">
- Verify Answer URL is correctly configured in Vonage application
- Ensure Answer URL is publicly accessible and returns valid NCCO
- Check that phone numbers are linked to the correct application
- Verify Event URL is configured for call tracking
- Verify the Vonage application's Answer URL is set to `https://api.dograh.com/api/v1/telephony/inbound/run`
- Ensure the Answer URL is publicly accessible
- Confirm the called number is linked to the correct Vonage application
- Confirm the called number exists in your Dograh telephony configuration and has an **Inbound workflow** assigned
</Accordion>
<Accordion title="Voice agent doesn't respond to inbound calls">
- Confirm NCCO response includes correct WebSocket endpoint
- Check that organization_id in Event URL matches your setup
- Verify voice agent workflow is properly configured
- Review webhook logs for error responses
- Confirm the phone number has an **Inbound workflow** assigned in /telephony-configurations
- Verify API Key matches the one stored in your Dograh telephony configuration (used to identify the org from the inbound webhook)
- Verify WebSocket connection establishes successfully
- Review call logs for error messages
</Accordion>
</AccordionGroup>
@ -155,42 +172,6 @@ Vonage pricing includes:
Check [Vonage pricing](https://www.vonage.com/communications-apis/voice/pricing/) for current rates.
## Advanced Configuration
### Custom Headers
Add custom headers to WebSocket connections:
```python
# In your webhook response
"headers": {
"X-Custom-Header": "value",
"Authorization": "Bearer token"
}
```
### Call Recording
Enable call recording via NCCO:
```json
{
"action": "record",
"eventUrl": ["https://your-domain/recording-webhook"],
"format": "mp3"
}
```
## API Differences from Twilio
| Feature | Twilio | Vonage |
|---------|---------|---------|
| Audio Format | 8kHz μ-law | 16kHz Linear PCM |
| Control Format | TwiML (XML) | NCCO (JSON) |
| Authentication | Basic Auth | JWT |
| WebSocket Data | Base64 text | Binary frames |
| Phone Format | With '+' | Without '+' |
## Next Steps
- Test your Vonage integration with a simple workflow

View file

@ -1,15 +0,0 @@
---
title: "Twilio"
description: "Setting up Twilio on Dograh AI"
---
### Introduction
You can setup Twilio on Dograh AI to make and receive calls when executing your Voice Agent Workflow. You can watch below video to get started with the setup once you have your Dograh AI stack running.
<iframe
className="w-full aspect-video rounded-xl"
src="https://www.youtube.com/embed/jlPD4CSJHHI"
title="Dogrh Twilio Setup"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>

@ -1 +1 @@
Subproject commit a6869df4bc7de8bd14f0533f7112f7d6a24891d9
Subproject commit 82432f4e8be05d6cd8e9a7cd578f3893005d157c

View file

@ -1,6 +1,7 @@
"use client";
import { Plus, X } from 'lucide-react';
import Link from 'next/link';
import { useId } from 'react';
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
@ -137,12 +138,12 @@ export default function CampaignAdvancedSettings({
</p>
{fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && (
<p className="text-sm text-amber-600 dark:text-amber-400">
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a>.
Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in <Link href="/telephony-configurations" className="underline font-medium">Telephony Configuration</Link>.
</p>
)}
{fromNumbersCount === 0 && (
<p className="text-sm text-amber-600 dark:text-amber-400">
No phone numbers configured. Add CLIs in <a href="/telephony-configurations" className="underline font-medium">Telephony Configuration</a> before running the campaign.
No phone numbers configured. Add CLIs in <Link href="/telephony-configurations" className="underline font-medium">Telephony Configuration</Link> before running the campaign.
</p>
)}
</div>

View file

@ -178,7 +178,7 @@ export default function EditCampaignPage() {
}
if (maxConcurrencyValue > effectiveLimit) {
if (fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured add more CLIs to increase concurrency.`);
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured - add more CLIs to increase concurrency.`);
} else {
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
}

View file

@ -1,6 +1,7 @@
"use client";
import { ArrowLeft, ChevronDown, ChevronRight } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import type { ITimezoneOption } from 'react-timezone-select';
@ -9,9 +10,10 @@ import { toast } from 'sonner';
import {
createCampaignApiV1CampaignCreatePost,
getCampaignDefaultsApiV1OrganizationsCampaignDefaultsGet,
getWorkflowsSummaryApiV1WorkflowSummaryGet
getWorkflowsSummaryApiV1WorkflowSummaryGet,
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
} from '@/client/sdk.gen';
import type { WorkflowSummaryResponse } from '@/client/types.gen';
import type { TelephonyConfigurationListItem, WorkflowSummaryResponse } from '@/client/types.gen';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
@ -48,6 +50,11 @@ export default function NewCampaignPage() {
const [workflows, setWorkflows] = useState<WorkflowSummaryResponse[]>([]);
const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true);
// Telephony configurations state
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
const [selectedTelephonyConfigId, setSelectedTelephonyConfigId] = useState<string>('');
const [isLoadingTelephonyConfigs, setIsLoadingTelephonyConfigs] = useState(true);
// Advanced settings state
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
const [orgConcurrentLimit, setOrgConcurrentLimit] = useState<number>(2);
@ -94,7 +101,10 @@ export default function NewCampaignPage() {
const response = await getWorkflowsSummaryApiV1WorkflowSummaryGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
},
query: {
status: 'active',
},
});
if (response.data) {
@ -108,6 +118,33 @@ export default function NewCampaignPage() {
}
}, [user, getAccessToken]);
// Fetch telephony configurations
const fetchTelephonyConfigs = useCallback(async () => {
if (!user) return;
try {
const accessToken = await getAccessToken();
const response = await listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
}
});
if (response.data) {
const configs = response.data.configurations ?? [];
setTelephonyConfigs(configs);
const defaultConfig = configs.find((c) => c.is_default_outbound) ?? configs[0];
if (defaultConfig) {
setSelectedTelephonyConfigId(String(defaultConfig.id));
}
}
} catch (error) {
console.error('Failed to fetch telephony configurations:', error);
toast.error('Failed to load telephony configurations');
} finally {
setIsLoadingTelephonyConfigs(false);
}
}, [user, getAccessToken]);
// Fetch campaign limits
const fetchCampaignDefaults = useCallback(async () => {
if (!user) return;
@ -183,12 +220,21 @@ export default function NewCampaignPage() {
if (user) {
fetchWorkflows();
fetchCampaignDefaults();
fetchTelephonyConfigs();
}
}, [fetchWorkflows, fetchCampaignDefaults, user]);
}, [fetchWorkflows, fetchCampaignDefaults, fetchTelephonyConfigs, user]);
// Phone-number count for the selected telephony config drives concurrency
// bounds. Falls back to the campaign-defaults endpoint's count (org default
// config) until the configs list resolves.
const selectedTelephonyConfig = telephonyConfigs.find(
(c) => String(c.id) === selectedTelephonyConfigId,
);
const availableFromNumbersCount = selectedTelephonyConfig?.phone_number_count ?? fromNumbersCount;
// Effective concurrency limit considering both org limit and available CLIs
const effectiveLimit = fromNumbersCount > 0
? Math.min(orgConcurrentLimit, fromNumbersCount)
const effectiveLimit = availableFromNumbersCount > 0
? Math.min(orgConcurrentLimit, availableFromNumbersCount)
: orgConcurrentLimit;
// Handle form submission
@ -196,7 +242,7 @@ export default function NewCampaignPage() {
e.preventDefault();
setCreateError(null);
if (!campaignName || !selectedWorkflowId || !sourceId) {
if (!campaignName || !selectedWorkflowId || !sourceId || !selectedTelephonyConfigId) {
toast.error('Please fill in all fields');
return;
}
@ -209,8 +255,8 @@ export default function NewCampaignPage() {
return;
}
if (maxConcurrencyValue > effectiveLimit) {
if (fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. You have ${fromNumbersCount} phone number(s) configured — add more CLIs to increase concurrency.`);
if (availableFromNumbersCount > 0 && availableFromNumbersCount < orgConcurrentLimit) {
toast.error(`Max concurrent calls cannot exceed ${effectiveLimit}. The selected configuration has ${availableFromNumbersCount} phone number(s) — add more CLIs to increase concurrency.`);
} else {
toast.error(`Max concurrent calls cannot exceed organization limit (${effectiveLimit})`);
}
@ -257,6 +303,7 @@ export default function NewCampaignPage() {
workflow_id: parseInt(selectedWorkflowId),
source_type: sourceType,
source_id: sourceId,
telephony_configuration_id: parseInt(selectedTelephonyConfigId),
retry_config: retryConfig,
max_concurrency: maxConcurrencyValue,
schedule_config: scheduleConfig,
@ -383,6 +430,52 @@ export default function NewCampaignPage() {
</p>
</div>
<div className="space-y-2">
<Label htmlFor="telephony-config">Telephony Configuration</Label>
{!isLoadingTelephonyConfigs && telephonyConfigs.length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-sm text-muted-foreground">
No telephony configurations yet.{' '}
<Link
href="/telephony-configurations"
className="underline text-foreground"
>
Add one
</Link>{' '}
to create a campaign.
</div>
) : (
<Select
value={selectedTelephonyConfigId}
onValueChange={setSelectedTelephonyConfigId}
required
>
<SelectTrigger id="telephony-config">
<SelectValue placeholder="Select a telephony configuration" />
</SelectTrigger>
<SelectContent>
{isLoadingTelephonyConfigs ? (
<SelectItem value="loading" disabled>
Loading configurations...
</SelectItem>
) : (
telephonyConfigs.map((config) => (
<SelectItem
key={config.id}
value={config.id.toString()}
>
{config.name} ({config.provider})
{config.is_default_outbound ? ' — default' : ''}
</SelectItem>
))
)}
</SelectContent>
</Select>
)}
<p className="text-sm text-muted-foreground">
Outbound calls for this campaign will use this configuration&apos;s caller IDs
</p>
</div>
<div className="space-y-2">
<Label htmlFor="source-type">Data Source Type</Label>
<Select
@ -480,7 +573,7 @@ export default function NewCampaignPage() {
<div className="flex gap-4 pt-4">
<Button
type="submit"
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId}
disabled={isSubmitting || !campaignName || !selectedWorkflowId || !sourceId || !selectedTelephonyConfigId}
>
{isSubmitting ? 'Creating...' : 'Create Campaign'}
</Button>

View file

@ -0,0 +1,419 @@
"use client";
import {
ArrowLeft,
ExternalLink,
Pencil,
Plus,
Star,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import {
deletePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdDelete,
getTelephonyConfigurationByIdApiV1OrganizationsTelephonyConfigsConfigIdGet,
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet,
setDefaultCallerIdApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdSetDefaultCallerPost,
setDefaultOutboundApiV1OrganizationsTelephonyConfigsConfigIdSetDefaultOutboundPost,
} from "@/client/sdk.gen";
import type {
PhoneNumberResponse,
TelephonyConfigurationDetail,
} from "@/client/types.gen";
import { ConfigFormDialog } from "@/components/telephony/ConfigFormDialog";
import { PhoneNumberDialog } from "@/components/telephony/PhoneNumberDialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAuth } from "@/lib/auth";
export default function TelephonyConfigurationDetailPage() {
const router = useRouter();
const params = useParams<{ configId: string }>();
const configId = Number(params.configId);
const { user, getAccessToken, loading: authLoading } = useAuth();
const [config, setConfig] = useState<TelephonyConfigurationDetail | null>(null);
const [phoneNumbers, setPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
const [loading, setLoading] = useState(true);
const [editConfigOpen, setEditConfigOpen] = useState(false);
const [phoneDialogOpen, setPhoneDialogOpen] = useState(false);
const [phoneEditTarget, setPhoneEditTarget] = useState<PhoneNumberResponse | null>(
null,
);
const [phoneDeleteTarget, setPhoneDeleteTarget] = useState<PhoneNumberResponse | null>(
null,
);
const fetchAll = useCallback(async () => {
if (authLoading || !user || !configId) return;
setLoading(true);
try {
const token = await getAccessToken();
const [cfgRes, numbersRes] = await Promise.all([
getTelephonyConfigurationByIdApiV1OrganizationsTelephonyConfigsConfigIdGet({
headers: { Authorization: `Bearer ${token}` },
path: { config_id: configId },
}),
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet({
headers: { Authorization: `Bearer ${token}` },
path: { config_id: configId },
}),
]);
if (cfgRes.error) throw new Error(detailFromError(cfgRes.error));
if (numbersRes.error) throw new Error(detailFromError(numbersRes.error));
setConfig(cfgRes.data ?? null);
setPhoneNumbers(numbersRes.data?.phone_numbers ?? []);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to load configuration");
} finally {
setLoading(false);
}
}, [authLoading, user, configId, getAccessToken]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const onSetDefaultOutbound = async () => {
if (!config) return;
try {
const token = await getAccessToken();
const res = await setDefaultOutboundApiV1OrganizationsTelephonyConfigsConfigIdSetDefaultOutboundPost(
{
headers: { Authorization: `Bearer ${token}` },
path: { config_id: config.id },
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success("Set as default outbound");
fetchAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to set default");
}
};
const onSetDefaultCaller = async (n: PhoneNumberResponse) => {
try {
const token = await getAccessToken();
const res = await setDefaultCallerIdApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdSetDefaultCallerPost(
{
headers: { Authorization: `Bearer ${token}` },
path: { config_id: configId, phone_number_id: n.id },
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success(`${n.address} is now the default caller ID`);
fetchAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to set default caller");
}
};
const onConfirmDeletePhone = async () => {
if (!phoneDeleteTarget) return;
try {
const token = await getAccessToken();
const res = await deletePhoneNumberApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersPhoneNumberIdDelete(
{
headers: { Authorization: `Bearer ${token}` },
path: {
config_id: configId,
phone_number_id: phoneDeleteTarget.id,
},
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success("Phone number deleted");
setPhoneDeleteTarget(null);
fetchAll();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to delete phone number");
}
};
if (loading) {
return (
<div className="container mx-auto px-4 py-8 space-y-3">
<Skeleton className="h-10 w-1/3" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!config) {
return (
<div className="container mx-auto px-4 py-8">
<Button variant="ghost" onClick={() => router.push("/telephony-configurations")}>
<ArrowLeft className="h-4 w-4 mr-2" /> Back
</Button>
<p className="mt-4 text-muted-foreground">Configuration not found.</p>
</div>
);
}
return (
<div className="container mx-auto px-4 py-8 space-y-6">
<div>
<Link
href="/telephony-configurations"
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
>
<ArrowLeft className="h-4 w-4 mr-1" /> All configurations
</Link>
</div>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<CardTitle className="truncate">{config.name}</CardTitle>
<Badge variant="secondary">{config.provider}</Badge>
{config.is_default_outbound && (
<Badge className="gap-1">
<Star className="h-3 w-3 fill-current" />
Default
</Badge>
)}
</div>
<CardDescription>
Updated {new Date(config.updated_at).toLocaleString()}
</CardDescription>
</div>
<div className="flex items-center gap-2 shrink-0">
{!config.is_default_outbound && (
<Button variant="outline" size="sm" onClick={onSetDefaultOutbound}>
<Star className="h-4 w-4 mr-2" /> Set as default
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setEditConfigOpen(true)}>
<Pencil className="h-4 w-4 mr-2" /> Edit credentials
</Button>
</div>
</CardHeader>
<CardContent>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
{Object.entries(config.credentials ?? {}).map(([k, v]) => (
<div key={k} className="flex justify-between gap-3">
<dt className="text-muted-foreground">{k}</dt>
<dd className="font-mono text-right truncate max-w-[60%]">
{String(v ?? "")}
</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div className="space-y-1">
<CardTitle>Phone numbers</CardTitle>
<CardDescription>
Numbers used as caller ID for outbound and accepted for inbound matching.
SIP URIs and extensions are supported alongside PSTN numbers.{" "}
<a
href="https://docs.dograh.com/integrations/telephony/inbound"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 underline"
>
Inbound docs <ExternalLink className="h-3 w-3" />
</a>
</CardDescription>
</div>
<Button
size="sm"
onClick={() => {
setPhoneEditTarget(null);
setPhoneDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" /> Add phone number
</Button>
</CardHeader>
<CardContent>
{phoneNumbers.length === 0 ? (
<p className="text-sm text-muted-foreground">
No phone numbers yet. Add one to start placing or receiving calls on this
configuration.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Address</TableHead>
<TableHead>Type</TableHead>
<TableHead>Label</TableHead>
<TableHead>Status</TableHead>
<TableHead>Inbound workflow</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{phoneNumbers.map((n) => (
<TableRow key={n.id}>
<TableCell className="font-mono">{n.address}</TableCell>
<TableCell>
<Badge variant="outline">{n.address_type}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{n.label ?? "-"}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{n.is_active ? (
<Badge variant="secondary">Active</Badge>
) : (
<Badge variant="outline">Inactive</Badge>
)}
{n.is_default_caller_id && (
<Badge className="gap-1">
<Star className="h-3 w-3 fill-current" /> Default caller
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-muted-foreground">
{n.inbound_workflow_id ? (
<Link
href={`/workflow/${n.inbound_workflow_id}`}
className="inline-flex items-center gap-1 hover:underline hover:text-foreground"
>
<span>#{n.inbound_workflow_id}</span>
{n.inbound_workflow_name && (
<span
className="truncate max-w-[160px]"
title={n.inbound_workflow_name}
>
{n.inbound_workflow_name.length > 24
? `${n.inbound_workflow_name.slice(0, 24)}`
: n.inbound_workflow_name}
</span>
)}
</Link>
) : (
"-"
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{!n.is_default_caller_id && n.is_active && (
<Button
variant="ghost"
size="sm"
onClick={() => onSetDefaultCaller(n)}
title="Set as default caller ID"
>
<Star className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => {
setPhoneEditTarget(n);
setPhoneDialogOpen(true);
}}
title="Edit"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setPhoneDeleteTarget(n)}
title="Delete"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<ConfigFormDialog
open={editConfigOpen}
onOpenChange={setEditConfigOpen}
existing={config}
onSaved={fetchAll}
/>
<PhoneNumberDialog
open={phoneDialogOpen}
onOpenChange={setPhoneDialogOpen}
configId={configId}
existing={phoneEditTarget}
onSaved={fetchAll}
/>
<AlertDialog
open={!!phoneDeleteTarget}
onOpenChange={(o) => !o && setPhoneDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete phone number?</AlertDialogTitle>
<AlertDialogDescription>
{phoneDeleteTarget?.address} will no longer accept inbound calls or be
available as a caller ID for this configuration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirmDeletePhone}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
function detailFromError(err: unknown): string {
if (typeof err === "string") return err;
const e = err as { detail?: unknown };
if (typeof e?.detail === "string") return e.detail;
if (Array.isArray(e?.detail) && e.detail.length > 0) {
const first = e.detail[0] as { msg?: string };
if (first?.msg) return first.msg;
}
return "Request failed";
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,340 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
createTelephonyConfigurationApiV1OrganizationsTelephonyConfigsPost,
getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGet,
updateTelephonyConfigurationApiV1OrganizationsTelephonyConfigsConfigIdPut,
} from "@/client/sdk.gen";
import type {
TelephonyConfigurationCreateRequest,
TelephonyConfigurationDetail,
TelephonyProviderMetadata,
} from "@/client/types.gen";
type TelephonyConfigPayload = TelephonyConfigurationCreateRequest["config"];
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/lib/auth";
interface ConfigFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
// When provided, the dialog is in edit mode.
existing?: TelephonyConfigurationDetail | null;
onSaved: () => void;
}
type FieldValues = Record<string, string | number | undefined>;
export function ConfigFormDialog({
open,
onOpenChange,
existing,
onSaved,
}: ConfigFormDialogProps) {
const { user, getAccessToken } = useAuth();
const [providers, setProviders] = useState<TelephonyProviderMetadata[]>([]);
const [providerName, setProviderName] = useState<string>("");
const [name, setName] = useState<string>("");
const [isDefault, setIsDefault] = useState<boolean>(false);
const [values, setValues] = useState<FieldValues>({});
const [submitting, setSubmitting] = useState<boolean>(false);
const isEdit = !!existing;
const lockedProvider = isEdit;
const currentProvider = useMemo(
() => providers.find((p) => p.provider === providerName),
[providers, providerName],
);
// Fetch provider metadata once when the dialog opens.
useEffect(() => {
if (!open || !user) return;
let cancelled = false;
(async () => {
const token = await getAccessToken();
const res = await getTelephonyProvidersMetadataApiV1OrganizationsTelephonyProvidersMetadataGet(
{ headers: { Authorization: `Bearer ${token}` } },
);
if (cancelled) return;
const list = res.data?.providers ?? [];
setProviders(list);
if (existing) {
setProviderName(existing.provider);
setName(existing.name);
setIsDefault(existing.is_default_outbound);
setValues((existing.credentials ?? {}) as FieldValues);
} else if (list.length > 0 && !providerName) {
setProviderName(list[0].provider);
setValues({});
}
})();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, existing, user, getAccessToken]);
// When provider changes during create, clear field values.
useEffect(() => {
if (!isEdit) setValues({});
}, [providerName, isEdit]);
const updateField = (fieldName: string, value: string | number) => {
setValues((prev) => ({ ...prev, [fieldName]: value }));
};
const handleSubmit = async () => {
if (!currentProvider) return;
if (!isEdit && !name.trim()) {
toast.error("Name is required");
return;
}
setSubmitting(true);
try {
const token = await getAccessToken();
// Build the provider-discriminated config payload from collected values.
const configPayload = {
provider: providerName,
...values,
} as unknown as TelephonyConfigPayload;
if (isEdit && existing) {
const res = await updateTelephonyConfigurationApiV1OrganizationsTelephonyConfigsConfigIdPut(
{
headers: { Authorization: `Bearer ${token}` },
path: { config_id: existing.id },
body: { name: name || undefined, config: configPayload },
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success("Configuration updated");
} else {
const res = await createTelephonyConfigurationApiV1OrganizationsTelephonyConfigsPost(
{
headers: { Authorization: `Bearer ${token}` },
body: {
name: name.trim(),
is_default_outbound: isDefault,
config: configPayload,
},
},
);
if (res.error) throw new Error(detailFromError(res.error));
toast.success("Configuration created");
}
onOpenChange(false);
onSaved();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to save");
} finally {
setSubmitting(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? "Edit telephony configuration" : "Add telephony configuration"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update credentials for this configuration. Phone numbers are managed separately."
: "Connect a telephony provider account. Phone numbers are added after the configuration is created."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cfg-name">Name</Label>
<Input
id="cfg-name"
placeholder="e.g. Twilio US prod"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cfg-provider">Provider</Label>
<Select
value={providerName}
onValueChange={setProviderName}
disabled={lockedProvider || providers.length === 0}
>
<SelectTrigger id="cfg-provider">
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p.provider} value={p.provider}>
{p.display_name}
</SelectItem>
))}
</SelectContent>
</Select>
{lockedProvider && (
<p className="text-xs text-muted-foreground">
Provider cannot be changed after creation.
</p>
)}
{currentProvider?.docs_url && (
<a
href={currentProvider.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-blue-600 underline"
>
{currentProvider.display_name} docs <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
{!isEdit && (
<div className="flex items-center justify-between rounded border p-3">
<div>
<Label className="text-sm">Set as default for outbound calls</Label>
<p className="text-xs text-muted-foreground">
Used by test calls and campaigns when no specific config is selected.
</p>
</div>
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
</div>
)}
{currentProvider && (
<div className="space-y-3 border-t pt-3">
{currentProvider.fields.map((field) => (
<div className="space-y-1" key={field.name}>
<Label htmlFor={`cfg-field-${field.name}`}>
{field.label}
{!field.required && (
<span className="ml-1 text-xs text-muted-foreground">
(optional)
</span>
)}
</Label>
<FieldInput
field={field}
value={values[field.name]}
onChange={(v) => updateField(field.name, v)}
isEdit={isEdit}
/>
{field.description && (
<p className="text-xs text-muted-foreground">{field.description}</p>
)}
</div>
))}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={submitting || !currentProvider}>
{submitting ? "Saving..." : isEdit ? "Save changes" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface FieldInputProps {
field: TelephonyProviderMetadata["fields"][number];
value: string | number | undefined;
onChange: (v: string | number) => void;
isEdit: boolean;
}
// Skip from_numbers in the metadata-driven form — phone numbers are managed
// via the dedicated phone-numbers endpoints and a different UI.
function FieldInput({ field, value, onChange, isEdit }: FieldInputProps) {
if (field.name === "from_numbers") {
return (
<p className="text-xs text-muted-foreground">
Phone numbers are managed separately on the configuration page.
</p>
);
}
const placeholder =
field.placeholder ??
(field.sensitive && isEdit ? "Leave masked to keep existing" : "");
if (field.type === "textarea") {
return (
<Textarea
id={`cfg-field-${field.name}`}
placeholder={placeholder}
value={(value as string) ?? ""}
onChange={(e) => onChange(e.target.value)}
rows={6}
className="field-sizing-fixed resize-y break-all font-mono text-xs"
/>
);
}
if (field.type === "number") {
return (
<Input
id={`cfg-field-${field.name}`}
type="number"
placeholder={placeholder}
value={value as number | string | undefined ?? ""}
onChange={(e) => onChange(e.target.value === "" ? "" : Number(e.target.value))}
/>
);
}
return (
<Input
id={`cfg-field-${field.name}`}
type={field.type === "password" || field.sensitive ? "password" : "text"}
placeholder={placeholder}
value={(value as string) ?? ""}
onChange={(e) => onChange(e.target.value)}
autoComplete={field.sensitive ? "current-password" : undefined}
/>
);
}
// FastAPI error responses come back as { detail: string } or
// { detail: [{loc, msg, ...}] }. Surface a useful message either way.
function detailFromError(err: unknown): string {
if (typeof err === "string") return err;
const e = err as { detail?: unknown };
if (typeof e?.detail === "string") return e.detail;
if (Array.isArray(e?.detail) && e.detail.length > 0) {
const first = e.detail[0] as { msg?: string };
if (first?.msg) return first.msg;
}
return "Failed to save configuration";
}

Some files were not shown because too many files have changed in this diff Show more