Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/database/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
Project,
Story,
StoryItem,
TelnyxCall,
TelnyxSettings,
VoiceProfile,
)
from .session import engine, SessionLocal, _db_path, init_db, get_db
Expand All @@ -44,6 +46,8 @@
"Project",
"Story",
"StoryItem",
"TelnyxCall",
"TelnyxSettings",
"VoiceProfile",
# Session
"engine",
Expand Down
71 changes: 71 additions & 0 deletions backend/database/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ def run_migrations(engine) -> None:
_migrate_generation_versions(engine, inspector, tables)
_migrate_capture_settings(engine, inspector, tables)
_migrate_mcp_bindings(engine, inspector, tables)
_migrate_telnyx_settings(engine, inspector, tables)
_migrate_telnyx_calls(engine, inspector, tables)
_normalize_storage_paths(engine, tables)


Expand Down Expand Up @@ -292,6 +294,75 @@ def _supports_drop_column(engine) -> bool:
return tuple(int(p) for p in sqlite3.sqlite_version.split(".")[:3]) >= (3, 35, 0)


def _migrate_telnyx_settings(engine, inspector, tables: set[str]) -> None:
"""Create the ``telnyx_settings`` table for the Telnyx PSTN sink.

One singleton row (id=1) holding the user's Telnyx API key, default
caller ID, public tunnel URL, and a few toggles. The table itself is
created here; the singleton row is lazily inserted by the settings
service on first read. Future sinks (Apple Notes, Obsidian, generic
webhook) ship their own singleton tables in their own PRs.
"""
if "telnyx_settings" in tables:
# Table predates connection_id (early builds of the sink omitted it
# and every dial 422'd). Backfill the column rather than recreate.
if "connection_id" not in _get_columns(inspector, "telnyx_settings"):
_add_column(
engine, "telnyx_settings", "connection_id VARCHAR", "connection_id"
)
return

with engine.begin() as conn:
conn.execute(text(
"""
CREATE TABLE telnyx_settings (
id INTEGER PRIMARY KEY DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT 0,
api_key VARCHAR,
connection_id VARCHAR,
from_number VARCHAR,
public_base_url VARCHAR,
default_profile_id VARCHAR REFERENCES profiles(id),
auto_hangup BOOLEAN NOT NULL DEFAULT 1,
updated_at DATETIME
)
"""
))
logger.info("Created telnyx_settings table")


def _migrate_telnyx_calls(engine, inspector, tables: set[str]) -> None:
"""Create the ``telnyx_calls`` table that tracks pending outbound calls.

One row per call. The webhook handler reads this to know which
generation to play when Telnyx fires ``call.answered``. The
``webhook_secret`` column is a per-call random token (sent as the
``token`` query param on the webhook URL) so a third party who knows
the webhook URL can't trigger playback on someone else's call.
"""
if "telnyx_calls" in tables:
return

with engine.begin() as conn:
conn.execute(text(
"""
CREATE TABLE telnyx_calls (
call_control_id VARCHAR PRIMARY KEY,
generation_id VARCHAR NOT NULL REFERENCES generations(id),
to_number VARCHAR NOT NULL,
from_number VARCHAR NOT NULL,
webhook_secret VARCHAR,
status VARCHAR NOT NULL DEFAULT 'initiating',
auto_hangup BOOLEAN NOT NULL DEFAULT 1,
error TEXT,
started_at DATETIME NOT NULL,
completed_at DATETIME
)
"""
))
logger.info("Created telnyx_calls table")


def _normalize_storage_paths(engine, tables: set[str]) -> None:
"""Normalize stored file paths to be relative to the configured data dir."""
from pathlib import Path
Expand Down
57 changes: 57 additions & 0 deletions backend/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,60 @@ class Capture(Base):
llm_model = Column(String, nullable=True)
refinement_flags = Column(Text, nullable=True) # JSON blob
created_at = Column(DateTime, default=datetime.utcnow)


class TelnyxSettings(Base):
"""Singleton row holding Telnyx sink credentials and defaults.

Populated by the "Settings → Sinks → Telnyx" UI. The ``api_key`` is a
bearer credential for ``api.telnyx.com`` — stored in the local app
database alongside the user's other data, masked in API responses. The
``id`` is always 1; a null ``api_key`` means "not configured".
"""

__tablename__ = "telnyx_settings"

id = Column(Integer, primary_key=True, default=1)
enabled = Column(Boolean, nullable=False, default=False)
api_key = Column(String, nullable=True)
# Call Control Application ID — required on every outbound dial.
connection_id = Column(String, nullable=True)
from_number = Column(String, nullable=True) # E.164, used as caller ID
public_base_url = Column(String, nullable=True) # ngrok/funnel/etc.
default_profile_id = Column(String, ForeignKey("profiles.id"), nullable=True)
auto_hangup = Column(Boolean, nullable=False, default=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)


class TelnyxCall(Base):
"""A pending or in-progress Telnyx call mapped to a Voicebox generation.

One row per outbound dial. The ``webhook_secret`` is a per-call random
token embedded in the ``webhook_url`` we send Telnyx at call-creation
time; Telnyx includes it back as the ``token`` query param on webhook
POSTs, and the webhook handler rejects any request whose token doesn't
match. This stops a third party who knows the webhook URL from
triggering playback on a live call without needing full HMAC signature
verification (a follow-up hardening step).

Status progression:
initiating → preparing → playing → playback_ended → hangup
↘ failed

``preparing`` is the claim state: the ``call.answered`` webhook sets it
before doing any slow work, so a retried delivery of the same event finds
the call already claimed and doesn't start a second playback.
"""

__tablename__ = "telnyx_calls"

call_control_id = Column(String, primary_key=True)
generation_id = Column(String, ForeignKey("generations.id"), nullable=False)
to_number = Column(String, nullable=False)
from_number = Column(String, nullable=False)
webhook_secret = Column(String, nullable=True)
status = Column(String, nullable=False, default="initiating")
auto_hangup = Column(Boolean, nullable=False, default=True)
error = Column(Text, nullable=True)
started_at = Column(DateTime, nullable=False, default=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
114 changes: 114 additions & 0 deletions backend/mcp_server/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,120 @@ async def voicebox_list_profiles() -> dict[str, Any]:
finally:
db.close()

@mcp.tool(
name="voicebox.call",
description=(
"Dial a phone number via the Telnyx sink and play a Voicebox "
"generation on the call. Returns a `call_control_id` and a "
"`generation_id` you can poll at /generate/{id}/status. The "
"call dials as soon as Telnyx accepts; playback happens "
"asynchronously once the callee picks up. Requires the Telnyx "
"sink to be configured in Voicebox → Settings → Sinks."
),
)
async def voicebox_call(
to: str,
text: str | None = None,
generation_id: str | None = None,
profile: str | None = None,
engine: str | None = None,
personality: bool | None = None,
language: str | None = None,
auto_hangup: bool | None = None,
) -> dict[str, Any]:
Comment on lines +238 to +247

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate to as E.164 in the MCP tool.

CallRequest.to enforces ^\+[1-9]\d{6,14}$ on the REST surface. This tool accepts any string. An agent that passes "555-123-4567" reaches Telnyx, and create_call raises httpx.HTTPStatusError, which the tool does not convert to a ValueError. The agent then receives an opaque error instead of a correction hint.

Validate the format before calling place_call.

🛡️ Proposed fix
+import re
...
             if bool(text) == bool(generation_id):
                 raise ValueError(
                     "Pass exactly one of `text` (generate inline) or "
                     "`generation_id` (use an existing generation)."
                 )
 
+            if not re.fullmatch(r"\+[1-9]\d{6,14}", to):
+                raise ValueError(
+                    "`to` must be an E.164 phone number, e.g. '+15551234567'."
+                )
+
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/mcp_server/tools.py` around lines 238 - 247, Update voicebox_call to
validate to against the same E.164 pattern enforced by CallRequest before
invoking place_call. Reject invalid values with a clear ValueError correction
message, and leave the existing call flow unchanged for valid numbers.

"""Dial ``to`` (E.164) and play a generation on the call.

Pass exactly one of ``text`` (generate fresh audio first) or
``generation_id`` (reuse an existing completed generation).

``profile`` accepts a voice profile name (e.g. "Morgan") or id, and
follows the same resolution chain as ``voicebox.speak``: explicit
arg → per-client MCP binding → sink default. If none resolve, the
tool raises a helpful error pointing at the Settings UI.

``auto_hangup`` overrides the sink's default — when true (the
default), the call hangs up after playback ends; when false, the
call stays connected after playback so the caller can issue more
actions (future: DTMF, recording, etc.).
"""
from ..database.models import MCPClientBinding
from ..services import sinks as sinks_service

db = next(get_db())
try:
s = sinks_service.get_settings(db)
missing = sinks_service.missing_settings(s)
if missing:
raise ValueError(sinks_service.setup_hint(missing))

if bool(text) == bool(generation_id):
raise ValueError(
"Pass exactly one of `text` (generate inline) or "
"`generation_id` (use an existing generation)."
)

client_id = current_client_id.get()

# One resolve_profile call covers explicit arg → per-client
# binding → global capture default, the same chain
# voicebox.speak uses. The sink default is the last resort.
vp = resolve_profile(profile, client_id, db)
if vp is None and profile:
raise ValueError(f"Voice profile '{profile}' not found.")
if vp is None and s.default_profile_id:
vp = resolve_profile(s.default_profile_id, client_id, db)
if vp is None:
raise ValueError(
"No voice profile resolved. Pass `profile=`, set a "
"default in Voicebox → Settings → Sinks → Telnyx, or "
"bind a profile to this client in Settings → MCP."
)

binding = None
if client_id:
binding = (
db.query(MCPClientBinding)
.filter(MCPClientBinding.client_id == client_id)
.first()
)

resolved_personality = personality
if resolved_personality is None and binding is not None:
resolved_personality = bool(binding.default_personality)
use_persona = bool(resolved_personality) and bool(vp.personality)

resolved_engine = engine
if resolved_engine is None and binding is not None:
resolved_engine = binding.default_engine

result = await sinks_service.place_call(
to=to,
text=text,
generation_id=generation_id,
profile_id=vp.id,
profile_name=vp.name,
engine=resolved_engine,
language=language,
personality=use_persona,
auto_hangup=auto_hangup,
db=db,
)

mcp_events.publish(
"call-start",
{
"call_control_id": result["call_control_id"],
"generation_id": result["generation_id"],
"to": to,
"profile_name": vp.name,
"source": "mcp",
"client_id": client_id,
},
)
return result
finally:
db.close()


# ─── Speak helper ──────────────────────────────────────────────────────────

Expand Down
Loading