Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
222 changes: 141 additions & 81 deletions Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs

Large diffs are not rendered by default.

18 changes: 11 additions & 7 deletions Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,22 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
var culture = session.Culture;
try
{
if (!intent.Parameters.TryGetValue("callId", out var callIdStr) || !int.TryParse(callIdStr, out var callId))
// The reference can be a raw call id ("1445"/"C1445") or a call number ("26-1") —
// see CallReferenceResolver. Tenant isolation (anti-IDOR) lives in the resolver: a call
// from another department resolves to null, indistinguishable from not-found.
intent.Parameters.TryGetValue("callId", out var reference);
if (string.IsNullOrWhiteSpace(reference))
intent.Parameters.TryGetValue("callRef", out reference);

if (string.IsNullOrWhiteSpace(reference))
{
return new ChatbotResponse { Text = ChatbotResources.Get("CallDetail_Specify", culture), Processed = false };
}

var call = await _callsService.GetCallByIdAsync(callId);

// Tenant isolation (anti-IDOR): a call that doesn't exist OR belongs to another
// department must be indistinguishable so call ids can't be enumerated across tenants.
if (call == null || call.DepartmentId != session.DepartmentId)
var call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference);
if (call == null)
{
return new ChatbotResponse { Text = ChatbotResources.Get("Call_NotFound", culture, callId), Processed = true };
return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoMatch", culture, reference), Processed = true };
}

// Authorization: the call is in the user's department, but they still need view permission.
Expand Down
7 changes: 6 additions & 1 deletion Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,12 @@ private async Task<ChatbotResponse> SwitchDepartmentAsync(ChatbotIntent intent,
return new ChatbotResponse { Text = ChatbotResources.Get("Dept_NoActiveMemberships", culture), Processed = true };

DepartmentMember targetMembership = null;
var trimmedId = departmentIdentifier.Trim();
// Trailing punctuation is never part of a department identifier ("switch 1?"). An input
// that normalizes to empty (e.g. "switch ???") must be rejected here — an empty string
// would pass the name IndexOf check below and silently switch to the first department.
var trimmedId = departmentIdentifier.Trim().TrimEnd('?', '!', '.', ',');
if (trimmedId.Length == 0)
return new ChatbotResponse { Text = ChatbotResources.Get("Dept_SwitchSpecify", culture), Processed = true };

if (int.TryParse(trimmedId, out var listIndex) && listIndex >= 1 && listIndex <= activeMemberships.Count)
targetMembership = activeMemberships[listIndex - 1];
Expand Down
220 changes: 157 additions & 63 deletions Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@

namespace Resgrid.Chatbot.Handlers
{
/// <summary>
/// Two-level help. Bare HELP returns a short topic menu (SMS replies cost per segment, so the
/// full command dump is never sent unsolicited); HELP &lt;topic&gt; returns the commands for that
/// operation. Status/staffing topics reflect the department's custom states when configured.
/// </summary>
public class HelpActionHandler : IChatbotActionHandler
{
private readonly ICustomStateService _customStateService;
Expand All @@ -24,77 +29,166 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
{
try
{
var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(session.DepartmentId);
var customActions = customStates?.FirstOrDefault(x => x.Type == (int)CustomStateTypes.Personnel);
var customStaffing = customStates?.FirstOrDefault(x => x.Type == (int)CustomStateTypes.Staffing);

var sb = new StringBuilder();
sb.AppendLine("Resgrid Chatbot Commands");
sb.AppendLine("----------------------");
sb.AppendLine("Quick Reference — text any command:");
sb.AppendLine();
sb.AppendLine("-- Core --");
sb.AppendLine("HELP: Show this help");
sb.AppendLine("STOP: End chatbot session");
sb.AppendLine("STATUS: Your current status & staffing");
sb.AppendLine("CALLS: List active calls");
sb.AppendLine("C#####: Call detail (e.g., C1445)");
sb.AppendLine("UNITS: List unit statuses");
sb.AppendLine("MSG: List messages");
sb.AppendLine("CALENDAR: Upcoming events");
sb.AppendLine("PERSONNEL: Personnel status list");
sb.AppendLine();
sb.AppendLine("-- Status --");

if (customActions != null && !customActions.IsDeleted && customActions.GetActiveDetails()?.Any() == true)
{
var details = customActions.GetActiveDetails();
for (int i = 0; i < details.Count; i++)
{
sb.AppendLine($"{details[i].ButtonText?.Replace(" ", "")} or {i + 1}: {details[i].ButtonText}");
}
}
else
{
sb.AppendLine("1 or RESPONDING: Responding");
sb.AppendLine("2 or NOTRESPONDING: Not Responding");
sb.AppendLine("3 or ONSCENE: On Scene");
sb.AppendLine("4 or STANDINGBY: Standing By");
}
string topic = null;
intent?.Parameters?.TryGetValue("topic", out topic);
topic = topic?.Trim().TrimEnd('?', '!', '.', ',').ToUpperInvariant();

sb.AppendLine();
sb.AppendLine("-- Staffing --");

if (customStaffing != null && !customStaffing.IsDeleted && customStaffing.GetActiveDetails()?.Any() == true)
{
var details = customStaffing.GetActiveDetails();
for (int i = 0; i < details.Count; i++)
{
sb.AppendLine($"S{i + 1}: {details[i].ButtonText}");
}
}
else
var text = topic switch
{
sb.AppendLine("S1 or AVAILABLE: Available");
sb.AppendLine("S2 or DELAYED: Delayed");
sb.AppendLine("S3 or UNAVAILABLE: Unavailable");
sb.AppendLine("S4 or COMMITTED: Committed");
sb.AppendLine("S5 or ONSHIFT: On Shift");
}

sb.AppendLine();
sb.AppendLine("-- Departments --");
sb.AppendLine("DEPARTMENTS: List your departments");
sb.AppendLine("ACTIVE DEPARTMENT: Show current department");
sb.AppendLine("SWITCH DEPARTMENT [name]: Switch departments");

return new ChatbotResponse { Text = sb.ToString(), Processed = true };
"STATUS" => await BuildStatusHelpAsync(session.DepartmentId),
"STAFFING" => await BuildStaffingHelpAsync(session.DepartmentId),
"CALLS" => BuildCallsHelp(),
"MESSAGES" or "MSG" or "MESSAGE" => BuildMessagesHelp(),
"UNITS" or "UNIT" => BuildUnitsHelp(),
"SHIFTS" or "SHIFT" => BuildShiftsHelp(),
"CALENDAR" or "EVENTS" => BuildCalendarHelp(),
"PERSONNEL" or "STAFF" => BuildPersonnelHelp(),
"DEPARTMENTS" or "DEPARTMENT" or "DEPTS" or "DEPT" => BuildDepartmentsHelp(),
"STOP" => BuildStopHelp(),
null or "" => BuildTopicMenu(),
_ => $"Unknown help topic \"{topic}\".\n{BuildTopicMenu()}"
};

return new ChatbotResponse { Text = text, Processed = true };
}
catch (Exception ex)
{
Framework.Logging.LogException(ex);
return new ChatbotResponse { Text = "Error generating help text.", Processed = false };
}
}

private static string BuildTopicMenu()
{
return "Resgrid Chatbot. Text a command, or HELP <topic> for details.\n"
+ "Topics: STATUS, STAFFING, CALLS, MESSAGES, UNITS, SHIFTS, CALENDAR, PERSONNEL, DEPARTMENTS, STOP";
}

private async Task<string> BuildStatusHelpAsync(int departmentId)
{
var sb = new StringBuilder();
sb.AppendLine("Status — text the word or number:");

var customActions = await GetCustomStateAsync(departmentId, CustomStateTypes.Personnel);
if (customActions != null)
{
// Custom statuses are reachable via the SET STATUS TO <name> form — the numeric/word
// shortcuts only map to the standard statuses.
var details = customActions.GetActiveDetails();
for (int i = 0; i < details.Count; i++)
sb.AppendLine($"SET STATUS TO {details[i].ButtonText}");
}
else
{
sb.AppendLine("1 or RESPONDING");
sb.AppendLine("2 or NOTRESPONDING");
sb.AppendLine("3 or ONSCENE");
sb.AppendLine("4 or STANDINGBY");
}

sb.Append("STATUS shows your current status & staffing.");
return sb.ToString();
}

private async Task<string> BuildStaffingHelpAsync(int departmentId)
{
var sb = new StringBuilder();
sb.AppendLine("Staffing — text the code or word:");

var customStaffing = await GetCustomStateAsync(departmentId, CustomStateTypes.Staffing);
if (customStaffing != null)
{
// Custom staffing levels are reachable via the SET STAFFING TO <name> form — the S-code
// shortcuts only map to the standard levels.
var details = customStaffing.GetActiveDetails();
for (int i = 0; i < details.Count; i++)
sb.AppendLine($"SET STAFFING TO {details[i].ButtonText}");
}
else
{
sb.AppendLine("S1 or AVAILABLE");
sb.AppendLine("S2 or DELAYED");
sb.AppendLine("S3 or UNAVAILABLE");
sb.AppendLine("S4 or COMMITTED");
sb.AppendLine("S5 or ONSHIFT");
}

sb.Append("STATUS shows your current status & staffing.");
return sb.ToString();
}

private static string BuildCallsHelp()
{
return "Calls:\n"
+ "CALLS: list active calls\n"
+ "C<id>: call detail (e.g. C1445)\n"
+ "RESPOND TO C<id>: mark responding to a call\n"
+ "DISPATCH <details>: create a new call\n"
+ "CLOSE CALL C<id>: close a call";
}

private static string BuildMessagesHelp()
{
return "Messages:\n"
+ "MESSAGES or MSG: list your messages\n"
+ "#<id>: read a message\n"
+ "REPLY YES/NO TO #<id>: respond\n"
+ "DELETE MSG <id>: delete\n"
+ "SEND MESSAGE TO <name>: <text>";
}

private static string BuildUnitsHelp()
{
return "Units:\n"
+ "UNITS: list unit statuses\n"
+ "SET UNIT <name> TO <status>";
}

private static string BuildShiftsHelp()
{
return "Shifts:\n"
+ "SHIFTS: list your shifts\n"
+ "SIGNUP SHIFT <id>: take a shift\n"
+ "DROP SHIFT <id>: release a shift";
}

private static string BuildCalendarHelp()
{
return "Calendar:\n"
+ "CALENDAR: upcoming events\n"
+ "RSVP YES/NO/MAYBE TO <event>";
}

private static string BuildPersonnelHelp()
{
return "Personnel:\n"
+ "PERSONNEL: personnel status list\n"
+ "WHO IS <name> / WHERE IS <name>";
}

private static string BuildDepartmentsHelp()
{
return "Departments:\n"
+ "DEPARTMENTS: list your departments\n"
+ "SWITCH <number or name>: change active department\n"
+ "ACTIVE DEPARTMENT: show current";
}

private static string BuildStopHelp()
{
return "STOP turns off ALL Resgrid text messages to this number (calls, messages, notifications). "
+ "Re-enable them on your Resgrid profile page.";
}

private async Task<CustomState> GetCustomStateAsync(int departmentId, CustomStateTypes type)
{
var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId);
var state = customStates?.FirstOrDefault(x => x.Type == (int)type);

if (state != null && !state.IsDeleted && state.GetActiveDetails()?.Any() == true)
return state;

return null;
}
}
}
14 changes: 10 additions & 4 deletions Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,18 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
var culture = session.Culture;
try
{
if (!intent.Parameters.TryGetValue("callId", out var callIdStr) || !int.TryParse(callIdStr, out var callId))
// The reference can be a raw call id ("1445"/"C1445"), a call number ("26-1"), or
// responder shorthand ("fire") matched against active calls — see CallReferenceResolver.
intent.Parameters.TryGetValue("callId", out var reference);
if (string.IsNullOrWhiteSpace(reference))
intent.Parameters.TryGetValue("callRef", out reference);

if (string.IsNullOrWhiteSpace(reference))
return new ChatbotResponse { Text = ChatbotResources.Get("Call_RespondWhich", culture), Processed = false };

var call = await _callsService.GetCallByIdAsync(callId);
if (call == null || call.DepartmentId != session.DepartmentId)
return new ChatbotResponse { Text = ChatbotResources.Get("Call_NotFound", culture, callId), Processed = true };
var call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference);
if (call == null)
return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoMatch", culture, reference), Processed = true };

await _actionLogsService.SetUserActionAsync(session.UserId, session.DepartmentId, (int)ActionTypes.Responding, string.Empty, call.CallId);

Expand Down
40 changes: 40 additions & 0 deletions Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Resgrid.Chatbot.Interfaces;
using Resgrid.Chatbot.Localization;
Expand Down Expand Up @@ -34,6 +35,17 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
{
staffingId = staffingTypeId;
}
else if (intent.Parameters.TryGetValue("staffingName", out var staffingNameInput) && !string.IsNullOrWhiteSpace(staffingNameInput))
{
// "SET STAFFING TO <name>": resolve against the department's custom staffing states
// first (the only text form that can reach custom staffing levels), then the
// standard staffing words.
var resolved = await ResolveStaffingNameAsync(session.DepartmentId, staffingNameInput);
if (resolved == null)
return new ChatbotResponse { Text = ChatbotResources.Get("Staffing_CouldNotDetermine", session.Culture), Processed = false };

staffingId = resolved.Value;
}
else
{
return new ChatbotResponse { Text = ChatbotResources.Get("Staffing_CouldNotDetermine", session.Culture), Processed = false };
Expand All @@ -56,5 +68,33 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
return new ChatbotResponse { Text = ChatbotResources.Get("Staffing_Error", session.Culture), Processed = false };
}
}

private async Task<int?> ResolveStaffingNameAsync(int departmentId, string staffingName)
{
var name = staffingName.Trim().TrimEnd('?', '!', '.', ',');

var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId);
var customStaffing = customStates?.FirstOrDefault(x => x.Type == (int)Model.CustomStateTypes.Staffing);
if (customStaffing != null && !customStaffing.IsDeleted && customStaffing.GetActiveDetails()?.Any() == true)
{
var detail = customStaffing.GetActiveDetails()
.FirstOrDefault(d => string.Equals(d.ButtonText?.Trim(), name, StringComparison.OrdinalIgnoreCase)
|| string.Equals(d.ButtonText?.Replace(" ", ""), name.Replace(" ", ""), StringComparison.OrdinalIgnoreCase));

if (detail != null)
return detail.CustomStateDetailId;
}

// Standard staffing words (spaces optional) — UserStateTypes enum values.
return name.Replace(" ", "").ToLowerInvariant() switch
{
"available" => (int)Model.UserStateTypes.Available,
"delayed" => (int)Model.UserStateTypes.Delayed,
"unavailable" => (int)Model.UserStateTypes.Unavailable,
"committed" => (int)Model.UserStateTypes.Committed,
"onshift" => (int)Model.UserStateTypes.OnShift,
_ => (int?)null
};
}
}
}
Loading
Loading