First commit

This commit is contained in:
2026-06-19 22:17:09 -03:00
commit 239203ee2a
533 changed files with 75195 additions and 0 deletions

View File

@@ -0,0 +1,17 @@
APP_NAME=external-channel-gateway
LOG_LEVEL=INFO
API_HOST=0.0.0.0
API_PORT=7000
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# adapter = receive channel payloads at /channels/* and convert to GatewayRequest.
# proxy = receive only GatewayRequest at /gateway/message and forward it.
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
AGENT_FRAMEWORK_BASE_URL=http://localhost:8000
AGENT_FRAMEWORK_GATEWAY_PATH=/gateway/message
DEFAULT_TENANT_ID=default
DEFAULT_AGENT_ID=telecom_contas
REQUEST_TIMEOUT_SECONDS=120
# Optional: shared token added to calls from channel_gateway to backend.
INTERNAL_GATEWAY_TOKEN=

View File

@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY config ./config
ENV PYTHONPATH=/app
EXPOSE 7000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7000"]

View File

@@ -0,0 +1,112 @@
# External Channel Gateway
This service is a separate Channel Gateway that sits in front of the Agent Framework backend.
It has its own runtime mode, independent from the backend input mode.
## Runtime modes
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
`adapter` means this service receives channel-specific payloads and translates them into `GatewayRequest` before calling the Agent Framework backend.
```env
CHANNEL_GATEWAY_RUNTIME_MODE=proxy
```
`proxy` means this service accepts only an already-built `GatewayRequest` at `/gateway/message` and forwards it after validation.
## Recommended enterprise setup
In `channel_gateway/.env`:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
AGENT_FRAMEWORK_BASE_URL=http://localhost:8000
DEFAULT_TENANT_ID=default
DEFAULT_AGENT_ID=telecom_contas
```
In `agent_template_backend/.env`:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
This means:
```text
channel_gateway:7000 = understands channel payloads and builds GatewayRequest
backend:8000 = accepts only GatewayRequest and does not parse native channel payloads
```
## Run
```bash
cd channel_gateway
cp .env.example .env
uvicorn app.main:app --host 0.0.0.0 --port 7000
```
## Test web adapter endpoint
```bash
curl -s -X POST "http://localhost:7000/channels/web/message" \
-H "Content-Type: application/json" \
-d '{
"message": "Quero consultar minha fatura",
"session_id": "external-gw-test-001",
"user_id": "user-external-001",
"message_id": "msg-external-001",
"customer_key": "11999999999",
"contract_key": "3000131180",
"interaction_key": "301953872",
"session_key": "external-gw-test-001"
}' | jq
```
## Test proxy mode
Set:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=proxy
```
Then call:
```bash
curl -s -X POST "http://localhost:7000/gateway/message" \
-H "Content-Type: application/json" \
-d '{
"channel": "web",
"tenant_id": "default",
"agent_id": "telecom_contas",
"payload": {
"message": "Quero consultar minha fatura",
"session_id": "proxy-test-001"
}
}' | jq
```
## Important distinction
Do not use `CHANNEL_GATEWAY_MODE=external` to mean “this service is external”.
Use:
```env
CHANNEL_GATEWAY_RUNTIME_MODE=adapter
```
for the external gateway service that owns adapters.
Use:
```env
FRAMEWORK_CHANNEL_INPUT_MODE=external
```
in the Agent Framework backend when the backend must accept only normalized `GatewayRequest` payloads.

View File

View File

@@ -0,0 +1,9 @@
from __future__ import annotations
from typing import Protocol
from app.schemas import GatewayRequest
class ChannelAdapter(Protocol):
name: str
async def to_gateway_request(self, payload) -> GatewayRequest: ...

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, VoiceTranscript
from app.settings import settings
class VoiceAdapter:
name = 'voice'
async def to_gateway_request(self, payload: VoiceTranscript) -> GatewayRequest:
session_id = payload.session_id or payload.call_id
message_id = payload.message_id or (f'{payload.call_id}-turn-1' if payload.call_id else None)
bc = BusinessContext(
customer_key=payload.customer_key or payload.caller,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key or payload.call_id,
session_key=session_id,
metadata={
'source_channel': 'voice',
'confidence': payload.confidence,
'language': payload.language,
**(payload.metadata or {}),
},
)
data = {
'message': payload.transcript,
'session_id': session_id,
'user_id': payload.caller,
'message_id': message_id,
'customer_key': bc.customer_key,
'contract_key': bc.contract_key,
'interaction_key': bc.interaction_key,
'session_key': bc.session_key,
'business_context': bc.model_dump(exclude_none=True),
'metadata': {
**(payload.metadata or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'voice',
'call_id': payload.call_id,
'confidence': payload.confidence,
'language': payload.language,
'contract_version': 'gateway-request-v1',
},
}
return GatewayRequest(channel='voice', tenant_id=settings.DEFAULT_TENANT_ID, agent_id=settings.DEFAULT_AGENT_ID, payload=data)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, WebMessage
from app.settings import settings
class WebAdapter:
name = 'web'
async def to_gateway_request(self, payload: WebMessage) -> GatewayRequest:
bc = payload.business_context or BusinessContext(
customer_key=payload.customer_key,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key,
account_key=payload.account_key,
resource_key=payload.resource_key,
session_key=payload.session_key or payload.session_id,
metadata={'source_channel': 'web', **(payload.metadata or {})},
)
data = payload.model_dump(exclude_none=True)
data['business_context'] = bc.model_dump(exclude_none=True)
data.setdefault('session_key', payload.session_key or payload.session_id)
data.setdefault('metadata', {})
data['metadata'] = {
**(data.get('metadata') or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'web',
'contract_version': 'gateway-request-v1',
}
return GatewayRequest(
channel='web',
tenant_id=settings.DEFAULT_TENANT_ID,
agent_id=settings.DEFAULT_AGENT_ID,
payload=data,
)

View File

@@ -0,0 +1,42 @@
from __future__ import annotations
from app.schemas import BusinessContext, GatewayRequest, WhatsAppWebhook
from app.settings import settings
class WhatsAppAdapter:
name = 'whatsapp'
async def to_gateway_request(self, payload: WhatsAppWebhook) -> GatewayRequest:
user_id = payload.wa_id or payload.from_
text = payload.message or payload.text or payload.interactive_title or payload.interactive_id
if not text:
raise ValueError('INVALID_WHATSAPP_PAYLOAD: message/text/interactive_title is required')
session_id = payload.session_id or user_id
message_id = payload.message_id or payload.interaction_key
bc = BusinessContext(
customer_key=payload.customer_key or user_id,
contract_key=payload.contract_key,
interaction_key=payload.interaction_key or message_id,
session_key=session_id,
metadata={'source_channel': 'whatsapp', **(payload.metadata or {})},
)
data = {
'message': text,
'session_id': session_id,
'user_id': user_id,
'message_id': message_id,
'customer_key': bc.customer_key,
'contract_key': bc.contract_key,
'interaction_key': bc.interaction_key,
'session_key': bc.session_key,
'business_context': bc.model_dump(exclude_none=True),
'metadata': {
**(payload.metadata or {}),
'external_gateway': settings.APP_NAME,
'source_channel': 'whatsapp',
'interactive_id': payload.interactive_id,
'contract_version': 'gateway-request-v1',
},
}
return GatewayRequest(channel='whatsapp', tenant_id=settings.DEFAULT_TENANT_ID, agent_id=settings.DEFAULT_AGENT_ID, payload=data)

View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import httpx
from .settings import settings
from .schemas import GatewayRequest
class AgentFrameworkClient:
def __init__(self):
self.url = settings.AGENT_FRAMEWORK_BASE_URL.rstrip('/') + settings.AGENT_FRAMEWORK_GATEWAY_PATH
async def send(self, request: GatewayRequest) -> dict:
headers = {'Content-Type': 'application/json'}
if settings.INTERNAL_GATEWAY_TOKEN:
headers['X-Channel-Gateway-Token'] = settings.INTERNAL_GATEWAY_TOKEN
async with httpx.AsyncClient(timeout=settings.REQUEST_TIMEOUT_SECONDS) as client:
resp = await client.post(self.url, json=request.model_dump(exclude_none=True), headers=headers)
try:
data = resp.json()
except Exception:
data = {'text': resp.text}
if resp.status_code >= 400:
return {
'ok': False,
'status_code': resp.status_code,
'error': data,
'forwarded_to': self.url,
}
if isinstance(data, dict):
data.setdefault('ok', True)
data.setdefault('forwarded_to', self.url)
return data
return {'ok': True, 'data': data, 'forwarded_to': self.url}

View File

@@ -0,0 +1,112 @@
from __future__ import annotations
import logging
from uuid import uuid4
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from app.adapters.web import WebAdapter
from app.adapters.whatsapp import WhatsAppAdapter
from app.adapters.voice import VoiceAdapter
from app.client import AgentFrameworkClient
from app.schemas import GatewayRequest, VoiceTranscript, WebMessage, WhatsAppWebhook
from app.settings import settings
logging.basicConfig(level=settings.LOG_LEVEL)
logger = logging.getLogger('external_channel_gateway')
app = FastAPI(title='External Channel Gateway')
app.add_middleware(
CORSMiddleware,
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(',')],
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
client = AgentFrameworkClient()
web_adapter = WebAdapter()
whatsapp_adapter = WhatsAppAdapter()
voice_adapter = VoiceAdapter()
def _require_mode(expected: str, endpoint_type: str):
if settings.runtime_mode != expected:
raise HTTPException(
status_code=409,
detail={
'error_code': 'CHANNEL_GATEWAY_MODE_MISMATCH',
'message': f'{endpoint_type} endpoints require CHANNEL_GATEWAY_RUNTIME_MODE={expected}',
'current_runtime_mode': settings.runtime_mode,
},
)
async def _forward(request: GatewayRequest) -> dict:
return await client.send(request)
@app.get('/health')
async def health():
return {
'status': 'ok',
'app_name': settings.APP_NAME,
'runtime_mode': settings.runtime_mode,
'configured_runtime_mode': settings.CHANNEL_GATEWAY_RUNTIME_MODE,
'legacy_channel_gateway_mode': settings.CHANNEL_GATEWAY_MODE,
'backend_url': client.url,
'default_tenant_id': settings.DEFAULT_TENANT_ID,
'default_agent_id': settings.DEFAULT_AGENT_ID,
}
@app.post('/channels/web/message')
async def web_message(payload: WebMessage, request: Request):
_require_mode('adapter', '/channels/*')
gateway_request = await web_adapter.to_gateway_request(payload)
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/web/message',
})
return await _forward(gateway_request)
@app.post('/channels/whatsapp/webhook')
async def whatsapp_webhook(payload: WhatsAppWebhook, request: Request):
_require_mode('adapter', '/channels/*')
try:
gateway_request = await whatsapp_adapter.to_gateway_request(payload)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/whatsapp/webhook',
})
return await _forward(gateway_request)
@app.post('/channels/voice/transcript')
async def voice_transcript(payload: VoiceTranscript, request: Request):
_require_mode('adapter', '/channels/*')
gateway_request = await voice_adapter.to_gateway_request(payload)
gateway_request.payload.setdefault('metadata', {})
gateway_request.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/channels/voice/transcript',
})
return await _forward(gateway_request)
@app.post('/gateway/message')
async def proxy_gateway_message(payload: GatewayRequest, request: Request):
_require_mode('proxy', '/gateway/message')
payload.payload.setdefault('metadata', {})
payload.payload['metadata'].update({
'channel_gateway_request_id': request.headers.get('x-request-id') or str(uuid4()),
'channel_gateway_endpoint': '/gateway/message',
'proxied_by': settings.APP_NAME,
})
return await _forward(payload)

View File

@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class BusinessContext(BaseModel):
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
account_key: str | None = None
resource_key: str | None = None
session_key: str | None = None
protocol_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class WebMessage(BaseModel):
message: str
session_id: str | None = None
user_id: str | None = None
message_id: str | None = None
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
account_key: str | None = None
resource_key: str | None = None
session_key: str | None = None
business_context: BusinessContext | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class WhatsAppWebhook(BaseModel):
wa_id: str | None = None
from_: str | None = Field(default=None, alias="from")
message: str | None = None
text: str | None = None
session_id: str | None = None
message_id: str | None = None
interactive_id: str | None = None
interactive_title: str | None = None
raw: dict[str, Any] = Field(default_factory=dict)
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class VoiceTranscript(BaseModel):
transcript: str
call_id: str | None = None
caller: str | None = None
session_id: str | None = None
message_id: str | None = None
confidence: float | None = None
language: str | None = None
customer_key: str | None = None
contract_key: str | None = None
interaction_key: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class GatewayRequest(BaseModel):
channel: str
payload: dict[str, Any]
agent_id: str | None = None
tenant_id: str | None = None
class GatewayResponse(BaseModel):
channel: str | None = None
session_id: str | None = None
text: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)

View File

@@ -0,0 +1,46 @@
from __future__ import annotations
from typing import Literal
from dotenv import load_dotenv
from pydantic_settings import BaseSettings, SettingsConfigDict
load_dotenv(override=False)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', extra='ignore')
APP_NAME: str = 'external-channel-gateway'
LOG_LEVEL: str = 'INFO'
API_HOST: str = '0.0.0.0'
API_PORT: int = 7000
CORS_ORIGINS: str = 'http://localhost:5173,http://127.0.0.1:5173'
# adapter = receive native/simple channel payloads and translate them to GatewayRequest.
# proxy = receive only GatewayRequest and forward it after validation.
CHANNEL_GATEWAY_RUNTIME_MODE: Literal['adapter','proxy'] = 'adapter'
# Legacy alias accepted only for compatibility. Prefer CHANNEL_GATEWAY_RUNTIME_MODE.
CHANNEL_GATEWAY_MODE: str | None = None
AGENT_FRAMEWORK_BASE_URL: str = 'http://localhost:8000'
AGENT_FRAMEWORK_GATEWAY_PATH: str = '/gateway/message'
DEFAULT_TENANT_ID: str = 'default'
DEFAULT_AGENT_ID: str = 'telecom_contas'
REQUEST_TIMEOUT_SECONDS: float = 120.0
INTERNAL_GATEWAY_TOKEN: str | None = None
@property
def runtime_mode(self) -> str:
legacy = (self.CHANNEL_GATEWAY_MODE or '').strip().lower()
if legacy in {'adapter', 'proxy'}:
return legacy
# Legacy mapping for old deployments: embedded meant adapters on.
if legacy == 'embedded':
return 'adapter'
if legacy == 'external':
return 'proxy'
return self.CHANNEL_GATEWAY_RUNTIME_MODE
settings = Settings()

View File

@@ -0,0 +1,13 @@
channels:
web:
enabled: true
endpoint: /channels/web/message
adapter: web
whatsapp:
enabled: true
endpoint: /channels/whatsapp/webhook
adapter: whatsapp
voice:
enabled: true
endpoint: /channels/voice/transcript
adapter: voice

View File

@@ -0,0 +1,7 @@
services:
channel_gateway:
build: .
env_file:
- .env
ports:
- "7000:7000"

View File

@@ -0,0 +1,6 @@
fastapi>=0.111.0
uvicorn[standard]>=0.30.0
httpx>=0.27.0
pydantic>=2.7.0
pydantic-settings>=2.2.0
python-dotenv>=1.0.0