mirror of
https://github.com/hoshikawa2/agent_platform_oci.git
synced 2026-07-09 22:04:21 +00:00
First commit
This commit is contained in:
0
apps/channel_gateway/app/__init__.py
Normal file
0
apps/channel_gateway/app/__init__.py
Normal file
0
apps/channel_gateway/app/adapters/__init__.py
Normal file
0
apps/channel_gateway/app/adapters/__init__.py
Normal file
9
apps/channel_gateway/app/adapters/base.py
Normal file
9
apps/channel_gateway/app/adapters/base.py
Normal 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: ...
|
||||
45
apps/channel_gateway/app/adapters/voice.py
Normal file
45
apps/channel_gateway/app/adapters/voice.py
Normal 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)
|
||||
35
apps/channel_gateway/app/adapters/web.py
Normal file
35
apps/channel_gateway/app/adapters/web.py
Normal 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,
|
||||
)
|
||||
42
apps/channel_gateway/app/adapters/whatsapp.py
Normal file
42
apps/channel_gateway/app/adapters/whatsapp.py
Normal 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)
|
||||
33
apps/channel_gateway/app/client.py
Normal file
33
apps/channel_gateway/app/client.py
Normal 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}
|
||||
112
apps/channel_gateway/app/main.py
Normal file
112
apps/channel_gateway/app/main.py
Normal 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)
|
||||
74
apps/channel_gateway/app/schemas.py
Normal file
74
apps/channel_gateway/app/schemas.py
Normal 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)
|
||||
46
apps/channel_gateway/app/settings.py
Normal file
46
apps/channel_gateway/app/settings.py
Normal 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()
|
||||
Reference in New Issue
Block a user