first pass and feature add ons

This commit is contained in:
Jacob Dubin
2026-04-11 21:19:35 -05:00
parent b1fa225d1d
commit d7ea8eebab
13 changed files with 918 additions and 45 deletions

View File

@@ -6,12 +6,28 @@ public interface ICloudStateStore
{
AccountProfile GetAccount();
DeviceRegistration GetRobot();
RobotProfile GetRobotProfile();
DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion);
string IssueHubToken();
string IssueRobotToken(string deviceId);
CloudSession OpenSession(string kind, string? deviceId, string? token, string? hostName, string? path);
CloudSession? FindSessionByToken(string token);
IReadOnlyList<LoopRecord> GetLoops();
IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null);
UpdateManifest GetUpdateFrom(string? subsystem, string? fromVersion, string? filter);
UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary<string, object?>? dependencies);
UpdateManifest RemoveUpdate(string? updateId);
IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null);
IReadOnlyList<MediaRecord> GetMedia(IReadOnlyList<string> paths);
IReadOnlyList<MediaRecord> RemoveMedia(IReadOnlyList<string> paths);
MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary<string, object?>? meta);
IReadOnlyList<BackupRecord> GetBackups();
bool ShouldCreateSymmetricKey(string loopId);
string GetOrCreateSymmetricKey(string loopId);
KeyRequestRecord CreateKeyRequest(string loopId, string publicKey);
KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey);
IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests();
IReadOnlyList<KeyRequestRecord> GetBinaryRequests();
IReadOnlyList<object> GetHolidays();
void UpdateRobot(DeviceRegistration registration);
}

View File

@@ -50,9 +50,19 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var servicePrefix = envelope.ServicePrefix ?? string.Empty;
var operation = envelope.Operation ?? string.Empty;
if (servicePrefix.StartsWith("Log_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleLog(operation, envelope));
}
if (servicePrefix.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleBackup(operation));
}
if (servicePrefix.StartsWith("Account_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleAccount(operation));
return Task.FromResult(HandleAccount(operation, envelope));
}
if (servicePrefix.StartsWith("Notification_", StringComparison.OrdinalIgnoreCase))
@@ -65,6 +75,21 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
return Task.FromResult(HandleLoop(operation));
}
if (servicePrefix.Equals("Media_20160725", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleMedia(operation, envelope));
}
if (servicePrefix.StartsWith("Key_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleKey(operation, envelope));
}
if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandlePerson(operation));
}
if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase))
{
return Task.FromResult(HandleRobot(operation, envelope));
@@ -78,28 +103,78 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
return Task.FromResult(ProtocolDispatchResult.Ok(new
{
ok = true,
service = servicePrefix,
operation
host = envelope.HostName,
target = $"{servicePrefix}.{operation}".Trim('.'),
operation,
note = "unknown target default response"
}));
}
private ProtocolDispatchResult HandleAccount(string operation)
private ProtocolDispatchResult HandleAccount(string operation, ProtocolEnvelope envelope)
{
var account = stateStore.GetAccount();
var body = envelope.TryParseBody();
return operation switch
if (operation.Equals("CreateHubToken", StringComparison.OrdinalIgnoreCase))
{
"CreateHubToken" => ProtocolDispatchResult.Ok(new
return ProtocolDispatchResult.Ok(new
{
token = stateStore.IssueHubToken(),
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
}),
"CreateAccessToken" => ProtocolDispatchResult.Ok(new
});
}
if (operation.Equals("CreateAccessToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
token = $"access-{account.AccountId}-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
expires = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeMilliseconds()
}),
"Get" => ProtocolDispatchResult.Ok(new[]
});
}
if (operation.Equals("CheckEmail", StringComparison.OrdinalIgnoreCase))
{
var email = ReadString(body, "email") ?? string.Empty;
return ProtocolDispatchResult.Ok(new
{
exists = email.Equals(account.Email, StringComparison.OrdinalIgnoreCase)
});
}
if (operation is "Create" or "Login")
{
return ProtocolDispatchResult.Ok(new
{
id = account.AccountId,
email = ReadString(body, "email") ?? account.Email,
firstName = ReadString(body, "firstName") ?? account.FirstName,
lastName = ReadString(body, "lastName") ?? account.LastName,
gender = "unknown",
birthday = 631152000000L,
phoneNumber = "+10000000000",
photoUrl = string.Empty,
isActive = true,
messagingAllowed = true,
accessKeyId = account.AccessKeyId,
secretAccessKey = account.SecretAccessKey,
roles = Array.Empty<object>(),
facebookConnected = false,
termsAccepted = true
});
}
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
{
var ids = ReadStringArray(body, "ids");
var matches = ids.Count == 0 || ids.Contains(account.AccountId, StringComparer.OrdinalIgnoreCase);
if (!matches)
{
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
return ProtocolDispatchResult.Ok(new[]
{
new
{
@@ -110,15 +185,89 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
accessKeyId = account.AccessKeyId,
secretAccessKey = account.SecretAccessKey
}
}),
_ => ProtocolDispatchResult.Ok(new
});
}
if (operation is "Update" or "ResetKeys" or "Remove" or "ActivateByCode" or "ResendActivationCode" or
"ChangePassword" or "SendPasswordReset" or "PasswordResetByCode" or "UpdatePhoto" or "RemovePhoto" or
"VerifyPhoneByCode" or "AcceptTerms" or "FacebookConnect" or "FacebookMobileConnect")
{
return ProtocolDispatchResult.Ok(new
{
id = account.AccountId,
email = account.Email,
firstName = account.FirstName,
lastName = account.LastName
})
};
lastName = account.LastName,
accessKeyId = account.AccessKeyId,
secretAccessKey = account.SecretAccessKey
});
}
if (operation is "ChangeEmail" or "SendPhoneVerificationCode")
{
return ProtocolDispatchResult.Ok(new
{
id = account.AccountId
});
}
if (operation.Equals("GetAccountByAccessToken", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
id = account.AccountId,
accessKeyId = account.AccessKeyId,
secretAccessKey = account.SecretAccessKey,
email = account.Email,
friendlyId = stateStore.GetRobot().RobotId,
payload = ReadObject(body, "payload")
});
}
if (operation.Equals("Search", StringComparison.OrdinalIgnoreCase))
{
var query = (ReadString(body, "query") ?? string.Empty).ToLowerInvariant();
var haystack = $"{account.Email} {account.FirstName} {account.LastName} {account.AccountId}".ToLowerInvariant();
return ProtocolDispatchResult.Ok(query.Length > 0 && haystack.Contains(query)
? new[]
{
new
{
id = account.AccountId,
email = account.Email,
firstName = account.FirstName,
lastName = account.LastName
}
}
: Array.Empty<object>());
}
if (operation.Equals("FacebookPrepareLogin", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
url = "https://example.com/facebook-login",
client_id = "fake-client-id",
scope = "email",
response_type = "token",
state = $"fb-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
redirect_uri = "https://api.jibo.com/facebook/callback"
});
}
if (operation.Equals("ConfirmEmailReset", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new { });
}
return ProtocolDispatchResult.Ok(new
{
id = account.AccountId,
email = account.Email,
firstName = account.FirstName,
lastName = account.LastName
});
}
private ProtocolDispatchResult HandleNotification(string operation, ProtocolEnvelope envelope)
@@ -131,7 +280,11 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
var body = envelope.TryParseBody();
var deviceId = envelope.DeviceId
?? ReadString(body, "deviceId")
?? ReadString(body, "serial_number")
?? ReadString(body, "serialNumber")
?? ReadString(body, "cpuid")
?? ReadString(body, "cpuId")
?? ReadString(body, "robotId")
?? "unknown-device";
stateStore.GetOrCreateDevice(deviceId, envelope.FirmwareVersion, envelope.ApplicationVersion);
@@ -149,24 +302,181 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
var robot = stateStore.GetRobot();
var account = stateStore.GetAccount();
return ProtocolDispatchResult.Ok(new[]
return ProtocolDispatchResult.Ok(stateStore.GetLoops().Select(loop => new
{
new
id = loop.LoopId,
name = loop.Name,
owner = loop.OwnerAccountId,
robot = loop.RobotId,
robotFriendlyId = loop.RobotFriendlyId,
members = Array.Empty<object>(),
isSuspended = loop.IsSuspended,
created = loop.CreatedUtc.ToUnixTimeMilliseconds(),
updated = loop.UpdatedUtc.ToUnixTimeMilliseconds()
}).ToArray());
}
private ProtocolDispatchResult HandleLog(string operation, ProtocolEnvelope envelope)
{
return operation switch
{
"PutEventsAsync" => ProtocolDispatchResult.Ok(new
{
id = "openjibo-default-loop",
name = "OpenJibo Default Loop",
owner = account.AccountId,
robot = robot.RobotId,
robotFriendlyId = robot.DeviceId,
members = Array.Empty<object>(),
isSuspended = false,
created = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
updated = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
}
});
contentEncoding = "gzip",
uploadUrl = "https://api.jibo.com/upload/log-events"
}),
"PutEvents" => ProtocolDispatchResult.Ok(new { }),
"PutBinaryAsync" => ProtocolDispatchResult.Ok(new
{
url = "https://api.jibo.com/log/binary/fake-id",
uploadUrl = "https://api.jibo.com/upload/log-binary"
}),
"PutAsrBinary" => ProtocolDispatchResult.Ok(new
{
bucketName = "openjibo-test",
key = "asr/fake-key",
uploadUrl = "https://api.jibo.com/upload/asr-binary"
}),
"NewKinesisCredentials" => ProtocolDispatchResult.Ok(new
{
credentials = new
{
AccessKeyId = "fake-access-key",
Expiration = DateTimeOffset.UtcNow.AddHours(1).ToString("O"),
SecretAccessKey = "fake-secret",
SessionToken = "fake-session"
},
region = "us-east-1",
streamName = "openjibo-log-stream"
}),
_ => ProtocolDispatchResult.Ok(new { })
};
}
private ProtocolDispatchResult HandleMedia(string operation, ProtocolEnvelope envelope)
{
var body = envelope.TryParseBody();
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.ListMedia(
ReadStringArray(body, "loopIds"),
ReadLong(body, "after"),
ReadLong(body, "before")).Select(MapMedia).ToArray());
}
if (operation.Equals("Get", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray());
}
if (operation.Equals("Remove", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.RemoveMedia(ReadStringArray(body, "paths")).Select(MapMedia).ToArray());
}
if (operation.Equals("Create", StringComparison.OrdinalIgnoreCase))
{
var loopId = ReadHeader(envelope, "x-loop-id") ?? ReadString(body, "loopId") ?? stateStore.GetLoops()[0].LoopId;
var path = ReadHeader(envelope, "x-path") ?? ReadString(body, "path") ?? $"/media/{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
var type = ReadHeader(envelope, "x-type") ?? ReadString(body, "type") ?? "unknown";
var reference = ReadHeader(envelope, "x-reference") ?? ReadString(body, "reference") ?? string.Empty;
var isEncrypted = ReadBooleanHeader(envelope, "x-encrypted") || ReadBool(body, "isEncrypted");
var meta = ReadObject(body, "meta");
return ProtocolDispatchResult.Ok(MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
}
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
private ProtocolDispatchResult HandlePerson(string operation)
{
if (operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetHolidays());
}
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
private ProtocolDispatchResult HandleBackup(string operation)
{
if (operation.Equals("List", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetBackups());
}
return ProtocolDispatchResult.Ok(Array.Empty<object>());
}
private ProtocolDispatchResult HandleKey(string operation, ProtocolEnvelope envelope)
{
var body = envelope.TryParseBody();
var loopId = ReadString(body, "loopId") ?? ReadString(body, "id") ?? stateStore.GetLoops()[0].LoopId;
if (operation.Equals("ShouldCreate", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(new
{
shouldCreate = stateStore.ShouldCreateSymmetricKey(loopId)
});
}
if (operation.Equals("CreateSymmetricKey", StringComparison.OrdinalIgnoreCase))
{
var symmetricKey = stateStore.GetOrCreateSymmetricKey(loopId);
return ProtocolDispatchResult.Ok(new
{
loopId,
key = symmetricKey,
symmetricKey,
created = true
});
}
if (operation is "CreateRequest" or "RequestSymmetricKey")
{
var record = stateStore.CreateKeyRequest(loopId, ReadString(body, "publicKey") ?? string.Empty);
return ProtocolDispatchResult.Ok(new
{
id = record.RequestId,
loopId = record.LoopId
});
}
if (operation.Equals("GetRequest", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetKeyRequest(loopId, ReadString(body, "id"), ReadString(body, "publicKey")));
}
if (operation.Equals("ListIncomingRequests", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetIncomingKeyRequests());
}
if (operation.Equals("ListBinaryRequests", StringComparison.OrdinalIgnoreCase))
{
return ProtocolDispatchResult.Ok(stateStore.GetBinaryRequests());
}
if (operation is "Share" or "ShareSymmetricKey" or "ShareBinary")
{
return ProtocolDispatchResult.Ok(new { ok = true });
}
if (operation.Equals("LoadSymmetricKey", StringComparison.OrdinalIgnoreCase))
{
var symmetricKey = stateStore.GetOrCreateSymmetricKey(loopId);
return ProtocolDispatchResult.Ok(new
{
loopId,
key = symmetricKey,
symmetricKey
});
}
return ProtocolDispatchResult.Ok(new { ok = true, operation });
}
private ProtocolDispatchResult HandleRobot(string operation, ProtocolEnvelope envelope)
@@ -186,16 +496,28 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
};
stateStore.UpdateRobot(updated);
robot = updated;
return ProtocolDispatchResult.Ok(new
{
result = "ok"
});
}
if (operation.Equals("GetRobot", StringComparison.OrdinalIgnoreCase))
{
var profile = stateStore.GetRobotProfile();
return ProtocolDispatchResult.Ok(new
{
id = ReadString(envelope.TryParseBody(), "id") ?? profile.RobotId,
payload = profile.Payload,
calibrationPayload = profile.CalibrationPayload,
updated = profile.UpdatedUtc.ToUnixTimeMilliseconds(),
created = profile.CreatedUtc.ToUnixTimeMilliseconds()
});
}
return ProtocolDispatchResult.Ok(new
{
id = robot.RobotId,
friendlyId = robot.DeviceId,
name = robot.FriendlyName,
firmwareVersion = robot.FirmwareVersion,
applicationVersion = robot.ApplicationVersion
result = "ok"
});
}
@@ -209,8 +531,21 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
return operation switch
{
"ListUpdates" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate).ToArray()),
"ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter).Select(MapUpdate).ToArray()),
"ListUpdatesFrom" => ProtocolDispatchResult.Ok(stateStore.ListUpdates(subsystem, filter)
.Where(update => fromVersion is null || update.FromVersion.Equals(fromVersion, StringComparison.OrdinalIgnoreCase))
.Select(MapUpdate)
.ToArray()),
"GetUpdateFrom" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.GetUpdateFrom(subsystem, fromVersion, filter))),
"CreateUpdate" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.CreateUpdate(
fromVersion,
ReadString(body, "toVersion"),
ReadString(body, "changes"),
ReadString(body, "shaHash"),
ReadLong(body, "length"),
subsystem,
filter,
ReadObject(body, "dependencies")))),
"RemoveUpdate" => ProtocolDispatchResult.Ok(MapUpdate(stateStore.RemoveUpdate(ReadString(body, "id")))),
_ => ProtocolDispatchResult.Ok(Array.Empty<object>())
};
}
@@ -221,6 +556,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
{
_id = update.UpdateId,
created = update.CreatedUtc.ToUnixTimeMilliseconds(),
accountId = "usr_openjibo_owner",
fromVersion = update.FromVersion,
toVersion = update.ToVersion,
changes = update.Changes,
@@ -228,18 +564,31 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
shaHash = update.ShaHash,
length = update.Length,
subsystem = update.Subsystem,
filter = update.Filter
filter = update.Filter,
dependencies = new Dictionary<string, object?>()
};
}
private static object MapMedia(MediaRecord item)
{
return new
{
path = item.Path,
created = item.CreatedUtc.ToUnixTimeMilliseconds(),
type = item.MediaType,
reference = item.Reference,
accountId = item.AccountId,
loopId = item.LoopId,
url = item.Url,
isEncrypted = item.IsEncrypted,
isDeleted = item.IsDeleted,
meta = item.Meta
};
}
private static string? ReadString(JsonElement? element, string propertyName)
{
if (element is null)
{
return null;
}
if (!element.Value.TryGetProperty(propertyName, out var property))
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
{
return null;
}
@@ -248,4 +597,88 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
? property.GetString()
: property.ToString();
}
private static long? ReadLong(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
{
return null;
}
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt64(out var number))
{
return number;
}
return long.TryParse(property.ToString(), out var parsed) ? parsed : null;
}
private static bool ReadBool(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
{
return false;
}
return property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => bool.TryParse(property.ToString(), out var parsed) && parsed
};
}
private static IReadOnlyList<string> ReadStringArray(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property) || property.ValueKind != JsonValueKind.Array)
{
return [];
}
return property.EnumerateArray()
.Select(item => item.ValueKind == JsonValueKind.String ? item.GetString() ?? string.Empty : item.ToString())
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToArray();
}
private static IDictionary<string, object?>? ReadObject(JsonElement? element, string propertyName)
{
if (element is null || !element.Value.TryGetProperty(propertyName, out var property))
{
return null;
}
if (property.ValueKind != JsonValueKind.Object)
{
return null;
}
var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
foreach (var child in property.EnumerateObject())
{
result[child.Name] = child.Value.ValueKind switch
{
JsonValueKind.String => child.Value.GetString(),
JsonValueKind.Number when child.Value.TryGetInt64(out var longValue) => longValue,
JsonValueKind.Number when child.Value.TryGetDouble(out var doubleValue) => doubleValue,
JsonValueKind.True => true,
JsonValueKind.False => false,
_ => child.Value.ToString()
};
}
return result;
}
private static string? ReadHeader(ProtocolEnvelope envelope, string headerName)
{
return envelope.Headers.TryGetValue(headerName, out var value) ? value : null;
}
private static bool ReadBooleanHeader(ProtocolEnvelope envelope, string headerName)
{
return envelope.Headers.TryGetValue(headerName, out var value) &&
bool.TryParse(value, out var parsed) &&
parsed;
}
}

View File

@@ -0,0 +1,8 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class BackupRecord
{
public string BackupId { get; init; } = Guid.NewGuid().ToString("N");
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public string Name { get; init; } = "backup";
}

View File

@@ -0,0 +1,10 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class KeyRequestRecord
{
public string RequestId { get; init; } = Guid.NewGuid().ToString("N");
public string LoopId { get; init; } = "openjibo-default-loop";
public string PublicKey { get; init; } = string.Empty;
public string EncryptedKey { get; init; } = string.Empty;
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,13 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class LoopRecord
{
public string LoopId { get; init; } = "openjibo-default-loop";
public string Name { get; init; } = "OpenJibo Default Loop";
public string OwnerAccountId { get; init; } = "usr_openjibo_owner";
public string RobotId { get; init; } = "my-robot-name";
public string RobotFriendlyId { get; init; } = "my-robot-serial-number";
public bool IsSuspended { get; init; }
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -0,0 +1,15 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class MediaRecord
{
public string Path { get; init; } = string.Empty;
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public string MediaType { get; init; } = "unknown";
public string Reference { get; init; } = string.Empty;
public string AccountId { get; init; } = "usr_openjibo_owner";
public string LoopId { get; init; } = "openjibo-default-loop";
public string Url { get; init; } = string.Empty;
public bool IsEncrypted { get; init; }
public bool IsDeleted { get; init; }
public IDictionary<string, object?> Meta { get; init; } = new Dictionary<string, object?>();
}

View File

@@ -0,0 +1,8 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class ProtocolFixture
{
public string Name { get; init; } = string.Empty;
public ProtocolEnvelope Request { get; init; } = new();
public int ExpectedStatusCode { get; init; } = 200;
}

View File

@@ -0,0 +1,10 @@
namespace Jibo.Cloud.Domain.Models;
public sealed class RobotProfile
{
public string RobotId { get; init; } = "my-robot-name";
public DateTimeOffset CreatedUtc { get; init; } = DateTimeOffset.UtcNow;
public IDictionary<string, object?> Payload { get; init; } = new Dictionary<string, object?>();
public IDictionary<string, object?> CalibrationPayload { get; init; } = new Dictionary<string, object?>();
public DateTimeOffset UpdatedUtc { get; init; } = DateTimeOffset.UtcNow;
}

View File

@@ -9,8 +9,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
private readonly AccountProfile _account = new();
private readonly ConcurrentDictionary<string, DeviceRegistration> _devices = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, CloudSession> _sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, KeyRequestRecord> _keyRequests = new(StringComparer.OrdinalIgnoreCase);
private readonly List<UpdateManifest> _updates;
private readonly List<MediaRecord> _media = [];
private readonly List<BackupRecord> _backups = [];
private readonly List<LoopRecord> _loops;
private DeviceRegistration _robot;
private RobotProfile _robotProfile;
public InMemoryCloudStateStore()
{
@@ -25,6 +31,26 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
};
_devices[_robot.DeviceId] = _robot;
_robotProfile = new RobotProfile
{
RobotId = _robot.RobotId,
Payload = new Dictionary<string, object?>
{
["SSID"] = "my-ssid",
["connectedAt"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
["platform"] = "12.10.0",
["serialNumber"] = _robot.DeviceId
}
};
_loops =
[
new LoopRecord
{
OwnerAccountId = _account.AccountId,
RobotId = _robot.RobotId,
RobotFriendlyId = _robot.DeviceId
}
];
_updates =
[
@@ -45,6 +71,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
public DeviceRegistration GetRobot() => _robot;
public RobotProfile GetRobotProfile() => _robotProfile;
public DeviceRegistration GetOrCreateDevice(string deviceId, string? firmwareVersion, string? applicationVersion)
{
return _devices.AddOrUpdate(
@@ -121,6 +149,8 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
return _sessionsByToken.GetValueOrDefault(token);
}
public IReadOnlyList<LoopRecord> GetLoops() => _loops.ToArray();
public IReadOnlyList<UpdateManifest> ListUpdates(string? subsystem = null, string? filter = null)
{
return _updates
@@ -141,9 +171,184 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
};
}
public UpdateManifest CreateUpdate(string? fromVersion, string? toVersion, string? changes, string? shaHash, long? length, string? subsystem, string? filter, IDictionary<string, object?>? dependencies)
{
var update = new UpdateManifest
{
UpdateId = $"upd-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
FromVersion = fromVersion ?? "unknown",
ToVersion = toVersion ?? fromVersion ?? "unknown",
Changes = changes ?? string.Empty,
Url = $"https://api.jibo.com/update/upd-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
ShaHash = shaHash ?? "fake-sha-hash",
Length = length ?? 0,
Subsystem = subsystem ?? "unknown",
Filter = filter
};
_updates.Add(update);
return update;
}
public UpdateManifest RemoveUpdate(string? updateId)
{
var existing = _updates.FirstOrDefault(update => update.UpdateId == updateId);
if (existing is not null)
{
_updates.Remove(existing);
return existing;
}
return new UpdateManifest
{
UpdateId = updateId ?? "unknown-update",
Changes = "Update not found",
Url = "https://api.jibo.com/update/missing",
ShaHash = "missing",
Subsystem = "unknown"
};
}
public IReadOnlyList<MediaRecord> ListMedia(IReadOnlyList<string>? loopIds = null, long? after = null, long? before = null)
{
return _media
.Where(item => loopIds is null || loopIds.Count == 0 || loopIds.Contains(item.LoopId))
.Where(item => after is null || item.CreatedUtc.ToUnixTimeMilliseconds() > after)
.Where(item => before is null || item.CreatedUtc.ToUnixTimeMilliseconds() < before)
.ToArray();
}
public IReadOnlyList<MediaRecord> GetMedia(IReadOnlyList<string> paths)
{
return _media.Where(item => paths.Contains(item.Path)).ToArray();
}
public IReadOnlyList<MediaRecord> RemoveMedia(IReadOnlyList<string> paths)
{
var replacements = new List<MediaRecord>();
for (var i = 0; i < _media.Count; i++)
{
if (!paths.Contains(_media[i].Path))
{
continue;
}
var updated = new MediaRecord
{
Path = _media[i].Path,
CreatedUtc = _media[i].CreatedUtc,
MediaType = _media[i].MediaType,
Reference = _media[i].Reference,
AccountId = _media[i].AccountId,
LoopId = _media[i].LoopId,
Url = _media[i].Url,
IsEncrypted = _media[i].IsEncrypted,
IsDeleted = true,
Meta = _media[i].Meta
};
_media[i] = updated;
replacements.Add(updated);
}
return replacements;
}
public MediaRecord CreateMedia(string loopId, string path, string type, string reference, bool isEncrypted, IDictionary<string, object?>? meta)
{
var item = new MediaRecord
{
Path = path,
MediaType = type,
Reference = reference,
AccountId = _account.AccountId,
LoopId = loopId,
Url = $"https://api.jibo.com/media/{Uri.EscapeDataString(path)}",
IsEncrypted = isEncrypted,
Meta = meta ?? new Dictionary<string, object?>()
};
_media.Add(item);
return item;
}
public IReadOnlyList<BackupRecord> GetBackups() => _backups.ToArray();
public bool ShouldCreateSymmetricKey(string loopId) => true;
public string GetOrCreateSymmetricKey(string loopId)
{
return _symmetricKeys.GetOrAdd(loopId, key => Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"open-jibo-symmetric-key:{key}")));
}
public KeyRequestRecord CreateKeyRequest(string loopId, string publicKey)
{
var record = new KeyRequestRecord
{
RequestId = $"req-{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
LoopId = loopId,
PublicKey = publicKey
};
_keyRequests[record.RequestId] = record;
return record;
}
public KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey)
{
if (!string.IsNullOrWhiteSpace(requestId) && _keyRequests.TryGetValue(requestId, out var record))
{
return record;
}
return new KeyRequestRecord
{
RequestId = requestId ?? "unknown-request",
LoopId = loopId,
PublicKey = publicKey ?? string.Empty
};
}
public IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests() => [];
public IReadOnlyList<KeyRequestRecord> GetBinaryRequests() => [];
public IReadOnlyList<object> GetHolidays()
{
return
[
new
{
id = "easter-1",
eventId = (string?)null,
name = "Easter",
category = "holiday",
subcategory = (string?)null,
loopId = _loops[0].LoopId,
memberId = (string?)null,
isEnabled = true,
date = "2026-04-05",
endDate = (string?)null,
created = DateTimeOffset.UtcNow.ToString("O")
}
];
}
public void UpdateRobot(DeviceRegistration registration)
{
_robot = registration;
_devices[registration.DeviceId] = registration;
_robotProfile = new RobotProfile
{
RobotId = registration.RobotId,
Payload = new Dictionary<string, object?>
{
["SSID"] = "my-ssid",
["connectedAt"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
["platform"] = registration.FirmwareVersion ?? "12.10.0",
["serialNumber"] = registration.DeviceId
},
UpdatedUtc = DateTimeOffset.UtcNow
};
}
}

View File

@@ -0,0 +1,48 @@
using System.Text.Json;
using Jibo.Cloud.Domain.Models;
namespace Jibo.Cloud.Tests.Fixtures;
internal static class ProtocolFixtureLoader
{
public static ProtocolFixture Load(string relativePath)
{
var fullPath = Path.Combine(AppContext.BaseDirectory, relativePath);
using var document = JsonDocument.Parse(File.ReadAllText(fullPath));
var root = document.RootElement;
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (root.TryGetProperty("headers", out var headerElement) && headerElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in headerElement.EnumerateObject())
{
headers[property.Name] = property.Value.ToString();
}
}
var bodyText = root.TryGetProperty("body", out var bodyElement)
? bodyElement.GetRawText()
: string.Empty;
var target = headers.TryGetValue("x-amz-target", out var targetValue)
? targetValue
: string.Empty;
var targetParts = target.Split('.', 2, StringSplitOptions.RemoveEmptyEntries);
return new ProtocolFixture
{
Name = Path.GetFileNameWithoutExtension(relativePath),
Request = new ProtocolEnvelope
{
HostName = root.TryGetProperty("host", out var hostElement) ? hostElement.GetString() ?? "api.jibo.com" : "api.jibo.com",
Method = root.TryGetProperty("method", out var methodElement) ? methodElement.GetString() ?? "POST" : "POST",
Path = root.TryGetProperty("path", out var pathElement) ? pathElement.GetString() ?? "/" : "/",
Headers = headers,
ServicePrefix = targetParts.Length > 0 ? targetParts[0] : null,
Operation = targetParts.Length > 1 ? targetParts[1] : null,
BodyText = bodyText
},
ExpectedStatusCode = 200
};
}
}

View File

@@ -19,4 +19,11 @@
<ProjectReference Include="..\..\src\Jibo.Runtime.Abstractions\Jibo.Runtime.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="..\..\src\Jibo.Cloud\node\fixtures\http\*.json">
<Link>fixtures\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -61,4 +61,82 @@ public sealed class JiboCloudProtocolServiceTests
Assert.Equal("robot", payload.RootElement.GetProperty("subsystem").GetString());
Assert.True(payload.RootElement.TryGetProperty("url", out _));
}
[Fact]
public async Task PutEventsAsync_ReturnsUploadUrl()
{
var result = await _service.DispatchAsync(new ProtocolEnvelope
{
HostName = "api.jibo.com",
Method = "POST",
ServicePrefix = "Log_20160715",
Operation = "PutEventsAsync",
BodyText = "{}"
});
using var payload = JsonDocument.Parse(result.BodyText);
Assert.Equal("gzip", payload.RootElement.GetProperty("contentEncoding").GetString());
Assert.Contains("/upload/log-events", payload.RootElement.GetProperty("uploadUrl").GetString());
}
[Fact]
public async Task MediaCreateAndGet_ReturnsCreatedItem()
{
var created = await _service.DispatchAsync(new ProtocolEnvelope
{
HostName = "api.jibo.com",
Method = "POST",
ServicePrefix = "Media_20160725",
Operation = "Create",
BodyText = """{"path":"/media/test-item","type":"image","reference":"demo"}"""
});
using var createdPayload = JsonDocument.Parse(created.BodyText);
Assert.Equal("/media/test-item", createdPayload.RootElement.GetProperty("path").GetString());
var fetched = await _service.DispatchAsync(new ProtocolEnvelope
{
HostName = "api.jibo.com",
Method = "POST",
ServicePrefix = "Media_20160725",
Operation = "Get",
BodyText = """{"paths":["/media/test-item"]}"""
});
using var fetchedPayload = JsonDocument.Parse(fetched.BodyText);
Assert.Single(fetchedPayload.RootElement.EnumerateArray());
}
[Fact]
public async Task KeyCreateSymmetricKey_ReturnsKeyPayload()
{
var result = await _service.DispatchAsync(new ProtocolEnvelope
{
HostName = "api.jibo.com",
Method = "POST",
ServicePrefix = "Key_20160715",
Operation = "CreateSymmetricKey",
BodyText = """{"loopId":"openjibo-default-loop"}"""
});
using var payload = JsonDocument.Parse(result.BodyText);
Assert.Equal("openjibo-default-loop", payload.RootElement.GetProperty("loopId").GetString());
Assert.False(string.IsNullOrWhiteSpace(payload.RootElement.GetProperty("key").GetString()));
}
[Fact]
public async Task PersonListHolidays_ReturnsHoliday()
{
var result = await _service.DispatchAsync(new ProtocolEnvelope
{
HostName = "api.jibo.com",
Method = "POST",
ServicePrefix = "Person_20160715",
Operation = "ListHolidays",
BodyText = "{}"
});
using var payload = JsonDocument.Parse(result.BodyText);
Assert.Single(payload.RootElement.EnumerateArray());
}
}

View File

@@ -0,0 +1,22 @@
using Jibo.Cloud.Application.Services;
using Jibo.Cloud.Infrastructure.Persistence;
using Jibo.Cloud.Tests.Fixtures;
namespace Jibo.Cloud.Tests.Protocol;
public sealed class ProtocolFixtureReplayTests
{
private readonly JiboCloudProtocolService _service = new(new InMemoryCloudStateStore());
[Theory]
[InlineData("fixtures\\create-hub-token.request.json")]
[InlineData("fixtures\\new-robot-token.request.json")]
public async Task FixtureRequest_ReplaysSuccessfully(string relativePath)
{
var fixture = ProtocolFixtureLoader.Load(relativePath);
var result = await _service.DispatchAsync(fixture.Request);
Assert.Equal(fixture.ExpectedStatusCode, result.StatusCode);
Assert.False(string.IsNullOrWhiteSpace(result.BodyText));
}
}