Add binary-safe media storage seam
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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>();
|
||||||
@@ -99,4 +114,4 @@ public static class ServiceCollectionExtensions
|
|||||||
? backendKind
|
? backendKind
|
||||||
: PersistenceBackendKind.File;
|
: PersistenceBackendKind.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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Jibo.Cloud.Infrastructure.Media;
|
||||||
|
|
||||||
|
public enum MediaContentStoreKind
|
||||||
|
{
|
||||||
|
File,
|
||||||
|
AzureBlob
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user