Packages

CMDC Gateway — HTTP + SSE + WebSocket 协议网关,接入 CMDC Agent 能力

Current section

Files

Jump to
cmdc_gateway lib cmdc_gateway ws_handler.ex
Raw

lib/cmdc_gateway/ws_handler.ex

defmodule CMDCGateway.WSHandler do
@moduledoc """
WebSocket 双向通信处理器。
基于 `:cowboy_websocket` 行为,同时推送事件 + 接收控制消息。
## 入站消息格式(JSON)
{"action": "prompt", "text": "hello"}
{"action": "approve", "approvalId": "abc123"}
{"action": "reject", "approvalId": "abc123"}
{"action": "respond", "ref": "r1", "response": "42"}
## 出站消息格式(JSON)
与 SSE 事件格式一致:`{"event": "message_delta", "data": {"delta": "hello"}}`
"""
@behaviour :cowboy_websocket
alias CMDCGateway.{EventTranslator, SessionStore}
@heartbeat_interval 30_000
# ==========================================================================
# Cowboy WebSocket Callbacks
# ==========================================================================
@impl true
def init(req, _state) do
session_id = :cowboy_req.binding(:session_id, req)
case extract_api_key(req) do
nil ->
req =
:cowboy_req.reply(
401,
%{"content-type" => "application/json"},
"{\"error\":\"unauthorized\"}",
req
)
{:ok, req, %{}}
_api_key ->
case SessionStore.get(session_id) do
{:ok, %{pid: _pid}} ->
{:cowboy_websocket, req, %{session_id: session_id}, %{idle_timeout: 300_000}}
{:error, :not_found} ->
req =
:cowboy_req.reply(
404,
%{"content-type" => "application/json"},
"{\"error\":\"session_not_found\"}",
req
)
{:ok, req, %{}}
end
end
end
@impl true
def websocket_init(state) do
CMDC.EventBus.subscribe(state.session_id)
schedule_heartbeat()
{[], state}
end
@impl true
def websocket_handle({:text, raw}, state) do
case Jason.decode(raw) do
{:ok, msg} ->
handle_inbound(msg, state)
{:error, _} ->
reply = Jason.encode!(%{error: "invalid_json", message: "Failed to parse JSON"})
{[{:text, reply}], state}
end
end
def websocket_handle(_frame, state), do: {[], state}
@impl true
def websocket_info({:cmdc_event, session_id, event}, %{session_id: session_id} = state) do
case EventTranslator.translate(event) do
{:ok, event_type, data} ->
payload = Jason.encode!(%{event: event_type, data: data})
{[{:text, payload}], state}
:skip ->
{[], state}
end
end
def websocket_info(:heartbeat, state) do
schedule_heartbeat()
{[{:ping, ""}], state}
end
def websocket_info(_msg, state), do: {[], state}
@impl true
def terminate(_reason, _req, %{session_id: session_id}) do
CMDC.EventBus.unsubscribe(session_id)
:ok
end
def terminate(_reason, _req, _state), do: :ok
# ==========================================================================
# Inbound Message Handling
# ==========================================================================
defp handle_inbound(%{"action" => "prompt", "text" => text}, state) do
case SessionStore.get(state.session_id) do
{:ok, %{pid: session_pid}} ->
CMDC.prompt(session_pid, text)
reply = Jason.encode!(%{ok: true, action: "prompt"})
{[{:text, reply}], state}
_ ->
error_reply("session_not_found", state)
end
end
defp handle_inbound(%{"action" => "approve", "approvalId" => approval_id}, state) do
case SessionStore.get(state.session_id) do
{:ok, %{pid: session_pid}} ->
CMDC.approve(session_pid, approval_id)
reply = Jason.encode!(%{ok: true, action: "approve"})
{[{:text, reply}], state}
_ ->
error_reply("session_not_found", state)
end
end
defp handle_inbound(%{"action" => "reject", "approvalId" => approval_id}, state) do
case SessionStore.get(state.session_id) do
{:ok, %{pid: session_pid}} ->
CMDC.reject(session_pid, approval_id)
reply = Jason.encode!(%{ok: true, action: "reject"})
{[{:text, reply}], state}
_ ->
error_reply("session_not_found", state)
end
end
defp handle_inbound(%{"action" => "respond", "ref" => ref, "response" => response}, state) do
CMDC.EventBus.broadcast(
state.session_id,
{:user_responded, state.session_id, ref, response}
)
reply = Jason.encode!(%{ok: true, action: "respond"})
{[{:text, reply}], state}
end
defp handle_inbound(%{"action" => action}, state) do
reply = Jason.encode!(%{error: "unknown_action", action: action})
{[{:text, reply}], state}
end
defp handle_inbound(_msg, state) do
reply =
Jason.encode!(%{error: "missing_action", message: "Message must contain 'action' field"})
{[{:text, reply}], state}
end
# ==========================================================================
# Private
# ==========================================================================
defp error_reply(error, state) do
reply = Jason.encode!(%{error: error})
{[{:text, reply}], state}
end
defp extract_api_key(req) do
qs = :cowboy_req.parse_qs(req)
case List.keyfind(qs, "api_key", 0) do
{_, key} ->
key
nil ->
case :cowboy_req.header("x-api-key", req, nil) do
nil -> nil
key -> key
end
end
end
defp schedule_heartbeat do
Process.send_after(self(), :heartbeat, @heartbeat_interval)
end
end