Add binary-safe media storage seam

This commit is contained in:
Jacob Dubin
2026-05-17 16:47:58 -05:00
parent 14b5cb74cc
commit 9353e8d2e3
17 changed files with 390 additions and 38 deletions

View File

@@ -466,7 +466,7 @@ Current release theme:
### 11. Binary-Safe Media Storage ### 11. Binary-Safe Media Storage
- Status: `ready` - Status: `in progress`
- Tags: `storage`, `protocol` - Tags: `storage`, `protocol`
- Why next: - Why next:
- the first gallery bridge stores metadata and text-body placeholders, but final gallery support needs originals and thumbnails - the first gallery bridge stores metadata and text-body placeholders, but final gallery support needs originals and thumbnails
@@ -474,6 +474,9 @@ Current release theme:
- whether stock gallery expects originals, thumbnails, or both - whether stock gallery expects originals, thumbnails, or both
- what upload metadata must survive for gallery refresh - what upload metadata must survive for gallery refresh
- how to map this cleanly to Blob Storage - how to map this cleanly to Blob Storage
- Implementation notes:
- media content now flows through a storage seam with file and Azure Blob adapters
- the protocol still serves the legacy text-body contract, but the original payload is now persisted separately and can be swapped to binary-native storage later
### Next Up (`2026-05-06`): Dialog Parsing Expansion And Ambiguity Guardrails ### Next Up (`2026-05-06`): Dialog Parsing Expansion And Ambiguity Guardrails

View File

@@ -343,6 +343,7 @@ First completed slice in this personal-report parity track:
8. STT noise-screening and short-utterance reliability pass 8. STT noise-screening and short-utterance reliability pass
9. Provider-backed news expansion and deeper weather parity 9. Provider-backed news expansion and deeper weather parity
10. Capture indexing and retention boundary for group testing, including a lightweight manifest beside raw capture files 10. Capture indexing and retention boundary for group testing, including a lightweight manifest beside raw capture files
11. Binary-safe media storage seam with file and Azure Blob adapters, ready for original/thumbnails follow-up
For slice 1, use the new import ladder above to keep the work grounded in what OpenJibo can already render today versus what needs new scaffolding. For slice 1, use the new import ladder above to keep the work grounded in what OpenJibo can already render today versus what needs new scaffolding.
For slices 2-5, use Pegasus phrase lists, MIM IDs, and behavior patterns as the source anchor before broadening into OpenJibo-native improvements. For slices 2-5, use Pegasus phrase lists, MIM IDs, and behavior patterns as the source anchor before broadening into OpenJibo-native improvements.

View File

@@ -57,11 +57,14 @@ It shows the expected keys for:
- `OpenJibo:State:ConnectionString` - `OpenJibo:State:ConnectionString`
- `OpenJibo:PersonalMemory:Backend` - `OpenJibo:PersonalMemory:Backend`
- `OpenJibo:PersonalMemory:ConnectionString` - `OpenJibo:PersonalMemory:ConnectionString`
- `OpenJibo:Media:Backend`
- `OpenJibo:Media:ConnectionString`
The connection string can also come from: The connection string can also come from:
- `OPENJIBO_STATE_STORAGE_CONNECTION_STRING` - `OPENJIBO_STATE_STORAGE_CONNECTION_STRING`
- `OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING` - `OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING`
- `OPENJIBO_MEDIA_STORAGE_CONNECTION_STRING`
For a real storage account, swap `UseDevelopmentStorage=true` with your Azure Storage connection string. For a real storage account, swap `UseDevelopmentStorage=true` with your Azure Storage connection string.
@@ -80,6 +83,8 @@ $env:OpenJibo__State__Backend = "AzureBlob"
$env:OpenJibo__State__ConnectionString = "UseDevelopmentStorage=true" $env:OpenJibo__State__ConnectionString = "UseDevelopmentStorage=true"
$env:OpenJibo__PersonalMemory__Backend = "AzureBlob" $env:OpenJibo__PersonalMemory__Backend = "AzureBlob"
$env:OpenJibo__PersonalMemory__ConnectionString = "UseDevelopmentStorage=true" $env:OpenJibo__PersonalMemory__ConnectionString = "UseDevelopmentStorage=true"
$env:OpenJibo__Media__Backend = "AzureBlob"
$env:OpenJibo__Media__ConnectionString = "UseDevelopmentStorage=true"
dotnet run --project dotnet/src/Jibo.Cloud.Api/Jibo.Cloud.Api.csproj dotnet run --project dotnet/src/Jibo.Cloud.Api/Jibo.Cloud.Api.csproj
``` ```

View File

@@ -16,7 +16,9 @@
"OpenJibo__State__Backend": "AzureBlob", "OpenJibo__State__Backend": "AzureBlob",
"OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true", "OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true",
"OpenJibo__PersonalMemory__Backend": "AzureBlob", "OpenJibo__PersonalMemory__Backend": "AzureBlob",
"OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true" "OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true",
"OpenJibo__Media__Backend": "AzureBlob",
"OpenJibo__Media__ConnectionString": "UseDevelopmentStorage=true"
}, },
"applicationUrl": "https://localhost:24604;http://localhost:24605" "applicationUrl": "https://localhost:24604;http://localhost:24605"
} }

View File

@@ -9,6 +9,11 @@
"Backend": "AzureBlob", "Backend": "AzureBlob",
"ConnectionString": "UseDevelopmentStorage=true", "ConnectionString": "UseDevelopmentStorage=true",
"PersistencePath": "App_Data/personal-memory.json" "PersistencePath": "App_Data/personal-memory.json"
},
"Media": {
"Backend": "AzureBlob",
"ConnectionString": "UseDevelopmentStorage=true",
"ContainerName": "openjibo-media"
} }
} }
} }

View File

@@ -0,0 +1,17 @@
namespace Jibo.Cloud.Application.Abstractions;
public interface IMediaContentStore
{
Task StoreAsync(string path, string contentType, byte[] content, IReadOnlyDictionary<string, object?>? meta,
CancellationToken cancellationToken = default);
Task<MediaContentSnapshot?> LoadAsync(string path, CancellationToken cancellationToken = default);
}
public sealed record MediaContentSnapshot
{
public string ContentType { get; init; } = "application/octet-stream";
public byte[] Content { get; init; } = [];
public IReadOnlyDictionary<string, object?> Meta { get; init; } =
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -4,8 +4,10 @@ using Jibo.Cloud.Domain.Models;
namespace Jibo.Cloud.Application.Services; namespace Jibo.Cloud.Application.Services;
public sealed class JiboCloudProtocolService(ICloudStateStore stateStore) public sealed class JiboCloudProtocolService
{ {
private readonly ICloudStateStore _stateStore;
private readonly IMediaContentStore _mediaContentStore;
private static readonly string[] AcceptedHosts = private static readonly string[] AcceptedHosts =
[ [
"api.jibo.com", "api.jibo.com",
@@ -14,6 +16,12 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
"localhost" "localhost"
]; ];
public JiboCloudProtocolService(ICloudStateStore stateStore, IMediaContentStore? mediaContentStore = null)
{
_stateStore = stateStore;
_mediaContentStore = mediaContentStore ?? new NullMediaContentStore();
}
public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope, public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
@@ -89,13 +97,13 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private ProtocolDispatchResult HandleAccount(string operation, ProtocolEnvelope envelope) private ProtocolDispatchResult HandleAccount(string operation, ProtocolEnvelope envelope)
{ {
var account = stateStore.GetAccount(); var account = _stateStore.GetAccount();
var body = envelope.TryParseBody(); var body = envelope.TryParseBody();
if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
token = stateStore.IssueHubToken(), token = _stateStore.IssueHubToken(),
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds() expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
}); });
@@ -184,7 +192,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
accessKeyId = account.AccessKeyId, accessKeyId = account.AccessKeyId,
secretAccessKey = account.SecretAccessKey, secretAccessKey = account.SecretAccessKey,
email = account.Email, email = account.Email,
friendlyId = stateStore.GetRobot().RobotId, friendlyId = _stateStore.GetRobot().RobotId,
payload = ReadObject(body, "payload") payload = ReadObject(body, "payload")
}); });
@@ -247,11 +255,11 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
?? ReadString(body, "robotId") ?? ReadString(body, "robotId")
?? "unknown-device"; ?? "unknown-device";
stateStore.GetOrCreateDevice(deviceId, envelope.FirmwareVersion, envelope.ApplicationVersion); _stateStore.GetOrCreateDevice(deviceId, envelope.FirmwareVersion, envelope.ApplicationVersion);
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
token = stateStore.IssueRobotToken(deviceId) token = _stateStore.IssueRobotToken(deviceId)
}); });
} }
@@ -259,7 +267,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
{ {
if (operation is not ("List" or "ListLoops")) return ProtocolDispatchResult.Ok(Array.Empty<object>()); if (operation is not ("List" or "ListLoops")) return ProtocolDispatchResult.Ok(Array.Empty<object>());
return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new return ProtocolDispatchResult.Ok(_stateStore.GetLoops().Select(loop => new
{ {
id = loop.LoopId, id = loop.LoopId,
name = loop.Name, name = loop.Name,
@@ -315,23 +323,23 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var body = envelope.TryParseBody(); var body = envelope.TryParseBody();
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(stateStore.ListMedia( return ProtocolDispatchResult.Ok(_stateStore.ListMedia(
ReadStringArray(body, "loopIds"), ReadStringArray(body, "loopIds"),
ReadLong(body, "after"), ReadLong(body, "after"),
ReadLong(body, "before")).Select(MapMedia).ToArray()); ReadLong(body, "before")).Select(MapMedia).ToArray());
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia) return ProtocolDispatchResult.Ok(_stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia)
.ToArray()); .ToArray());
if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia) return ProtocolDispatchResult.Ok(_stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia)
.ToArray()); .ToArray());
if (!operation.Equals("Create", StringComparison.OrdinalIgnoreCase)) if (!operation.Equals("Create", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(Array.Empty<object>()); return ProtocolDispatchResult.Ok(Array.Empty<object>());
var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId; var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? _stateStore.GetLoops()[0].LoopId;
var path = ReadHeader(envelope, "x-path") ?? var path = ReadHeader(envelope, "x-path") ??
ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}"; ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown"; var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown";
@@ -342,27 +350,31 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
meta["contentType"] = contentType; meta["contentType"] = contentType;
if (!string.IsNullOrWhiteSpace(envelope.BodyText)) meta["bodyText"] = envelope.BodyText; if (!string.IsNullOrWhiteSpace(envelope.BodyText)) meta["bodyText"] = envelope.BodyText;
_mediaContentStore.StoreAsync(path, contentType,
string.IsNullOrWhiteSpace(envelope.BodyText) ? [] : System.Text.Encoding.UTF8.GetBytes(envelope.BodyText),
meta as IReadOnlyDictionary<string, object?>, CancellationToken.None).GetAwaiter().GetResult();
return ProtocolDispatchResult.Ok( return ProtocolDispatchResult.Ok(
MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta))); MapMedia(_stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
} }
private ProtocolDispatchResult HandlePerson(string operation) private ProtocolDispatchResult HandlePerson(string operation)
{ {
return ProtocolDispatchResult.Ok(operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase) return ProtocolDispatchResult.Ok(operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase)
? stateStore.GetHolidays() ? _stateStore.GetHolidays()
: []); : []);
} }
private ProtocolDispatchResult HandleBackup(string operation, ProtocolEnvelope envelope) private ProtocolDispatchResult HandleBackup(string operation, ProtocolEnvelope envelope)
{ {
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(stateStore.GetBackups()); return ProtocolDispatchResult.Ok(_stateStore.GetBackups());
if (operation.Equals("Create", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("Create", StringComparison.OrdinalIgnoreCase))
{ {
var body = envelope.TryParseBody(); var body = envelope.TryParseBody();
var requestedName = ReadString(body, "name") ?? ReadString(body, "backupName"); var requestedName = ReadString(body, "name") ?? ReadString(body, "backupName");
return ProtocolDispatchResult.Ok(stateStore.CreateBackup(requestedName ?? $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}")); return ProtocolDispatchResult.Ok(_stateStore.CreateBackup(requestedName ?? $"backup-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"));
} }
return ProtocolDispatchResult.Ok(Array.Empty<object>()); return ProtocolDispatchResult.Ok(Array.Empty<object>());
@@ -371,18 +383,18 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private ProtocolDispatchResult HandleKey(string operation, ProtocolEnvelope envelope) private ProtocolDispatchResult HandleKey(string operation, ProtocolEnvelope envelope)
{ {
var body = envelope.TryParseBody(); var body = envelope.TryParseBody();
var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId; var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? _stateStore.GetLoops()[0].LoopId;
if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId) shouldCreate = _stateStore.ShouldCreateSymmetricKey(loopId)
}); });
string? symmetricKey; string? symmetricKey;
if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase))
{ {
symmetricKey = stateStore.GetOrCreateSymmetricKey(loopId); symmetricKey = _stateStore.GetOrCreateSymmetricKey(loopId);
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
loopId, loopId,
@@ -394,7 +406,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
if (operation is "CreateRequest" or "RequestSymmetricKey") if (operation is "CreateRequest" or "RequestSymmetricKey")
{ {
var record = stateStore.CreateKeyRequest(loopId, ReadString(body, "publicKey") ?? string.Empty); var record = _stateStore.CreateKeyRequest(loopId, ReadString(body, "publicKey") ?? string.Empty);
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
id = record.RequestId, id = record.RequestId,
@@ -403,14 +415,14 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
} }
if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), return ProtocolDispatchResult.Ok(_stateStore.GetKeyRequest(loopId, ReadString(body, "id"),
ReadString(body, "publicKey"))); ReadString(body, "publicKey")));
if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests()); return ProtocolDispatchResult.Ok(_stateStore.GetIncomingKeyRequests());
if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests()); return ProtocolDispatchResult.Ok(_stateStore.GetBinaryRequests());
if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary") if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary")
return ProtocolDispatchResult.Ok(new { ok = true }); return ProtocolDispatchResult.Ok(new { ok = true });
@@ -418,7 +430,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
if (!operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase)) if (!operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase))
return ProtocolDispatchResult.Ok(new { ok = true, operation }); return ProtocolDispatchResult.Ok(new { ok = true, operation });
symmetricKey = stateStore.GetOrCreateSymmetricKey(loopId); symmetricKey = _stateStore.GetOrCreateSymmetricKey(loopId);
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
loopId, loopId,
@@ -429,7 +441,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope) private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope)
{ {
var robot = stateStore.GetRobot(); var robot = _stateStore.GetRobot();
if (operation.Equals("UpdateRobot", StringComparison.OrdinalIgnoreCase)) if (operation.Equals("UpdateRobot", StringComparison.OrdinalIgnoreCase))
{ {
@@ -443,7 +455,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
HostMappings = robot.HostMappings HostMappings = robot.HostMappings
}; };
stateStore.UpdateRobot(updated); _stateStore.UpdateRobot(updated);
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
result = "ok" result = "ok"
@@ -456,7 +468,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
result = "ok" result = "ok"
}); });
var profile = stateStore.GetRobotProfile(); var profile = _stateStore.GetRobotProfile();
return ProtocolDispatchResult.Ok(new return ProtocolDispatchResult.Ok(new
{ {
id = ReadString(envelope.TryParseBody(), "id") ?? profile.RobotId, id = ReadString(envelope.TryParseBody(), "id") ?? profile.RobotId,
@@ -476,15 +488,15 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
return operation switch return operation switch
{ {
"ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate) "ListUpdates" => ProtocolDispatchResult.Ok(_stateStore.ListUpdates(subsystem, filter).Select(MapUpdate)
.ToArray()), .ToArray()),
"ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter) "ListUpdatesFrom" => ProtocolDispatchResult.Ok(_stateStore.ListUpdates(subsystem, filter)
.Where(update => .Where(update =>
fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase)) fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase))
.Select(MapUpdate) .Select(MapUpdate)
.ToArray()), .ToArray()),
"GetUpdateFrom" => HandleGetUpdateFrom(subsystem, fromVersion, filter), "GetUpdateFrom" => HandleGetUpdateFrom(subsystem, fromVersion, filter),
"CreateUpdate" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.CreateUpdate( "CreateUpdate" => ProtocolDispatchResult.Ok(MapUpdate(_stateStore.CreateUpdate(
fromVersion, fromVersion,
ReadString(body, "toVersion"), ReadString(body, "toVersion"),
ReadString(body, "changes"), ReadString(body, "changes"),
@@ -493,7 +505,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
subsystem, subsystem,
filter, filter,
ReadObject(body, "dependencies")))), ReadObject(body, "dependencies")))),
"RemoveUpdate" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.RemoveUpdate(ReadString(body, "id")))), "RemoveUpdate" => ProtocolDispatchResult.Ok(MapUpdate(_stateStore.RemoveUpdate(ReadString(body, "id")))),
_ => ProtocolDispatchResult.Ok(Array.Empty<object>()) _ => ProtocolDispatchResult.Ok(Array.Empty<object>())
}; };
} }
@@ -502,17 +514,35 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
{ {
var path = Uri.UnescapeDataString(envelope.Path["/media/".Length..]); var path = Uri.UnescapeDataString(envelope.Path["/media/".Length..]);
var candidatePaths = new[] { path, $"/{path}" }; var candidatePaths = new[] { path, $"/{path}" };
var media = stateStore.GetMedia(candidatePaths).FirstOrDefault(); var media = _stateStore.GetMedia(candidatePaths).FirstOrDefault();
if (media is null || media.IsDeleted) return ProtocolDispatchResult.Raw(404, string.Empty); if (media is null || media.IsDeleted) return ProtocolDispatchResult.Raw(404, string.Empty);
var contentType = TryReadMetaString(media.Meta, "contentType") ?? "application/octet-stream"; var storedContent = _mediaContentStore.LoadAsync(media.Path, CancellationToken.None).GetAwaiter().GetResult();
var bodyText = TryReadMetaString(media.Meta, "bodyText") ?? string.Empty; var contentType = storedContent?.ContentType ?? TryReadMetaString(media.Meta, "contentType") ??
"application/octet-stream";
var bodyText = storedContent is not null
? System.Text.Encoding.UTF8.GetString(storedContent.Content)
: TryReadMetaString(media.Meta, "bodyText") ?? string.Empty;
return ProtocolDispatchResult.Raw(200, bodyText, contentType); return ProtocolDispatchResult.Raw(200, bodyText, contentType);
} }
private sealed class NullMediaContentStore : IMediaContentStore
{
public Task StoreAsync(string path, string contentType, byte[] content,
IReadOnlyDictionary<string, object?>? meta, CancellationToken cancellationToken = default)
{
return Task.CompletedTask;
}
public Task<MediaContentSnapshot?> LoadAsync(string path, CancellationToken cancellationToken = default)
{
return Task.FromResult<MediaContentSnapshot?>(null);
}
}
private ProtocolDispatchResult HandleGetUpdateFrom(string? subsystem, string? fromVersion, string? filter) private ProtocolDispatchResult HandleGetUpdateFrom(string? subsystem, string? fromVersion, string? filter)
{ {
var update = stateStore.GetUpdateFrom(subsystem, fromVersion, filter); var update = _stateStore.GetUpdateFrom(subsystem, fromVersion, filter);
return update is null return update is null
? ProtocolDispatchResult.Ok(new { }) ? ProtocolDispatchResult.Ok(new { })
: ProtocolDispatchResult.Ok(MapUpdate(update)); : ProtocolDispatchResult.Ok(MapUpdate(update));

View File

@@ -2,6 +2,7 @@ using Jibo.Cloud.Application.Abstractions;
using Jibo.Cloud.Application.Services; using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Infrastructure.Audio; using Jibo.Cloud.Infrastructure.Audio;
using Jibo.Cloud.Infrastructure.Content; using Jibo.Cloud.Infrastructure.Content;
using Jibo.Cloud.Infrastructure.Media;
using Jibo.Cloud.Infrastructure.News; using Jibo.Cloud.Infrastructure.News;
using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Cloud.Infrastructure.Telemetry; using Jibo.Cloud.Infrastructure.Telemetry;
@@ -59,7 +60,15 @@ public static class ServiceCollectionExtensions
"OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING") "OPENJIBO_PERSONAL_MEMORY_STORAGE_CONNECTION_STRING")
?? Environment.GetEnvironmentVariable( ?? Environment.GetEnvironmentVariable(
"OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING"); "OPENJIBO_PERSONAL_MEMORY_SQL_CONNECTION_STRING");
var mediaOptions = new MediaContentStoreOptions();
if (configuration is not null)
configuration.GetSection("OpenJibo:Media").Bind(mediaOptions);
if (string.IsNullOrWhiteSpace(mediaOptions.ConnectionString))
mediaOptions.ConnectionString = Environment.GetEnvironmentVariable("OPENJIBO_MEDIA_STORAGE_CONNECTION_STRING");
services.AddSingleton<IPersistenceSnapshotStoreFactory, PersistenceSnapshotStoreFactory>(); services.AddSingleton<IPersistenceSnapshotStoreFactory, PersistenceSnapshotStoreFactory>();
services.AddSingleton<IMediaContentStoreFactory, MediaContentStoreFactory>();
services.AddSingleton<ICloudStateStore>(provider => services.AddSingleton<ICloudStateStore>(provider =>
{ {
var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>(); var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>();
@@ -84,6 +93,12 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IWebSocketTelemetrySink, FileWebSocketTelemetrySink>(); services.AddSingleton<IWebSocketTelemetrySink, FileWebSocketTelemetrySink>();
services.AddSingleton<IProtocolTelemetrySink, FileProtocolTelemetrySink>(); services.AddSingleton<IProtocolTelemetrySink, FileProtocolTelemetrySink>();
services.AddSingleton<ITurnTelemetrySink, FileTurnTelemetrySink>(); services.AddSingleton<ITurnTelemetrySink, FileTurnTelemetrySink>();
services.AddSingleton<IMediaContentStore>(provider =>
{
var factory = provider.GetRequiredService<IMediaContentStoreFactory>();
return factory.Create(mediaOptions.DirectoryPath, mediaOptions.Backend, mediaOptions.ContainerName,
mediaOptions.ConnectionString);
});
services.AddSingleton<ProtocolToTurnContextMapper>(); services.AddSingleton<ProtocolToTurnContextMapper>();
services.AddSingleton<ResponsePlanToSocketMessagesMapper>(); services.AddSingleton<ResponsePlanToSocketMessagesMapper>();
services.AddSingleton<WebSocketTurnFinalizationService>(); services.AddSingleton<WebSocketTurnFinalizationService>();

View File

@@ -0,0 +1,82 @@
using System.Text.Json;
using Azure.Storage.Blobs;
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Infrastructure.Media;
internal sealed class AzureBlobMediaContentStore : IMediaContentStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
PropertyNameCaseInsensitive = true
};
private readonly BlobContainerClient _containerClient;
public AzureBlobMediaContentStore(string? connectionString, string containerName = "openjibo-media")
{
if (string.IsNullOrWhiteSpace(connectionString))
throw new InvalidOperationException("Azure Blob media persistence requires a storage connection string.");
_containerClient = new BlobContainerClient(connectionString,
string.IsNullOrWhiteSpace(containerName) ? "openjibo-media" : containerName);
}
public async Task StoreAsync(string path, string contentType, byte[] content,
IReadOnlyDictionary<string, object?>? meta, CancellationToken cancellationToken = default)
{
var relative = MediaPathHelper.GetRelativeStoragePath(path);
var contentBlob = _containerClient.GetBlobClient($"{relative}.bin");
var metaBlob = _containerClient.GetBlobClient($"{relative}.json");
await _containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
await contentBlob.UploadAsync(new MemoryStream(content), overwrite: true, cancellationToken);
var payload = JsonSerializer.Serialize(new
{
path,
contentType,
meta
}, JsonOptions);
await metaBlob.UploadAsync(BinaryData.FromString(payload), overwrite: true, cancellationToken);
}
public async Task<MediaContentSnapshot?> LoadAsync(string path, CancellationToken cancellationToken = default)
{
var relative = MediaPathHelper.GetRelativeStoragePath(path);
var contentBlob = _containerClient.GetBlobClient($"{relative}.bin");
if (!await contentBlob.ExistsAsync(cancellationToken)) return null;
var content = await contentBlob.DownloadContentAsync(cancellationToken);
var contentType = "application/octet-stream";
IDictionary<string, object?> meta = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
var metaBlob = _containerClient.GetBlobClient($"{relative}.json");
if (await metaBlob.ExistsAsync(cancellationToken))
{
try
{
var json = (await metaBlob.DownloadContentAsync(cancellationToken)).Value.Content.ToString();
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.TryGetProperty("contentType", out var type) && type.ValueKind == JsonValueKind.String)
contentType = type.GetString() ?? contentType;
if (root.TryGetProperty("meta", out var metaElement) && metaElement.ValueKind == JsonValueKind.Object)
meta = JsonSerializer.Deserialize<Dictionary<string, object?>>(metaElement.GetRawText(),
JsonOptions) ??
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
catch
{
// Keep the raw binary available even if metadata parsing fails.
}
}
return new MediaContentSnapshot
{
ContentType = contentType,
Content = content.Value.Content.ToArray(),
Meta = meta as IReadOnlyDictionary<string, object?> ?? new Dictionary<string, object?>(meta)
};
}
}

View File

@@ -0,0 +1,84 @@
using System.Text.Json;
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Infrastructure.Media;
internal sealed class FileMediaContentStore : IMediaContentStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true
};
public FileMediaContentStore(string? directoryPath)
{
DirectoryPath = string.IsNullOrWhiteSpace(directoryPath) ? null : directoryPath;
}
private string? DirectoryPath { get; }
public async Task StoreAsync(string path, string contentType, byte[] content,
IReadOnlyDictionary<string, object?>? meta, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(DirectoryPath) || string.IsNullOrWhiteSpace(path)) return;
var root = Path.GetFullPath(DirectoryPath);
var relative = MediaPathHelper.GetRelativeStoragePath(path);
var contentPath = Path.Combine(root, $"{relative}.bin");
var metaPath = Path.Combine(root, $"{relative}.json");
Directory.CreateDirectory(Path.GetDirectoryName(contentPath)!);
await File.WriteAllBytesAsync(contentPath, content, cancellationToken);
var payload = new
{
path,
contentType,
meta
};
await File.WriteAllTextAsync(metaPath, JsonSerializer.Serialize(payload, JsonOptions), cancellationToken);
}
public async Task<MediaContentSnapshot?> LoadAsync(string path, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(DirectoryPath) || string.IsNullOrWhiteSpace(path)) return null;
var root = Path.GetFullPath(DirectoryPath);
var relative = MediaPathHelper.GetRelativeStoragePath(path);
var contentPath = Path.Combine(root, $"{relative}.bin");
var metaPath = Path.Combine(root, $"{relative}.json");
if (!File.Exists(contentPath)) return null;
var content = await File.ReadAllBytesAsync(contentPath, cancellationToken);
var contentType = "application/octet-stream";
IDictionary<string, object?> meta = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
if (File.Exists(metaPath))
{
try
{
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(metaPath, cancellationToken));
var rootElement = document.RootElement;
if (rootElement.TryGetProperty("contentType", out var type) &&
type.ValueKind == JsonValueKind.String)
contentType = type.GetString() ?? contentType;
if (rootElement.TryGetProperty("meta", out var metaElement) &&
metaElement.ValueKind == JsonValueKind.Object)
meta = JsonSerializer.Deserialize<Dictionary<string, object?>>(metaElement.GetRawText(),
JsonOptions) ??
new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
}
catch
{
// Keep binary content available even if the manifest is malformed.
}
}
return new MediaContentSnapshot
{
ContentType = contentType,
Content = content,
Meta = meta as IReadOnlyDictionary<string, object?> ?? new Dictionary<string, object?>(meta)
};
}
}

View File

@@ -0,0 +1,9 @@
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Infrastructure.Media;
internal interface IMediaContentStoreFactory
{
IMediaContentStore Create(string? directoryPath, MediaContentStoreKind backendKind, string containerName,
string? connectionString);
}

View File

@@ -0,0 +1,16 @@
using Jibo.Cloud.Application.Abstractions;
namespace Jibo.Cloud.Infrastructure.Media;
internal sealed class MediaContentStoreFactory : IMediaContentStoreFactory
{
public IMediaContentStore Create(string? directoryPath, MediaContentStoreKind backendKind, string containerName,
string? connectionString)
{
return backendKind switch
{
MediaContentStoreKind.AzureBlob => new AzureBlobMediaContentStore(connectionString, containerName),
_ => new FileMediaContentStore(directoryPath)
};
}
}

View File

@@ -0,0 +1,7 @@
namespace Jibo.Cloud.Infrastructure.Media;
public enum MediaContentStoreKind
{
File,
AzureBlob
}

View File

@@ -0,0 +1,9 @@
namespace Jibo.Cloud.Infrastructure.Media;
public sealed class MediaContentStoreOptions
{
public MediaContentStoreKind Backend { get; set; } = MediaContentStoreKind.File;
public string? DirectoryPath { get; set; }
public string? ConnectionString { get; set; }
public string ContainerName { get; set; } = "openjibo-media";
}

View File

@@ -0,0 +1,23 @@
namespace Jibo.Cloud.Infrastructure.Media;
internal static class MediaPathHelper
{
public static string GetRelativeStoragePath(string path)
{
var trimmed = path.Trim().TrimStart('/', '\\');
var segments = trimmed.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(SanitizeSegment)
.Where(segment => !string.IsNullOrWhiteSpace(segment))
.ToArray();
return segments.Length == 0 ? "media-item" : Path.Combine(segments);
}
private static string SanitizeSegment(string value)
{
var chars = value
.Select(character => char.IsLetterOrDigit(character) || character is '-' or '_' or '.' ? character : '_')
.ToArray();
return string.Join(string.Empty, chars);
}
}

View File

@@ -6,7 +6,9 @@
"OpenJibo__State__Backend": "AzureBlob", "OpenJibo__State__Backend": "AzureBlob",
"OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true", "OpenJibo__State__ConnectionString": "UseDevelopmentStorage=true",
"OpenJibo__PersonalMemory__Backend": "AzureBlob", "OpenJibo__PersonalMemory__Backend": "AzureBlob",
"OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true" "OpenJibo__PersonalMemory__ConnectionString": "UseDevelopmentStorage=true",
"OpenJibo__Media__Backend": "AzureBlob",
"OpenJibo__Media__ConnectionString": "UseDevelopmentStorage=true"
} }
} }
} }

View File

@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Jibo.Cloud.Application.Services; using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Domain.Models; using Jibo.Cloud.Domain.Models;
using Jibo.Cloud.Infrastructure.Media;
using Jibo.Cloud.Infrastructure.Persistence; using Jibo.Cloud.Infrastructure.Persistence;
namespace Jibo.Cloud.Tests.Protocol; namespace Jibo.Cloud.Tests.Protocol;
@@ -255,6 +256,47 @@ public sealed class JiboCloudProtocolServiceTests
Assert.Equal("binary-photo-placeholder", mediaGet.BodyText); Assert.Equal("binary-photo-placeholder", mediaGet.BodyText);
} }
[Fact]
public async Task MediaCreate_PersistsBinaryContentThroughFileMediaStore()
{
var directoryPath = Path.Combine(Path.GetTempPath(), "OpenJibo.Media.Tests", Guid.NewGuid().ToString("N"));
var service = new JiboCloudProtocolService(new InMemoryCloudStateStore(),
new FileMediaContentStore(directoryPath));
var result = await service.DispatchAsync(new ProtocolEnvelope
{
HostName = "api.jibo.com",
Method = "POST",
ServicePrefix = "Media_20160725",
Operation = "Create",
Headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Content-Type"] = "image/jpeg",
["x-path"] = "photo-blob-2",
["x-type"] = "image"
},
BodyText = "binary-photo-placeholder"
});
using var createdPayload = JsonDocument.Parse(result.BodyText);
Assert.Equal("https://api.jibo.com/media/photo-blob-2",
createdPayload.RootElement.GetProperty("url").GetString());
var storedFile = Path.Combine(directoryPath, "photo-blob-2.bin");
Assert.True(File.Exists(storedFile));
var mediaGet = await service.DispatchAsync(new ProtocolEnvelope
{
HostName = "api.jibo.com",
Method = "GET",
Path = "/media/photo-blob-2"
});
Assert.Equal(200, mediaGet.StatusCode);
Assert.Equal("image/jpeg", mediaGet.ContentType);
Assert.Equal("binary-photo-placeholder", mediaGet.BodyText);
}
[Fact] [Fact]
public async Task KeyCreateSymmetricKey_ReturnsKeyPayload() public async Task KeyCreateSymmetricKey_ReturnsKeyPayload()
{ {