mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-05 09:17:42 +02:00
[eric] cute is law, communication is law
This commit is contained in:
@@ -17,3 +17,25 @@ APPLE_TEAM_ID=ABCDE12345
|
||||
# GitHub Releases (required for --publish)
|
||||
# =============================================================================
|
||||
GH_TOKEN=ghp_your-github-personal-access-token
|
||||
|
||||
# =============================================================================
|
||||
# Channels: SMS, WhatsApp, Voice Calling
|
||||
# =============================================================================
|
||||
TWILIO_ACCOUNT_SID=your-twilio-account-sid
|
||||
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
||||
TWILIO_PHONE_NUMBER=+1234567890
|
||||
|
||||
TELNYX_API_KEY=your-telnyx-api-key
|
||||
TELNYX_PUBLIC_KEY=your-telnyx-public-key
|
||||
|
||||
# =============================================================================
|
||||
# TTS / STT Providers
|
||||
# =============================================================================
|
||||
ELEVENLABS_API_KEY=your-elevenlabs-api-key
|
||||
DEEPGRAM_API_KEY=your-deepgram-api-key
|
||||
OPENAI_API_KEY=your-openai-api-key
|
||||
|
||||
# =============================================================================
|
||||
# Webhook URL (required for inbound SMS/calls — use ngrok or Tailscale)
|
||||
# =============================================================================
|
||||
WEBHOOK_BASE_URL=https://your-public-url.ngrok.io
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Telnyx implementation of BaseChannelAdapter.
|
||||
|
||||
Uses Telnyx Call Control v2 for voice and Messaging API for SMS.
|
||||
"""
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
from typing import Optional
|
||||
from functools import partial
|
||||
|
||||
from backend.apps.channels.base_adapter import BaseChannelAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelnyxAdapter(BaseChannelAdapter):
|
||||
|
||||
def __init__(self, api_key: str, public_key: str = ""):
|
||||
self._api_key = api_key
|
||||
self._public_key = public_key
|
||||
self._telnyx = None
|
||||
|
||||
def _get_telnyx(self):
|
||||
if self._telnyx is None:
|
||||
import telnyx
|
||||
telnyx.api_key = self._api_key
|
||||
self._telnyx = telnyx
|
||||
return self._telnyx
|
||||
|
||||
async def send_sms(self, to: str, from_: str, body: str) -> dict:
|
||||
telnyx = self._get_telnyx()
|
||||
loop = asyncio.get_event_loop()
|
||||
msg = await loop.run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
telnyx.Message.create,
|
||||
to=to,
|
||||
from_=from_,
|
||||
text=body,
|
||||
),
|
||||
)
|
||||
return {"id": msg.id, "status": getattr(msg, "status", "queued")}
|
||||
|
||||
async def send_whatsapp(self, to: str, from_: str, body: str) -> dict:
|
||||
# Telnyx WhatsApp uses the same messaging API with messaging_profile_id
|
||||
return await self.send_sms(to, from_, body)
|
||||
|
||||
async def initiate_call(
|
||||
self, to: str, from_: str, webhook_url: str, greeting: str = ""
|
||||
) -> dict:
|
||||
telnyx = self._get_telnyx()
|
||||
loop = asyncio.get_event_loop()
|
||||
call = await loop.run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
telnyx.Call.create,
|
||||
to=to,
|
||||
from_=from_,
|
||||
connection_id=self._api_key, # connection_id should be set separately
|
||||
webhook_url=webhook_url,
|
||||
),
|
||||
)
|
||||
return {"call_control_id": call.call_control_id, "status": "initiated"}
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, request_url: str, params: dict, signature: str, auth_token: str
|
||||
) -> bool:
|
||||
if not self._public_key:
|
||||
# Fail closed: no public key means reject
|
||||
logger.error("Telnyx public key not configured — rejecting webhook")
|
||||
return False
|
||||
try:
|
||||
# Telnyx uses Ed25519 signature verification
|
||||
# The signature and timestamp are in webhook headers
|
||||
import base64
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||||
|
||||
public_key = load_pem_public_key(self._public_key.encode())
|
||||
sig_bytes = base64.b64decode(signature)
|
||||
payload = params.get("_raw_body", "")
|
||||
timestamp = params.get("_timestamp", "")
|
||||
signed_payload = f"{timestamp}|{payload}"
|
||||
public_key.verify(sig_bytes, signed_payload.encode())
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Telnyx signature verification failed")
|
||||
return False
|
||||
|
||||
def generate_twiml_gather(
|
||||
self,
|
||||
prompt: str,
|
||||
action_url: str,
|
||||
voice: str = "Polly.Joanna",
|
||||
language: str = "en-US",
|
||||
timeout: int = 10,
|
||||
) -> str:
|
||||
# Telnyx uses TeXML (Twilio-compatible XML)
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
"<Response>"
|
||||
f'<Gather input="speech" action="{action_url}" '
|
||||
f'language="{language}" speechTimeout="{timeout}">'
|
||||
f'<Say voice="{voice}">{_escape_xml(prompt)}</Say>'
|
||||
"</Gather>"
|
||||
f'<Say voice="{voice}">I didn\'t hear anything. Goodbye.</Say>'
|
||||
"</Response>"
|
||||
)
|
||||
|
||||
def generate_twiml_say(
|
||||
self, text: str, voice: str = "Polly.Joanna", language: str = "en-US"
|
||||
) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
"<Response>"
|
||||
f'<Say voice="{voice}">{_escape_xml(text)}</Say>'
|
||||
"</Response>"
|
||||
)
|
||||
|
||||
def generate_twiml_hangup(self) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
"<Response><Hangup/></Response>"
|
||||
)
|
||||
|
||||
async def get_recording_audio(self, recording_url: str, auth_token: str) -> bytes:
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
recording_url,
|
||||
headers={"Authorization": f"Bearer {self._api_key}"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def _escape_xml(text: str) -> str:
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
@@ -0,0 +1,139 @@
|
||||
"""Twilio implementation of BaseChannelAdapter.
|
||||
|
||||
Handles SMS, WhatsApp, and Voice via the Twilio Python SDK.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
from functools import partial
|
||||
|
||||
from backend.apps.channels.base_adapter import BaseChannelAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TwilioAdapter(BaseChannelAdapter):
|
||||
|
||||
def __init__(self, account_sid: str, auth_token: str):
|
||||
self._account_sid = account_sid
|
||||
self._auth_token = auth_token
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
if self._client is None:
|
||||
from twilio.rest import Client
|
||||
self._client = Client(self._account_sid, self._auth_token)
|
||||
return self._client
|
||||
|
||||
async def send_sms(self, to: str, from_: str, body: str) -> dict:
|
||||
client = self._get_client()
|
||||
loop = asyncio.get_event_loop()
|
||||
msg = await loop.run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
client.messages.create,
|
||||
to=to,
|
||||
from_=from_,
|
||||
body=body,
|
||||
),
|
||||
)
|
||||
return {"sid": msg.sid, "status": msg.status}
|
||||
|
||||
async def send_whatsapp(self, to: str, from_: str, body: str) -> dict:
|
||||
wa_to = to if to.startswith("whatsapp:") else f"whatsapp:{to}"
|
||||
wa_from = from_ if from_.startswith("whatsapp:") else f"whatsapp:{from_}"
|
||||
client = self._get_client()
|
||||
loop = asyncio.get_event_loop()
|
||||
msg = await loop.run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
client.messages.create,
|
||||
to=wa_to,
|
||||
from_=wa_from,
|
||||
body=body,
|
||||
),
|
||||
)
|
||||
return {"sid": msg.sid, "status": msg.status}
|
||||
|
||||
async def initiate_call(
|
||||
self, to: str, from_: str, webhook_url: str, greeting: str = ""
|
||||
) -> dict:
|
||||
client = self._get_client()
|
||||
loop = asyncio.get_event_loop()
|
||||
call = await loop.run_in_executor(
|
||||
None,
|
||||
partial(
|
||||
client.calls.create,
|
||||
to=to,
|
||||
from_=from_,
|
||||
url=webhook_url,
|
||||
),
|
||||
)
|
||||
return {"sid": call.sid, "status": call.status}
|
||||
|
||||
def verify_webhook_signature(
|
||||
self, request_url: str, params: dict, signature: str, auth_token: str
|
||||
) -> bool:
|
||||
try:
|
||||
from twilio.request_validator import RequestValidator
|
||||
validator = RequestValidator(auth_token)
|
||||
return validator.validate(request_url, params, signature)
|
||||
except Exception:
|
||||
logger.exception("Twilio signature verification failed")
|
||||
return False
|
||||
|
||||
def generate_twiml_gather(
|
||||
self,
|
||||
prompt: str,
|
||||
action_url: str,
|
||||
voice: str = "Polly.Joanna",
|
||||
language: str = "en-US",
|
||||
timeout: int = 10,
|
||||
) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
"<Response>"
|
||||
f'<Gather input="speech" action="{action_url}" '
|
||||
f'language="{language}" speechTimeout="{timeout}">'
|
||||
f'<Say voice="{voice}">{_escape_xml(prompt)}</Say>'
|
||||
"</Gather>"
|
||||
f'<Say voice="{voice}">I didn\'t hear anything. Goodbye.</Say>'
|
||||
"</Response>"
|
||||
)
|
||||
|
||||
def generate_twiml_say(
|
||||
self, text: str, voice: str = "Polly.Joanna", language: str = "en-US"
|
||||
) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
"<Response>"
|
||||
f'<Say voice="{voice}">{_escape_xml(text)}</Say>'
|
||||
"</Response>"
|
||||
)
|
||||
|
||||
def generate_twiml_hangup(self) -> str:
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
"<Response><Hangup/></Response>"
|
||||
)
|
||||
|
||||
async def get_recording_audio(self, recording_url: str, auth_token: str) -> bytes:
|
||||
import httpx
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
recording_url,
|
||||
auth=(self._account_sid, auth_token),
|
||||
follow_redirects=True,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
|
||||
|
||||
def _escape_xml(text: str) -> str:
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class BaseChannelAdapter(ABC):
|
||||
"""Provider-agnostic interface for telephony operations."""
|
||||
|
||||
@abstractmethod
|
||||
async def send_sms(self, to: str, from_: str, body: str) -> dict:
|
||||
"""Send an SMS message. Returns provider response dict."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def send_whatsapp(self, to: str, from_: str, body: str) -> dict:
|
||||
"""Send a WhatsApp message. Returns provider response dict."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def initiate_call(
|
||||
self, to: str, from_: str, webhook_url: str, greeting: str = ""
|
||||
) -> dict:
|
||||
"""Initiate an outbound voice call. Returns provider response dict."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def verify_webhook_signature(
|
||||
self, request_url: str, params: dict, signature: str, auth_token: str
|
||||
) -> bool:
|
||||
"""Verify that an inbound webhook is authentic."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def generate_twiml_gather(
|
||||
self,
|
||||
prompt: str,
|
||||
action_url: str,
|
||||
voice: str = "Polly.Joanna",
|
||||
language: str = "en-US",
|
||||
timeout: int = 10,
|
||||
) -> str:
|
||||
"""Generate TwiML (or equivalent) to play a prompt and gather speech."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def generate_twiml_say(
|
||||
self, text: str, voice: str = "Polly.Joanna", language: str = "en-US"
|
||||
) -> str:
|
||||
"""Generate TwiML (or equivalent) to speak text."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def generate_twiml_hangup(self) -> str:
|
||||
"""Generate TwiML (or equivalent) to end a call."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def get_recording_audio(self, recording_url: str, auth_token: str) -> bytes:
|
||||
"""Download audio from a recording URL."""
|
||||
...
|
||||
|
||||
def chunk_message(self, text: str, max_length: int = 1600) -> list[str]:
|
||||
"""Split a long message into chunks respecting sentence boundaries."""
|
||||
if len(text) <= max_length:
|
||||
return [text]
|
||||
|
||||
chunks: list[str] = []
|
||||
remaining = text
|
||||
|
||||
while remaining:
|
||||
if len(remaining) <= max_length:
|
||||
chunks.append(remaining)
|
||||
break
|
||||
|
||||
# Try to split at sentence boundary
|
||||
split_at = -1
|
||||
for sep in [". ", "! ", "? ", "\n\n", "\n", " "]:
|
||||
idx = remaining.rfind(sep, 0, max_length)
|
||||
if idx > 0:
|
||||
split_at = idx + len(sep)
|
||||
break
|
||||
|
||||
if split_at <= 0:
|
||||
split_at = max_length
|
||||
|
||||
chunks.append(remaining[:split_at].rstrip())
|
||||
remaining = remaining[split_at:].lstrip()
|
||||
|
||||
return chunks
|
||||
@@ -0,0 +1,129 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Literal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CallStatus = Literal[
|
||||
"ringing", "connected", "gathering", "processing", "responding", "completed", "failed"
|
||||
]
|
||||
|
||||
VALID_TRANSITIONS: dict[CallStatus, set[CallStatus]] = {
|
||||
"ringing": {"connected", "completed", "failed"},
|
||||
"connected": {"gathering", "completed", "failed"},
|
||||
"gathering": {"processing", "completed", "failed"},
|
||||
"processing": {"responding", "completed", "failed"},
|
||||
"responding": {"gathering", "completed", "failed"},
|
||||
"completed": set(),
|
||||
"failed": set(),
|
||||
}
|
||||
|
||||
|
||||
class CallState:
|
||||
"""Tracks the lifecycle of a single voice call."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
call_sid: str,
|
||||
channel_id: str,
|
||||
from_number: str,
|
||||
to_number: str,
|
||||
):
|
||||
self.call_sid = call_sid
|
||||
self.channel_id = channel_id
|
||||
self.from_number = from_number
|
||||
self.to_number = to_number
|
||||
self.agent_session_id: Optional[str] = None
|
||||
self.status: CallStatus = "ringing"
|
||||
self.turns: list[dict] = []
|
||||
self.created_at = datetime.now()
|
||||
self.last_activity = datetime.now()
|
||||
self.error: Optional[str] = None
|
||||
|
||||
def transition(self, new_status: CallStatus) -> bool:
|
||||
"""Attempt a state transition. Returns True if valid."""
|
||||
if new_status in VALID_TRANSITIONS.get(self.status, set()):
|
||||
logger.info(
|
||||
"Call %s: %s -> %s", self.call_sid, self.status, new_status
|
||||
)
|
||||
self.status = new_status
|
||||
self.last_activity = datetime.now()
|
||||
return True
|
||||
logger.warning(
|
||||
"Call %s: invalid transition %s -> %s",
|
||||
self.call_sid, self.status, new_status,
|
||||
)
|
||||
return False
|
||||
|
||||
def add_turn(self, role: str, content: str):
|
||||
self.turns.append({
|
||||
"role": role,
|
||||
"content": content,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
})
|
||||
self.last_activity = datetime.now()
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status not in ("completed", "failed")
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> float:
|
||||
return (datetime.now() - self.created_at).total_seconds()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"call_sid": self.call_sid,
|
||||
"channel_id": self.channel_id,
|
||||
"from_number": self.from_number,
|
||||
"to_number": self.to_number,
|
||||
"agent_session_id": self.agent_session_id,
|
||||
"status": self.status,
|
||||
"turns": self.turns,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"last_activity": self.last_activity.isoformat(),
|
||||
"duration_seconds": self.duration_seconds,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
class CallManager:
|
||||
"""Manages all active voice calls."""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: dict[str, CallState] = {}
|
||||
|
||||
def create_call(
|
||||
self,
|
||||
call_sid: str,
|
||||
channel_id: str,
|
||||
from_number: str,
|
||||
to_number: str,
|
||||
) -> CallState:
|
||||
call = CallState(call_sid, channel_id, from_number, to_number)
|
||||
self.calls[call_sid] = call
|
||||
return call
|
||||
|
||||
def get_call(self, call_sid: str) -> Optional[CallState]:
|
||||
return self.calls.get(call_sid)
|
||||
|
||||
def end_call(self, call_sid: str, status: CallStatus = "completed"):
|
||||
call = self.calls.get(call_sid)
|
||||
if call:
|
||||
call.transition(status)
|
||||
|
||||
def cleanup_stale(self, max_duration_seconds: int = 3600):
|
||||
"""Remove calls that have exceeded max duration."""
|
||||
stale = [
|
||||
sid
|
||||
for sid, call in self.calls.items()
|
||||
if not call.is_active or call.duration_seconds > max_duration_seconds
|
||||
]
|
||||
for sid in stale:
|
||||
if self.calls[sid].is_active:
|
||||
self.calls[sid].transition("failed")
|
||||
self.calls[sid].error = "Exceeded max call duration"
|
||||
del self.calls[sid]
|
||||
|
||||
def get_active_calls(self) -> list[dict]:
|
||||
return [c.to_dict() for c in self.calls.values() if c.is_active]
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Channels SubApp — REST endpoints and Twilio/Telnyx webhooks."""
|
||||
import logging
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from backend.config.Apps import SubApp
|
||||
from backend.apps.channels.models import (
|
||||
ChannelConfig, ChannelCreate, ChannelUpdate, VoiceConfig, TTSConfig, STTConfig,
|
||||
)
|
||||
from backend.apps.channels.orchestrator import channel_orchestrator
|
||||
from backend.apps.channels import ws_events
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def channels_lifespan():
|
||||
logger.info("Channels sub-app starting")
|
||||
await channel_orchestrator.restore_all()
|
||||
yield
|
||||
logger.info("Channels sub-app shutting down")
|
||||
await channel_orchestrator.persist_all()
|
||||
|
||||
|
||||
channels = SubApp("channels", channels_lifespan)
|
||||
|
||||
|
||||
# ─── CRUD Endpoints ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@channels.router.get("/list")
|
||||
async def list_channels():
|
||||
configs = list(channel_orchestrator.configs.values())
|
||||
return {
|
||||
"channels": [c.model_dump(mode="json") for c in configs],
|
||||
}
|
||||
|
||||
|
||||
@channels.router.get("/{channel_id}")
|
||||
async def get_channel(channel_id: str):
|
||||
config = channel_orchestrator.configs.get(channel_id)
|
||||
if not config:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
return config.model_dump(mode="json")
|
||||
|
||||
|
||||
@channels.router.post("/create")
|
||||
async def create_channel(body: ChannelCreate):
|
||||
config = ChannelConfig(
|
||||
name=body.name,
|
||||
channel_type=body.channel_type,
|
||||
provider=body.provider,
|
||||
phone_number=body.phone_number,
|
||||
credentials=body.credentials,
|
||||
)
|
||||
if body.agent_config:
|
||||
config.agent_config = body.agent_config
|
||||
if body.security:
|
||||
config.security = body.security
|
||||
if body.voice_config:
|
||||
config.voice_config = body.voice_config
|
||||
if body.tts_config:
|
||||
config.tts_config = body.tts_config
|
||||
if body.stt_config:
|
||||
config.stt_config = body.stt_config
|
||||
|
||||
channel_orchestrator.save_config(config)
|
||||
return {"channel": config.model_dump(mode="json")}
|
||||
|
||||
|
||||
@channels.router.put("/{channel_id}")
|
||||
async def update_channel(channel_id: str, body: ChannelUpdate):
|
||||
config = channel_orchestrator.configs.get(channel_id)
|
||||
if not config:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
for key, val in updates.items():
|
||||
setattr(config, key, val)
|
||||
|
||||
# Re-create adapter if credentials changed
|
||||
if "credentials" in updates or "provider" in updates:
|
||||
channel_orchestrator.adapters.pop(channel_id, None)
|
||||
|
||||
channel_orchestrator.save_config(config)
|
||||
return {"channel": config.model_dump(mode="json")}
|
||||
|
||||
|
||||
@channels.router.delete("/{channel_id}")
|
||||
async def delete_channel(channel_id: str):
|
||||
if channel_id not in channel_orchestrator.configs:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
channel_orchestrator.delete_config(channel_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ─── Enable / Disable / Test ─────────────────────────────────────
|
||||
|
||||
|
||||
@channels.router.post("/{channel_id}/enable")
|
||||
async def enable_channel(channel_id: str):
|
||||
config = channel_orchestrator.configs.get(channel_id)
|
||||
if not config:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
|
||||
try:
|
||||
channel_orchestrator.get_adapter(config)
|
||||
config.enabled = True
|
||||
config.status = "active"
|
||||
config.status_message = None
|
||||
channel_orchestrator.save_config(config)
|
||||
await ws_events.emit_channel_status(channel_id, "active")
|
||||
return {"ok": True, "status": "active"}
|
||||
except Exception as e:
|
||||
config.status = "error"
|
||||
config.status_message = str(e)
|
||||
channel_orchestrator.save_config(config)
|
||||
raise HTTPException(400, f"Failed to enable channel: {e}")
|
||||
|
||||
|
||||
@channels.router.post("/{channel_id}/disable")
|
||||
async def disable_channel(channel_id: str):
|
||||
config = channel_orchestrator.configs.get(channel_id)
|
||||
if not config:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
|
||||
config.enabled = False
|
||||
config.status = "inactive"
|
||||
channel_orchestrator.adapters.pop(channel_id, None)
|
||||
channel_orchestrator.save_config(config)
|
||||
await ws_events.emit_channel_status(channel_id, "inactive")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@channels.router.post("/{channel_id}/test")
|
||||
async def test_channel(channel_id: str, body: dict | None = None):
|
||||
config = channel_orchestrator.configs.get(channel_id)
|
||||
if not config:
|
||||
raise HTTPException(404, "Channel not found")
|
||||
|
||||
to_number = (body or {}).get("to_number", "")
|
||||
if not to_number:
|
||||
raise HTTPException(400, "to_number is required for test")
|
||||
|
||||
try:
|
||||
adapter = channel_orchestrator.get_adapter(config)
|
||||
if config.channel_type == "whatsapp":
|
||||
result = await adapter.send_whatsapp(to_number, config.phone_number, "Test message from Open Swarm")
|
||||
elif config.channel_type == "voice":
|
||||
result = {"message": "Voice test: configure webhook and call the number"}
|
||||
else:
|
||||
result = await adapter.send_sms(to_number, config.phone_number, "Test message from Open Swarm")
|
||||
return {"ok": True, "result": result}
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Test failed: {e}")
|
||||
|
||||
|
||||
# ─── Conversations ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@channels.router.get("/{channel_id}/conversations")
|
||||
async def list_conversations(channel_id: str):
|
||||
convs = [
|
||||
c.model_dump(mode="json")
|
||||
for c in channel_orchestrator.conversations.values()
|
||||
if c.channel_id == channel_id
|
||||
]
|
||||
return {"conversations": convs}
|
||||
|
||||
|
||||
@channels.router.get("/{channel_id}/conversations/{conversation_id}")
|
||||
async def get_conversation(channel_id: str, conversation_id: str):
|
||||
for conv in channel_orchestrator.conversations.values():
|
||||
if conv.id == conversation_id and conv.channel_id == channel_id:
|
||||
return conv.model_dump(mode="json")
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
|
||||
|
||||
# ─── Outbound ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@channels.router.post("/{channel_id}/send")
|
||||
async def send_outbound(channel_id: str, body: dict):
|
||||
to_number = body.get("to_number", "")
|
||||
message = body.get("message", "")
|
||||
if not to_number or not message:
|
||||
raise HTTPException(400, "to_number and message are required")
|
||||
try:
|
||||
result = await channel_orchestrator.send_outbound(channel_id, to_number, message)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@channels.router.post("/{channel_id}/call")
|
||||
async def initiate_call(channel_id: str, body: dict):
|
||||
to_number = body.get("to_number", "")
|
||||
if not to_number:
|
||||
raise HTTPException(400, "to_number is required")
|
||||
try:
|
||||
result = await channel_orchestrator.initiate_outbound_call(channel_id, to_number)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
# ─── Twilio Webhooks ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@channels.router.post("/webhooks/twilio/sms")
|
||||
async def twilio_sms_webhook(request: Request):
|
||||
"""Inbound SMS webhook from Twilio."""
|
||||
form = await request.form()
|
||||
channel_id = request.query_params.get("channel_id", "")
|
||||
|
||||
# Find channel by phone number if channel_id not provided
|
||||
if not channel_id:
|
||||
to_number = form.get("To", "")
|
||||
for cfg in channel_orchestrator.configs.values():
|
||||
if cfg.phone_number == to_number and cfg.channel_type == "sms":
|
||||
channel_id = cfg.id
|
||||
break
|
||||
|
||||
config = channel_orchestrator.configs.get(channel_id)
|
||||
if not config:
|
||||
return Response(status_code=404)
|
||||
|
||||
# Verify signature
|
||||
if config.security.verify_signatures:
|
||||
adapter = channel_orchestrator.get_adapter(config)
|
||||
sig = request.headers.get("X-Twilio-Signature", "")
|
||||
url = str(request.url)
|
||||
if not adapter.verify_webhook_signature(url, dict(form), sig, config.credentials.get("auth_token", "")):
|
||||
logger.warning("Invalid Twilio signature for channel %s", channel_id)
|
||||
return Response(status_code=403)
|
||||
|
||||
from_number = form.get("From", "")
|
||||
body = form.get("Body", "")
|
||||
num_media = int(form.get("NumMedia", "0"))
|
||||
media_urls = [form.get(f"MediaUrl{i}", "") for i in range(num_media)]
|
||||
media_urls = [u for u in media_urls if u]
|
||||
|
||||
await channel_orchestrator.handle_inbound_sms(channel_id, from_number, body, media_urls)
|
||||
|
||||
# Return empty TwiML (Twilio expects XML response)
|
||||
return Response(
|
||||
content='<?xml version="1.0"?><Response></Response>',
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
|
||||
@channels.router.post("/webhooks/twilio/whatsapp")
|
||||
async def twilio_whatsapp_webhook(request: Request):
|
||||
"""Inbound WhatsApp webhook from Twilio."""
|
||||
form = await request.form()
|
||||
channel_id = request.query_params.get("channel_id", "")
|
||||
|
||||
if not channel_id:
|
||||
to_number = form.get("To", "").replace("whatsapp:", "")
|
||||
for cfg in channel_orchestrator.configs.values():
|
||||
if cfg.phone_number == to_number and cfg.channel_type == "whatsapp":
|
||||
channel_id = cfg.id
|
||||
break
|
||||
|
||||
config = channel_orchestrator.configs.get(channel_id)
|
||||
if not config:
|
||||
return Response(status_code=404)
|
||||
|
||||
if config.security.verify_signatures:
|
||||
adapter = channel_orchestrator.get_adapter(config)
|
||||
sig = request.headers.get("X-Twilio-Signature", "")
|
||||
if not adapter.verify_webhook_signature(str(request.url), dict(form), sig, config.credentials.get("auth_token", "")):
|
||||
return Response(status_code=403)
|
||||
|
||||
from_number = form.get("From", "").replace("whatsapp:", "")
|
||||
body = form.get("Body", "")
|
||||
num_media = int(form.get("NumMedia", "0"))
|
||||
media_urls = [form.get(f"MediaUrl{i}", "") for i in range(num_media)]
|
||||
|
||||
await channel_orchestrator.handle_inbound_sms(channel_id, from_number, body, media_urls or None)
|
||||
|
||||
return Response(
|
||||
content='<?xml version="1.0"?><Response></Response>',
|
||||
media_type="application/xml",
|
||||
)
|
||||
|
||||
|
||||
@channels.router.post("/webhooks/twilio/voice")
|
||||
async def twilio_voice_webhook(request: Request):
|
||||
"""Inbound voice call webhook from Twilio."""
|
||||
form = await request.form()
|
||||
channel_id = request.query_params.get("channel_id", "")
|
||||
|
||||
if not channel_id:
|
||||
to_number = form.get("To", "")
|
||||
for cfg in channel_orchestrator.configs.values():
|
||||
if cfg.phone_number == to_number and cfg.channel_type == "voice":
|
||||
channel_id = cfg.id
|
||||
break
|
||||
|
||||
call_sid = form.get("CallSid", "")
|
||||
from_number = form.get("From", "")
|
||||
to_number = form.get("To", "")
|
||||
|
||||
twiml = await channel_orchestrator.handle_inbound_call(
|
||||
channel_id, call_sid, from_number, to_number
|
||||
)
|
||||
|
||||
return Response(content=twiml, media_type="application/xml")
|
||||
|
||||
|
||||
@channels.router.post("/webhooks/twilio/voice/gather")
|
||||
async def twilio_voice_gather_webhook(request: Request):
|
||||
"""Speech gathered from a voice call."""
|
||||
form = await request.form()
|
||||
channel_id = request.query_params.get("channel_id", "")
|
||||
call_sid = request.query_params.get("call_sid", "") or form.get("CallSid", "")
|
||||
|
||||
speech_result = form.get("SpeechResult", "")
|
||||
|
||||
if not speech_result:
|
||||
# No speech detected, ask again or hang up
|
||||
config = channel_orchestrator.configs.get(channel_id)
|
||||
if config:
|
||||
adapter = channel_orchestrator.get_adapter(config)
|
||||
voice_cfg = config.voice_config or VoiceConfig()
|
||||
twiml = adapter.generate_twiml_say(
|
||||
"I didn't catch that. Goodbye.", voice=voice_cfg.voice
|
||||
)
|
||||
else:
|
||||
twiml = '<?xml version="1.0"?><Response><Say>Goodbye.</Say><Hangup/></Response>'
|
||||
return Response(content=twiml, media_type="application/xml")
|
||||
|
||||
twiml = await channel_orchestrator.handle_voice_gather(
|
||||
channel_id, call_sid, speech_result
|
||||
)
|
||||
|
||||
return Response(content=twiml, media_type="application/xml")
|
||||
|
||||
|
||||
@channels.router.post("/webhooks/twilio/voice/status")
|
||||
async def twilio_voice_status_webhook(request: Request):
|
||||
"""Call status update from Twilio."""
|
||||
form = await request.form()
|
||||
call_sid = form.get("CallSid", "")
|
||||
status = form.get("CallStatus", "")
|
||||
|
||||
channel_orchestrator.handle_call_status(call_sid, status)
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
# ─── Telnyx Webhook ───────────────────────────────────────────────
|
||||
|
||||
|
||||
@channels.router.post("/webhooks/telnyx")
|
||||
async def telnyx_webhook(request: Request):
|
||||
"""Unified Telnyx webhook for SMS and Voice events."""
|
||||
body = await request.json()
|
||||
event_type = body.get("data", {}).get("event_type", "")
|
||||
payload = body.get("data", {}).get("payload", {})
|
||||
|
||||
channel_id = request.query_params.get("channel_id", "")
|
||||
|
||||
if event_type == "message.received":
|
||||
from_number = payload.get("from", {}).get("phone_number", "")
|
||||
text = payload.get("text", "")
|
||||
await channel_orchestrator.handle_inbound_sms(channel_id, from_number, text)
|
||||
elif event_type in ("call.initiated", "call.answered"):
|
||||
call_sid = payload.get("call_control_id", "")
|
||||
from_number = payload.get("from", "")
|
||||
to_number = payload.get("to", "")
|
||||
# Telnyx voice uses Call Control commands rather than TwiML
|
||||
logger.info("Telnyx call event: %s for %s", event_type, call_sid)
|
||||
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
# ─── Active Calls ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@channels.router.get("/calls/active")
|
||||
async def list_active_calls():
|
||||
return {"calls": channel_orchestrator.call_manager.get_active_calls()}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Audio attachment processing for WhatsApp voice notes and media messages."""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.apps.channels.models import STTConfig
|
||||
from backend.apps.channels import stt_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_FORMATS = {
|
||||
"audio/ogg", "audio/mpeg", "audio/wav", "audio/mp4",
|
||||
"audio/flac", "audio/webm", "audio/x-wav",
|
||||
}
|
||||
MAX_MEDIA_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
async def process_audio_attachment(
|
||||
url: str,
|
||||
content_type: str,
|
||||
stt_config: STTConfig,
|
||||
auth: tuple[str, str] | None = None,
|
||||
) -> Optional[str]:
|
||||
"""Download an audio attachment and return its transcript.
|
||||
|
||||
Args:
|
||||
url: URL to download the audio from.
|
||||
content_type: MIME type of the audio.
|
||||
stt_config: STT configuration for transcription.
|
||||
auth: Optional (username, password) tuple for basic auth (e.g. Twilio).
|
||||
|
||||
Returns:
|
||||
Transcript string, or None on failure.
|
||||
"""
|
||||
if content_type not in SUPPORTED_FORMATS:
|
||||
logger.warning("Unsupported audio format: %s", content_type)
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
kwargs = {"follow_redirects": True}
|
||||
if auth:
|
||||
kwargs["auth"] = auth
|
||||
resp = await client.get(url, **kwargs)
|
||||
resp.raise_for_status()
|
||||
audio_bytes = resp.content
|
||||
except Exception:
|
||||
logger.exception("Failed to download audio from %s", url)
|
||||
return None
|
||||
|
||||
if len(audio_bytes) > MAX_MEDIA_BYTES:
|
||||
logger.warning("Audio attachment exceeds %d bytes", MAX_MEDIA_BYTES)
|
||||
return None
|
||||
|
||||
if len(audio_bytes) < 1024:
|
||||
logger.debug("Audio attachment too small, skipping")
|
||||
return None
|
||||
|
||||
return await stt_service.transcribe(audio_bytes, stt_config, content_type)
|
||||
@@ -0,0 +1,116 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Literal, Any
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
|
||||
class ChannelAgentConfig(BaseModel):
|
||||
mode: str = "agent"
|
||||
model: str = "sonnet"
|
||||
system_prompt: Optional[str] = None
|
||||
max_turns: int = 10
|
||||
allowed_tools: Optional[list[str]] = None
|
||||
|
||||
|
||||
class ChannelSecurityConfig(BaseModel):
|
||||
verify_signatures: bool = True
|
||||
allowlist: list[str] = Field(default_factory=list)
|
||||
blocklist: list[str] = Field(default_factory=list)
|
||||
rate_limit_per_minute: int = 10
|
||||
rate_limit_per_hour: int = 60
|
||||
|
||||
|
||||
class VoiceConfig(BaseModel):
|
||||
mode: Literal["conversation", "notify"] = "conversation"
|
||||
greeting_message: str = "Hello, how can I help you?"
|
||||
silence_timeout_ms: int = 700
|
||||
max_call_duration_seconds: int = 600
|
||||
gather_timeout_seconds: int = 10
|
||||
voice: str = "Polly.Joanna"
|
||||
language: str = "en-US"
|
||||
|
||||
|
||||
class TTSConfig(BaseModel):
|
||||
provider: Literal["twilio_say", "elevenlabs", "openai_tts", "edge_tts"] = "twilio_say"
|
||||
auto_tts_mode: Literal["off", "always", "inbound", "tagged"] = "off"
|
||||
elevenlabs_voice_id: Optional[str] = None
|
||||
elevenlabs_model_id: str = "eleven_v3"
|
||||
openai_voice: str = "alloy"
|
||||
skip_short_text: bool = True
|
||||
summarize_long_replies: bool = True
|
||||
max_tts_chars: int = 4000
|
||||
|
||||
|
||||
class STTConfig(BaseModel):
|
||||
provider: Literal["twilio_builtin", "deepgram", "openai_whisper"] = "twilio_builtin"
|
||||
deepgram_model: str = "nova-3"
|
||||
language: str = "en-US"
|
||||
fallback_chain: list[str] = Field(default_factory=lambda: ["twilio_builtin"])
|
||||
|
||||
|
||||
class ChannelConfig(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
name: str = ""
|
||||
channel_type: Literal["sms", "whatsapp", "voice"] = "sms"
|
||||
provider: Literal["twilio", "telnyx"] = "twilio"
|
||||
enabled: bool = False
|
||||
phone_number: str = ""
|
||||
credentials: dict[str, str] = Field(default_factory=dict)
|
||||
agent_config: ChannelAgentConfig = Field(default_factory=ChannelAgentConfig)
|
||||
security: ChannelSecurityConfig = Field(default_factory=ChannelSecurityConfig)
|
||||
voice_config: Optional[VoiceConfig] = None
|
||||
tts_config: Optional[TTSConfig] = None
|
||||
stt_config: Optional[STTConfig] = None
|
||||
status: Literal["inactive", "active", "error"] = "inactive"
|
||||
status_message: Optional[str] = None
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
last_message_at: Optional[str] = None
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
class ChannelMessage(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
direction: Literal["inbound", "outbound"] = "inbound"
|
||||
content: str = ""
|
||||
media_urls: list[str] = Field(default_factory=list)
|
||||
timestamp: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
channel_type: str = ""
|
||||
provider_message_id: Optional[str] = None
|
||||
|
||||
|
||||
class ChannelConversation(BaseModel):
|
||||
id: str = Field(default_factory=lambda: uuid4().hex)
|
||||
channel_id: str = ""
|
||||
phone_number: str = ""
|
||||
agent_session_id: Optional[str] = None
|
||||
messages: list[ChannelMessage] = Field(default_factory=list)
|
||||
created_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
updated_at: str = Field(default_factory=lambda: datetime.now().isoformat())
|
||||
status: Literal["active", "closed"] = "active"
|
||||
|
||||
|
||||
class ChannelCreate(BaseModel):
|
||||
name: str
|
||||
channel_type: Literal["sms", "whatsapp", "voice"] = "sms"
|
||||
provider: Literal["twilio", "telnyx"] = "twilio"
|
||||
phone_number: str = ""
|
||||
credentials: dict[str, str] = Field(default_factory=dict)
|
||||
agent_config: Optional[ChannelAgentConfig] = None
|
||||
security: Optional[ChannelSecurityConfig] = None
|
||||
voice_config: Optional[VoiceConfig] = None
|
||||
tts_config: Optional[TTSConfig] = None
|
||||
stt_config: Optional[STTConfig] = None
|
||||
|
||||
|
||||
class ChannelUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
channel_type: Optional[Literal["sms", "whatsapp", "voice"]] = None
|
||||
provider: Optional[Literal["twilio", "telnyx"]] = None
|
||||
phone_number: Optional[str] = None
|
||||
credentials: Optional[dict[str, str]] = None
|
||||
agent_config: Optional[ChannelAgentConfig] = None
|
||||
security: Optional[ChannelSecurityConfig] = None
|
||||
voice_config: Optional[VoiceConfig] = None
|
||||
tts_config: Optional[TTSConfig] = None
|
||||
stt_config: Optional[STTConfig] = None
|
||||
@@ -0,0 +1,559 @@
|
||||
"""Channel orchestrator — routes inbound messages/calls to agent sessions.
|
||||
|
||||
This is the central routing layer that bridges telephony events to the
|
||||
existing AgentManager. Each phone number gets its own ChannelConversation
|
||||
which maps to an AgentSession.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from backend.apps.channels.models import (
|
||||
ChannelConfig, ChannelConversation, ChannelMessage,
|
||||
)
|
||||
from backend.apps.channels.call_state import CallManager, CallState
|
||||
from backend.apps.channels.base_adapter import BaseChannelAdapter
|
||||
from backend.apps.channels import ws_events
|
||||
from backend.apps.agents.models import AgentConfig
|
||||
from backend.config.paths import DATA_ROOT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHANNELS_DIR = os.path.join(DATA_ROOT, "channels")
|
||||
CHANNELS_SESSIONS_DIR = os.path.join(DATA_ROOT, "channels", "sessions")
|
||||
|
||||
PLATFORM_MAX_LENGTH = {
|
||||
"sms": 1600,
|
||||
"whatsapp": 4096,
|
||||
"voice": 100000,
|
||||
}
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Simple token-bucket rate limiter per phone number."""
|
||||
|
||||
def __init__(self):
|
||||
self._buckets: dict[str, list[float]] = {}
|
||||
|
||||
def check(self, key: str, per_minute: int, per_hour: int) -> bool:
|
||||
now = time.time()
|
||||
if key not in self._buckets:
|
||||
self._buckets[key] = []
|
||||
|
||||
# Prune old entries
|
||||
self._buckets[key] = [t for t in self._buckets[key] if now - t < 3600]
|
||||
|
||||
recent_minute = sum(1 for t in self._buckets[key] if now - t < 60)
|
||||
recent_hour = len(self._buckets[key])
|
||||
|
||||
if recent_minute >= per_minute or recent_hour >= per_hour:
|
||||
return False
|
||||
|
||||
self._buckets[key].append(now)
|
||||
return True
|
||||
|
||||
|
||||
class ChannelOrchestrator:
|
||||
"""Manages channel configs, conversations, and message routing."""
|
||||
|
||||
def __init__(self):
|
||||
self.configs: dict[str, ChannelConfig] = {}
|
||||
self.conversations: dict[str, ChannelConversation] = {} # key: "{channel_id}:{phone}"
|
||||
self.adapters: dict[str, BaseChannelAdapter] = {}
|
||||
self.call_manager = CallManager()
|
||||
self.rate_limiter = RateLimiter()
|
||||
self._agent_listeners: dict[str, asyncio.Task] = {}
|
||||
|
||||
# ─── Config persistence ───────────────────────────────────────
|
||||
|
||||
def _ensure_dirs(self):
|
||||
os.makedirs(CHANNELS_DIR, exist_ok=True)
|
||||
os.makedirs(CHANNELS_SESSIONS_DIR, exist_ok=True)
|
||||
|
||||
def _config_path(self, channel_id: str) -> str:
|
||||
return os.path.join(CHANNELS_DIR, f"{channel_id}.json")
|
||||
|
||||
def _conv_path(self, channel_id: str) -> str:
|
||||
return os.path.join(CHANNELS_SESSIONS_DIR, f"{channel_id}.json")
|
||||
|
||||
def save_config(self, config: ChannelConfig):
|
||||
self._ensure_dirs()
|
||||
config.updated_at = datetime.now().isoformat()
|
||||
self.configs[config.id] = config
|
||||
with open(self._config_path(config.id), "w") as f:
|
||||
json.dump(config.model_dump(mode="json"), f, indent=2)
|
||||
|
||||
def delete_config(self, channel_id: str):
|
||||
self.configs.pop(channel_id, None)
|
||||
self.adapters.pop(channel_id, None)
|
||||
path = self._config_path(channel_id)
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
|
||||
def load_all_configs(self):
|
||||
self._ensure_dirs()
|
||||
self.configs.clear()
|
||||
for fname in os.listdir(CHANNELS_DIR):
|
||||
if fname.endswith(".json"):
|
||||
try:
|
||||
with open(os.path.join(CHANNELS_DIR, fname)) as f:
|
||||
data = json.load(f)
|
||||
config = ChannelConfig(**data)
|
||||
self.configs[config.id] = config
|
||||
except Exception:
|
||||
logger.exception("Failed to load channel config: %s", fname)
|
||||
|
||||
# ─── Conversation persistence ─────────────────────────────────
|
||||
|
||||
def save_conversations(self, channel_id: str):
|
||||
self._ensure_dirs()
|
||||
convs = [
|
||||
c.model_dump(mode="json")
|
||||
for c in self.conversations.values()
|
||||
if c.channel_id == channel_id
|
||||
]
|
||||
with open(self._conv_path(channel_id), "w") as f:
|
||||
json.dump(convs, f, indent=2)
|
||||
|
||||
def load_all_conversations(self):
|
||||
self._ensure_dirs()
|
||||
self.conversations.clear()
|
||||
for fname in os.listdir(CHANNELS_SESSIONS_DIR):
|
||||
if fname.endswith(".json"):
|
||||
try:
|
||||
with open(os.path.join(CHANNELS_SESSIONS_DIR, fname)) as f:
|
||||
convs = json.load(f)
|
||||
for data in convs:
|
||||
conv = ChannelConversation(**data)
|
||||
key = f"{conv.channel_id}:{conv.phone_number}"
|
||||
self.conversations[key] = conv
|
||||
except Exception:
|
||||
logger.exception("Failed to load conversations: %s", fname)
|
||||
|
||||
# ─── Adapter management ───────────────────────────────────────
|
||||
|
||||
def get_adapter(self, config: ChannelConfig) -> BaseChannelAdapter:
|
||||
if config.id not in self.adapters:
|
||||
self.adapters[config.id] = self._create_adapter(config)
|
||||
return self.adapters[config.id]
|
||||
|
||||
def _create_adapter(self, config: ChannelConfig) -> BaseChannelAdapter:
|
||||
if config.provider == "twilio":
|
||||
from backend.apps.channels.adapters.twilio_adapter import TwilioAdapter
|
||||
return TwilioAdapter(
|
||||
account_sid=config.credentials.get("account_sid", ""),
|
||||
auth_token=config.credentials.get("auth_token", ""),
|
||||
)
|
||||
elif config.provider == "telnyx":
|
||||
from backend.apps.channels.adapters.telnyx_adapter import TelnyxAdapter
|
||||
return TelnyxAdapter(
|
||||
api_key=config.credentials.get("api_key", ""),
|
||||
public_key=config.credentials.get("public_key", ""),
|
||||
)
|
||||
raise ValueError(f"Unknown provider: {config.provider}")
|
||||
|
||||
# ─── Security checks ─────────────────────────────────────────
|
||||
|
||||
def _check_allowlist(self, config: ChannelConfig, phone: str) -> bool:
|
||||
sec = config.security
|
||||
if phone in sec.blocklist:
|
||||
return False
|
||||
if sec.allowlist and phone not in sec.allowlist:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _check_rate_limit(self, config: ChannelConfig, phone: str) -> bool:
|
||||
sec = config.security
|
||||
return self.rate_limiter.check(
|
||||
phone, sec.rate_limit_per_minute, sec.rate_limit_per_hour
|
||||
)
|
||||
|
||||
# ─── Inbound SMS / WhatsApp ───────────────────────────────────
|
||||
|
||||
async def handle_inbound_sms(
|
||||
self,
|
||||
channel_id: str,
|
||||
from_number: str,
|
||||
body: str,
|
||||
media_urls: list[str] | None = None,
|
||||
) -> Optional[str]:
|
||||
"""Handle an inbound SMS or WhatsApp message. Returns agent response or None."""
|
||||
config = self.configs.get(channel_id)
|
||||
if not config or not config.enabled:
|
||||
logger.warning("Channel %s not found or disabled", channel_id)
|
||||
return None
|
||||
|
||||
if not self._check_allowlist(config, from_number):
|
||||
logger.info("Blocked message from %s (not in allowlist)", from_number)
|
||||
return None
|
||||
|
||||
if not self._check_rate_limit(config, from_number):
|
||||
logger.info("Rate limited: %s", from_number)
|
||||
return None
|
||||
|
||||
# Process media attachments (voice notes)
|
||||
if media_urls and config.stt_config:
|
||||
from backend.apps.channels.media_handler import process_audio_attachment
|
||||
for url in media_urls:
|
||||
transcript = await process_audio_attachment(
|
||||
url, "audio/ogg", config.stt_config,
|
||||
auth=(
|
||||
config.credentials.get("account_sid", ""),
|
||||
config.credentials.get("auth_token", ""),
|
||||
) if config.provider == "twilio" else None,
|
||||
)
|
||||
if transcript:
|
||||
body = f"{body}\n\n[Voice Note Transcript]: {transcript}" if body else transcript
|
||||
|
||||
# Get or create conversation
|
||||
conv_key = f"{channel_id}:{from_number}"
|
||||
conv = self.conversations.get(conv_key)
|
||||
if not conv:
|
||||
conv = ChannelConversation(
|
||||
channel_id=channel_id,
|
||||
phone_number=from_number,
|
||||
)
|
||||
self.conversations[conv_key] = conv
|
||||
|
||||
# Record inbound message
|
||||
inbound_msg = ChannelMessage(
|
||||
direction="inbound",
|
||||
content=body,
|
||||
media_urls=media_urls or [],
|
||||
channel_type=config.channel_type,
|
||||
)
|
||||
conv.messages.append(inbound_msg)
|
||||
conv.updated_at = datetime.now().isoformat()
|
||||
|
||||
await ws_events.emit_channel_message(
|
||||
channel_id, conv.id, inbound_msg.model_dump(mode="json")
|
||||
)
|
||||
|
||||
# Launch or reuse agent session
|
||||
agent_response = await self._route_to_agent(config, conv, body)
|
||||
|
||||
if agent_response:
|
||||
# Send response back via SMS/WhatsApp
|
||||
adapter = self.get_adapter(config)
|
||||
max_len = PLATFORM_MAX_LENGTH.get(config.channel_type, 1600)
|
||||
chunks = adapter.chunk_message(agent_response, max_len)
|
||||
|
||||
for chunk in chunks:
|
||||
if config.channel_type == "whatsapp":
|
||||
await adapter.send_whatsapp(from_number, config.phone_number, chunk)
|
||||
else:
|
||||
await adapter.send_sms(from_number, config.phone_number, chunk)
|
||||
|
||||
outbound_msg = ChannelMessage(
|
||||
direction="outbound",
|
||||
content=agent_response,
|
||||
channel_type=config.channel_type,
|
||||
)
|
||||
conv.messages.append(outbound_msg)
|
||||
conv.updated_at = datetime.now().isoformat()
|
||||
config.message_count += 1
|
||||
config.last_message_at = datetime.now().isoformat()
|
||||
|
||||
await ws_events.emit_channel_message(
|
||||
channel_id, conv.id, outbound_msg.model_dump(mode="json")
|
||||
)
|
||||
|
||||
self.save_conversations(channel_id)
|
||||
self.save_config(config)
|
||||
|
||||
return agent_response
|
||||
|
||||
# ─── Inbound Voice ────────────────────────────────────────────
|
||||
|
||||
async def handle_inbound_call(
|
||||
self, channel_id: str, call_sid: str, from_number: str, to_number: str
|
||||
) -> str:
|
||||
"""Handle an inbound voice call. Returns initial TwiML."""
|
||||
config = self.configs.get(channel_id)
|
||||
if not config or not config.enabled:
|
||||
adapter = self._fallback_adapter(config)
|
||||
return adapter.generate_twiml_hangup()
|
||||
|
||||
if not self._check_allowlist(config, from_number):
|
||||
adapter = self.get_adapter(config)
|
||||
return adapter.generate_twiml_say("Sorry, you are not authorized to call this number.")
|
||||
|
||||
voice_cfg = config.voice_config or VoiceConfig()
|
||||
adapter = self.get_adapter(config)
|
||||
|
||||
# Create call state
|
||||
call = self.call_manager.create_call(call_sid, channel_id, from_number, to_number)
|
||||
call.transition("connected")
|
||||
call.transition("gathering")
|
||||
|
||||
await ws_events.emit_call_event(channel_id, call_sid, "call_started", {
|
||||
"from": from_number, "to": to_number,
|
||||
})
|
||||
|
||||
# Return TwiML to greet and gather speech
|
||||
from backend.apps.settings.settings import load_settings
|
||||
settings = load_settings()
|
||||
webhook_base = getattr(settings, "webhook_base_url", "") or ""
|
||||
gather_url = f"{webhook_base}/api/channels/webhooks/twilio/voice/gather?channel_id={channel_id}&call_sid={call_sid}"
|
||||
|
||||
return adapter.generate_twiml_gather(
|
||||
prompt=voice_cfg.greeting_message,
|
||||
action_url=gather_url,
|
||||
voice=voice_cfg.voice,
|
||||
language=voice_cfg.language,
|
||||
timeout=voice_cfg.gather_timeout_seconds,
|
||||
)
|
||||
|
||||
async def handle_voice_gather(
|
||||
self, channel_id: str, call_sid: str, speech_result: str
|
||||
) -> str:
|
||||
"""Handle gathered speech from a voice call. Returns response TwiML."""
|
||||
config = self.configs.get(channel_id)
|
||||
if not config:
|
||||
return '<?xml version="1.0"?><Response><Hangup/></Response>'
|
||||
|
||||
call = self.call_manager.get_call(call_sid)
|
||||
if not call or not call.is_active:
|
||||
adapter = self.get_adapter(config)
|
||||
return adapter.generate_twiml_hangup()
|
||||
|
||||
call.transition("processing")
|
||||
call.add_turn("user", speech_result)
|
||||
|
||||
voice_cfg = config.voice_config or VoiceConfig()
|
||||
adapter = self.get_adapter(config)
|
||||
|
||||
# Route speech to agent
|
||||
conv_key = f"{channel_id}:{call.from_number}"
|
||||
conv = self.conversations.get(conv_key)
|
||||
if not conv:
|
||||
conv = ChannelConversation(
|
||||
channel_id=channel_id,
|
||||
phone_number=call.from_number,
|
||||
)
|
||||
self.conversations[conv_key] = conv
|
||||
|
||||
agent_response = await self._route_to_agent(config, conv, speech_result)
|
||||
|
||||
if not agent_response:
|
||||
agent_response = "I'm sorry, I couldn't process that. Could you try again?"
|
||||
|
||||
call.transition("responding")
|
||||
call.add_turn("assistant", agent_response)
|
||||
|
||||
await ws_events.emit_call_event(channel_id, call_sid, "turn_complete", {
|
||||
"user": speech_result, "assistant": agent_response,
|
||||
})
|
||||
|
||||
# Check if we should continue or end
|
||||
if voice_cfg.mode == "notify":
|
||||
call.transition("completed")
|
||||
return adapter.generate_twiml_say(agent_response, voice=voice_cfg.voice)
|
||||
|
||||
# Conversation mode: say response then gather again
|
||||
from backend.apps.settings.settings import load_settings
|
||||
settings = load_settings()
|
||||
webhook_base = getattr(settings, "webhook_base_url", "") or ""
|
||||
gather_url = f"{webhook_base}/api/channels/webhooks/twilio/voice/gather?channel_id={channel_id}&call_sid={call_sid}"
|
||||
|
||||
call.transition("gathering")
|
||||
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
"<Response>"
|
||||
f'<Say voice="{voice_cfg.voice}">{_escape_xml(agent_response)}</Say>'
|
||||
f'<Gather input="speech" action="{gather_url}" '
|
||||
f'language="{voice_cfg.language}" speechTimeout="{voice_cfg.gather_timeout_seconds}">'
|
||||
"</Gather>"
|
||||
f'<Say voice="{voice_cfg.voice}">Are you still there? Goodbye.</Say>'
|
||||
"</Response>"
|
||||
)
|
||||
|
||||
def handle_call_status(self, call_sid: str, status: str):
|
||||
"""Handle Twilio call status callback."""
|
||||
call = self.call_manager.get_call(call_sid)
|
||||
if not call:
|
||||
return
|
||||
if status in ("completed", "busy", "no-answer", "canceled", "failed"):
|
||||
final = "failed" if status == "failed" else "completed"
|
||||
call.transition(final)
|
||||
asyncio.create_task(
|
||||
ws_events.emit_call_event(call.channel_id, call_sid, "call_ended", {
|
||||
"status": status,
|
||||
})
|
||||
)
|
||||
|
||||
# ─── Agent routing ────────────────────────────────────────────
|
||||
|
||||
async def _route_to_agent(
|
||||
self, config: ChannelConfig, conv: ChannelConversation, text: str
|
||||
) -> Optional[str]:
|
||||
"""Send a message to an agent session and wait for the response."""
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.ws_manager import ws_manager
|
||||
|
||||
# Launch agent if no session exists
|
||||
if not conv.agent_session_id or not agent_manager.get_session(conv.agent_session_id):
|
||||
ac = config.agent_config
|
||||
agent_cfg = AgentConfig(
|
||||
name=f"{config.channel_type}: {conv.phone_number}",
|
||||
model=ac.model,
|
||||
mode=ac.mode,
|
||||
system_prompt=ac.system_prompt,
|
||||
max_turns=ac.max_turns,
|
||||
)
|
||||
if ac.allowed_tools:
|
||||
agent_cfg.allowed_tools = ac.allowed_tools
|
||||
|
||||
session = await agent_manager.launch_agent(agent_cfg)
|
||||
conv.agent_session_id = session.id
|
||||
|
||||
session_id = conv.agent_session_id
|
||||
|
||||
# Set up a future to capture the agent's response
|
||||
response_future: asyncio.Future[str] = asyncio.get_event_loop().create_future()
|
||||
|
||||
async def _on_agent_event(event: str, data: dict):
|
||||
if response_future.done():
|
||||
return
|
||||
if event == "agent:message":
|
||||
msg = data.get("message", {})
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
# Extract text from content blocks
|
||||
parts = [
|
||||
b.get("text", "")
|
||||
for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
]
|
||||
content = "\n".join(parts)
|
||||
if content and not response_future.done():
|
||||
response_future.set_result(content)
|
||||
elif event == "agent:status":
|
||||
status = data.get("status", "")
|
||||
if status in ("completed", "error", "stopped") and not response_future.done():
|
||||
response_future.set_result("")
|
||||
|
||||
# Register listener for this session's events
|
||||
# We tap into ws_manager's send_to_session by monkey-patching temporarily
|
||||
original_send = ws_manager.send_to_session
|
||||
|
||||
async def _hooked_send(sid: str, event: str, data: dict):
|
||||
await original_send(sid, event, data)
|
||||
if sid == session_id:
|
||||
await _on_agent_event(event, data)
|
||||
|
||||
ws_manager.send_to_session = _hooked_send
|
||||
|
||||
try:
|
||||
await agent_manager.send_message(session_id, text)
|
||||
response = await asyncio.wait_for(response_future, timeout=120)
|
||||
return response if response else None
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Agent response timed out for session %s", session_id)
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception("Error routing to agent")
|
||||
return None
|
||||
finally:
|
||||
ws_manager.send_to_session = original_send
|
||||
|
||||
def _fallback_adapter(self, config: Optional[ChannelConfig] = None) -> BaseChannelAdapter:
|
||||
"""Return a minimal adapter for generating hangup TwiML."""
|
||||
from backend.apps.channels.adapters.twilio_adapter import TwilioAdapter
|
||||
return TwilioAdapter("", "")
|
||||
|
||||
# ─── Outbound ─────────────────────────────────────────────────
|
||||
|
||||
async def send_outbound(
|
||||
self, channel_id: str, to_number: str, message: str
|
||||
) -> dict:
|
||||
config = self.configs.get(channel_id)
|
||||
if not config:
|
||||
raise ValueError(f"Channel {channel_id} not found")
|
||||
|
||||
adapter = self.get_adapter(config)
|
||||
max_len = PLATFORM_MAX_LENGTH.get(config.channel_type, 1600)
|
||||
chunks = adapter.chunk_message(message, max_len)
|
||||
results = []
|
||||
|
||||
for chunk in chunks:
|
||||
if config.channel_type == "whatsapp":
|
||||
r = await adapter.send_whatsapp(to_number, config.phone_number, chunk)
|
||||
else:
|
||||
r = await adapter.send_sms(to_number, config.phone_number, chunk)
|
||||
results.append(r)
|
||||
|
||||
# Record outbound
|
||||
conv_key = f"{channel_id}:{to_number}"
|
||||
conv = self.conversations.get(conv_key)
|
||||
if not conv:
|
||||
conv = ChannelConversation(channel_id=channel_id, phone_number=to_number)
|
||||
self.conversations[conv_key] = conv
|
||||
|
||||
conv.messages.append(ChannelMessage(
|
||||
direction="outbound", content=message, channel_type=config.channel_type,
|
||||
))
|
||||
conv.updated_at = datetime.now().isoformat()
|
||||
self.save_conversations(channel_id)
|
||||
|
||||
return {"sent": len(chunks), "results": results}
|
||||
|
||||
async def initiate_outbound_call(
|
||||
self, channel_id: str, to_number: str
|
||||
) -> dict:
|
||||
config = self.configs.get(channel_id)
|
||||
if not config:
|
||||
raise ValueError(f"Channel {channel_id} not found")
|
||||
|
||||
from backend.apps.settings.settings import load_settings
|
||||
settings = load_settings()
|
||||
webhook_base = getattr(settings, "webhook_base_url", "") or ""
|
||||
voice_webhook = f"{webhook_base}/api/channels/webhooks/twilio/voice?channel_id={channel_id}"
|
||||
|
||||
adapter = self.get_adapter(config)
|
||||
result = await adapter.initiate_call(
|
||||
to=to_number,
|
||||
from_=config.phone_number,
|
||||
webhook_url=voice_webhook,
|
||||
)
|
||||
return result
|
||||
|
||||
# ─── Lifecycle ────────────────────────────────────────────────
|
||||
|
||||
async def restore_all(self):
|
||||
self.load_all_configs()
|
||||
self.load_all_conversations()
|
||||
for config in self.configs.values():
|
||||
if config.enabled:
|
||||
try:
|
||||
self.get_adapter(config)
|
||||
config.status = "active"
|
||||
except Exception:
|
||||
config.status = "error"
|
||||
config.status_message = "Failed to initialize adapter"
|
||||
|
||||
async def persist_all(self):
|
||||
for config in self.configs.values():
|
||||
self.save_config(config)
|
||||
self.save_conversations(config.id)
|
||||
|
||||
|
||||
def _escape_xml(text: str) -> str:
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
.replace("'", "'")
|
||||
)
|
||||
|
||||
|
||||
# Singleton
|
||||
from backend.apps.channels.models import VoiceConfig # noqa: E402
|
||||
channel_orchestrator = ChannelOrchestrator()
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Provider-abstracted Speech-to-Text service.
|
||||
|
||||
Supports: Twilio built-in (via Gather), Deepgram Nova-3, OpenAI Whisper.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.apps.channels.models import STTConfig
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SUPPORTED_AUDIO_FORMATS = {
|
||||
"audio/ogg", "audio/mpeg", "audio/wav", "audio/mp4",
|
||||
"audio/flac", "audio/webm", "audio/x-wav",
|
||||
}
|
||||
MAX_MEDIA_BYTES = 20 * 1024 * 1024 # 20 MB
|
||||
|
||||
|
||||
async def transcribe(
|
||||
audio_bytes: bytes,
|
||||
config: STTConfig,
|
||||
content_type: str = "audio/wav",
|
||||
) -> Optional[str]:
|
||||
"""Transcribe audio bytes to text using the configured provider chain."""
|
||||
if len(audio_bytes) > MAX_MEDIA_BYTES:
|
||||
logger.warning("Audio exceeds %d bytes limit", MAX_MEDIA_BYTES)
|
||||
return None
|
||||
if len(audio_bytes) < 1024:
|
||||
logger.debug("Audio too short, skipping")
|
||||
return None
|
||||
|
||||
providers = config.fallback_chain or [config.provider]
|
||||
|
||||
for provider in providers:
|
||||
try:
|
||||
if provider == "twilio_builtin":
|
||||
# Twilio STT is handled inline by <Gather> — no bytes to process
|
||||
continue
|
||||
elif provider == "deepgram":
|
||||
result = await _deepgram_transcribe(audio_bytes, config, content_type)
|
||||
elif provider == "openai_whisper":
|
||||
result = await _openai_transcribe(audio_bytes, config, content_type)
|
||||
else:
|
||||
logger.warning("Unknown STT provider: %s", provider)
|
||||
continue
|
||||
|
||||
if result:
|
||||
return result
|
||||
except Exception:
|
||||
logger.exception("STT provider %s failed, trying next", provider)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def transcribe_from_url(
|
||||
url: str, config: STTConfig, content_type: str = "audio/ogg"
|
||||
) -> Optional[str]:
|
||||
"""Download audio from URL and transcribe."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.get(url, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return await transcribe(resp.content, config, content_type)
|
||||
except Exception:
|
||||
logger.exception("Failed to download audio from %s", url)
|
||||
return None
|
||||
|
||||
|
||||
async def _deepgram_transcribe(
|
||||
audio_bytes: bytes, config: STTConfig, content_type: str
|
||||
) -> Optional[str]:
|
||||
settings = load_settings()
|
||||
api_key = settings.deepgram_api_key if hasattr(settings, "deepgram_api_key") else None
|
||||
if not api_key:
|
||||
logger.warning("Deepgram API key not configured")
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"https://api.deepgram.com/v1/listen",
|
||||
headers={
|
||||
"Authorization": f"Token {api_key}",
|
||||
"Content-Type": content_type,
|
||||
},
|
||||
params={
|
||||
"model": config.deepgram_model,
|
||||
"language": config.language,
|
||||
"smart_format": "true",
|
||||
"punctuate": "true",
|
||||
},
|
||||
content=audio_bytes,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return (
|
||||
data.get("results", {})
|
||||
.get("channels", [{}])[0]
|
||||
.get("alternatives", [{}])[0]
|
||||
.get("transcript", "")
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Deepgram transcription failed")
|
||||
return None
|
||||
|
||||
|
||||
async def _openai_transcribe(
|
||||
audio_bytes: bytes, config: STTConfig, content_type: str
|
||||
) -> Optional[str]:
|
||||
settings = load_settings()
|
||||
api_key = settings.openai_api_key if hasattr(settings, "openai_api_key") else None
|
||||
if not api_key:
|
||||
logger.warning("OpenAI API key not configured")
|
||||
return None
|
||||
|
||||
ext_map = {
|
||||
"audio/ogg": "ogg",
|
||||
"audio/mpeg": "mp3",
|
||||
"audio/wav": "wav",
|
||||
"audio/x-wav": "wav",
|
||||
"audio/mp4": "m4a",
|
||||
"audio/flac": "flac",
|
||||
"audio/webm": "webm",
|
||||
}
|
||||
ext = ext_map.get(content_type, "wav")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"https://api.openai.com/v1/audio/transcriptions",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
files={"file": (f"audio.{ext}", audio_bytes, content_type)},
|
||||
data={"model": "whisper-1", "language": config.language[:2]},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("text", "")
|
||||
except Exception:
|
||||
logger.exception("OpenAI Whisper transcription failed")
|
||||
return None
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Browser-based Talk Mode — continuous voice conversation via WebSocket.
|
||||
|
||||
Pipeline: Mic → WebSocket → STT → Agent → TTS → WebSocket → Speaker
|
||||
|
||||
This runs as a separate WebSocket endpoint /ws/talk/{session_id} that
|
||||
streams audio bidirectionally between the browser and the STT/TTS services.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
|
||||
from backend.apps.channels import stt_service, tts_service
|
||||
from backend.apps.channels.models import STTConfig, TTSConfig
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.ws_manager import ws_manager
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default configs for talk mode
|
||||
DEFAULT_STT = STTConfig(
|
||||
provider="openai_whisper",
|
||||
fallback_chain=["openai_whisper", "deepgram"],
|
||||
)
|
||||
DEFAULT_TTS = TTSConfig(
|
||||
provider="elevenlabs",
|
||||
skip_short_text=False,
|
||||
)
|
||||
|
||||
|
||||
async def handle_talk_session(websocket: WebSocket, session_id: str):
|
||||
"""Handle a talk-mode WebSocket connection.
|
||||
|
||||
Protocol:
|
||||
- Client sends: {"type": "audio", "data": "<base64 audio>", "format": "webm"}
|
||||
- Client sends: {"type": "config", "stt": {...}, "tts": {...}}
|
||||
- Client sends: {"type": "end_utterance"} when silence detected
|
||||
- Server sends: {"type": "transcript", "text": "..."}
|
||||
- Server sends: {"type": "audio", "data": "<base64 audio>", "format": "mp3"}
|
||||
- Server sends: {"type": "agent_response", "text": "..."}
|
||||
- Server sends: {"type": "status", "status": "listening|processing|speaking"}
|
||||
"""
|
||||
await websocket.accept()
|
||||
|
||||
stt_config = DEFAULT_STT
|
||||
tts_config = DEFAULT_TTS
|
||||
audio_buffer = bytearray()
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
msg = json.loads(data)
|
||||
msg_type = msg.get("type", "")
|
||||
|
||||
if msg_type == "config":
|
||||
if msg.get("stt"):
|
||||
stt_config = STTConfig(**msg["stt"])
|
||||
if msg.get("tts"):
|
||||
tts_config = TTSConfig(**msg["tts"])
|
||||
await websocket.send_text(json.dumps({"type": "status", "status": "listening"}))
|
||||
|
||||
elif msg_type == "audio":
|
||||
import base64
|
||||
chunk = base64.b64decode(msg.get("data", ""))
|
||||
audio_buffer.extend(chunk)
|
||||
|
||||
elif msg_type == "end_utterance":
|
||||
if not audio_buffer:
|
||||
continue
|
||||
|
||||
await websocket.send_text(json.dumps({"type": "status", "status": "processing"}))
|
||||
|
||||
audio_bytes = bytes(audio_buffer)
|
||||
audio_buffer.clear()
|
||||
|
||||
audio_format = msg.get("format", "webm")
|
||||
content_type = f"audio/{audio_format}"
|
||||
|
||||
# STT
|
||||
transcript = await stt_service.transcribe(
|
||||
audio_bytes, stt_config, content_type
|
||||
)
|
||||
|
||||
if not transcript:
|
||||
await websocket.send_text(json.dumps({"type": "status", "status": "listening"}))
|
||||
continue
|
||||
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "transcript", "text": transcript,
|
||||
}))
|
||||
|
||||
# Route to agent
|
||||
agent_response = await _get_agent_response(session_id, transcript)
|
||||
|
||||
if agent_response:
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "agent_response", "text": agent_response,
|
||||
}))
|
||||
|
||||
# TTS
|
||||
await websocket.send_text(json.dumps({"type": "status", "status": "speaking"}))
|
||||
|
||||
audio = await tts_service.synthesize(agent_response, tts_config)
|
||||
if audio:
|
||||
import base64 as b64
|
||||
await websocket.send_text(json.dumps({
|
||||
"type": "audio",
|
||||
"data": b64.b64encode(audio).decode(),
|
||||
"format": "mp3",
|
||||
}))
|
||||
|
||||
await websocket.send_text(json.dumps({"type": "status", "status": "listening"}))
|
||||
|
||||
elif msg_type == "stop":
|
||||
break
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.info("Talk mode disconnected for session %s", session_id)
|
||||
except Exception:
|
||||
logger.exception("Talk mode error for session %s", session_id)
|
||||
|
||||
|
||||
async def _get_agent_response(session_id: str, text: str) -> Optional[str]:
|
||||
"""Send text to agent and wait for response."""
|
||||
session = agent_manager.get_session(session_id)
|
||||
if not session:
|
||||
return None
|
||||
|
||||
response_future: asyncio.Future[str] = asyncio.get_event_loop().create_future()
|
||||
|
||||
original_send = ws_manager.send_to_session
|
||||
|
||||
async def _hooked_send(sid: str, event: str, data: dict):
|
||||
await original_send(sid, event, data)
|
||||
if sid == session_id and not response_future.done():
|
||||
if event == "agent:message":
|
||||
msg = data.get("message", {})
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, list):
|
||||
parts = [
|
||||
b.get("text", "")
|
||||
for b in content
|
||||
if isinstance(b, dict) and b.get("type") == "text"
|
||||
]
|
||||
content = "\n".join(parts)
|
||||
if content:
|
||||
response_future.set_result(content)
|
||||
elif event == "agent:status":
|
||||
if data.get("status") in ("completed", "error", "stopped"):
|
||||
response_future.set_result("")
|
||||
|
||||
ws_manager.send_to_session = _hooked_send
|
||||
try:
|
||||
await agent_manager.send_message(session_id, text)
|
||||
return await asyncio.wait_for(response_future, timeout=120) or None
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
finally:
|
||||
ws_manager.send_to_session = original_send
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Provider-abstracted Text-to-Speech service.
|
||||
|
||||
Supports: Twilio built-in Say, ElevenLabs, OpenAI TTS, Microsoft Edge TTS.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.apps.channels.models import TTSConfig
|
||||
from backend.apps.settings.settings import load_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def synthesize(text: str, config: TTSConfig) -> Optional[bytes]:
|
||||
"""Convert text to audio bytes. Returns None for twilio_say (handled in TwiML)."""
|
||||
if should_skip(text, config):
|
||||
return None
|
||||
|
||||
if len(text) > config.max_tts_chars and config.summarize_long_replies:
|
||||
text = text[: config.max_tts_chars]
|
||||
|
||||
provider = config.provider
|
||||
if provider == "twilio_say":
|
||||
# Twilio renders speech inline via <Say> — no audio bytes needed
|
||||
return None
|
||||
elif provider == "elevenlabs":
|
||||
return await _elevenlabs_synthesize(text, config)
|
||||
elif provider == "openai_tts":
|
||||
return await _openai_synthesize(text, config)
|
||||
elif provider == "edge_tts":
|
||||
return await _edge_synthesize(text, config)
|
||||
|
||||
logger.warning("Unknown TTS provider: %s", provider)
|
||||
return None
|
||||
|
||||
|
||||
def should_skip(text: str, config: TTSConfig) -> bool:
|
||||
if config.skip_short_text and len(text.strip()) < 20:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _elevenlabs_synthesize(text: str, config: TTSConfig) -> Optional[bytes]:
|
||||
settings = load_settings()
|
||||
api_key = settings.elevenlabs_api_key if hasattr(settings, "elevenlabs_api_key") else None
|
||||
if not api_key:
|
||||
logger.warning("ElevenLabs API key not configured, falling back to edge_tts")
|
||||
return await _edge_synthesize(text, config)
|
||||
|
||||
voice_id = config.elevenlabs_voice_id or "21m00Tcm4TlvDq8ikWAM" # Rachel default
|
||||
model_id = config.elevenlabs_model_id
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
|
||||
headers={
|
||||
"xi-api-key": api_key,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "audio/mpeg",
|
||||
},
|
||||
json={
|
||||
"text": text,
|
||||
"model_id": model_id,
|
||||
"voice_settings": {
|
||||
"stability": 0.5,
|
||||
"similarity_boost": 0.75,
|
||||
},
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except Exception:
|
||||
logger.exception("ElevenLabs TTS failed, falling back to edge_tts")
|
||||
return await _edge_synthesize(text, config)
|
||||
|
||||
|
||||
async def _openai_synthesize(text: str, config: TTSConfig) -> Optional[bytes]:
|
||||
settings = load_settings()
|
||||
api_key = settings.openai_api_key if hasattr(settings, "openai_api_key") else None
|
||||
if not api_key:
|
||||
logger.warning("OpenAI API key not configured, falling back to edge_tts")
|
||||
return await _edge_synthesize(text, config)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
resp = await client.post(
|
||||
"https://api.openai.com/v1/audio/speech",
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "tts-1",
|
||||
"input": text,
|
||||
"voice": config.openai_voice,
|
||||
"response_format": "mp3",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.content
|
||||
except Exception:
|
||||
logger.exception("OpenAI TTS failed, falling back to edge_tts")
|
||||
return await _edge_synthesize(text, config)
|
||||
|
||||
|
||||
async def _edge_synthesize(text: str, config: TTSConfig) -> Optional[bytes]:
|
||||
"""Free fallback TTS via Microsoft Edge neural voices. No API key needed."""
|
||||
try:
|
||||
import edge_tts
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
communicate = edge_tts.Communicate(text, "en-US-JennyNeural")
|
||||
with tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) as f:
|
||||
tmp_path = f.name
|
||||
|
||||
await communicate.save(tmp_path)
|
||||
with open(tmp_path, "rb") as f:
|
||||
audio = f.read()
|
||||
os.unlink(tmp_path)
|
||||
return audio
|
||||
except Exception:
|
||||
logger.exception("Edge TTS failed")
|
||||
return None
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Voice Wake Word Detection — scaffolded interface.
|
||||
|
||||
Matches OpenClaw's current state: the interface is defined but full
|
||||
implementation is deferred. Supports future integration with Vosk
|
||||
(offline) or Porcupine wake word engines.
|
||||
|
||||
Usage:
|
||||
This module defines the configuration and interface. Actual wake word
|
||||
detection runs on the client device (macOS/iOS/Android) and sends
|
||||
a "wake" event to the gateway when triggered.
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WakeWordConfig(BaseModel):
|
||||
"""Configuration for wake word detection."""
|
||||
enabled: bool = False
|
||||
wake_words: list[str] = Field(default_factory=lambda: ["hey swarm", "open swarm"])
|
||||
sensitivity: float = 0.5 # 0.0 - 1.0
|
||||
engine: str = "vosk" # "vosk" | "porcupine"
|
||||
|
||||
|
||||
class WakeWordManager:
|
||||
"""Manages wake word detection state.
|
||||
|
||||
In the current scaffolded implementation, this stores configuration
|
||||
and handles wake events from client devices. The actual audio
|
||||
processing runs on the client side.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.config = WakeWordConfig()
|
||||
self._active_devices: dict[str, bool] = {}
|
||||
|
||||
def update_config(self, **kwargs):
|
||||
for k, v in kwargs.items():
|
||||
if hasattr(self.config, k):
|
||||
setattr(self.config, k, v)
|
||||
|
||||
def register_device(self, device_id: str):
|
||||
self._active_devices[device_id] = True
|
||||
logger.info("Wake word device registered: %s", device_id)
|
||||
|
||||
def unregister_device(self, device_id: str):
|
||||
self._active_devices.pop(device_id, None)
|
||||
|
||||
def handle_wake_event(self, device_id: str, wake_word: str) -> bool:
|
||||
"""Called when a client device detects a wake word.
|
||||
|
||||
Returns True if the wake event should trigger a talk session.
|
||||
"""
|
||||
if not self.config.enabled:
|
||||
return False
|
||||
if device_id not in self._active_devices:
|
||||
return False
|
||||
if wake_word.lower() not in [w.lower() for w in self.config.wake_words]:
|
||||
return False
|
||||
|
||||
logger.info("Wake word detected: '%s' from device %s", wake_word, device_id)
|
||||
return True
|
||||
|
||||
|
||||
# Singleton
|
||||
wake_word_manager = WakeWordManager()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""WebSocket event emitters for channel events.
|
||||
|
||||
Uses the existing ws_manager.broadcast_global() — no new WebSocket
|
||||
infrastructure needed.
|
||||
"""
|
||||
import logging
|
||||
from backend.apps.agents.ws_manager import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def emit_channel_message(
|
||||
channel_id: str, conversation_id: str, message: dict
|
||||
):
|
||||
await ws_manager.broadcast_global("channel:message", {
|
||||
"channel_id": channel_id,
|
||||
"conversation_id": conversation_id,
|
||||
"message": message,
|
||||
})
|
||||
|
||||
|
||||
async def emit_channel_status(
|
||||
channel_id: str, status: str, detail: str = ""
|
||||
):
|
||||
await ws_manager.broadcast_global("channel:status", {
|
||||
"channel_id": channel_id,
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
})
|
||||
|
||||
|
||||
async def emit_call_event(
|
||||
channel_id: str, call_sid: str, event: str, data: dict | None = None
|
||||
):
|
||||
await ws_manager.broadcast_global("channel:call_event", {
|
||||
"channel_id": channel_id,
|
||||
"call_sid": call_sid,
|
||||
"event": event,
|
||||
**(data or {}),
|
||||
})
|
||||
@@ -21,3 +21,11 @@ class AppSettings(BaseModel):
|
||||
new_agent_shortcut: str = "Meta+l"
|
||||
anthropic_api_key: Optional[str] = None
|
||||
browser_homepage: str = "https://www.google.com"
|
||||
# Telephony / Channel credentials
|
||||
twilio_account_sid: Optional[str] = None
|
||||
twilio_auth_token: Optional[str] = None
|
||||
telnyx_api_key: Optional[str] = None
|
||||
elevenlabs_api_key: Optional[str] = None
|
||||
deepgram_api_key: Optional[str] = None
|
||||
openai_api_key: Optional[str] = None
|
||||
webhook_base_url: Optional[str] = None
|
||||
|
||||
@@ -35,5 +35,7 @@ OUTPUTS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "outputs_workspace")
|
||||
SKILLS_WORKSPACE_DIR = os.path.join(DATA_ROOT, "skills_workspace")
|
||||
DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
|
||||
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
|
||||
CHANNELS_DIR = os.path.join(DATA_ROOT, "channels")
|
||||
CHANNELS_SESSIONS_DIR = os.path.join(DATA_ROOT, "channels", "sessions")
|
||||
|
||||
BACKEND_DIR = _BACKEND_DIR
|
||||
|
||||
+8
-1
@@ -16,11 +16,12 @@ from backend.apps.mcp_registry.mcp_registry import mcp_registry
|
||||
from backend.apps.skill_registry.skill_registry import skill_registry
|
||||
from backend.apps.outputs.outputs import outputs
|
||||
from backend.apps.dashboards.dashboards import dashboards
|
||||
from backend.apps.channels.channels import channels
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi import WebSocket, WebSocketDisconnect
|
||||
import json
|
||||
|
||||
main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards])
|
||||
main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, channels])
|
||||
app = main_app.app
|
||||
|
||||
app.add_middleware(
|
||||
@@ -96,6 +97,12 @@ async def websocket_dashboard(websocket: WebSocket):
|
||||
ws_manager.disconnect_global(websocket)
|
||||
|
||||
|
||||
@app.websocket("/ws/talk/{session_id}")
|
||||
async def websocket_talk_mode(websocket: WebSocket, session_id: str):
|
||||
from backend.apps.channels.talk_mode import handle_talk_session
|
||||
await handle_talk_session(websocket, session_id)
|
||||
|
||||
|
||||
@app.post("/api/browser/command")
|
||||
async def browser_command(request: Request):
|
||||
"""HTTP endpoint called by the browser MCP server subprocess.
|
||||
|
||||
@@ -9,4 +9,10 @@ pytest==8.3.4
|
||||
pytest-asyncio==0.25.2
|
||||
typeguard==4.4.2
|
||||
python-dotenv==1.1.1
|
||||
Pillow
|
||||
Pillow
|
||||
# Channels: SMS, WhatsApp, Voice
|
||||
twilio>=9.0.0
|
||||
telnyx>=2.0.0
|
||||
edge-tts>=6.1.0
|
||||
httpx>=0.27.0
|
||||
cryptography>=42.0.0
|
||||
@@ -22,6 +22,7 @@ import Tools from './pages/Tools/Tools';
|
||||
import Modes from './pages/Modes/Modes';
|
||||
import Views from './pages/Views/Views';
|
||||
import Customization from './pages/Customization/Customization';
|
||||
import Channels from './pages/Channels/Channels';
|
||||
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
|
||||
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
|
||||
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
@@ -207,6 +208,7 @@ const ThemedApp: React.FC = () => {
|
||||
<Route path="/modes" element={<Modes />} />
|
||||
<Route path="/apps" element={<Views />} />
|
||||
<Route path="/apps/:id" element={<Views />} />
|
||||
<Route path="/channels" element={<Channels />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</UpdateListener>
|
||||
|
||||
@@ -23,6 +23,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import ExtensionIcon from '@mui/icons-material/Extension';
|
||||
import PhoneIcon from '@mui/icons-material/Phone';
|
||||
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
|
||||
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
|
||||
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
|
||||
@@ -129,6 +130,7 @@ const AppShell: React.FC = () => {
|
||||
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard/');
|
||||
const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/');
|
||||
const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname);
|
||||
const isChannelsRoute = location.pathname === '/channels';
|
||||
const activeDashboardId = location.pathname.startsWith('/dashboard/')
|
||||
? location.pathname.split('/dashboard/')[1]
|
||||
: null;
|
||||
@@ -647,6 +649,38 @@ const AppShell: React.FC = () => {
|
||||
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<Box sx={{ mx: 1.5, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
|
||||
|
||||
{/* Channels section */}
|
||||
<Box sx={{ px: 1, mb: 0.25 }}>
|
||||
<ListItemButton
|
||||
onClick={() => navigate('/channels')}
|
||||
sx={{
|
||||
borderRadius: 1.5,
|
||||
py: 0.6,
|
||||
px: 1.25,
|
||||
bgcolor: isChannelsRoute ? `${c.accent.primary}12` : 'transparent',
|
||||
'&:hover': { bgcolor: isChannelsRoute ? `${c.accent.primary}18` : `${c.text.tertiary}0A` },
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ color: isChannelsRoute ? c.accent.primary : c.text.tertiary, minWidth: 32 }}>
|
||||
<PhoneIcon sx={{ fontSize: 20 }} />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary="Channels"
|
||||
sx={{
|
||||
'& .MuiListItemText-primary': {
|
||||
color: isChannelsRoute ? c.text.primary : c.text.muted,
|
||||
fontSize: '0.82rem',
|
||||
fontWeight: isChannelsRoute ? 600 : 400,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
|
||||
{/* Settings */}
|
||||
<Box
|
||||
sx={{
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import Button from '@mui/material/Button';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Select from '@mui/material/Select';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
import Tabs from '@mui/material/Tabs';
|
||||
import Tab from '@mui/material/Tab';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import PhoneIcon from '@mui/icons-material/Phone';
|
||||
import SmsIcon from '@mui/icons-material/Sms';
|
||||
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
import StopIcon from '@mui/icons-material/Stop';
|
||||
import SendIcon from '@mui/icons-material/Send';
|
||||
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
|
||||
import {
|
||||
fetchChannels,
|
||||
createChannel,
|
||||
updateChannel,
|
||||
deleteChannel,
|
||||
enableChannel,
|
||||
disableChannel,
|
||||
testChannel,
|
||||
fetchConversations,
|
||||
ChannelConfig,
|
||||
} from '@/shared/state/channelsSlice';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
|
||||
const CHANNEL_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
sms: <SmsIcon />,
|
||||
whatsapp: <WhatsAppIcon />,
|
||||
voice: <PhoneIcon />,
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: '#4caf50',
|
||||
inactive: '#9e9e9e',
|
||||
error: '#f44336',
|
||||
};
|
||||
|
||||
const Channels: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
const channels = useAppSelector((s) => s.channels.items);
|
||||
const conversations = useAppSelector((s) => s.channels.conversations);
|
||||
const channelList = Object.values(channels).sort(
|
||||
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
|
||||
);
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
// Create form
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newType, setNewType] = useState<'sms' | 'whatsapp' | 'voice'>('sms');
|
||||
const [newProvider, setNewProvider] = useState<'twilio' | 'telnyx'>('twilio');
|
||||
const [newPhone, setNewPhone] = useState('');
|
||||
const [newAccountSid, setNewAccountSid] = useState('');
|
||||
const [newAuthToken, setNewAuthToken] = useState('');
|
||||
|
||||
// Test
|
||||
const [testNumber, setTestNumber] = useState('');
|
||||
const [testResult, setTestResult] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchChannels());
|
||||
}, [dispatch]);
|
||||
|
||||
const selected = selectedId ? channels[selectedId] : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) dispatch(fetchConversations(selectedId));
|
||||
}, [selectedId, dispatch]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const result = await dispatch(
|
||||
createChannel({
|
||||
name: newName,
|
||||
channel_type: newType,
|
||||
provider: newProvider,
|
||||
phone_number: newPhone,
|
||||
credentials: {
|
||||
account_sid: newAccountSid,
|
||||
auth_token: newAuthToken,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (createChannel.fulfilled.match(result)) {
|
||||
setSelectedId(result.payload.id);
|
||||
setCreateOpen(false);
|
||||
setNewName('');
|
||||
setNewPhone('');
|
||||
setNewAccountSid('');
|
||||
setNewAuthToken('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await dispatch(deleteChannel(id));
|
||||
if (selectedId === id) setSelectedId(null);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!selectedId || !testNumber) return;
|
||||
try {
|
||||
const result = await dispatch(testChannel({ id: selectedId, to_number: testNumber }));
|
||||
if (testChannel.fulfilled.match(result)) {
|
||||
setTestResult('Test sent successfully!');
|
||||
} else {
|
||||
setTestResult('Test failed');
|
||||
}
|
||||
} catch {
|
||||
setTestResult('Test failed');
|
||||
}
|
||||
};
|
||||
|
||||
const convList = Object.values(conversations).filter(
|
||||
(cv) => cv.channel_id === selectedId,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Left: Channel list */}
|
||||
<Box
|
||||
sx={{
|
||||
width: 320,
|
||||
flexShrink: 0,
|
||||
borderRight: `1px solid ${c.border.subtle}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: c.bg.secondary,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ p: 2, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography sx={{ fontSize: '1rem', fontWeight: 600, color: c.text.primary }}>
|
||||
Channels
|
||||
</Typography>
|
||||
<Tooltip title="New channel">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
sx={{ color: c.accent.primary }}
|
||||
>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 1.5, pb: 1 }}>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, lineHeight: 1.4 }}>
|
||||
Connect SMS, WhatsApp, or voice calls to your agents.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ flex: 1, overflow: 'auto', px: 1 }}>
|
||||
{channelList.length === 0 && (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem' }}>
|
||||
No channels configured yet
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{channelList.map((ch) => (
|
||||
<Box
|
||||
key={ch.id}
|
||||
onClick={() => setSelectedId(ch.id)}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
mb: 0.5,
|
||||
borderRadius: 2,
|
||||
cursor: 'pointer',
|
||||
bgcolor: selectedId === ch.id ? `${c.accent.primary}14` : 'transparent',
|
||||
border: selectedId === ch.id ? `1px solid ${c.accent.primary}40` : '1px solid transparent',
|
||||
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box sx={{ color: c.text.muted }}>{CHANNEL_TYPE_ICONS[ch.channel_type]}</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography
|
||||
sx={{
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
color: c.text.primary,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{ch.name}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
|
||||
{ch.phone_number} · {ch.provider}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
bgcolor: STATUS_COLORS[ch.status] || '#9e9e9e',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Right: Detail panel */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 3 }}>
|
||||
{!selected ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Typography sx={{ color: c.text.muted }}>Select a channel or create a new one</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<Box sx={{ color: c.text.muted, fontSize: 28 }}>{CHANNEL_TYPE_ICONS[selected.channel_type]}</Box>
|
||||
<Box sx={{ flex: 1 }}>
|
||||
<Typography sx={{ fontSize: '1.2rem', fontWeight: 600, color: c.text.primary }}>
|
||||
{selected.name}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 0.5 }}>
|
||||
<Chip
|
||||
label={selected.channel_type.toUpperCase()}
|
||||
size="small"
|
||||
sx={{ fontSize: '0.7rem', height: 22 }}
|
||||
/>
|
||||
<Chip
|
||||
label={selected.status}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '0.7rem',
|
||||
height: 22,
|
||||
bgcolor: `${STATUS_COLORS[selected.status]}20`,
|
||||
color: STATUS_COLORS[selected.status],
|
||||
}}
|
||||
/>
|
||||
<Chip label={`${selected.message_count} messages`} size="small" sx={{ fontSize: '0.7rem', height: 22 }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{selected.enabled ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={() => dispatch(disableChannel(selected.id))}
|
||||
color="error"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
startIcon={<PlayArrowIcon />}
|
||||
onClick={() => dispatch(enableChannel(selected.id))}
|
||||
sx={{ bgcolor: c.accent.primary }}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
<IconButton size="small" onClick={() => handleDelete(selected.id)} sx={{ color: c.status.error }}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2, borderBottom: `1px solid ${c.border.subtle}` }}>
|
||||
<Tab label="Connection" sx={{ textTransform: 'none' }} />
|
||||
<Tab label="Agent" sx={{ textTransform: 'none' }} />
|
||||
<Tab label="Security" sx={{ textTransform: 'none' }} />
|
||||
{selected.channel_type === 'voice' && <Tab label="Voice" sx={{ textTransform: 'none' }} />}
|
||||
<Tab label="Conversations" sx={{ textTransform: 'none' }} />
|
||||
<Tab label="Test" sx={{ textTransform: 'none' }} />
|
||||
</Tabs>
|
||||
|
||||
{/* Connection Tab */}
|
||||
{tab === 0 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 500 }}>
|
||||
<TextField
|
||||
label="Phone Number"
|
||||
value={selected.phone_number}
|
||||
size="small"
|
||||
onChange={(e) =>
|
||||
dispatch(updateChannel({ id: selected.id, phone_number: e.target.value }))
|
||||
}
|
||||
/>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select
|
||||
value={selected.provider}
|
||||
label="Provider"
|
||||
onChange={(e) =>
|
||||
dispatch(updateChannel({ id: selected.id, provider: e.target.value as any }))
|
||||
}
|
||||
>
|
||||
<MenuItem value="twilio">Twilio</MenuItem>
|
||||
<MenuItem value="telnyx">Telnyx</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Account SID / API Key"
|
||||
value={selected.credentials.account_sid || selected.credentials.api_key || ''}
|
||||
size="small"
|
||||
type="password"
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
credentials: {
|
||||
...selected.credentials,
|
||||
[selected.provider === 'twilio' ? 'account_sid' : 'api_key']: e.target.value,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Auth Token / Public Key"
|
||||
value={selected.credentials.auth_token || selected.credentials.public_key || ''}
|
||||
size="small"
|
||||
type="password"
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
credentials: {
|
||||
...selected.credentials,
|
||||
[selected.provider === 'twilio' ? 'auth_token' : 'public_key']: e.target.value,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Typography sx={{ fontSize: '0.75rem', color: c.text.muted, mt: 1 }}>
|
||||
Webhook URL for Twilio: <code>{window.location.origin.replace(/:\d+$/, ':8324')}/api/channels/webhooks/twilio/{selected.channel_type}?channel_id={selected.id}</code>
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Agent Tab */}
|
||||
{tab === 1 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 500 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Mode</InputLabel>
|
||||
<Select
|
||||
value={selected.agent_config.mode}
|
||||
label="Mode"
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
agent_config: { ...selected.agent_config, mode: e.target.value },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="agent">Agent</MenuItem>
|
||||
<MenuItem value="ask">Ask</MenuItem>
|
||||
<MenuItem value="plan">Plan</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Model</InputLabel>
|
||||
<Select
|
||||
value={selected.agent_config.model}
|
||||
label="Model"
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
agent_config: { ...selected.agent_config, model: e.target.value },
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="haiku">Haiku</MenuItem>
|
||||
<MenuItem value="sonnet">Sonnet</MenuItem>
|
||||
<MenuItem value="opus">Opus</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="System Prompt (optional)"
|
||||
value={selected.agent_config.system_prompt || ''}
|
||||
size="small"
|
||||
multiline
|
||||
rows={4}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
agent_config: { ...selected.agent_config, system_prompt: e.target.value || undefined },
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Security Tab */}
|
||||
{tab === 2 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 500 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={selected.security.verify_signatures}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
security: { ...selected.security, verify_signatures: e.target.checked },
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label="Verify webhook signatures"
|
||||
/>
|
||||
<TextField
|
||||
label="Allowlist (one phone per line)"
|
||||
value={selected.security.allowlist.join('\n')}
|
||||
size="small"
|
||||
multiline
|
||||
rows={4}
|
||||
placeholder="+1234567890"
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
security: {
|
||||
...selected.security,
|
||||
allowlist: e.target.value.split('\n').filter(Boolean),
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Rate limit (per minute)"
|
||||
value={selected.security.rate_limit_per_minute}
|
||||
size="small"
|
||||
type="number"
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
security: {
|
||||
...selected.security,
|
||||
rate_limit_per_minute: parseInt(e.target.value) || 10,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Voice Tab */}
|
||||
{tab === 3 && selected.channel_type === 'voice' && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 500 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Call Mode</InputLabel>
|
||||
<Select
|
||||
value={selected.voice_config?.mode || 'conversation'}
|
||||
label="Call Mode"
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
voice_config: {
|
||||
...(selected.voice_config || {}),
|
||||
mode: e.target.value,
|
||||
} as any,
|
||||
}),
|
||||
)
|
||||
}
|
||||
>
|
||||
<MenuItem value="conversation">Conversation (multi-turn)</MenuItem>
|
||||
<MenuItem value="notify">Notify (one-shot)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Greeting Message"
|
||||
value={selected.voice_config?.greeting_message || 'Hello, how can I help you?'}
|
||||
size="small"
|
||||
multiline
|
||||
rows={2}
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
voice_config: {
|
||||
...(selected.voice_config || {}),
|
||||
greeting_message: e.target.value,
|
||||
} as any,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
label="Voice (e.g. Polly.Joanna)"
|
||||
value={selected.voice_config?.voice || 'Polly.Joanna'}
|
||||
size="small"
|
||||
onChange={(e) =>
|
||||
dispatch(
|
||||
updateChannel({
|
||||
id: selected.id,
|
||||
voice_config: {
|
||||
...(selected.voice_config || {}),
|
||||
voice: e.target.value,
|
||||
} as any,
|
||||
}),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Conversations Tab */}
|
||||
{tab === (selected.channel_type === 'voice' ? 4 : 3) && (
|
||||
<Box>
|
||||
{convList.length === 0 ? (
|
||||
<Typography sx={{ color: c.text.muted }}>No conversations yet</Typography>
|
||||
) : (
|
||||
convList.map((conv) => (
|
||||
<Box
|
||||
key={conv.id}
|
||||
sx={{
|
||||
p: 2,
|
||||
mb: 1,
|
||||
borderRadius: 2,
|
||||
border: `1px solid ${c.border.subtle}`,
|
||||
bgcolor: c.bg.surface,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 500, fontSize: '0.85rem', color: c.text.primary }}>
|
||||
{conv.phone_number}
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted }}>
|
||||
{conv.messages.length} messages · {conv.status}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 1, maxHeight: 200, overflow: 'auto' }}>
|
||||
{conv.messages.slice(-5).map((msg) => (
|
||||
<Box
|
||||
key={msg.id}
|
||||
sx={{
|
||||
p: 0.75,
|
||||
mb: 0.5,
|
||||
borderRadius: 1,
|
||||
bgcolor: msg.direction === 'inbound' ? `${c.accent.primary}0A` : c.bg.secondary,
|
||||
fontSize: '0.78rem',
|
||||
color: c.text.primary,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.65rem', color: c.text.muted, mb: 0.25 }}>
|
||||
{msg.direction === 'inbound' ? 'Received' : 'Sent'} ·{' '}
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</Typography>
|
||||
{msg.content}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Test Tab */}
|
||||
{tab === (selected.channel_type === 'voice' ? 5 : 4) && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, maxWidth: 500 }}>
|
||||
<TextField
|
||||
label="Send test to phone number"
|
||||
value={testNumber}
|
||||
size="small"
|
||||
placeholder="+1234567890"
|
||||
onChange={(e) => setTestNumber(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SendIcon />}
|
||||
onClick={handleTest}
|
||||
sx={{ bgcolor: c.accent.primary, alignSelf: 'flex-start' }}
|
||||
>
|
||||
Send Test
|
||||
</Button>
|
||||
{testResult && (
|
||||
<Typography sx={{ fontSize: '0.85rem', color: testResult.includes('success') ? c.status.success : c.status.error }}>
|
||||
{testResult}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>New Channel</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: '16px !important' }}>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
size="small"
|
||||
placeholder="My SMS Channel"
|
||||
/>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select value={newType} label="Type" onChange={(e) => setNewType(e.target.value as any)}>
|
||||
<MenuItem value="sms">SMS</MenuItem>
|
||||
<MenuItem value="whatsapp">WhatsApp</MenuItem>
|
||||
<MenuItem value="voice">Voice</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Provider</InputLabel>
|
||||
<Select value={newProvider} label="Provider" onChange={(e) => setNewProvider(e.target.value as any)}>
|
||||
<MenuItem value="twilio">Twilio</MenuItem>
|
||||
<MenuItem value="telnyx">Telnyx</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Phone Number"
|
||||
value={newPhone}
|
||||
onChange={(e) => setNewPhone(e.target.value)}
|
||||
size="small"
|
||||
placeholder="+1234567890"
|
||||
/>
|
||||
<TextField
|
||||
label="Account SID / API Key"
|
||||
value={newAccountSid}
|
||||
onChange={(e) => setNewAccountSid(e.target.value)}
|
||||
size="small"
|
||||
type="password"
|
||||
/>
|
||||
<TextField
|
||||
label="Auth Token"
|
||||
value={newAuthToken}
|
||||
onChange={(e) => setNewAuthToken(e.target.value)}
|
||||
size="small"
|
||||
type="password"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleCreate}
|
||||
disabled={!newName || !newPhone}
|
||||
sx={{ bgcolor: c.accent.primary }}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Channels;
|
||||
@@ -0,0 +1,264 @@
|
||||
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { API_BASE } from '@/shared/config';
|
||||
|
||||
const CHANNELS_API = `${API_BASE}/channels`;
|
||||
|
||||
export interface ChannelAgentConfig {
|
||||
mode: string;
|
||||
model: string;
|
||||
system_prompt?: string;
|
||||
max_turns: number;
|
||||
allowed_tools?: string[];
|
||||
}
|
||||
|
||||
export interface ChannelSecurityConfig {
|
||||
verify_signatures: boolean;
|
||||
allowlist: string[];
|
||||
blocklist: string[];
|
||||
rate_limit_per_minute: number;
|
||||
rate_limit_per_hour: number;
|
||||
}
|
||||
|
||||
export interface VoiceConfig {
|
||||
mode: 'conversation' | 'notify';
|
||||
greeting_message: string;
|
||||
silence_timeout_ms: number;
|
||||
max_call_duration_seconds: number;
|
||||
gather_timeout_seconds: number;
|
||||
voice: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface TTSConfig {
|
||||
provider: 'twilio_say' | 'elevenlabs' | 'openai_tts' | 'edge_tts';
|
||||
auto_tts_mode: 'off' | 'always' | 'inbound' | 'tagged';
|
||||
elevenlabs_voice_id?: string;
|
||||
elevenlabs_model_id: string;
|
||||
openai_voice: string;
|
||||
skip_short_text: boolean;
|
||||
summarize_long_replies: boolean;
|
||||
max_tts_chars: number;
|
||||
}
|
||||
|
||||
export interface STTConfig {
|
||||
provider: 'twilio_builtin' | 'deepgram' | 'openai_whisper';
|
||||
deepgram_model: string;
|
||||
language: string;
|
||||
fallback_chain: string[];
|
||||
}
|
||||
|
||||
export interface ChannelConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
channel_type: 'sms' | 'whatsapp' | 'voice';
|
||||
provider: 'twilio' | 'telnyx';
|
||||
enabled: boolean;
|
||||
phone_number: string;
|
||||
credentials: Record<string, string>;
|
||||
agent_config: ChannelAgentConfig;
|
||||
security: ChannelSecurityConfig;
|
||||
voice_config?: VoiceConfig;
|
||||
tts_config?: TTSConfig;
|
||||
stt_config?: STTConfig;
|
||||
status: 'inactive' | 'active' | 'error';
|
||||
status_message?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_message_at?: string;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface ChannelMessage {
|
||||
id: string;
|
||||
direction: 'inbound' | 'outbound';
|
||||
content: string;
|
||||
media_urls: string[];
|
||||
timestamp: string;
|
||||
channel_type: string;
|
||||
provider_message_id?: string;
|
||||
}
|
||||
|
||||
export interface ChannelConversation {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
phone_number: string;
|
||||
agent_session_id?: string;
|
||||
messages: ChannelMessage[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: 'active' | 'closed';
|
||||
}
|
||||
|
||||
interface ChannelsState {
|
||||
items: Record<string, ChannelConfig>;
|
||||
conversations: Record<string, ChannelConversation>;
|
||||
loading: boolean;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const initialState: ChannelsState = {
|
||||
items: {},
|
||||
conversations: {},
|
||||
loading: false,
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
export const fetchChannels = createAsyncThunk(
|
||||
'channels/fetch',
|
||||
async () => {
|
||||
const res = await fetch(`${CHANNELS_API}/list`);
|
||||
const data = await res.json();
|
||||
return data.channels as ChannelConfig[];
|
||||
},
|
||||
{ condition: (_, { getState }) => !(getState() as { channels: ChannelsState }).channels.loading },
|
||||
);
|
||||
|
||||
export const createChannel = createAsyncThunk(
|
||||
'channels/create',
|
||||
async (body: Partial<ChannelConfig> & { name: string }) => {
|
||||
const res = await fetch(`${CHANNELS_API}/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.channel as ChannelConfig;
|
||||
},
|
||||
);
|
||||
|
||||
export const updateChannel = createAsyncThunk(
|
||||
'channels/update',
|
||||
async ({ id, ...updates }: Partial<ChannelConfig> & { id: string }) => {
|
||||
const res = await fetch(`${CHANNELS_API}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
const data = await res.json();
|
||||
return data.channel as ChannelConfig;
|
||||
},
|
||||
);
|
||||
|
||||
export const deleteChannel = createAsyncThunk(
|
||||
'channels/delete',
|
||||
async (id: string) => {
|
||||
await fetch(`${CHANNELS_API}/${id}`, { method: 'DELETE' });
|
||||
return id;
|
||||
},
|
||||
);
|
||||
|
||||
export const enableChannel = createAsyncThunk(
|
||||
'channels/enable',
|
||||
async (id: string) => {
|
||||
const res = await fetch(`${CHANNELS_API}/${id}/enable`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Failed to enable');
|
||||
return id;
|
||||
},
|
||||
);
|
||||
|
||||
export const disableChannel = createAsyncThunk(
|
||||
'channels/disable',
|
||||
async (id: string) => {
|
||||
await fetch(`${CHANNELS_API}/${id}/disable`, { method: 'POST' });
|
||||
return id;
|
||||
},
|
||||
);
|
||||
|
||||
export const testChannel = createAsyncThunk(
|
||||
'channels/test',
|
||||
async ({ id, to_number }: { id: string; to_number: string }) => {
|
||||
const res = await fetch(`${CHANNELS_API}/${id}/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to_number }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ detail: 'Test failed' }));
|
||||
throw new Error(err.detail || 'Test failed');
|
||||
}
|
||||
return await res.json();
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchConversations = createAsyncThunk(
|
||||
'channels/fetchConversations',
|
||||
async (channelId: string) => {
|
||||
const res = await fetch(`${CHANNELS_API}/${channelId}/conversations`);
|
||||
const data = await res.json();
|
||||
return { channelId, conversations: data.conversations as ChannelConversation[] };
|
||||
},
|
||||
);
|
||||
|
||||
export const sendOutbound = createAsyncThunk(
|
||||
'channels/sendOutbound',
|
||||
async ({ channelId, to_number, message }: { channelId: string; to_number: string; message: string }) => {
|
||||
const res = await fetch(`${CHANNELS_API}/${channelId}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to_number, message }),
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
);
|
||||
|
||||
export const initiateCall = createAsyncThunk(
|
||||
'channels/initiateCall',
|
||||
async ({ channelId, to_number }: { channelId: string; to_number: string }) => {
|
||||
const res = await fetch(`${CHANNELS_API}/${channelId}/call`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to_number }),
|
||||
});
|
||||
return await res.json();
|
||||
},
|
||||
);
|
||||
|
||||
const channelsSlice = createSlice({
|
||||
name: 'channels',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateChannelStatus(state, action: PayloadAction<{ channel_id: string; status: string }>) {
|
||||
const ch = state.items[action.payload.channel_id];
|
||||
if (ch) ch.status = action.payload.status as ChannelConfig['status'];
|
||||
},
|
||||
addInboundMessage(
|
||||
state,
|
||||
action: PayloadAction<{ channel_id: string; conversation_id: string; message: ChannelMessage }>,
|
||||
) {
|
||||
const conv = state.conversations[action.payload.conversation_id];
|
||||
if (conv) {
|
||||
conv.messages.push(action.payload.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchChannels.pending, (state) => { state.loading = true; })
|
||||
.addCase(fetchChannels.fulfilled, (state, action) => {
|
||||
state.loading = false;
|
||||
state.loaded = true;
|
||||
state.items = {};
|
||||
for (const c of action.payload) state.items[c.id] = c;
|
||||
})
|
||||
.addCase(fetchChannels.rejected, (state) => { state.loading = false; state.loaded = true; })
|
||||
.addCase(createChannel.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
|
||||
.addCase(updateChannel.fulfilled, (state, action) => { state.items[action.payload.id] = action.payload; })
|
||||
.addCase(deleteChannel.fulfilled, (state, action) => { delete state.items[action.payload]; })
|
||||
.addCase(enableChannel.fulfilled, (state, action) => {
|
||||
const ch = state.items[action.payload];
|
||||
if (ch) { ch.enabled = true; ch.status = 'active'; }
|
||||
})
|
||||
.addCase(disableChannel.fulfilled, (state, action) => {
|
||||
const ch = state.items[action.payload];
|
||||
if (ch) { ch.enabled = false; ch.status = 'inactive'; }
|
||||
})
|
||||
.addCase(fetchConversations.fulfilled, (state, action) => {
|
||||
for (const conv of action.payload.conversations) {
|
||||
state.conversations[conv.id] = conv;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { updateChannelStatus, addInboundMessage } = channelsSlice.actions;
|
||||
export default channelsSlice.reducer;
|
||||
@@ -12,6 +12,7 @@ import outputsReducer from './outputsSlice';
|
||||
import dashboardLayoutReducer from './dashboardLayoutSlice';
|
||||
import dashboardsReducer from './dashboardsSlice';
|
||||
import updateReducer from './updateSlice';
|
||||
import channelsReducer from './channelsSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -28,6 +29,7 @@ export const store = configureStore({
|
||||
dashboardLayout: dashboardLayoutReducer,
|
||||
dashboards: dashboardsReducer,
|
||||
update: updateReducer,
|
||||
channels: channelsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user