Skip to content

Commit 0b2c2b9

Browse files
committed
RG-T117 PR#430 fixes
1 parent 939bd97 commit 0b2c2b9

49 files changed

Lines changed: 1861 additions & 180 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,5 @@ Web/Resgrid.WebCore/wwwroot/lib/*
277277
opencode.json
278278
.dual-graph-pro/
279279
.mcp.json
280+
/.codex
281+
/.claude

Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,14 +200,14 @@ public class KeywordIntentClassifier : INLUProvider
200200

201201
// === Respond to Call ===
202202
(R(@"^(not\s*responding|not\s+going|unable\s+to\s+respond)\s+(?:to\s+)?(.+)$"),
203-
"respond_to_call", m => P2("callRef", m.Groups[2].Value.Trim(), "response", "no")),
203+
"respond_to_call", m => P2("callRef", CleanReference(m.Groups[2].Value), "response", "no")),
204204
(R(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)$"),
205205
"respond_to_call", m => P2("callId", m.Groups[2].Value, "response", "yes")),
206206
// Responder shorthand: "omw to 26-1", "omw to fire", "enroute to c1445", "headed to Main St".
207207
// The reference can be a call id, a call number (yy-N), or a term matched against active
208208
// calls — resolved by the handler.
209209
(R(@"^(omw|on\s+my\s+way|respond(?:ing)?|going|headed|enroute|en\s+route)\s+(?:to\s+)?(.+)$"),
210-
"respond_to_call", m => P2("callRef", m.Groups[2].Value.Trim(), "response", "yes")),
210+
"respond_to_call", m => P2("callRef", CleanReference(m.Groups[2].Value), "response", "yes")),
211211

212212
// === Shift Drop (must precede shift signup/detail so 'drop shift 5' isn't misread) ===
213213
(R(@"^(drop|cancel|release)\s+(my\s+)?shift\s+#?(\d+)"),

Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ namespace Resgrid.Chatbot.Handlers
1919
/// </summary>
2020
public class CallRespondersActionHandler : IChatbotActionHandler
2121
{
22+
private enum ResponderMode
23+
{
24+
All,
25+
EnRoute,
26+
OnScene
27+
}
28+
2229
private enum ResponderBucket
2330
{
2431
None,
@@ -56,8 +63,8 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
5663
var culture = session.Culture;
5764
try
5865
{
59-
intent.Parameters.TryGetValue("mode", out var mode);
60-
mode = string.IsNullOrWhiteSpace(mode) ? "all" : mode.Trim().ToLowerInvariant();
66+
intent.Parameters.TryGetValue("mode", out var modeValue);
67+
var mode = ParseResponderMode(modeValue);
6168

6269
var call = await ResolveCallAsync(intent, session.DepartmentId);
6370
if (call == null)
@@ -68,11 +75,11 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
6875

6976
var callLabel = string.IsNullOrWhiteSpace(call.Number) ? call.Name : $"{call.Number} {call.Name}";
7077

71-
// Latest status per person tied to this call.
78+
// Current status per person tied to this call.
7279
var actionLogs = await _actionLogsService.GetActionLogsForCallAsync(session.DepartmentId, call.CallId);
80+
var currentActionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(session.DepartmentId) ?? new List<ActionLog>();
7381
var latestPerUser = (actionLogs ?? new List<ActionLog>())
74-
.GroupBy(x => x.UserId)
75-
.Select(g => g.OrderByDescending(x => x.Timestamp).First())
82+
.Where(x => currentActionLogs.Any(current => current.ActionLogId == x.ActionLogId && current.DestinationId == call.CallId))
7683
.ToList();
7784

7885
var personnelLines = new StringBuilder();
@@ -94,11 +101,11 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
94101
}
95102
}
96103

97-
// Latest status per unit tied to this call.
104+
// Current status per unit tied to this call.
98105
var unitStates = await _unitsService.GetUnitStatesForCallAsync(session.DepartmentId, call.CallId);
106+
var currentUnitStates = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(session.DepartmentId) ?? new List<UnitState>();
99107
var latestPerUnit = (unitStates ?? new List<UnitState>())
100-
.GroupBy(x => x.UnitId)
101-
.Select(g => g.OrderByDescending(x => x.Timestamp).First())
108+
.Where(x => currentUnitStates.Any(current => current.UnitStateId == x.UnitStateId && current.DestinationId == call.CallId))
102109
.ToList();
103110

104111
var unitLines = new StringBuilder();
@@ -129,8 +136,8 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
129136

130137
var headerKey = mode switch
131138
{
132-
"enroute" => "CallResp_HeaderEnroute",
133-
"onscene" => "CallResp_HeaderOnScene",
139+
ResponderMode.EnRoute => "CallResp_HeaderEnroute",
140+
ResponderMode.OnScene => "CallResp_HeaderOnScene",
134141
_ => "CallResp_HeaderAll"
135142
};
136143

@@ -180,12 +187,22 @@ private async Task<Call> ResolveCallAsync(ChatbotIntent intent, int departmentId
180187
return activeCalls?.Count == 1 ? activeCalls[0] : null;
181188
}
182189

183-
private static bool BucketMatches(ResponderBucket bucket, string mode)
190+
private static ResponderMode ParseResponderMode(string value)
191+
{
192+
var normalized = value?.Trim();
193+
return !int.TryParse(normalized, out _)
194+
&& Enum.TryParse(normalized, true, out ResponderMode mode)
195+
&& Enum.IsDefined(typeof(ResponderMode), mode)
196+
? mode
197+
: ResponderMode.All;
198+
}
199+
200+
private static bool BucketMatches(ResponderBucket bucket, ResponderMode mode)
184201
{
185202
return mode switch
186203
{
187-
"enroute" => bucket == ResponderBucket.EnRoute,
188-
"onscene" => bucket == ResponderBucket.OnScene,
204+
ResponderMode.EnRoute => bucket == ResponderBucket.EnRoute,
205+
ResponderMode.OnScene => bucket == ResponderBucket.OnScene,
189206
_ => bucket != ResponderBucket.None
190207
};
191208
}

Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ namespace Resgrid.Chatbot.Handlers
2020
/// </summary>
2121
public class MyCallsActionHandler : IChatbotActionHandler
2222
{
23-
private const int MaxActiveCallsToScan = 25;
23+
private const int MaxConcurrentCallPopulations = 5;
2424
private const int MaxCallsToList = 10;
2525

2626
private readonly ICallsService _callsService;
@@ -118,9 +118,10 @@ private async Task<List<Call>> GetActiveCallsWithDispatchesAsync(int departmentI
118118
var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(departmentId);
119119
var populated = new List<Call>();
120120

121-
foreach (var call in (activeCalls ?? new List<Call>()).OrderByDescending(c => c.LoggedOn).Take(MaxActiveCallsToScan))
121+
var orderedCalls = (activeCalls ?? new List<Call>()).OrderByDescending(c => c.LoggedOn);
122+
foreach (var batch in orderedCalls.Chunk(MaxConcurrentCallPopulations))
122123
{
123-
populated.Add(await _callsService.PopulateCallData(call,
124+
var populatedBatch = await Task.WhenAll(batch.Select(call => _callsService.PopulateCallData(call,
124125
getDispatches: !forUnits,
125126
getAttachments: false,
126127
getNotes: false,
@@ -129,7 +130,8 @@ private async Task<List<Call>> GetActiveCallsWithDispatchesAsync(int departmentI
129130
getRoleDispatches: false,
130131
getProtocols: false,
131132
getReferences: false,
132-
getContacts: false));
133+
getContacts: false)));
134+
populated.AddRange(populatedBatch);
133135
}
134136

135137
return populated;

Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,18 @@ public MyScheduleActionHandler(
4040
public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session)
4141
{
4242
var culture = session.Culture;
43+
var cultureInfo = CultureInfo.GetCultureInfo(ChatbotResources.NormalizeCulture(culture));
4344
try
4445
{
4546
var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId);
4647
var nowLocal = DateTime.UtcNow.TimeConverter(department);
4748

4849
intent.Parameters.TryGetValue("day", out var dayText);
49-
var targetDate = ParseDay(dayText, nowLocal);
50+
var targetDate = ParseDay(dayText, nowLocal, cultureInfo);
5051
if (targetDate == null)
5152
return new ChatbotResponse { Text = ChatbotResources.Get("Sched_BadDate", culture), Processed = false };
5253

53-
var dateLabel = targetDate.Value.ToString("ddd M/d", CultureInfo.InvariantCulture);
54+
var dateLabel = targetDate.Value.ToString("ddd M/d", cultureInfo);
5455
var lines = new List<string>();
5556

5657
// Shifts: shift days on the target date the user is assigned to (shift personnel) or has
@@ -73,7 +74,7 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
7374
continue;
7475

7576
var shift = shiftDay.Shift ?? await _shiftsService.GetShiftByIdAsync(shiftDay.ShiftId);
76-
var times = $"{shiftDay.Start:t} - {shiftDay.End:t}";
77+
var times = $"{shiftDay.Start.ToString("t", cultureInfo)} - {shiftDay.End.ToString("t", cultureInfo)}";
7778
lines.Add(ChatbotResources.Get("Sched_ShiftLine", culture, shift?.Name ?? $"Shift {shiftDay.ShiftId}", times));
7879
}
7980
}
@@ -95,7 +96,7 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
9596
if (attendee == null || attendee.AttendeeType == (int)CalendarItemAttendeeTypes.NotAttending)
9697
continue;
9798

98-
var timeText = item.IsAllDay ? ChatbotResources.Get("Sched_AllDay", culture) : startLocal.ToString("t");
99+
var timeText = item.IsAllDay ? ChatbotResources.Get("Sched_AllDay", culture) : startLocal.ToString("t", cultureInfo);
99100
lines.Add(ChatbotResources.Get("Sched_EventLine", culture, timeText, item.Title?.Truncate(50)));
100101
}
101102

@@ -119,33 +120,41 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
119120

120121
// Accepts: empty (today), "today", "tomorrow", a weekday name (next occurrence, today included),
121122
// or a date ("7/22", "7/22/2026", "2026-07-22"). Returns null when unparseable.
122-
private static DateTime? ParseDay(string dayText, DateTime nowLocal)
123+
private static DateTime? ParseDay(string dayText, DateTime nowLocal, CultureInfo cultureInfo)
123124
{
124125
if (string.IsNullOrWhiteSpace(dayText))
125126
return nowLocal.Date;
126127

127-
var text = dayText.Trim().TrimEnd('?', '!', '.', ',').ToLowerInvariant();
128+
var text = dayText.Trim().TrimEnd('?', '!', '.', ',');
128129

129-
if (text == "today")
130+
if (string.Equals(text, "today", StringComparison.OrdinalIgnoreCase))
130131
return nowLocal.Date;
131-
if (text == "tomorrow")
132+
if (string.Equals(text, "tomorrow", StringComparison.OrdinalIgnoreCase))
132133
return nowLocal.Date.AddDays(1);
133134

134135
foreach (DayOfWeek dow in Enum.GetValues(typeof(DayOfWeek)))
135136
{
136-
var name = dow.ToString().ToLowerInvariant();
137-
if (text == name || text == name.Substring(0, 3))
137+
var englishName = dow.ToString();
138+
var localizedName = cultureInfo.DateTimeFormat.GetDayName(dow);
139+
var localizedAbbreviation = cultureInfo.DateTimeFormat.GetAbbreviatedDayName(dow).TrimEnd('.');
140+
if (string.Equals(text, englishName, StringComparison.OrdinalIgnoreCase)
141+
|| string.Equals(text, englishName.Substring(0, 3), StringComparison.OrdinalIgnoreCase)
142+
|| cultureInfo.CompareInfo.Compare(text, localizedName, CompareOptions.IgnoreCase) == 0
143+
|| cultureInfo.CompareInfo.Compare(text, localizedAbbreviation, CompareOptions.IgnoreCase) == 0)
138144
{
139145
var daysAhead = ((int)dow - (int)nowLocal.DayOfWeek + 7) % 7;
140146
return nowLocal.Date.AddDays(daysAhead);
141147
}
142148
}
143149

144-
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
150+
if (DateTime.TryParse(text, cultureInfo, DateTimeStyles.None, out var parsed))
145151
{
146152
// "7/22" parses with the current year; a date months in the past most likely means next
147153
// year (people ask about upcoming days).
148-
if (parsed.Date < nowLocal.Date.AddMonths(-1) && !text.Any(char.IsLetter) && text.Count(c => c == '/') == 1)
154+
var dateSeparator = cultureInfo.DateTimeFormat.DateSeparator;
155+
var hasSingleDateSeparator = !string.IsNullOrEmpty(dateSeparator)
156+
&& text.Split(new[] { dateSeparator }, StringSplitOptions.None).Length == 2;
157+
if (parsed.Date < nowLocal.Date.AddMonths(-1) && !text.Any(char.IsLetter) && hasSingleDateSeparator)
149158
parsed = parsed.AddYears(1);
150159
return parsed.Date;
151160
}

Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Resgrid.Chatbot.Models;
88
using Resgrid.Framework;
99
using Resgrid.Model;
10+
using Resgrid.Model.Messages;
1011
using Resgrid.Model.Services;
1112

1213
namespace Resgrid.Chatbot.Handlers
@@ -93,7 +94,10 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
9394
};
9495

9596
foreach (var userId in recipients)
97+
{
9698
msg.AddRecipient(userId);
99+
msg.MessageRecipients.Last().Note = TextResponsePromptMetadata.ForPoll(session.DepartmentId);
100+
}
97101

98102
var saved = await _messageService.SaveMessageAsync(msg);
99103
await _messageService.SendMessageAsync(saved, senderName, session.DepartmentId);

Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ private async Task<Call> ResolveMostRecentDispatchAsync(string userId, int depar
118118
? null
119119
: ResolveDispatchTimestamp(dispatch.LastDispatchedOn, dispatch.DispatchedOn, call);
120120

121-
if (dispatch == null && _departmentGroupsService != null)
121+
if (_departmentGroupsService != null)
122122
{
123123
foreach (var groupDispatch in call?.GroupDispatches ?? new List<CallDispatchGroup>())
124124
{
@@ -135,7 +135,7 @@ private async Task<Call> ResolveMostRecentDispatchAsync(string userId, int depar
135135
}
136136
}
137137

138-
if (dispatch == null && _personnelRolesService != null && call?.RoleDispatches?.Any() == true)
138+
if (_personnelRolesService != null && call?.RoleDispatches?.Any() == true)
139139
{
140140
if (userRoleIds == null)
141141
{

Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
using System;
22
using System.Collections.Generic;
3-
using System.Linq;
43
using System.Threading.Tasks;
54
using Resgrid.Chatbot.Interfaces;
65
using Resgrid.Chatbot.Localization;
@@ -113,10 +112,6 @@ private async Task<CustomStateDetail> ResolveStaffingIdAsync(int departmentId, i
113112
if (match != null)
114113
return match;
115114

116-
var active = levels?.Where(x => x != null && !x.IsDeleted).OrderBy(x => x.Order).ToList();
117-
if (active != null && staffingId >= 0 && staffingId < active.Count)
118-
return active[staffingId];
119-
120115
return levels == null || levels.Count == 0 ? fallback : null;
121116
}
122117

Core/Resgrid.Chatbot/Interfaces/IChatbotSessionManager.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ namespace Resgrid.Chatbot.Interfaces
55
{
66
public interface IChatbotSessionManager
77
{
8-
Task<ChatbotSession> GetOrCreateSessionAsync(string userId, int departmentId, ChatbotPlatform platform, string fromIdentifier);
8+
Task<ChatbotSession> GetOrCreateSessionAsync(string userId, int departmentId, ChatbotPlatform platform,
9+
string fromIdentifier, int ttlMinutes = 0);
910
Task<ChatbotSession> GetSessionAsync(string sessionId);
1011
Task SaveSessionAsync(ChatbotSession session);
1112
Task EndSessionAsync(string sessionId);

Core/Resgrid.Chatbot/Interfaces/IChatbotSessionStore.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ namespace Resgrid.Chatbot.Interfaces
66
{
77
public interface IChatbotSessionStore
88
{
9-
Task<ChatbotSession> GetOrCreateAsync(string userId, int departmentId, ChatbotPlatform platform, string identifier);
9+
Task<ChatbotSession> GetOrCreateAsync(string userId, int departmentId, ChatbotPlatform platform,
10+
string identifier, int ttlMinutes = 0);
1011
Task<ChatbotSession> GetAsync(string sessionId);
1112
Task SaveAsync(ChatbotSession session);
1213
Task DeleteAsync(string sessionId);

0 commit comments

Comments
 (0)