[eric] cute is law, communication is law

This commit is contained in:
ciregenz
2026-03-19 17:15:18 -07:00
parent 1b90adb58b
commit da6c2c305e
25 changed files with 3194 additions and 2 deletions
+22
View File
@@ -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
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
+88
View File
@@ -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
+129
View File
@@ -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]
+387
View File
@@ -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()}
+60
View File
@@ -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)
+116
View File
@@ -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
+559
View File
@@ -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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("'", "&apos;")
)
# Singleton
from backend.apps.channels.models import VoiceConfig # noqa: E402
channel_orchestrator = ChannelOrchestrator()
+142
View File
@@ -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
+163
View File
@@ -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
+127
View File
@@ -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
+68
View File
@@ -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()
+40
View File
@@ -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 {}),
})
+8
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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.
+7 -1
View File
@@ -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
+2
View File
@@ -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;
+264
View File
@@ -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;
+2
View File
@@ -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,
},
});