diff --git a/api/AGENTS.md b/api/AGENTS.md index 9c7e287..f57e8e8 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -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 diff --git a/api/alembic/versions/4d8e9b2a3c5f_drop_workflow_run_mode_enum.py b/api/alembic/versions/4d8e9b2a3c5f_drop_workflow_run_mode_enum.py new file mode 100644 index 0000000..650ea7a --- /dev/null +++ b/api/alembic/versions/4d8e9b2a3c5f_drop_workflow_run_mode_enum.py @@ -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" + ) diff --git a/api/alembic/versions/a2355fc6bdc1_add_multi_telephony_config_tables.py b/api/alembic/versions/a2355fc6bdc1_add_multi_telephony_config_tables.py new file mode 100644 index 0000000..3299aa2 --- /dev/null +++ b/api/alembic/versions/a2355fc6bdc1_add_multi_telephony_config_tables.py @@ -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"^(?Psips?):(?:(?P[^@;?]+)@)?(?P[^:;?]+)" + r"(?::(?P\d+))?(?P[;?].*)?$", + 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 ### diff --git a/api/db/campaign_client.py b/api/db/campaign_client.py index 0c789ba..f494e55 100644 --- a/api/db/campaign_client.py +++ b/api/db/campaign_client.py @@ -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: diff --git a/api/db/db_client.py b/api/db/db_client.py index f55fe3e..35bf800 100644 --- a/api/db/db_client.py +++ b/api/db/db_client.py @@ -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. diff --git a/api/db/models.py b/api/db/models.py index 4a2f0f7..dfba730 100644 --- a/api/db/models.py +++ b/api/db/models.py @@ -30,7 +30,6 @@ from ..enums import ( ToolStatus, TriggerState, WebhookCredentialType, - WorkflowRunMode, WorkflowRunState, WorkflowStatus, ) @@ -178,6 +177,117 @@ class OrganizationConfigurationModel(Base): ) +class TelephonyConfigurationModel(Base): + __tablename__ = "telephony_configurations" + + id = Column(Integer, primary_key=True, index=True) + organization_id = Column( + Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False + ) + name = Column(String(64), nullable=False) + provider = Column(String(32), nullable=False) + credentials = Column(JSON, nullable=False, default=dict) + is_default_outbound = Column( + Boolean, nullable=False, default=False, server_default=text("false") + ) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + updated_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + organization = relationship("OrganizationModel") + phone_numbers = relationship( + "TelephonyPhoneNumberModel", + back_populates="configuration", + cascade="all, delete-orphan", + ) + + __table_args__ = ( + UniqueConstraint( + "organization_id", "name", name="uq_telephony_configurations_org_name" + ), + Index("ix_telephony_configurations_org", "organization_id"), + Index( + "uq_telephony_configurations_default", + "organization_id", + unique=True, + postgresql_where=text("is_default_outbound = true"), + ), + ) + + +class TelephonyPhoneNumberModel(Base): + __tablename__ = "telephony_phone_numbers" + + id = Column(Integer, primary_key=True, index=True) + organization_id = Column( + Integer, ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False + ) + telephony_configuration_id = Column( + Integer, + ForeignKey("telephony_configurations.id", ondelete="CASCADE"), + nullable=False, + ) + address = Column(String(255), nullable=False) + address_normalized = Column(String(255), nullable=False) + address_type = Column(String(16), nullable=False) + country_code = Column(String(2), nullable=True) + label = Column(String(64), nullable=True) + inbound_workflow_id = Column( + Integer, + ForeignKey("workflows.id", ondelete="SET NULL"), + nullable=True, + ) + is_active = Column( + Boolean, nullable=False, default=True, server_default=text("true") + ) + is_default_caller_id = Column( + Boolean, nullable=False, default=False, server_default=text("false") + ) + extra_metadata = Column( + JSON, nullable=False, default=dict, server_default=text("'{}'::json") + ) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) + updated_at = Column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + onupdate=lambda: datetime.now(UTC), + ) + + configuration = relationship( + "TelephonyConfigurationModel", back_populates="phone_numbers" + ) + inbound_workflow = relationship("WorkflowModel") + + __table_args__ = ( + UniqueConstraint( + "organization_id", + "address_normalized", + name="uq_phone_numbers_org_address", + ), + Index("ix_phone_numbers_config", "telephony_configuration_id"), + Index( + "ix_phone_numbers_workflow", + "inbound_workflow_id", + postgresql_where=text("inbound_workflow_id IS NOT NULL"), + ), + Index( + "ix_phone_numbers_inbound_lookup", + "address_normalized", + "organization_id", + postgresql_where=text("is_active = true"), + ), + Index( + "uq_phone_numbers_default_caller", + "telephony_configuration_id", + unique=True, + postgresql_where=text("is_default_caller_id = true"), + ), + ) + + class IntegrationModel(Base): __tablename__ = "integrations" @@ -334,10 +444,10 @@ class WorkflowRunModel(Base): Integer, ForeignKey("workflow_definitions.id"), nullable=True ) definition = relationship("WorkflowDefinitionModel", back_populates="workflow_runs") - mode = Column( - Enum(*[mode.value for mode in WorkflowRunMode], name="workflow_run_mode"), - nullable=False, - ) + # Stored as VARCHAR (not a Postgres ENUM) so new telephony providers can + # be added purely in application code without a database migration. + # See WorkflowRunMode in api/enums.py for the canonical value set. + mode = Column(String(64), nullable=False) call_type = Column( Enum(*[call_type.value for call_type in CallType], name="workflow_call_type"), nullable=False, @@ -519,6 +629,11 @@ class CampaignModel(Base): organization_id = Column(Integer, ForeignKey("organizations.id"), nullable=False) workflow_id = Column(Integer, ForeignKey("workflows.id"), nullable=False) created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + # Nullable during the legacy → multi-config migration window. Backfilled to the + # org's default config by the migration; will become NOT NULL in a follow-up. + telephony_configuration_id = Column( + Integer, ForeignKey("telephony_configurations.id"), nullable=True + ) # Source configuration source_type = Column(String, nullable=False, default="google-sheet") @@ -588,6 +703,11 @@ class CampaignModel(Base): Index("ix_campaigns_org_id", "organization_id"), Index("ix_campaigns_state", "state"), Index("ix_campaigns_workflow_id", "workflow_id"), + Index( + "ix_campaigns_telephony_config", + "telephony_configuration_id", + postgresql_where=text("telephony_configuration_id IS NOT NULL"), + ), # Index for efficient querying of active campaigns Index( "idx_campaigns_active_status", diff --git a/api/db/telephony_configuration_client.py b/api/db/telephony_configuration_client.py new file mode 100644 index 0000000..f70148b --- /dev/null +++ b/api/db/telephony_configuration_client.py @@ -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) + ) diff --git a/api/db/telephony_phone_number_client.py b/api/db/telephony_phone_number_client.py new file mode 100644 index 0000000..058e42f --- /dev/null +++ b/api/db/telephony_phone_number_client.py @@ -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) + ) diff --git a/api/routes/campaign.py b/api/routes/campaign.py index b42d2d0..c91a65e 100644 --- a/api/routes/campaign.py +++ b/api/routes/campaign.py @@ -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) diff --git a/api/routes/organization.py b/api/routes/organization.py index 9db9b8a..6cd8ea1 100644 --- a/api/routes/organization.py +++ b/api/routes/organization.py @@ -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//__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 diff --git a/api/routes/public_agent.py b/api/routes/public_agent.py index 9cb7f4f..7bf372c 100644 --- a/api/routes/public_agent.py +++ b/api/routes/public_agent.py @@ -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, diff --git a/api/routes/telephony.py b/api/routes/telephony.py index b9951f2..78bb72c 100644 --- a/api/routes/telephony.py +++ b/api/routes/telephony.py @@ -5,40 +5,32 @@ Consolidated from split modules for easier maintenance. import json import uuid -from datetime import UTC, datetime from typing import Optional from fastapi import ( APIRouter, Depends, - Header, HTTPException, Request, WebSocket, ) from loguru import logger from pydantic import BaseModel, field_validator -from sqlalchemy import text -from sqlalchemy.future import select -from starlette.responses import HTMLResponse from starlette.websockets import WebSocketDisconnect from api.db import db_client -from api.db.models import OrganizationConfigurationModel, UserModel -from api.db.workflow_client import WorkflowClient -from api.db.workflow_run_client import WorkflowRunClient -from api.enums import CallType, OrganizationConfigurationKey, WorkflowRunState +from api.db.models import UserModel +from api.enums import CallType, WorkflowRunState from api.errors.telephony_errors import TelephonyError from api.sdk_expose import sdk_expose from api.services.auth.depends import get_user -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 from api.services.quota_service import check_dograh_quota, check_dograh_quota_by_user_id from api.services.telephony.call_transfer_manager import get_call_transfer_manager from api.services.telephony.factory import ( get_all_telephony_providers, + get_default_telephony_provider, get_telephony_provider, + get_telephony_provider_by_id, ) from api.services.telephony.transfer_event_protocol import ( TransferEvent, @@ -60,111 +52,9 @@ class InitiateCallRequest(BaseModel): workflow_id: int workflow_run_id: int | None = None phone_number: str | None = None - - -class StatusCallbackRequest(BaseModel): - """Generic status callback that can handle different providers""" - - # Common fields - call_id: str - status: str - from_number: Optional[str] = None - to_number: Optional[str] = None - direction: Optional[str] = None - duration: Optional[str] = None - - # Provider-specific fields stored as extra - 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""" - # Map Vonage status to common 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""" - # Map Cloudonix disposition to common 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, - ) + # Optional explicit telephony config to use for the test call. If omitted, + # falls back to the user's per-user default (when set), then the org default. + telephony_configuration_id: int | None = None @router.post( @@ -180,8 +70,32 @@ async def initiate_call( """Initiate a call using the configured telephony provider from web browser. This is supposed to be a test call method for the draft version of the agent.""" - # Get the telephony provider for the organization - provider = await get_telephony_provider(user.selected_organization_id) + user_configuration = await db_client.get_user_configurations(user.id) + + # Resolve which telephony config to use: explicit request value, otherwise + # the org's default outbound config. + 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" + ) + provider = await get_telephony_provider_by_id(telephony_configuration_id) + else: + try: + provider = await get_default_telephony_provider( + user.selected_organization_id + ) + except ValueError: + raise HTTPException(status_code=400, detail="telephony_not_configured") + default_cfg = await db_client.get_default_telephony_configuration( + user.selected_organization_id + ) + telephony_configuration_id = default_cfg.id if default_cfg else None # Validate provider is configured if not provider.validate_config(): @@ -190,16 +104,15 @@ async def initiate_call( detail="telephony_not_configured", ) - # 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 the keys we will actually use are the ones checked). + quota_result = await check_dograh_quota(user, workflow_id=request.workflow_id) if not quota_result.has_quota: raise HTTPException(status_code=402, detail=quota_result.error_message) # Determine the workflow run mode based on provider type workflow_run_mode = provider.PROVIDER_NAME - user_configuration = await db_client.get_user_configurations(user.id) - phone_number = request.phone_number or user_configuration.test_phone_number if not phone_number: @@ -214,8 +127,12 @@ async def initiate_call( if not workflow_run_id: # Fetch workflow to merge template context variables (e.g. caller_number, # called_number set in workflow settings for testing pre-call data fetch) - workflow = await db_client.get_workflow_by_id(request.workflow_id) - template_vars = (workflow.template_context_variables or {}) if workflow else {} + workflow = await db_client.get_workflow( + request.workflow_id, organization_id=user.selected_organization_id + ) + if not workflow: + raise HTTPException(status_code=404, detail="Workflow not found") + template_vars = workflow.template_context_variables or {} numeric_suffix = int(str(uuid.uuid4()).replace("-", "")[:8], 16) % 100000000 workflow_run_name = f"WR-TEL-OUT-{numeric_suffix:08d}" @@ -230,6 +147,7 @@ async def initiate_call( "phone_number": phone_number, "called_number": phone_number, "provider": provider.PROVIDER_NAME, + "telephony_configuration_id": telephony_configuration_id, }, use_draft=True, ) @@ -272,6 +190,7 @@ async def initiate_call( updated_initial_context = { **(workflow_run.initial_context or {}), "called_number": phone_number, + "telephony_configuration_id": telephony_configuration_id, } if result.caller_number: updated_initial_context["caller_number"] = result.caller_number @@ -287,65 +206,55 @@ async def initiate_call( async def _verify_organization_phone_number( phone_number: str, organization_id: int, + telephony_configuration_id: int, + provider: str, to_country: str = None, from_country: str = None, -) -> bool: - """ - Verify that a phone number belongs to the specified organization. +) -> Optional[int]: + """Verify the called number is registered to the matched config and return + its ``telephony_phone_numbers.id``, or None when no row matches. - Args: - phone_number: The phone number to verify - organization_id: The organization ID to check against - to_country: ISO country code for the called number (e.g., "US", "IN") - from_country: ISO country code for the caller (e.g., "IN", "GB") - - Returns: - True if the phone number belongs to the organization, False otherwise + Primary path: deterministic E.164 / SIP lookup via the new phone-number table. + Legacy fallback: ``numbers_match()`` over the matched config's active numbers, + so non-E.164 rows that survived the migration still route correctly. """ try: - async with db_client.async_session() as session: - result = await session.execute( - select(OrganizationConfigurationModel).where( - OrganizationConfigurationModel.organization_id == organization_id, - OrganizationConfigurationModel.key - == OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value, + match = await db_client.find_active_phone_number_for_inbound( + organization_id, phone_number, provider, country_hint=to_country + ) + if match and match.telephony_configuration_id == telephony_configuration_id: + logger.info( + f"Phone number {phone_number} matched row {match.id} for org " + f"{organization_id} / config {telephony_configuration_id}" + ) + return match.id + + # Legacy fallback: scan the matched config's active numbers and apply + # the country-aware fuzzy matcher (covers non-E.164 storage). + rows = await db_client.list_phone_numbers_for_config(telephony_configuration_id) + for row in rows: + if not row.is_active: + continue + if numbers_match(phone_number, row.address, to_country, from_country): + logger.info( + f"Phone number {phone_number} matched (fuzzy) row {row.id} " + f"for config {telephony_configuration_id}" ) - ) + return row.id - config = result.scalars().first() - - if not config or not config.value: - logger.warning( - f"No telephony configuration found for organization {organization_id}" - ) - return False - - from_numbers = config.value.get("from_numbers", []) - logger.debug( - f"Organization {organization_id} has from_numbers: {from_numbers}" - ) - - for configured_number in from_numbers: - if numbers_match( - phone_number, configured_number, to_country, from_country - ): - logger.info( - f"Phone number {phone_number} verified for organization {organization_id} " - f"(matches {configured_number}, to_country={to_country}, from_country={from_country})" - ) - return True - - logger.warning( - f"Phone number {phone_number} not found in organization {organization_id} from_numbers: {from_numbers} " - f"(to_country={to_country}, from_country={from_country})" - ) - return False + logger.warning( + f"Phone number {phone_number} not registered to config " + f"{telephony_configuration_id} (org={organization_id}, " + f"to_country={to_country}, from_country={from_country})" + ) + return None except Exception as e: logger.error( - f"Error verifying phone number {phone_number} for organization {organization_id}: {e}" + f"Error verifying phone number {phone_number} for organization " + f"{organization_id} / config {telephony_configuration_id}: {e}" ) - return False + return None async def _detect_provider(webhook_data: dict, headers: dict): @@ -365,15 +274,8 @@ async def _validate_inbound_request( provider_class, normalized_data, webhook_data: dict, - webhook_body: str = "", - x_twilio_signature: str = None, - x_plivo_signature: str = None, - x_plivo_signature_ma: str = None, - x_plivo_signature_nonce: str = None, - x_vobiz_signature: str = None, - x_vobiz_timestamp: str = None, - x_cx_apikey: str = None, - telnyx_signature: str = None, + headers: dict, + raw_body: str = "", ) -> tuple[bool, TelephonyError, dict, object]: """ Validate all aspects of inbound request. @@ -388,87 +290,45 @@ async def _validate_inbound_request( user_id = workflow.user_id provider = normalized_data.provider - # Validate provider and account_id - validation_result = await _validate_organization_provider_config( + # Resolve which of the org's configs this webhook came from (account_id match). + ( + validation_result, + telephony_configuration_id, + ) = await _resolve_inbound_telephony_config( organization_id, provider_class, normalized_data.account_id ) if validation_result != TelephonyError.VALID: return False, validation_result, {}, None - # Verify phone number belongs to organization - is_valid = await _verify_organization_phone_number( + # Verify the called number is registered to that config. + phone_number_id = await _verify_organization_phone_number( normalized_data.to_number, organization_id, + telephony_configuration_id, + provider_class.PROVIDER_NAME, normalized_data.to_country, normalized_data.from_country, ) - if not is_valid: + if phone_number_id is None: return False, TelephonyError.PHONE_NUMBER_NOT_CONFIGURED, {}, None - # Verify webhook signature/API key if provided - provider_instance = None - if ( - x_twilio_signature - or x_plivo_signature - or x_plivo_signature_ma - or x_vobiz_signature - or x_cx_apikey - or telnyx_signature - ): - backend_endpoint, _ = await get_backend_endpoints() - webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/{workflow_id}" - - # Get the real telephony provider with actual credentials for signature verification - provider_instance = await get_telephony_provider(organization_id) - - if provider_class.PROVIDER_NAME == "twilio" and x_twilio_signature: - logger.info(f"Verifying Twilio signature for URL: {webhook_url}") - signature_valid = await provider_instance.verify_inbound_signature( - webhook_url, webhook_data, x_twilio_signature - ) - elif provider_class.PROVIDER_NAME == "plivo" and ( - x_plivo_signature or x_plivo_signature_ma - ): - logger.info(f"Verifying Plivo signature for URL: {webhook_url}") - signature_valid = await provider_instance.verify_inbound_signature( - webhook_url, - webhook_data, - x_plivo_signature or x_plivo_signature_ma, - x_plivo_signature_nonce, - ) - elif provider_class.PROVIDER_NAME == "vobiz" and x_vobiz_signature: - logger.info(f"Verifying Vobiz signature for URL: {webhook_url}") - signature_valid = await provider_instance.verify_inbound_signature( - webhook_url, - webhook_data, - x_vobiz_signature, - x_vobiz_timestamp, - webhook_body, - ) - elif provider_class.PROVIDER_NAME == "cloudonix" and x_cx_apikey: - logger.info(f"Verifying Cloudonix API key for URL: {webhook_url}") - signature_valid = await provider_instance.verify_inbound_signature( - webhook_url, webhook_data, x_cx_apikey - ) - elif provider_class.PROVIDER_NAME == "telnyx" and telnyx_signature: - logger.info(f"Verifying Telnyx signature for URL: {webhook_url}") - signature_valid = await provider_instance.verify_inbound_signature( - webhook_url, webhook_data, telnyx_signature - ) - else: - logger.warning( - f"No signature/API key validation for provider {provider_class.PROVIDER_NAME}" - ) - signature_valid = True - - logger.info(f"Signature/API key validation result: {signature_valid}") - if not signature_valid: - return ( - False, - TelephonyError.SIGNATURE_VALIDATION_FAILED, - {}, - provider_instance, - ) + # Verify webhook signature using the matched config's credentials. The + # provider extracts its own signature/timestamp/nonce headers from the + # dict, so this dispatcher stays generic. + backend_endpoint, _ = await get_backend_endpoints() + webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/{workflow_id}" + provider_instance = await get_telephony_provider_by_id(telephony_configuration_id) + signature_valid = await provider_instance.verify_inbound_signature( + webhook_url, webhook_data, headers, raw_body + ) + logger.info(f"Signature validation for {provider}: {signature_valid}") + if not signature_valid: + return ( + False, + TelephonyError.SIGNATURE_VALIDATION_FAILED, + {}, + provider_instance, + ) # Return success with workflow context workflow_context = { @@ -476,17 +336,19 @@ async def _validate_inbound_request( "organization_id": organization_id, "user_id": user_id, "provider": provider, + "telephony_configuration_id": telephony_configuration_id, + "from_phone_number_id": phone_number_id, } - return ( - True, - "", - workflow_context, - provider_instance, - ) # TODO: do we still need instance in the client code + return (True, "", workflow_context, provider_instance) async def _create_inbound_workflow_run( - workflow_id: int, user_id: int, provider: str, normalized_data, data_source: str + workflow_id: int, + user_id: int, + provider: str, + normalized_data, + telephony_configuration_id: int, + from_phone_number_id: Optional[int] = None, ) -> int: """Create workflow run for inbound call and return run ID""" call_id = normalized_data.call_id @@ -505,10 +367,11 @@ async def _create_inbound_workflow_run( "direction": "inbound", "account_id": normalized_data.account_id, "provider": provider, - "data_source": data_source, "from_country": normalized_data.from_country, "to_country": normalized_data.to_country, "raw_webhook_data": normalized_data.raw_data, + "telephony_configuration_id": telephony_configuration_id, + "from_phone_number_id": from_phone_number_id, }, gathered_context={ "call_id": call_id, @@ -521,81 +384,43 @@ async def _create_inbound_workflow_run( return workflow_run.id -async def _validate_organization_provider_config( +async def _resolve_inbound_telephony_config( organization_id: int, provider_class, account_id: str -) -> TelephonyError: - """Validate provider and account_id, returning specific error type""" +) -> tuple[TelephonyError, Optional[int]]: + """Find which of the org's telephony configs the inbound webhook came from. + + Returns ``(VALID, config_id)`` on success or ``(error, None)`` otherwise. + Replaces the single-config check that assumed one provider per org. + """ + from api.services.telephony.factory import find_telephony_config_for_inbound + try: - config = await db_client.get_configuration( - organization_id, - OrganizationConfigurationKey.TELEPHONY_CONFIGURATION.value, + candidates = await db_client.list_telephony_configurations_by_provider( + organization_id, provider_class.PROVIDER_NAME ) - - if not config or not config.value: + if not candidates: logger.warning( - f"No telephony configuration found for organization {organization_id}" + f"No {provider_class.PROVIDER_NAME} configuration for org " + f"{organization_id}" ) - return TelephonyError.ACCOUNT_VALIDATION_FAILED + return TelephonyError.PROVIDER_MISMATCH, None - stored_provider = config.value.get("provider") - if stored_provider != provider_class.PROVIDER_NAME: + match = await find_telephony_config_for_inbound( + organization_id, provider_class.PROVIDER_NAME, account_id + ) + if not match: logger.warning( - f"Provider mismatch: webhook={provider_class.PROVIDER_NAME}, config={stored_provider}" + f"Account validation failed for {provider_class.PROVIDER_NAME}: " + f"webhook account_id={account_id} (org {organization_id})" ) - return TelephonyError.PROVIDER_MISMATCH + return TelephonyError.ACCOUNT_VALIDATION_FAILED, None - # Use provider-specific validation - is_valid = provider_class.validate_account_id(config.value, account_id) - if not is_valid: - logger.warning( - f"Account validation failed for {provider_class.PROVIDER_NAME}: webhook={account_id}" - ) - return TelephonyError.ACCOUNT_VALIDATION_FAILED - - return TelephonyError.VALID + config_id, _ = match + return TelephonyError.VALID, config_id except Exception as e: logger.error(f"Exception during account validation: {e}") - return TelephonyError.ACCOUNT_VALIDATION_FAILED - - -@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.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) + return TelephonyError.ACCOUNT_VALIDATION_FAILED, None @router.websocket("/ws/ari") @@ -726,1053 +551,175 @@ async def _handle_telephony_websocket( pass -@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) +@router.post("/inbound/run") +async def handle_inbound_run(request: Request): + """Workflow-agnostic inbound dispatcher. - # Parse form data - form_data = await request.form() - callback_data = dict(form_data) + All providers can point a single webhook at this endpoint instead of one + URL per workflow. The dispatcher resolves the org from the webhook's + account_id and the workflow from the called number's + ``inbound_workflow_id``. This is what ``configure_inbound`` writes into + each provider's resource so per-workflow webhook bookkeeping disappears. - 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"} - - -async def _process_status_update(workflow_run_id: int, status: StatusCallbackRequest): - """Process status updates from telephony providers.""" - - # Fetch fresh workflow_run to ensure we have the latest state - 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 - - # Log the status callback - 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, # Include provider-specific data - } - telephony_callback_logs.append(telephony_callback_log) - - # Update workflow run logs - await db_client.update_workflow_run( - run_id=workflow_run_id, - logs={"telephony_status_callbacks": telephony_callback_logs}, - ) - - # Handle call completion - make these updates idempotent - i.e - # they should handle multiple API calls (one due to status update, - # and other due to CDR updates.) - if status.status == "completed": - logger.info( - f"[run {workflow_run_id}] Call completed with duration: {status.duration}s" - ) - - # Release concurrent slot if this was a campaign call - 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 - ) - - # Mark workflow run as completed - 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}" - ) - - # Release concurrent slot for terminal statuses if this was a campaign call - 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"), - ) - - # Check if retry is needed for campaign calls (busy/no-answer) - 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( - "-", "_" - ), # Convert no-answer to no_answer - campaign_id=workflow_run.campaign_id, - queued_run_id=workflow_run.queued_run_id, - ) - - # Mark workflow run as completed with failure tags - 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 - pass - else: - logger.warning( - f"[run {workflow_run_id}] Unexpected status update: {status.status}" - ) - - -@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. + Provider-specific signature/timestamp headers are not enumerated here — + each provider's ``verify_inbound_signature`` reads its own headers from + the dict, so adding a new provider doesn't require changes to this route. """ - set_current_run_id(workflow_run_id) + from api.services.telephony import registry as telephony_registry - 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"} - - -@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"} - - -@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("/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, signature, x_plivo_signature_v3_nonce - ) - 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") - - -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, - signature, - x_plivo_signature_v3_nonce, - ) - 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/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, - ) - - -@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("/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("/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)}" - ) + logger.info("Inbound /run dispatch received") 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)} - - -@router.post("/inbound/{workflow_id}") -async def handle_inbound_telephony( - workflow_id: int, - request: Request, - x_twilio_signature: Optional[str] = Header(None), - 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), - x_vobiz_signature: Optional[str] = Header(None), - x_vobiz_timestamp: Optional[str] = Header(None), - x_cx_apikey: Optional[str] = Header(None), - telnyx_signature: Optional[str] = Header(None, alias="telnyx-signature-ed25519"), -): - """Handle inbound telephony calls from any supported provider with common processing""" - logger.info(f"Inbound call received for workflow_id: {workflow_id}") - - try: - webhook_data, data_source = await parse_webhook_request(request) - logger.info( - f"Inbound call data with data source: {data_source} and data :{dict(webhook_data)}" - ) + webhook_data, raw_body = await parse_webhook_request(request) headers = dict(request.headers) - # Detect provider and normalize data provider_class = await _detect_provider(webhook_data, headers) if not provider_class: - logger.error("Unable to detect provider for webhook") + logger.error("Unable to detect provider for /inbound/run webhook") return generic_hangup_response() normalized_data = normalize_webhook_data(provider_class, webhook_data) - logger.info( - f"Inbound call - Provider: {normalized_data.provider}, Data source: {data_source}" + f"/inbound/run normalized data — provider={normalized_data.provider} " + f"to={normalized_data.to_number} from={normalized_data.from_number}" ) - logger.info(f"Normalized data: {normalized_data}") - # Validate inbound direction if normalized_data.direction != "inbound": - logger.warning(f"Non-inbound call received: {normalized_data.direction}") + logger.warning( + f"Non-inbound call on /inbound/run: {normalized_data.direction}" + ) return generic_hangup_response() - logger.info(f"Inbound call headers: {dict(request.headers)}") - logger.info(f"Twilio signature header: {x_twilio_signature}") - logger.info(f"Vobiz signature header: {x_vobiz_signature}") - logger.info(f"Vobiz timestamp header: {x_vobiz_timestamp}") - - webhook_body = "" - if provider_class.PROVIDER_NAME == "vobiz": - webhook_body = data_source - logger.info(f"Vobiz inbound call - Body: {json.dumps(webhook_data)}") - - ( - is_valid, - error_type, - workflow_context, - provider_instance, - ) = await _validate_inbound_request( - workflow_id, - provider_class, - normalized_data, - webhook_data, - webhook_body, - x_twilio_signature, - x_plivo_signature_v3, - x_plivo_signature_ma_v3, - x_plivo_signature_v3_nonce, - x_vobiz_signature, - x_vobiz_timestamp, - x_cx_apikey, - telnyx_signature, + # 1. Resolve config globally from (provider, account_id). + spec = telephony_registry.get_optional(provider_class.PROVIDER_NAME) + account_field = spec.account_id_credential_field if spec else "" + config = await db_client.find_telephony_config_by_account( + provider_class.PROVIDER_NAME, + account_field, + normalized_data.account_id or "", ) + if not config: + logger.warning( + f"/inbound/run: no config matched provider=" + f"{provider_class.PROVIDER_NAME} account_id={normalized_data.account_id}" + ) + return provider_class.generate_validation_error_response( + TelephonyError.ACCOUNT_VALIDATION_FAILED + ) - if not is_valid: - logger.error(f"Request validation failed: {error_type}") - return provider_class.generate_validation_error_response(error_type) + organization_id = config.organization_id + telephony_configuration_id = config.id - # Check quota before processing - user_id = workflow_context["user_id"] - quota_result = await check_dograh_quota_by_user_id(user_id) + # 2. Resolve workflow via the called number's inbound_workflow_id. + phone_row = await db_client.find_active_phone_number_for_inbound( + organization_id, + normalized_data.to_number, + provider_class.PROVIDER_NAME, + country_hint=normalized_data.to_country, + ) + # Legacy fallback for non-E.164 stored addresses. + if ( + not phone_row + or phone_row.telephony_configuration_id != telephony_configuration_id + ): + phone_row = None + for row in await db_client.list_phone_numbers_for_config( + telephony_configuration_id + ): + if not row.is_active: + continue + if numbers_match( + normalized_data.to_number, + row.address, + normalized_data.to_country, + normalized_data.from_country, + ): + phone_row = row + break + + if not phone_row: + logger.warning( + f"/inbound/run: number {normalized_data.to_number} not registered " + f"in config {telephony_configuration_id}" + ) + return provider_class.generate_validation_error_response( + TelephonyError.PHONE_NUMBER_NOT_CONFIGURED + ) + + if not phone_row.inbound_workflow_id: + logger.warning( + f"/inbound/run: number {normalized_data.to_number} has no " + f"inbound_workflow_id assigned" + ) + return provider_class.generate_validation_error_response( + TelephonyError.WORKFLOW_NOT_FOUND + ) + + workflow_id = phone_row.inbound_workflow_id + workflow = await db_client.get_workflow(workflow_id) + if not workflow: + return provider_class.generate_validation_error_response( + TelephonyError.WORKFLOW_NOT_FOUND + ) + user_id = workflow.user_id + + # 3. Verify webhook signature against the matched config's credentials. + backend_endpoint, wss_backend_endpoint = await get_backend_endpoints() + webhook_url = f"{backend_endpoint}/api/v1/telephony/inbound/run" + provider_instance = await get_telephony_provider_by_id( + telephony_configuration_id + ) + signature_valid = await provider_instance.verify_inbound_signature( + webhook_url, webhook_data, headers, raw_body + ) + if not signature_valid: + logger.warning( + f"/inbound/run: signature validation failed for " + f"{provider_class.PROVIDER_NAME}" + ) + return provider_class.generate_validation_error_response( + TelephonyError.SIGNATURE_VALIDATION_FAILED + ) + + # 4. Quota check (use the workflow's model_overrides if set). + quota_result = await check_dograh_quota_by_user_id( + user_id, workflow_id=workflow_id + ) if not quota_result.has_quota: logger.warning( - f"User {user_id} has exceeded quota for inbound calls: {quota_result.error_message}" + f"User {user_id} has exceeded quota: {quota_result.error_message}" ) return provider_class.generate_validation_error_response( TelephonyError.QUOTA_EXCEEDED ) - # Create workflow run + # 5. Create workflow run + return provider-shaped response. workflow_run_id = await _create_inbound_workflow_run( workflow_id, - workflow_context["user_id"], - workflow_context["provider"], + user_id, + provider_class.PROVIDER_NAME, normalized_data, - data_source, + telephony_configuration_id=telephony_configuration_id, + from_phone_number_id=phone_row.id, ) - # Generate response URLs - backend_endpoint, wss_backend_endpoint = await get_backend_endpoints() - websocket_url = f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{workflow_context['user_id']}/{workflow_run_id}" - - # Telnyx requires answering the call via REST API (not via webhook response) - if provider_class.PROVIDER_NAME == "telnyx": - # Get provider instance with credentials if not already loaded - if not provider_instance: - provider_instance = await get_telephony_provider( - workflow_context["organization_id"] - ) - - events_url = ( - f"{backend_endpoint}/api/v1/telephony/telnyx/events/{workflow_run_id}" - ) - - try: - await provider_instance.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 provider_class.generate_error_response( - "ANSWER_FAILED", "Failed to answer call" - ) - - logger.info( - f"Answered Telnyx inbound call {normalized_data.call_id} for workflow_run {workflow_run_id}" - ) - return {"status": "ok"} - - response = await provider_class.generate_inbound_response( - websocket_url, workflow_run_id + websocket_url = ( + f"{wss_backend_endpoint}/api/v1/telephony/ws/" + f"{workflow_id}/{user_id}/{workflow_run_id}" ) - logger.info( - f"Generated {normalized_data.provider} response for call {normalized_data.call_id}" + return await provider_instance.start_inbound_stream( + websocket_url=websocket_url, + workflow_run_id=workflow_run_id, + normalized_data=normalized_data, + backend_endpoint=backend_endpoint, ) - return response except ValueError as e: - logger.error(f"Request parsing error: {e}") + logger.error(f"/inbound/run request parsing error: {e}") return generic_hangup_response() except Exception as e: - logger.error(f"Error processing inbound call: {e}") + logger.error(f"/inbound/run unexpected error: {e}") return generic_hangup_response() @@ -1810,57 +757,108 @@ async def handle_inbound_fallback(request: Request): return generic_hangup_response() -@router.post("/cloudonix/cdr") -async def handle_cloudonix_cdr(request: Request): - """Handle Cloudonix CDR (Call Detail Record) webhooks. +@router.post("/inbound/{workflow_id}", deprecated=True) +async def handle_inbound_telephony( + workflow_id: int, + request: Request, +): + """[LEGACY] Per-workflow inbound webhook. - 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 + Superseded by ``POST /inbound/run``, which resolves the workflow from + the called number's ``inbound_workflow_id`` and lets a single webhook + URL serve every workflow in the org. New integrations should point + their provider at ``/inbound/run``; this route is kept only for + existing provider configurations that still encode ``workflow_id`` + in the URL. """ - 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}" + f"[legacy /inbound/{{workflow_id}}] Inbound call received for workflow_id: {workflow_id}" ) - return {"status": "success"} + try: + webhook_data, raw_body = await parse_webhook_request(request) + logger.info(f"Inbound call data: {dict(webhook_data)}") + headers = dict(request.headers) + + # Detect provider and normalize data + provider_class = await _detect_provider(webhook_data, headers) + if not provider_class: + logger.error("Unable to detect provider for webhook") + return generic_hangup_response() + + normalized_data = normalize_webhook_data(provider_class, webhook_data) + + logger.info(f"Inbound call - Provider: {normalized_data.provider}") + logger.info(f"Normalized data: {normalized_data}") + + # Validate inbound direction + if normalized_data.direction != "inbound": + logger.warning(f"Non-inbound call received: {normalized_data.direction}") + return generic_hangup_response() + + ( + is_valid, + error_type, + workflow_context, + provider_instance, + ) = await _validate_inbound_request( + workflow_id, + provider_class, + normalized_data, + webhook_data, + headers, + raw_body, + ) + + if not is_valid: + logger.error(f"Request validation failed: {error_type}") + return provider_class.generate_validation_error_response(error_type) + + # Check quota before processing (apply per-workflow model_overrides). + user_id = workflow_context["user_id"] + quota_result = await check_dograh_quota_by_user_id( + user_id, workflow_id=workflow_id + ) + if not quota_result.has_quota: + logger.warning( + f"User {user_id} has exceeded quota for inbound calls: {quota_result.error_message}" + ) + return provider_class.generate_validation_error_response( + TelephonyError.QUOTA_EXCEEDED + ) + + # Create workflow run + workflow_run_id = await _create_inbound_workflow_run( + workflow_id, + workflow_context["user_id"], + workflow_context["provider"], + normalized_data, + telephony_configuration_id=workflow_context["telephony_configuration_id"], + from_phone_number_id=workflow_context.get("from_phone_number_id"), + ) + + # Generate response URLs + backend_endpoint, wss_backend_endpoint = await get_backend_endpoints() + websocket_url = f"{wss_backend_endpoint}/api/v1/telephony/ws/{workflow_id}/{workflow_context['user_id']}/{workflow_run_id}" + + response = await provider_instance.start_inbound_stream( + websocket_url=websocket_url, + workflow_run_id=workflow_run_id, + normalized_data=normalized_data, + backend_endpoint=backend_endpoint, + ) + + logger.info( + f"Generated {normalized_data.provider} response for call {normalized_data.call_id}" + ) + return response + + except ValueError as e: + logger.error(f"Request parsing error: {e}") + return generic_hangup_response() + except Exception as e: + logger.error(f"Error processing inbound call: {e}") + return generic_hangup_response() class TransferCallRequest(BaseModel): @@ -2065,3 +1063,33 @@ async def complete_transfer_function_call(transfer_id: str, request: Request): logger.error(f"Error completing transfer {transfer_id}: {e}") return {"status": "completed", "result": result} + + +# Mount per-provider routers (webhook, status callbacks, answer URLs). +# +# Each provider's routes live at ``providers//routes.py`` and expose +# a module-level ``router``. We discover them through the registry rather +# than pre-importing them from each provider's __init__.py so that the +# (heavy) route module — which transitively depends on status_processor, +# campaign helpers, etc. — is only loaded when the HTTP layer is actually +# being wired up, not when someone merely asks for a TelephonyProvider +# class. This is what keeps the package init free of cycles. +def _mount_provider_routers() -> None: + import importlib + + from api.services.telephony import registry as _telephony_registry + + for spec in _telephony_registry.all_specs(): + try: + module = importlib.import_module( + f"api.services.telephony.providers.{spec.name}.routes" + ) + except ModuleNotFoundError: + # Provider has no routes (e.g. ARI, which only has a WebSocket). + continue + provider_router = getattr(module, "router", None) + if provider_router is not None: + router.include_router(provider_router) + + +_mount_provider_routers() diff --git a/api/routes/webrtc_signaling.py b/api/routes/webrtc_signaling.py index 6de2bb4..04eee4b 100644 --- a/api/routes/webrtc_signaling.py +++ b/api/routes/webrtc_signaling.py @@ -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( diff --git a/api/routes/workflow.py b/api/routes/workflow.py index b663860..5509db0 100644 --- a/api/routes/workflow.py +++ b/api/routes/workflow.py @@ -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) diff --git a/api/schemas/telephony_config.py b/api/schemas/telephony_config.py index 805060d..b056400 100644 --- a/api/schemas/telephony_config.py +++ b/api/schemas/telephony_config.py @@ -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//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", +] diff --git a/api/schemas/telephony_phone_number.py b/api/schemas/telephony_phone_number.py new file mode 100644 index 0000000..f02d009 --- /dev/null +++ b/api/schemas/telephony_phone_number.py @@ -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] diff --git a/api/services/campaign/campaign_call_dispatcher.py b/api/services/campaign/campaign_call_dispatcher.py index b414d5f..c3fe1a0 100644 --- a/api/services/campaign/campaign_call_dispatcher.py +++ b/api/services/campaign/campaign_call_dispatcher.py @@ -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}") diff --git a/api/services/pipecat/audio_config.py b/api/services/pipecat/audio_config.py index c0ba9e8..a083494 100644 --- a/api/services/pipecat/audio_config.py +++ b/api/services/pipecat/audio_config.py @@ -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, + ) diff --git a/api/services/pipecat/audio_mixer.py b/api/services/pipecat/audio_mixer.py new file mode 100644 index 0000000..a1a9ee4 --- /dev/null +++ b/api/services/pipecat/audio_mixer.py @@ -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, + ) diff --git a/api/services/pipecat/run_pipeline.py b/api/services/pipecat/run_pipeline.py index 88234c6..660efe3 100644 --- a/api/services/pipecat/run_pipeline.py +++ b/api/services/pipecat/run_pipeline.py @@ -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_ 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, diff --git a/api/services/pipecat/transport_setup.py b/api/services/pipecat/transport_setup.py index 4b0df33..61b23ae 100644 --- a/api/services/pipecat/transport_setup.py +++ b/api/services/pipecat/transport_setup.py @@ -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//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, - # ) diff --git a/api/services/quota_service.py b/api/services/quota_service.py index 6a9b4c4..929acb1 100644 --- a/api/services/quota_service.py +++ b/api/services/quota_service.py @@ -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) diff --git a/api/services/telephony/__init__.py b/api/services/telephony/__init__.py index e69de29..d5affb2 100644 --- a/api/services/telephony/__init__.py +++ b/api/services/telephony/__init__.py @@ -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) +) diff --git a/api/services/telephony/ari_manager.py b/api/services/telephony/ari_manager.py index eebfde7..6b12fd2 100644 --- a/api/services/telephony/ari_manager.py +++ b/api/services/telephony/ari_manager.py @@ -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"), } ) diff --git a/api/services/telephony/base.py b/api/services/telephony/base.py index 3c75865..4d289ce 100644 --- a/api/services/telephony/base.py +++ b/api/services/telephony/base.py @@ -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: diff --git a/api/services/telephony/factory.py b/api/services/telephony/factory.py index 02a5b52..d5ad819 100644 --- a/api/services/telephony/factory.py +++ b/api/services/telephony/factory.py @@ -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) diff --git a/api/services/telephony/providers/CLAUDE.md b/api/services/telephony/providers/CLAUDE.md new file mode 100644 index 0000000..4db70d8 --- /dev/null +++ b/api/services/telephony/providers/CLAUDE.md @@ -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// +├── __init__.py # Required. Builds + register()s ProviderSpec +├── config.py # Required. Pydantic Request + Response, both with `provider: Literal[""]` +├── 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//`: + +1. `providers/__init__.py` — add `` to the import-for-side-effects list. Registration runs at import time. +2. `api/schemas/telephony_config.py` — import `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//routes.py` (auto-mounted via `importlib`) | + +## ProviderSpec — minimum viable shape + +```python +SPEC = ProviderSpec( + 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[""]` 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="", +) +``` + +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[]`. 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//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. diff --git a/api/services/telephony/providers/__init__.py b/api/services/telephony/providers/__init__.py index 16d28ea..4df4e7f 100644 --- a/api/services/telephony/providers/__init__.py +++ b/api/services/telephony/providers/__init__.py @@ -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, +) diff --git a/api/services/telephony/providers/ari/__init__.py b/api/services/telephony/providers/ari/__init__.py new file mode 100644 index 0000000..576f7f8 --- /dev/null +++ b/api/services/telephony/providers/ari/__init__.py @@ -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", +] diff --git a/api/services/telephony/providers/ari/config.py b/api/services/telephony/providers/ari/config.py new file mode 100644 index 0000000..73d8750 --- /dev/null +++ b/api/services/telephony/providers/ari/config.py @@ -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] diff --git a/api/services/telephony/providers/ari_provider.py b/api/services/telephony/providers/ari/provider.py similarity index 96% rename from api/services/telephony/providers/ari_provider.py rename to api/services/telephony/providers/ari/provider.py index 125fddb..3da7a9c 100644 --- a/api/services/telephony/providers/ari_provider.py +++ b/api/services/telephony/providers/ari/provider.py @@ -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 diff --git a/api/services/telephony/providers/ari/serializers.py b/api/services/telephony/providers/ari/serializers.py new file mode 100644 index 0000000..d1da211 --- /dev/null +++ b/api/services/telephony/providers/ari/serializers.py @@ -0,0 +1,5 @@ +"""Asterisk frame serializer (re-exported from pipecat).""" + +from pipecat.serializers.asterisk import AsteriskFrameSerializer + +__all__ = ["AsteriskFrameSerializer"] diff --git a/api/services/telephony/providers/ari_call_strategies.py b/api/services/telephony/providers/ari/strategies.py similarity index 100% rename from api/services/telephony/providers/ari_call_strategies.py rename to api/services/telephony/providers/ari/strategies.py diff --git a/api/services/telephony/providers/ari/transport.py b/api/services/telephony/providers/ari/transport.py new file mode 100644 index 0000000..cb35bbf --- /dev/null +++ b/api/services/telephony/providers/ari/transport.py @@ -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, + ), + ) diff --git a/api/services/telephony/providers/cloudonix/__init__.py b/api/services/telephony/providers/cloudonix/__init__.py new file mode 100644 index 0000000..6bde7bf --- /dev/null +++ b/api/services/telephony/providers/cloudonix/__init__.py @@ -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", +] diff --git a/api/services/telephony/providers/cloudonix/config.py b/api/services/telephony/providers/cloudonix/config.py new file mode 100644 index 0000000..bc71a41 --- /dev/null +++ b/api/services/telephony/providers/cloudonix/config.py @@ -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] diff --git a/api/services/telephony/providers/cloudonix_provider.py b/api/services/telephony/providers/cloudonix/provider.py similarity index 84% rename from api/services/telephony/providers/cloudonix_provider.py rename to api/services/telephony/providers/cloudonix/provider.py index 367a8c7..e4632f5 100644 --- a/api/services/telephony/providers/cloudonix_provider.py +++ b/api/services/telephony/providers/cloudonix/provider.py @@ -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. diff --git a/api/services/telephony/providers/cloudonix/routes.py b/api/services/telephony/providers/cloudonix/routes.py new file mode 100644 index 0000000..da54746 --- /dev/null +++ b/api/services/telephony/providers/cloudonix/routes.py @@ -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"} diff --git a/api/services/telephony/providers/cloudonix/serializers.py b/api/services/telephony/providers/cloudonix/serializers.py new file mode 100644 index 0000000..03a5a4c --- /dev/null +++ b/api/services/telephony/providers/cloudonix/serializers.py @@ -0,0 +1,5 @@ +"""Cloudonix frame serializer (re-exported from pipecat).""" + +from pipecat.serializers.cloudonix import CloudonixFrameSerializer + +__all__ = ["CloudonixFrameSerializer"] diff --git a/api/services/telephony/providers/cloudonix_call_strategies.py b/api/services/telephony/providers/cloudonix/strategies.py similarity index 100% rename from api/services/telephony/providers/cloudonix_call_strategies.py rename to api/services/telephony/providers/cloudonix/strategies.py diff --git a/api/services/telephony/providers/cloudonix/transport.py b/api/services/telephony/providers/cloudonix/transport.py new file mode 100644 index 0000000..77af543 --- /dev/null +++ b/api/services/telephony/providers/cloudonix/transport.py @@ -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, + ), + ) diff --git a/api/services/telephony/providers/plivo/__init__.py b/api/services/telephony/providers/plivo/__init__.py new file mode 100644 index 0000000..a546b7c --- /dev/null +++ b/api/services/telephony/providers/plivo/__init__.py @@ -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", +] diff --git a/api/services/telephony/providers/plivo/config.py b/api/services/telephony/providers/plivo/config.py new file mode 100644 index 0000000..1668b4e --- /dev/null +++ b/api/services/telephony/providers/plivo/config.py @@ -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] diff --git a/api/services/telephony/providers/plivo_provider.py b/api/services/telephony/providers/plivo/provider.py similarity index 73% rename from api/services/telephony/providers/plivo_provider.py rename to api/services/telephony/providers/plivo/provider.py index 6fb2c6f..2c0e4d7 100644 --- a/api/services/telephony/providers/plivo_provider.py +++ b/api/services/telephony/providers/plivo/provider.py @@ -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""" diff --git a/api/services/telephony/providers/plivo/routes.py b/api/services/telephony/providers/plivo/routes.py new file mode 100644 index 0000000..9b13684 --- /dev/null +++ b/api/services/telephony/providers/plivo/routes.py @@ -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, + ) diff --git a/api/services/telephony/providers/plivo/serializers.py b/api/services/telephony/providers/plivo/serializers.py new file mode 100644 index 0000000..9dd9118 --- /dev/null +++ b/api/services/telephony/providers/plivo/serializers.py @@ -0,0 +1,5 @@ +"""Plivo frame serializer (re-exported from pipecat).""" + +from pipecat.serializers.plivo import PlivoFrameSerializer + +__all__ = ["PlivoFrameSerializer"] diff --git a/api/services/telephony/providers/plivo/transport.py b/api/services/telephony/providers/plivo/transport.py new file mode 100644 index 0000000..cd765a2 --- /dev/null +++ b/api/services/telephony/providers/plivo/transport.py @@ -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, + ), + ) diff --git a/api/services/telephony/providers/telnyx/__init__.py b/api/services/telephony/providers/telnyx/__init__.py new file mode 100644 index 0000000..b30b9a9 --- /dev/null +++ b/api/services/telephony/providers/telnyx/__init__.py @@ -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", +] diff --git a/api/services/telephony/providers/telnyx/config.py b/api/services/telephony/providers/telnyx/config.py new file mode 100644 index 0000000..255cf1b --- /dev/null +++ b/api/services/telephony/providers/telnyx/config.py @@ -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] diff --git a/api/services/telephony/providers/telnyx_provider.py b/api/services/telephony/providers/telnyx/provider.py similarity index 72% rename from api/services/telephony/providers/telnyx_provider.py rename to api/services/telephony/providers/telnyx/provider.py index a57ba93..d73c5ac 100644 --- a/api/services/telephony/providers/telnyx_provider.py +++ b/api/services/telephony/providers/telnyx/provider.py @@ -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: diff --git a/api/services/telephony/providers/telnyx/routes.py b/api/services/telephony/providers/telnyx/routes.py new file mode 100644 index 0000000..5e02030 --- /dev/null +++ b/api/services/telephony/providers/telnyx/routes.py @@ -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"} diff --git a/api/services/telephony/providers/telnyx/serializers.py b/api/services/telephony/providers/telnyx/serializers.py new file mode 100644 index 0000000..a59ff61 --- /dev/null +++ b/api/services/telephony/providers/telnyx/serializers.py @@ -0,0 +1,5 @@ +"""Telnyx frame serializer (re-exported from pipecat).""" + +from pipecat.serializers.telnyx import TelnyxFrameSerializer + +__all__ = ["TelnyxFrameSerializer"] diff --git a/api/services/telephony/providers/telnyx/transport.py b/api/services/telephony/providers/telnyx/transport.py new file mode 100644 index 0000000..f41cc2a --- /dev/null +++ b/api/services/telephony/providers/telnyx/transport.py @@ -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, + ), + ) diff --git a/api/services/telephony/providers/twilio/__init__.py b/api/services/telephony/providers/twilio/__init__.py new file mode 100644 index 0000000..8a518a0 --- /dev/null +++ b/api/services/telephony/providers/twilio/__init__.py @@ -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", +] diff --git a/api/services/telephony/providers/twilio/config.py b/api/services/telephony/providers/twilio/config.py new file mode 100644 index 0000000..6aa5a1d --- /dev/null +++ b/api/services/telephony/providers/twilio/config.py @@ -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] diff --git a/api/services/telephony/providers/twilio_provider.py b/api/services/telephony/providers/twilio/provider.py similarity index 81% rename from api/services/telephony/providers/twilio_provider.py rename to api/services/telephony/providers/twilio/provider.py index e92281a..5d81622 100644 --- a/api/services/telephony/providers/twilio_provider.py +++ b/api/services/telephony/providers/twilio/provider.py @@ -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}"' diff --git a/api/services/telephony/providers/twilio/routes.py b/api/services/telephony/providers/twilio/routes.py new file mode 100644 index 0000000..3bf630c --- /dev/null +++ b/api/services/telephony/providers/twilio/routes.py @@ -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"} diff --git a/api/services/telephony/providers/twilio/serializers.py b/api/services/telephony/providers/twilio/serializers.py new file mode 100644 index 0000000..4ab9340 --- /dev/null +++ b/api/services/telephony/providers/twilio/serializers.py @@ -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"] diff --git a/api/services/telephony/providers/twilio_call_strategies.py b/api/services/telephony/providers/twilio/strategies.py similarity index 100% rename from api/services/telephony/providers/twilio_call_strategies.py rename to api/services/telephony/providers/twilio/strategies.py diff --git a/api/services/telephony/providers/twilio/transport.py b/api/services/telephony/providers/twilio/transport.py new file mode 100644 index 0000000..e363f26 --- /dev/null +++ b/api/services/telephony/providers/twilio/transport.py @@ -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, + ), + ) diff --git a/api/services/telephony/providers/vobiz/__init__.py b/api/services/telephony/providers/vobiz/__init__.py new file mode 100644 index 0000000..f6dd006 --- /dev/null +++ b/api/services/telephony/providers/vobiz/__init__.py @@ -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", +] diff --git a/api/services/telephony/providers/vobiz/config.py b/api/services/telephony/providers/vobiz/config.py new file mode 100644 index 0000000..b45a006 --- /dev/null +++ b/api/services/telephony/providers/vobiz/config.py @@ -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] diff --git a/api/services/telephony/providers/vobiz_provider.py b/api/services/telephony/providers/vobiz/provider.py similarity index 82% rename from api/services/telephony/providers/vobiz_provider.py rename to api/services/telephony/providers/vobiz/provider.py index 18a3f2f..3011e5f 100644 --- a/api/services/telephony/providers/vobiz_provider.py +++ b/api/services/telephony/providers/vobiz/provider.py @@ -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. diff --git a/api/services/telephony/providers/vobiz/routes.py b/api/services/telephony/providers/vobiz/routes.py new file mode 100644 index 0000000..b6ca2f0 --- /dev/null +++ b/api/services/telephony/providers/vobiz/routes.py @@ -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)} diff --git a/api/services/telephony/providers/vobiz/serializers.py b/api/services/telephony/providers/vobiz/serializers.py new file mode 100644 index 0000000..30d2701 --- /dev/null +++ b/api/services/telephony/providers/vobiz/serializers.py @@ -0,0 +1,5 @@ +"""Vobiz frame serializer (re-exported from pipecat).""" + +from pipecat.serializers.vobiz import VobizFrameSerializer + +__all__ = ["VobizFrameSerializer"] diff --git a/api/services/telephony/providers/vobiz/transport.py b/api/services/telephony/providers/vobiz/transport.py new file mode 100644 index 0000000..2a2a042 --- /dev/null +++ b/api/services/telephony/providers/vobiz/transport.py @@ -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 diff --git a/api/services/telephony/providers/vonage/__init__.py b/api/services/telephony/providers/vonage/__init__.py new file mode 100644 index 0000000..e708f39 --- /dev/null +++ b/api/services/telephony/providers/vonage/__init__.py @@ -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", +] diff --git a/api/services/telephony/providers/vonage/config.py b/api/services/telephony/providers/vonage/config.py new file mode 100644 index 0000000..54a31c5 --- /dev/null +++ b/api/services/telephony/providers/vonage/config.py @@ -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] diff --git a/api/services/telephony/providers/vonage_provider.py b/api/services/telephony/providers/vonage/provider.py similarity index 75% rename from api/services/telephony/providers/vonage_provider.py rename to api/services/telephony/providers/vonage/provider.py index 1d7975d..6fae755 100644 --- a/api/services/telephony/providers/vonage_provider.py +++ b/api/services/telephony/providers/vonage/provider.py @@ -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. """ diff --git a/api/services/telephony/providers/vonage/routes.py b/api/services/telephony/providers/vonage/routes.py new file mode 100644 index 0000000..d0efa64 --- /dev/null +++ b/api/services/telephony/providers/vonage/routes.py @@ -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"} diff --git a/api/services/telephony/providers/vonage/serializers.py b/api/services/telephony/providers/vonage/serializers.py new file mode 100644 index 0000000..ae17805 --- /dev/null +++ b/api/services/telephony/providers/vonage/serializers.py @@ -0,0 +1,5 @@ +"""Vonage frame serializer (re-exported from pipecat).""" + +from pipecat.serializers.vonage import VonageFrameSerializer + +__all__ = ["VonageFrameSerializer"] diff --git a/api/services/telephony/providers/vonage/transport.py b/api/services/telephony/providers/vonage/transport.py new file mode 100644 index 0000000..8e895c3 --- /dev/null +++ b/api/services/telephony/providers/vonage/transport.py @@ -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, + ), + ) diff --git a/api/services/telephony/registry.py b/api/services/telephony/registry.py new file mode 100644 index 0000000..ab98f99 --- /dev/null +++ b/api/services/telephony/registry.py @@ -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//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) diff --git a/api/services/telephony/status_processor.py b/api/services/telephony/status_processor.py new file mode 100644 index 0000000..f4385c5 --- /dev/null +++ b/api/services/telephony/status_processor.py @@ -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}" + ) diff --git a/api/tests/test_circuit_breaker.py b/api/tests/test_circuit_breaker.py index a88a5ea..23f0671 100644 --- a/api/tests/test_circuit_breaker.py +++ b/api/tests/test_circuit_breaker.py @@ -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() diff --git a/api/utils/telephony_address.py b/api/utils/telephony_address.py new file mode 100644 index 0000000..eea6cb7 --- /dev/null +++ b/api/utils/telephony_address.py @@ -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"^(?Psips?):(?:(?P[^@;?]+)@)?(?P[^:;?]+)" + r"(?::(?P\d+))?(?P[;?].*)?$", + 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") diff --git a/api/utils/telephony_helper.py b/api/utils/telephony_helper.py index ee51e46..2078bb1 100644 --- a/api/utils/telephony_helper.py +++ b/api/utils/telephony_helper.py @@ -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: diff --git a/docs/docs.json b/docs/docs.json index ddb4405..cbefd02 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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", diff --git a/docs/images/twilio-inbound-config.png b/docs/images/twilio-inbound-config.png index a0bb2c5..72573ee 100644 Binary files a/docs/images/twilio-inbound-config.png and b/docs/images/twilio-inbound-config.png differ diff --git a/docs/integrations/telephony/asterisk-ari.mdx b/docs/integrations/telephony/asterisk-ari.mdx index da2c3c7..8a40dcf 100644 --- a/docs/integrations/telephony/asterisk-ari.mdx +++ b/docs/integrations/telephony/asterisk-ari.mdx @@ -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() +``` - -If no Inbound Workflow ID is configured, inbound calls will be hung up immediately. You must set this field for inbound calling to work. - +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 + + + 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. + + +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 - - 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" diff --git a/docs/integrations/telephony/cloudonix.mdx b/docs/integrations/telephony/cloudonix.mdx index 612c628..241cad2 100644 --- a/docs/integrations/telephony/cloudonix.mdx +++ b/docs/integrations/telephony/cloudonix.mdx @@ -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 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 - 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`. + + + Cloudonix application form showing Remote Application Resource type, Cloudonix CXML runtime, the Dograh inbound URL, POST method, and a DNID number entry + 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` + + + 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. Cloudonix application creation with webhook URL configuration Cloudonix application creation with 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 - - 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 - + - Verify WebSocket connection is established - Check firewall rules for WebSocket traffic - Ensure audio pipeline is configured correctly - + - - 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 - + - - 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 ## 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 diff --git a/docs/integrations/telephony/custom.mdx b/docs/integrations/telephony/custom.mdx index 6be889f..686ff7c 100644 --- a/docs/integrations/telephony/custom.mdx +++ b/docs/integrations/telephony/custom.mdx @@ -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//`. 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 | - 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. - -## 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 - - Twilio - Vonage - Your Provider - -``` - -**C. Add Form Fields** - -Render your provider's fields conditionally: - -```tsx -{selectedProvider === "your_provider" && ( - <> -
- - -
- {/* 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 -``` \ No newline at end of file diff --git a/docs/integrations/telephony/inbound.mdx b/docs/integrations/telephony/inbound.mdx index 5e7452c..a0a7251 100644 --- a/docs/integrations/telephony/inbound.mdx +++ b/docs/integrations/telephony/inbound.mdx @@ -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 Industry-leading platform with robust inbound call handling + + Application-level inbound routing with high-quality 16kHz audio + + + Application-level inbound routing on Plivo's global voice cloud + + + Application-level inbound via Telnyx Call Control API + SIP-based inbound calling with flexible trunk configuration @@ -24,59 +33,67 @@ Dograh AI supports inbound calling across all supported telephony providers. Whe - -**Vonage**: Inbound calling support is coming soon. Currently supports outbound calling only. - - ## 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. - - If you're using a self-hosted Dograh instance, replace `api.dograh.com` with your own domain. - +### 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 +``` + + +If you're self-hosting Dograh, replace `api.dograh.com` with your own domain. + + +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: - **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 @@ -126,8 +143,8 @@ After completing both the Dograh AI configuration and provider-specific setup: - **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 @@ -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 \ No newline at end of file +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 \ No newline at end of file diff --git a/docs/integrations/telephony/overview.mdx b/docs/integrations/telephony/overview.mdx index e262b55..e0fff99 100644 --- a/docs/integrations/telephony/overview.mdx +++ b/docs/integrations/telephony/overview.mdx @@ -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 High-quality voice with 16kHz audio and excellent international coverage + + Cloud communications platform with programmable voice and global PSTN reach + + + SIP-based telephony with flexible trunk configuration + + + Cloud-based telephony with global reach and competitive pricing + + + Connect to your own Asterisk PBX via the Asterisk REST Interface + Build your own telephony provider integration -## 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 + + + +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 - 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 @@ -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 - - 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 @@ -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) \ No newline at end of file diff --git a/docs/integrations/telephony/plivo.mdx b/docs/integrations/telephony/plivo.mdx new file mode 100644 index 0000000..643414d --- /dev/null +++ b/docs/integrations/telephony/plivo.mdx @@ -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` + + + 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. + + +### 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 + + + + Ensure phone numbers include country code in E.164 format: `+1234567890` + + + + - Verify Auth ID and Auth Token are correct - Check for extra spaces in + credentials - Ensure credentials haven't been regenerated in Plivo Console + + + + - Confirm your Auth Token matches exactly - Verify the webhook URL matches + what Plivo sends - Check if you're behind a proxy that modifies requests + + + + - Verify WebSocket connection is established - Check firewall rules for + WebSocket traffic - Ensure audio pipeline is configured correctly + + + + - 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 + + + + - 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 + + + +## 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 diff --git a/docs/integrations/telephony/telnyx.mdx b/docs/integrations/telephony/telnyx.mdx new file mode 100644 index 0000000..9942b40 --- /dev/null +++ b/docs/integrations/telephony/telnyx.mdx @@ -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 + + + 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. + + +### 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 + + + + Ensure phone numbers include country code in E.164 format: `+1234567890` + + + + - 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 + + + + - 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 + + + + - Verify WebSocket connection is established - Check firewall rules for + WebSocket traffic - Ensure audio pipeline is configured correctly + + + + - 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 + + + + - 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 + + + +## 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 diff --git a/docs/integrations/telephony/twilio.mdx b/docs/integrations/telephony/twilio.mdx index c8ac60e..061e131 100644 --- a/docs/integrations/telephony/twilio.mdx +++ b/docs/integrations/telephony/twilio.mdx @@ -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: - - - ## 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` - 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. 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: Ensure phone numbers include country code in E.164 format: `+1234567890` - + - - 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 - + - - 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 - + - - 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 - + - - 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 - + - - 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 ## 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 \ No newline at end of file +- Monitor Twilio Console for usage and billing diff --git a/docs/integrations/telephony/vobiz.mdx b/docs/integrations/telephony/vobiz.mdx index 9f96d7f..10a5433 100644 --- a/docs/integrations/telephony/vobiz.mdx +++ b/docs/integrations/telephony/vobiz.mdx @@ -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** - - -Vobiz provides cloud-based telephony services with global reach and competitive pricing. - +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 Vobiz console showing Applications section navigation Vobiz console showing Applications section navigation -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` - 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. - Vobiz XML application configuration showing Answer URL, Hangup URL, and Fallback Answer URL - 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 - - Vobiz application showing phone number attachment interface - 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 - - - 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 + + - 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 - + - - 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 ## 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 \ No newline at end of file +- 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 \ No newline at end of file diff --git a/docs/integrations/telephony/vonage.mdx b/docs/integrations/telephony/vonage.mdx index 11944cd..e57bfd6 100644 --- a/docs/integrations/telephony/vonage.mdx +++ b/docs/integrations/telephony/vonage.mdx @@ -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: - - - ## 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 - -**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. - +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` + + + 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. + + +### 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: - - 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 - - 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 @@ -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 diff --git a/docs/telephony/twilio.mdx b/docs/telephony/twilio.mdx deleted file mode 100644 index c1a9766..0000000 --- a/docs/telephony/twilio.mdx +++ /dev/null @@ -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. - - \ No newline at end of file diff --git a/pipecat b/pipecat index a6869df..82432f4 160000 --- a/pipecat +++ b/pipecat @@ -1 +1 @@ -Subproject commit a6869df4bc7de8bd14f0533f7112f7d6a24891d9 +Subproject commit 82432f4e8be05d6cd8e9a7cd578f3893005d157c diff --git a/ui/src/app/campaigns/CampaignAdvancedSettings.tsx b/ui/src/app/campaigns/CampaignAdvancedSettings.tsx index 7a49499..5d92056 100644 --- a/ui/src/app/campaigns/CampaignAdvancedSettings.tsx +++ b/ui/src/app/campaigns/CampaignAdvancedSettings.tsx @@ -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({

{fromNumbersCount > 0 && fromNumbersCount < orgConcurrentLimit && (

- Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in Telephony Configuration. + Concurrency is limited to {fromNumbersCount} by your configured phone numbers. To use the full org limit of {orgConcurrentLimit}, add more CLIs in Telephony Configuration.

)} {fromNumbersCount === 0 && (

- No phone numbers configured. Add CLIs in Telephony Configuration before running the campaign. + No phone numbers configured. Add CLIs in Telephony Configuration before running the campaign.

)} diff --git a/ui/src/app/campaigns/[campaignId]/edit/page.tsx b/ui/src/app/campaigns/[campaignId]/edit/page.tsx index 2935a73..b447eec 100644 --- a/ui/src/app/campaigns/[campaignId]/edit/page.tsx +++ b/ui/src/app/campaigns/[campaignId]/edit/page.tsx @@ -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})`); } diff --git a/ui/src/app/campaigns/new/page.tsx b/ui/src/app/campaigns/new/page.tsx index 248d5f7..4d77841 100644 --- a/ui/src/app/campaigns/new/page.tsx +++ b/ui/src/app/campaigns/new/page.tsx @@ -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([]); const [isLoadingWorkflows, setIsLoadingWorkflows] = useState(true); + // Telephony configurations state + const [telephonyConfigs, setTelephonyConfigs] = useState([]); + const [selectedTelephonyConfigId, setSelectedTelephonyConfigId] = useState(''); + const [isLoadingTelephonyConfigs, setIsLoadingTelephonyConfigs] = useState(true); + // Advanced settings state const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [orgConcurrentLimit, setOrgConcurrentLimit] = useState(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() {

+
+ + {!isLoadingTelephonyConfigs && telephonyConfigs.length === 0 ? ( +
+ No telephony configurations yet.{' '} + + Add one + {' '} + to create a campaign. +
+ ) : ( + + )} +

+ Outbound calls for this campaign will use this configuration's caller IDs +

+
+
setValue("provider", value)} - > - - - - - Twilio - Vonage - Plivo - Vobiz - Telnyx - Cloudonix - Asterisk (ARI) - - - {hasExistingConfig && ( -

- ⚠️ Switching providers will require entering new credentials -

- )} -
+

Telephony configurations

+

+ Connect one or more telephony provider accounts. Each campaign uses one + configuration; inbound calls are routed to the right one by account ID.{" "} + + Learn more + +

+ + + - {/* Twilio-specific fields */} - {selectedProvider === "twilio" && ( - <> -
- - - {errors.account_sid && ( -

- {errors.account_sid.message} -

+ {loading ? ( +
+ + +
+ ) : items.length === 0 ? ( + + + No telephony configurations yet + + Add one to enable outbound calls and receive inbound calls. + + + + + + + ) : ( +
+ {items.map((item) => ( + + + +
+
+ {item.name} + {item.provider} + {item.is_default_outbound && ( + + + Default + )}
- -
- - - {errors.auth_token && ( -

- {errors.auth_token.message} -

- )} -
- -
- - {fromNumbers.map((number, index) => ( -
- updatePhoneNumber(index, e.target.value)} - /> - {fromNumbers.length > 1 && ( - - )} -
- ))} - - {fromNumbers.some(n => n.trim() !== "" && !/^\+[1-9]\d{1,14}$/.test(n)) && ( -

- Enter valid phone numbers with country code (e.g., +1234567890) -

- )} -
- - )} - - {/* Vonage-specific fields */} - {selectedProvider === "vonage" && ( - <> -
- - - {errors.application_id && ( -

- {errors.application_id.message} -

- )} -
- -
- -