Add loop-scoped holiday list support
This commit is contained in:
@@ -924,6 +924,9 @@ For `1.0.19`:
|
||||
6. Presence-aware greetings and identity-triggered proactivity - implemented (trigger path, identity-aware reactive/proactive replies, cooldown metadata wiring, focused websocket coverage)
|
||||
7. Personal report parity track (weather visuals, live news path, commute path, calendar parity matrix) - in progress (`2026-05-10` first live-news provider slice implemented)
|
||||
8. Holidays and seasonal personality behavior built on the new memory/proactivity foundation
|
||||
- system holidays should come from an up-to-date provider and merge with loop-scoped custom holiday records
|
||||
- allow disabled holiday records to suppress reminders for people who do not celebrate a holiday
|
||||
- birthdays and other personal dates should flow into the same loop-scoped holiday list once authoring is wired up
|
||||
9. Durable memory persistence path (multi-tenant backing store)
|
||||
- reference design captured in `docs/persistence-architecture.md`
|
||||
- store contracts are now tightened around account/loop/device/person scoping, revision tracking, and explicit load/save boundaries
|
||||
|
||||
22
OpenJibo/docs/holiday-architecture.md
Normal file
22
OpenJibo/docs/holiday-architecture.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Holiday Architecture
|
||||
|
||||
Pegasus exposed holidays as a loop-scoped list synchronized into `/jibo/holidays`.
|
||||
|
||||
In OpenJibo, the holiday path now follows the same broad model:
|
||||
|
||||
- system holidays come from a live holiday source
|
||||
- custom holidays are loop-scoped
|
||||
- suppressed holidays are represented as disabled records
|
||||
- the cloud protocol returns the merged list for `PersonListHolidays`
|
||||
|
||||
Current behavior:
|
||||
|
||||
- `Person/ListHolidays` uses the loop from the request when available
|
||||
- if no loop is supplied, the cloud falls back safely instead of throwing
|
||||
- the merged list is built from system holidays plus any custom loop entries
|
||||
|
||||
Notes:
|
||||
|
||||
- `IsEnabled = false` can be used to suppress a holiday later
|
||||
- birthdays and other personal events can be added as loop-scoped custom records
|
||||
- the current system holiday source uses Nager.Date with a safe local fallback for uptime
|
||||
@@ -90,6 +90,9 @@ The goal is to port these in small batches, capture the source-backed phrasing w
|
||||
- port holiday-aware personality responses as a visible extension of the new persona slice
|
||||
- start with a small, source-backed set (for example birthdays/holidays already represented in legacy data paths)
|
||||
- ensure holiday responses feel characterful while still routing through stock-compatible payloads
|
||||
- use a loop-scoped merged holiday list in the cloud protocol so system holidays and custom person holidays can coexist
|
||||
- source system holidays from a live holiday provider and keep `IsEnabled = false` records available for holiday suppression
|
||||
- keep birthday/custom holiday authoring aligned with person memory so future proactivity can suppress or promote holidays per loop
|
||||
|
||||
### 5. Multi-Tenant Memory Storage Foundation
|
||||
|
||||
@@ -111,6 +114,7 @@ The goal is to port these in small batches, capture the source-backed phrasing w
|
||||
Reference design:
|
||||
|
||||
- [persistence-architecture.md](persistence-architecture.md)
|
||||
- [holiday-architecture.md](holiday-architecture.md)
|
||||
|
||||
## First Implemented Slice In `1.0.19`
|
||||
|
||||
|
||||
@@ -91,6 +91,18 @@ dotnet run --project dotnet/src/Jibo.Cloud.Api/Jibo.Cloud.Api.csproj
|
||||
Replace `UseDevelopmentStorage=true` with your real storage account connection string when you move
|
||||
from local emulation to Azure.
|
||||
|
||||
## Holiday Wiring
|
||||
|
||||
Holiday lists are now sourced from a live holiday provider and merged with loop-scoped custom
|
||||
holiday records.
|
||||
|
||||
The default country code is `US`, but you can override it with:
|
||||
|
||||
- `OpenJibo:Holiday:CountryCode`
|
||||
|
||||
If you later add custom holiday authoring, disabled records can be used to suppress a holiday for a
|
||||
loop without removing the underlying system holiday source.
|
||||
|
||||
## Recovery Strategy
|
||||
|
||||
The first supported device path is:
|
||||
|
||||
@@ -42,6 +42,6 @@ public interface ICloudStateStore
|
||||
KeyRequestRecord GetKeyRequest(string loopId, string? requestId, string? publicKey);
|
||||
IReadOnlyList<KeyRequestRecord> GetIncomingKeyRequests();
|
||||
IReadOnlyList<KeyRequestRecord> GetBinaryRequests();
|
||||
IReadOnlyList<object> GetHolidays();
|
||||
IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null);
|
||||
void UpdateRobot(DeviceRegistration registration);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
|
||||
namespace Jibo.Cloud.Application.Abstractions;
|
||||
|
||||
public interface IHolidayCalendarProvider
|
||||
{
|
||||
IReadOnlyList<HolidayRecord> GetPublicHolidays(string? countryCode, int year);
|
||||
}
|
||||
@@ -72,7 +72,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
|
||||
return Task.FromResult(HandleKey(operation, envelope));
|
||||
|
||||
if (servicePrefix.StartsWith("Person_", StringComparison.OrdinalIgnoreCase))
|
||||
return Task.FromResult(HandlePerson(operation));
|
||||
return Task.FromResult(HandlePerson(operation, envelope));
|
||||
|
||||
if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase))
|
||||
return Task.FromResult(HandleRobot(operation, envelope));
|
||||
@@ -353,11 +353,14 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
|
||||
MapMedia(stateStore.CreateMedia(loopId, path, type, reference, isEncrypted, meta)));
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandlePerson(string operation)
|
||||
private ProtocolDispatchResult HandlePerson(string operation, ProtocolEnvelope envelope)
|
||||
{
|
||||
return ProtocolDispatchResult.Ok(operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase)
|
||||
? stateStore.GetHolidays()
|
||||
: []);
|
||||
if (!operation.Equals("ListHolidays", StringComparison.OrdinalIgnoreCase))
|
||||
return ProtocolDispatchResult.Ok(Array.Empty<object>());
|
||||
|
||||
var body = envelope.TryParseBody();
|
||||
var loopId = ReadString(body, "loopId");
|
||||
return ProtocolDispatchResult.Ok(stateStore.GetHolidays(loopId).Select(MapHoliday));
|
||||
}
|
||||
|
||||
private ProtocolDispatchResult HandleBackup(string operation, ProtocolEnvelope envelope)
|
||||
@@ -549,6 +552,26 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore, IMedia
|
||||
};
|
||||
}
|
||||
|
||||
private static object MapHoliday(HolidayRecord holiday)
|
||||
{
|
||||
return new
|
||||
{
|
||||
id = holiday.Id,
|
||||
eventId = holiday.EventId,
|
||||
name = holiday.Name,
|
||||
category = holiday.Category,
|
||||
subcategory = holiday.Subcategory,
|
||||
loopId = holiday.LoopId,
|
||||
memberId = holiday.MemberId,
|
||||
isEnabled = holiday.IsEnabled,
|
||||
date = holiday.Date,
|
||||
endDate = holiday.EndDate,
|
||||
source = holiday.Source,
|
||||
countryCode = holiday.CountryCode,
|
||||
created = holiday.Created
|
||||
};
|
||||
}
|
||||
|
||||
private static object MapMedia(MediaRecord item)
|
||||
{
|
||||
return new
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Jibo.Cloud.Domain.Models;
|
||||
|
||||
public sealed class HolidayRecord
|
||||
{
|
||||
public string Id { get; init; } = $"holiday-{Guid.NewGuid():N}";
|
||||
public string EventId { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = "Holiday";
|
||||
public string Category { get; init; } = "holiday";
|
||||
public string? Subcategory { get; init; }
|
||||
public string LoopId { get; init; } = "openjibo-default-loop";
|
||||
public string? MemberId { get; init; }
|
||||
public bool IsEnabled { get; init; } = true;
|
||||
public DateOnly Date { get; init; } = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
public DateOnly? EndDate { get; init; }
|
||||
public string Source { get; init; } = "nager-date";
|
||||
public string CountryCode { get; init; } = "US";
|
||||
public DateTimeOffset Created { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Jibo.Cloud.Application.Services;
|
||||
using Jibo.Cloud.Infrastructure.Audio;
|
||||
using Jibo.Cloud.Infrastructure.Commute;
|
||||
using Jibo.Cloud.Infrastructure.Content;
|
||||
using Jibo.Cloud.Infrastructure.Holidays;
|
||||
using Jibo.Cloud.Infrastructure.Media;
|
||||
using Jibo.Cloud.Infrastructure.News;
|
||||
using Jibo.Cloud.Infrastructure.Persistence;
|
||||
@@ -41,11 +42,17 @@ public static class ServiceCollectionExtensions
|
||||
if (string.IsNullOrWhiteSpace(newsApiOptions.ApiKey))
|
||||
newsApiOptions.ApiKey = Environment.GetEnvironmentVariable("NEWSAPI_KEY");
|
||||
|
||||
var holidayOptions = new HolidayCalendarOptions();
|
||||
if (configuration is not null) configuration.GetSection("OpenJibo:Holiday").Bind(holidayOptions);
|
||||
|
||||
services.AddSingleton(sttOptions);
|
||||
services.AddSingleton(openWeatherOptions);
|
||||
services.AddSingleton(newsApiOptions);
|
||||
services.AddSingleton(holidayOptions);
|
||||
services.AddHttpClient<IWeatherReportProvider, OpenWeatherReportProvider>();
|
||||
services.AddHttpClient<INewsBriefingProvider, NewsApiBriefingProvider>();
|
||||
services.AddSingleton<IHolidayCalendarProvider>(provider =>
|
||||
new NagerDateHolidayCalendarProvider(provider.GetRequiredService<HolidayCalendarOptions>()));
|
||||
services.AddSingleton<ICommuteReportProvider, UnavailableCommuteReportProvider>();
|
||||
var statePersistencePath = configuration?["OpenJibo:State:PersistencePath"]
|
||||
?? Path.Combine(AppContext.BaseDirectory, "App_Data", "cloud-state.json");
|
||||
@@ -75,8 +82,10 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<ICloudStateStore>(provider =>
|
||||
{
|
||||
var snapshotFactory = provider.GetRequiredService<IPersistenceSnapshotStoreFactory>();
|
||||
return new InMemoryCloudStateStore(snapshotFactory.Create(statePersistencePath, stateBackendKind,
|
||||
"cloud-state", stateConnectionString));
|
||||
var holidayCalendarProvider = provider.GetRequiredService<IHolidayCalendarProvider>();
|
||||
return new InMemoryCloudStateStore(
|
||||
snapshotFactory.Create(statePersistencePath, stateBackendKind, "cloud-state", stateConnectionString),
|
||||
holidayCalendarProvider);
|
||||
});
|
||||
services.AddSingleton<IPersonalMemoryStore>(provider =>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Jibo.Cloud.Infrastructure.Holidays;
|
||||
|
||||
public sealed class HolidayCalendarOptions
|
||||
{
|
||||
public string CountryCode { get; set; } = "US";
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
|
||||
namespace Jibo.Cloud.Infrastructure.Holidays;
|
||||
|
||||
public sealed class NagerDateHolidayCalendarProvider : IHolidayCalendarProvider
|
||||
{
|
||||
private static readonly HttpClient HttpClient = new();
|
||||
private static readonly ConcurrentDictionary<string, HolidayRecord[]> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private readonly string _countryCode;
|
||||
|
||||
public NagerDateHolidayCalendarProvider()
|
||||
: this(new HolidayCalendarOptions())
|
||||
{
|
||||
}
|
||||
|
||||
public NagerDateHolidayCalendarProvider(HolidayCalendarOptions options)
|
||||
{
|
||||
_countryCode = string.IsNullOrWhiteSpace(options.CountryCode) ? "US" : options.CountryCode.Trim();
|
||||
}
|
||||
|
||||
public IReadOnlyList<HolidayRecord> GetPublicHolidays(string? countryCode, int year)
|
||||
{
|
||||
var resolvedCountryCode = string.IsNullOrWhiteSpace(countryCode) ? _countryCode : countryCode.Trim();
|
||||
var cacheKey = $"{resolvedCountryCode.ToUpperInvariant()}-{year}";
|
||||
return Cache.GetOrAdd(cacheKey, _ => LoadHolidays(resolvedCountryCode, year));
|
||||
}
|
||||
|
||||
private static HolidayRecord[] LoadHolidays(string countryCode, int year)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = $"https://date.nager.at/api/v3/publicholidays/{year}/{countryCode}";
|
||||
using var response = HttpClient.GetAsync(uri).GetAwaiter().GetResult();
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
using var stream = response.Content.ReadAsStream();
|
||||
var payload = JsonSerializer.Deserialize<NagerDateHolidayDto[]>(stream, JsonOptions) ?? [];
|
||||
var records = payload
|
||||
.Where(item => item.Date.Year == year)
|
||||
.Select(item => ToHolidayRecord(item, countryCode))
|
||||
.OrderBy(record => record.Date)
|
||||
.ThenBy(record => record.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (records.Length > 0) return records;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to a small local holiday set so the robot still has something sensible to show.
|
||||
}
|
||||
|
||||
return BuildFallbackHolidays(countryCode, year);
|
||||
}
|
||||
|
||||
private static HolidayRecord ToHolidayRecord(NagerDateHolidayDto dto, string countryCode)
|
||||
{
|
||||
var eventId = $"{countryCode.ToUpperInvariant()}-{Slugify(dto.Name)}";
|
||||
return new HolidayRecord
|
||||
{
|
||||
Id = eventId,
|
||||
EventId = eventId,
|
||||
Name = dto.Name,
|
||||
Category = "holiday",
|
||||
LoopId = string.Empty,
|
||||
IsEnabled = true,
|
||||
Date = DateOnly.FromDateTime(dto.Date),
|
||||
Source = "nager-date",
|
||||
CountryCode = countryCode.ToUpperInvariant(),
|
||||
Created = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static HolidayRecord[] BuildFallbackHolidays(string countryCode, int year)
|
||||
{
|
||||
if (!countryCode.Equals("US", StringComparison.OrdinalIgnoreCase))
|
||||
return [];
|
||||
|
||||
var easterSunday = CalculateEasterSunday(year);
|
||||
var holidays = new List<HolidayRecord>
|
||||
{
|
||||
FixedHoliday("New Year's Day", year, 1, 1, countryCode),
|
||||
ObservedHoliday("Martin Luther King Jr. Day", NthWeekdayOfMonth(year, 1, DayOfWeek.Monday, 3), countryCode),
|
||||
ObservedHoliday("Presidents Day", NthWeekdayOfMonth(year, 2, DayOfWeek.Monday, 3), countryCode),
|
||||
ObservedHoliday("Memorial Day", LastWeekdayOfMonth(year, 5, DayOfWeek.Monday), countryCode),
|
||||
FixedHoliday("Juneteenth", year, 6, 19, countryCode),
|
||||
FixedHoliday("Independence Day", year, 7, 4, countryCode),
|
||||
ObservedHoliday("Labor Day", NthWeekdayOfMonth(year, 9, DayOfWeek.Monday, 1), countryCode),
|
||||
ObservedHoliday("Thanksgiving", NthWeekdayOfMonth(year, 11, DayOfWeek.Thursday, 4), countryCode),
|
||||
FixedHoliday("Christmas", year, 12, 25, countryCode),
|
||||
ObservedHoliday("Easter", easterSunday, countryCode),
|
||||
ObservedHoliday("Good Friday", easterSunday.AddDays(-2), countryCode),
|
||||
ObservedHoliday("Palm Sunday", easterSunday.AddDays(-7), countryCode),
|
||||
ObservedHoliday("Ash Wednesday", easterSunday.AddDays(-46), countryCode),
|
||||
FixedHoliday("Halloween", year, 10, 31, countryCode),
|
||||
FixedHoliday("Valentine's Day", year, 2, 14, countryCode),
|
||||
ObservedHoliday("Mother's Day", NthWeekdayOfMonth(year, 5, DayOfWeek.Sunday, 2), countryCode),
|
||||
ObservedHoliday("Father's Day", NthWeekdayOfMonth(year, 6, DayOfWeek.Sunday, 3), countryCode)
|
||||
};
|
||||
|
||||
return holidays
|
||||
.OrderBy(holiday => holiday.Date)
|
||||
.ThenBy(holiday => holiday.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static HolidayRecord FixedHoliday(string name, int year, int month, int day, string countryCode)
|
||||
{
|
||||
return CreateHoliday(name, new DateOnly(year, month, day), countryCode);
|
||||
}
|
||||
|
||||
private static HolidayRecord ObservedHoliday(string name, DateOnly date, string countryCode)
|
||||
{
|
||||
return CreateHoliday(name, date, countryCode);
|
||||
}
|
||||
|
||||
private static HolidayRecord CreateHoliday(string name, DateOnly date, string countryCode)
|
||||
{
|
||||
var eventId = $"{countryCode.ToUpperInvariant()}-{Slugify(name)}";
|
||||
return new HolidayRecord
|
||||
{
|
||||
Id = eventId,
|
||||
EventId = eventId,
|
||||
Name = name,
|
||||
Category = "holiday",
|
||||
LoopId = string.Empty,
|
||||
IsEnabled = true,
|
||||
Date = date,
|
||||
Source = "fallback",
|
||||
CountryCode = countryCode.ToUpperInvariant(),
|
||||
Created = DateTimeOffset.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
private static DateOnly NthWeekdayOfMonth(int year, int month, DayOfWeek dayOfWeek, int occurrence)
|
||||
{
|
||||
var date = new DateOnly(year, month, 1);
|
||||
var offset = ((int)dayOfWeek - (int)date.DayOfWeek + 7) % 7;
|
||||
return date.AddDays(offset + 7 * (occurrence - 1));
|
||||
}
|
||||
|
||||
private static DateOnly LastWeekdayOfMonth(int year, int month, DayOfWeek dayOfWeek)
|
||||
{
|
||||
var date = new DateOnly(year, month, DateTime.DaysInMonth(year, month));
|
||||
var offset = ((int)date.DayOfWeek - (int)dayOfWeek + 7) % 7;
|
||||
return date.AddDays(-offset);
|
||||
}
|
||||
|
||||
private static DateOnly CalculateEasterSunday(int year)
|
||||
{
|
||||
var a = year % 19;
|
||||
var b = year / 100;
|
||||
var c = year % 100;
|
||||
var d = b / 4;
|
||||
var e = b % 4;
|
||||
var f = (b + 8) / 25;
|
||||
var g = (b - f + 1) / 3;
|
||||
var h = (19 * a + b - d - g + 15) % 30;
|
||||
var i = c / 4;
|
||||
var k = c % 4;
|
||||
var l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
var m = (a + 11 * h + 22 * l) / 451;
|
||||
var month = (h + l - 7 * m + 114) / 31;
|
||||
var day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new DateOnly(year, month, day);
|
||||
}
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
var builder = new System.Text.StringBuilder(value.Length);
|
||||
var lastWasDash = false;
|
||||
foreach (var ch in value.ToLowerInvariant())
|
||||
{
|
||||
if (char.IsLetterOrDigit(ch))
|
||||
{
|
||||
builder.Append(ch);
|
||||
lastWasDash = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastWasDash)
|
||||
{
|
||||
builder.Append('-');
|
||||
lastWasDash = true;
|
||||
}
|
||||
}
|
||||
|
||||
return builder.ToString().Trim('-');
|
||||
}
|
||||
|
||||
private sealed class NagerDateHolidayDto
|
||||
{
|
||||
public DateTime Date { get; init; }
|
||||
public string LocalName { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string CountryCode { get; init; } = string.Empty;
|
||||
public bool Global { get; init; }
|
||||
public string[]? Counties { get; init; }
|
||||
public string[]? Types { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Jibo.Cloud.Infrastructure.Holidays;
|
||||
|
||||
namespace Jibo.Cloud.Infrastructure.Persistence;
|
||||
|
||||
@@ -23,12 +24,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
_keyRequests = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly List<LoopRecord> _loops;
|
||||
private readonly List<HolidayRecord> _holidayOverrides = [];
|
||||
private readonly List<MediaRecord> _media = [];
|
||||
private readonly List<PersonRecord> _people;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CloudSession>
|
||||
_sessionsByToken = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly IHolidayCalendarProvider _holidayCalendarProvider;
|
||||
private readonly ISnapshotStore _snapshotStore;
|
||||
private readonly ConcurrentDictionary<string, string> _symmetricKeys = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _syncRoot = new();
|
||||
@@ -47,8 +50,14 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
}
|
||||
|
||||
public InMemoryCloudStateStore(ISnapshotStore snapshotStore)
|
||||
: this(snapshotStore, new NagerDateHolidayCalendarProvider())
|
||||
{
|
||||
}
|
||||
|
||||
public InMemoryCloudStateStore(ISnapshotStore snapshotStore, IHolidayCalendarProvider holidayCalendarProvider)
|
||||
{
|
||||
_snapshotStore = snapshotStore;
|
||||
_holidayCalendarProvider = holidayCalendarProvider;
|
||||
_robot = new DeviceRegistration
|
||||
{
|
||||
HostMappings = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -155,6 +164,9 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
_loops.Clear();
|
||||
_loops.AddRange(snapshot.Loops ?? []);
|
||||
|
||||
_holidayOverrides.Clear();
|
||||
_holidayOverrides.AddRange(snapshot.Holidays ?? []);
|
||||
|
||||
_people.Clear();
|
||||
_people.AddRange(snapshot.People ?? []);
|
||||
|
||||
@@ -201,6 +213,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
Media = _media.ToArray(),
|
||||
Backups = _backups.ToArray(),
|
||||
Loops = _loops.ToArray(),
|
||||
Holidays = _holidayOverrides.ToArray(),
|
||||
People = _people.ToArray()
|
||||
};
|
||||
_snapshotStore.Save(snapshot);
|
||||
@@ -519,25 +532,50 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
return [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<object> GetHolidays()
|
||||
public IReadOnlyList<HolidayRecord> GetHolidays(string? loopId = null)
|
||||
{
|
||||
return
|
||||
[
|
||||
new
|
||||
var resolvedLoopId = string.IsNullOrWhiteSpace(loopId) ? ResolveDefaultLoopId() : loopId.Trim();
|
||||
var years = new[] { DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Year + 1 };
|
||||
|
||||
var systemHolidays = years
|
||||
.SelectMany(year => _holidayCalendarProvider.GetPublicHolidays(null, year))
|
||||
.Where(holiday => holiday.IsEnabled)
|
||||
.Select(holiday => new HolidayRecord
|
||||
{
|
||||
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")
|
||||
}
|
||||
];
|
||||
Id = holiday.Id,
|
||||
EventId = holiday.EventId,
|
||||
Name = holiday.Name,
|
||||
Category = holiday.Category,
|
||||
Subcategory = holiday.Subcategory,
|
||||
LoopId = resolvedLoopId,
|
||||
MemberId = holiday.MemberId,
|
||||
IsEnabled = true,
|
||||
Date = holiday.Date,
|
||||
EndDate = holiday.EndDate,
|
||||
Source = holiday.Source,
|
||||
CountryCode = holiday.CountryCode,
|
||||
Created = holiday.Created
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var overrides = _holidayOverrides
|
||||
.Where(holiday => string.Equals(holiday.LoopId, resolvedLoopId, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
foreach (var overrideHoliday in overrides)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(overrideHoliday.EventId))
|
||||
continue;
|
||||
|
||||
systemHolidays.RemoveAll(systemHoliday =>
|
||||
string.Equals(systemHoliday.EventId, overrideHoliday.EventId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
return systemHolidays
|
||||
.Concat(overrides.Where(holiday => holiday.IsEnabled))
|
||||
.OrderBy(holiday => holiday.Date)
|
||||
.ThenBy(holiday => holiday.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public void UpdateRobot(DeviceRegistration registration)
|
||||
@@ -628,6 +666,7 @@ public sealed class InMemoryCloudStateStore : ICloudStateStore
|
||||
public MediaRecord[]? Media { get; init; }
|
||||
public BackupRecord[]? Backups { get; init; }
|
||||
public LoopRecord[]? Loops { get; init; }
|
||||
public HolidayRecord[]? Holidays { get; init; }
|
||||
public PersonRecord[]? People { get; init; }
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,92 @@ public sealed class JiboCloudProtocolServiceTests
|
||||
Assert.Contains("/upload/log-events", payload.RootElement.GetProperty("uploadUrl").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PersonListHolidays_DoesNotThrow_WhenLoopStateIsEmpty()
|
||||
{
|
||||
var persistencePath = Path.Combine(Path.GetTempPath(), $"openjibo-empty-holidays-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(persistencePath, """
|
||||
{
|
||||
"SchemaVersion": "1",
|
||||
"Revision": 0,
|
||||
"Loops": [],
|
||||
"Holidays": []
|
||||
}
|
||||
""");
|
||||
|
||||
var service = new JiboCloudProtocolService(new InMemoryCloudStateStore(persistencePath));
|
||||
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.Equal(200, result.StatusCode);
|
||||
Assert.Equal(JsonValueKind.Array, payload.RootElement.ValueKind);
|
||||
Assert.NotEmpty(payload.RootElement.EnumerateArray());
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(persistencePath)) File.Delete(persistencePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PersonListHolidays_MergesPersistedLoopHolidayOverrides()
|
||||
{
|
||||
var persistencePath = Path.Combine(Path.GetTempPath(), $"openjibo-loop-holidays-{Guid.NewGuid():N}.json");
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(persistencePath, """
|
||||
{
|
||||
"SchemaVersion": "1",
|
||||
"Revision": 0,
|
||||
"Loops": [],
|
||||
"Holidays": [
|
||||
{
|
||||
"Id": "birthday-1",
|
||||
"EventId": "birthday-1",
|
||||
"Name": "Jake's Birthday",
|
||||
"Category": "birthday",
|
||||
"LoopId": "loop-123",
|
||||
"MemberId": "person-123",
|
||||
"IsEnabled": true,
|
||||
"Date": "2026-05-19",
|
||||
"Source": "manual",
|
||||
"CountryCode": "US",
|
||||
"Created": "2026-05-19T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var service = new JiboCloudProtocolService(new InMemoryCloudStateStore(persistencePath));
|
||||
var result = await service.DispatchAsync(new ProtocolEnvelope
|
||||
{
|
||||
HostName = "api.jibo.com",
|
||||
Method = "POST",
|
||||
ServicePrefix = "Person_20160715",
|
||||
Operation = "ListHolidays",
|
||||
BodyText = """{"loopId":"loop-123"}"""
|
||||
});
|
||||
|
||||
using var payload = JsonDocument.Parse(result.BodyText);
|
||||
Assert.Equal(200, result.StatusCode);
|
||||
Assert.Contains(payload.RootElement.EnumerateArray(),
|
||||
item => item.GetProperty("name").GetString() == "Jake's Birthday");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(persistencePath)) File.Delete(persistencePath);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MediaCreateAndGet_ReturnsCreatedItem()
|
||||
{
|
||||
@@ -327,7 +413,7 @@ public sealed class JiboCloudProtocolServiceTests
|
||||
});
|
||||
|
||||
using var payload = JsonDocument.Parse(result.BodyText);
|
||||
Assert.Single(payload.RootElement.EnumerateArray());
|
||||
Assert.NotEmpty(payload.RootElement.EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
Reference in New Issue
Block a user