DeviceHub: /device WS with bearer auth + hello/hello_ack + ping/pong
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using backend.Data;
|
||||
using backend.Devices;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace backend.DeviceHub;
|
||||
|
||||
public static class DeviceHubEndpoint
|
||||
{
|
||||
public static IEndpointRouteBuilder MapDeviceHub(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.Map("/device", async (HttpContext ctx, DeviceAuth auth, DeviceRegistry reg, AppDbContext db) =>
|
||||
{
|
||||
if (!ctx.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
|
||||
var device = await auth.AuthenticateAsync(
|
||||
ctx.Request.Headers.Authorization.ToString(), ctx.RequestAborted);
|
||||
if (device is null)
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
|
||||
await using var active = new ActiveDevice(device.Id, ws);
|
||||
if (!reg.TryRegister(active))
|
||||
{
|
||||
await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation,
|
||||
"device already connected", ctx.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await HandleAsync(active, device, db, ctx.RequestAborted);
|
||||
}
|
||||
finally
|
||||
{
|
||||
reg.Unregister(device.Id);
|
||||
}
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task HandleAsync(ActiveDevice active, Device device, AppDbContext db, CancellationToken ct)
|
||||
{
|
||||
var buffer = new byte[8192];
|
||||
var ws = active.Ws;
|
||||
|
||||
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
|
||||
{
|
||||
using var msg = new MemoryStream();
|
||||
WebSocketReceiveResult res;
|
||||
while (true)
|
||||
{
|
||||
res = await ws.ReceiveAsync(buffer, ct);
|
||||
if (res.MessageType == WebSocketMessageType.Close) return;
|
||||
msg.Write(buffer, 0, res.Count);
|
||||
if (res.EndOfMessage) break;
|
||||
}
|
||||
|
||||
if (res.MessageType == WebSocketMessageType.Binary)
|
||||
{
|
||||
if (msg.Length != 3840) continue;
|
||||
// Binary routing into a session lives in Task 18.
|
||||
continue;
|
||||
}
|
||||
|
||||
var text = Encoding.UTF8.GetString(msg.ToArray());
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
var type = doc.RootElement.GetProperty("type").GetString();
|
||||
switch (type)
|
||||
{
|
||||
case "hello":
|
||||
await SendHelloAckAsync(active, device, ct);
|
||||
break;
|
||||
|
||||
case "ping":
|
||||
device.LastSeenAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
await active.SendEnvelopeAsync(new PongEnvelope("pong"), ct);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SendHelloAckAsync(ActiveDevice active, Device device, CancellationToken ct)
|
||||
{
|
||||
var enabled = device.Config?.EnabledToolsJson ?? "[]";
|
||||
var config = JsonNode.Parse($$"""
|
||||
{
|
||||
"voice": "{{Escape(device.Config?.Voice)}}",
|
||||
"model": "{{Escape(device.Config?.Model)}}",
|
||||
"system_prompt": "{{Escape(device.Config?.SystemPrompt)}}",
|
||||
"idle_timeout_seconds": {{device.Config?.IdleTimeoutSeconds ?? 30}},
|
||||
"enabled_tools": {{enabled}}
|
||||
}
|
||||
""")!.AsObject();
|
||||
|
||||
await active.WriteEnvelopeAsync(new JsonObject
|
||||
{
|
||||
["type"] = "hello_ack",
|
||||
["config"] = config,
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private static string Escape(string? s) =>
|
||||
(s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||
}
|
||||
Reference in New Issue
Block a user