diff --git a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs index e6fb82c9a..dcd07a1ed 100644 --- a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs +++ b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs @@ -15,138 +15,174 @@ public class KeywordIntentClassifier : INLUProvider private static readonly List<(Regex pattern, string intent, Func> extractParams)> _patterns = new() { // === Status Commands (rigid + natural language) === - (new Regex(@"^(responding|1)$", RegexOptions.IgnoreCase), "set_status", m => P("actionType", "1")), - (new Regex(@"^(not\s*responding|2)$", RegexOptions.IgnoreCase), "set_status", m => P("actionType", "2")), - (new Regex(@"^(on\s*scene|onscene|3)$", RegexOptions.IgnoreCase), "set_status", m => P("actionType", "3")), - (new Regex(@"^(standing\s*by|standingby|4)$", RegexOptions.IgnoreCase), "set_status", m => P("actionType", "4")), - (new Regex(@"^(i'?m|i\s+am)\s+(responding|on\s*scene|standing\s*by|en\s*route|available|not\s*responding)", RegexOptions.IgnoreCase), + // The SMS shortcut numbers (1-4) are the user-facing scheme from the legacy text commands; + // the emitted actionType values are ActionTypes enum values (Responding=2, NotResponding=1, + // OnScene=3, StandingBy/Available=0) — the handler passes them straight to SetUserActionAsync. + (R(@"^(responding|1)$"), "set_status", m => P("actionType", "2")), + (R(@"^(not\s*responding|2)$"), "set_status", m => P("actionType", "1")), + (R(@"^(on\s*scene|onscene|3)$"), "set_status", m => P("actionType", "3")), + (R(@"^(standing\s*by|standingby|4)$"), "set_status", m => P("actionType", "0")), + // Responder shorthand for "responding" with no target call. + (R(@"^(omw|on\s+my\s+way|enroute|en\s+route)$"), + "set_status", m => P("actionType", "2")), + (R(@"^(i'?m|i\s+am)\s+(responding|on\s*scene|standing\s*by|en\s*route|available|not\s*responding)"), "set_status", m => P("actionType", MapStatusWord(m.Groups[2].Value))), - (new Regex(@"^(set|change|mark)\s+(my\s+)?status\s+to\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^(set|change|mark)\s+(my\s+)?status\s+to\s+(.+)"), "set_status", m => P("statusName", m.Groups[3].Value.Trim())), // === Staffing Commands === - (new Regex(@"^(available|s1)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "1")), - (new Regex(@"^(delayed|s2)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "2")), - (new Regex(@"^(unavailable|s3)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "3")), - (new Regex(@"^(committed|s4)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "4")), - (new Regex(@"^(on\s*shift|onshift|s5)$", RegexOptions.IgnoreCase), "set_staffing", m => P("staffingType", "5")), - (new Regex(@"^(i'?m|i\s+am)\s+(available|delayed|unavailable|committed|on\s*shift)", RegexOptions.IgnoreCase), + // S1-S5 is the user-facing scheme; emitted staffingType values are UserStateTypes enum values + // (Available=0, Delayed=1, Unavailable=2, Committed=3, OnShift=4) — passed straight to CreateUserState. + (R(@"^(available|s1)$"), "set_staffing", m => P("staffingType", "0")), + (R(@"^(delayed|s2)$"), "set_staffing", m => P("staffingType", "1")), + (R(@"^(unavailable|s3)$"), "set_staffing", m => P("staffingType", "2")), + (R(@"^(committed|s4)$"), "set_staffing", m => P("staffingType", "3")), + (R(@"^(on\s*shift|onshift|s5)$"), "set_staffing", m => P("staffingType", "4")), + (R(@"^(i'?m|i\s+am)\s+(available|delayed|unavailable|committed|on\s*shift)"), "set_staffing", m => P("staffingType", MapStaffingWord(m.Groups[2].Value))), - (new Regex(@"^(set|change|mark)\s+(my\s+)?staffing\s+to\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^(set|change|mark)\s+(my\s+)?staffing\s+to\s+(.+)"), "set_staffing", m => P("staffingName", m.Groups[3].Value.Trim())), // === Query Commands (rigid) === - (new Regex(@"^calls?$", RegexOptions.IgnoreCase), "list_calls", null), - (new Regex(@"^c(\d+)$", RegexOptions.IgnoreCase), "call_detail", m => P("callId", m.Groups[1].Value)), - (new Regex(@"^units?$", RegexOptions.IgnoreCase), "list_units", null), - (new Regex(@"^(my\s+)?status$", RegexOptions.IgnoreCase), "my_status", null), - (new Regex(@"^messages?$", RegexOptions.IgnoreCase), "list_messages", null), - (new Regex(@"^(calendar|events?)$", RegexOptions.IgnoreCase), "list_calendar", null), - (new Regex(@"^shifts?$", RegexOptions.IgnoreCase), "list_shifts", null), - (new Regex(@"^(personnel|staff)$", RegexOptions.IgnoreCase), "personnel_lookup", null), - (new Regex(@"^weather$", RegexOptions.IgnoreCase), "weather_alert", null), + (R(@"^calls?$"), "list_calls", null), + (R(@"^c(\d+)$"), "call_detail", m => P("callId", m.Groups[1].Value)), + // Call number form ("26-1" / "C26-1"): two-digit year + sequence — resolved by the handler. + (R(@"^c?(\d{2,4}-\d+)$"), "call_detail", m => P("callRef", m.Groups[1].Value)), + (R(@"^units?$"), "list_units", null), + (R(@"^(my\s+)?status$"), "my_status", null), + // "my staffing" — the my_status handler reports both status and staffing. + (R(@"^(my\s+)?staffing$"), "my_status", null), + (R(@"^(messages?|msgs?)$"), "list_messages", null), + (R(@"^(calendar|events?|cal)$"), "list_calendar", null), + (R(@"^shifts?$"), "list_shifts", null), + (R(@"^(personnel|staff)$"), "personnel_lookup", null), + (R(@"^weather$"), "weather_alert", null), // === Help / Stop === - (new Regex(@"^(help|info|commands|menu|what\s+can\s+you\s+do)$", RegexOptions.IgnoreCase), "help", null), - (new Regex(@"^(stop|end|quit|cancel|unsubscribe)$", RegexOptions.IgnoreCase), "stop", null), + (R(@"^(help|info|commands|menu|what\s+can\s+you\s+do)$"), "help", null), + // STOP is explicit-only (plus UNSUBSCRIBE, the other unambiguous opt-out word). END/QUIT/CANCEL + // must NOT trigger the opt-out flow — they carry other meanings in conversation flows. + (R(@"^(stop|unsubscribe)$"), "stop", null), // === Emergency === - (new Regex(@"^(mayday|emergency|sos|help\s*me|officer\s*down|ff?\s*down|firefighter\s*down)$", RegexOptions.IgnoreCase), + (R(@"^(mayday|emergency|sos|help\s*me|officer\s*down|ff?\s*down|firefighter\s*down)$"), "emergency_mayday", null), + // === Help topic detail (after Emergency; "me" excluded so "help me!" stays a mayday even + // when the punctuated original is matched before the stripped copy reaches the pattern above) === + (R(@"^(help|info|commands|menu)\s+(?!me\b)(.+)$"), + "help", m => P("topic", m.Groups[2].Value.Trim())), + // === Link / Unlink === - (new Regex(@"^(link|login|verify|auth)$", RegexOptions.IgnoreCase), "link_account", null), - (new Regex(@"^(unlink|logout|unauth)$", RegexOptions.IgnoreCase), "unlink_account", null), + (R(@"^(link|login|verify|auth)$"), "link_account", null), + (R(@"^(unlink|logout|unauth)$"), "unlink_account", null), // === Message Detail / Delete / Respond (must precede the natural-language message patterns) === - (new Regex(@"^#(\d+)$", RegexOptions.IgnoreCase), + (R(@"^#(\d+)$"), "message_detail", m => P("messageId", m.Groups[1].Value)), - (new Regex(@"^(read|show|open|view|get)\s+(message|msg)\s+#?(\d+)", RegexOptions.IgnoreCase), + (R(@"^(read|show|open|view|get)\s+(message|msg)\s+#?(\d+)"), "message_detail", m => P("messageId", m.Groups[3].Value)), - (new Regex(@"^(delete|remove|del)\s+(message|msg)?\s*#?(\d+)$", RegexOptions.IgnoreCase), + (R(@"^(delete|remove|del)\s+(message|msg)?\s*#?(\d+)$"), "delete_message", m => P("messageId", m.Groups[3].Value)), - (new Regex(@"^(reply|respond)\s+(yes|no|acknowledge|ack)\s+to\s+(message|msg|#)?\s*#?(\d+)", RegexOptions.IgnoreCase), + (R(@"^(reply|respond)\s+(yes|no|acknowledge|ack)\s+to\s+(message|msg|#)?\s*#?(\d+)"), "respond_to_message", m => P2("response", m.Groups[2].Value, "messageId", m.Groups[4].Value)), // === Natural Language Query Commands === - (new Regex(@"^(show|list|get|what)\s+(are\s+)?(active|open)?\s*(calls|incidents)", RegexOptions.IgnoreCase), + (R(@"^(show|list|get|what)\s+(are\s+)?(active|open)?\s*(calls|incidents)"), "list_calls", null), - (new Regex(@"^(show|tell|get|details?|what\s+about).*\bc(\d+)\b", RegexOptions.IgnoreCase), + // Hyphenated call-number references ("what about c26-1") must match before the plain + // numeric form below — its \b sits at the hyphen and would bind just "c26" (the wrong call). + (R(@"^(show|tell|get|details?|what\s+about).*\bc?(\d{2,4}-\d+)\b"), + "call_detail", m => P("callRef", m.Groups[2].Value)), + (R(@"^(show|tell|get|details?|what\s+about).*\bc(\d+)\b"), "call_detail", m => P("callId", m.Groups[m.Groups.Count - 1].Value)), - (new Regex(@"^(show|list|get|what)\s+(are\s+)?(units?|apparatus|rigs?)", RegexOptions.IgnoreCase), + (R(@"^(show|list|get|what)\s+(are\s+)?(units?|apparatus|rigs?)"), "list_units", null), - (new Regex(@"^(who|where)\s+(is|are)\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^(who|where)\s+(is|are)\s+(.+)"), "personnel_lookup", m => P("query", m.Groups[3].Value.Trim())), - (new Regex(@"^(show|list|get)\s+(personnel|staff|members|crew)", RegexOptions.IgnoreCase), + (R(@"^(show|list|get)\s+(personnel|staff|members|crew)"), "personnel_lookup", null), - (new Regex(@"^(what'?s|what\s+is)\s+(my\s+)?(status|staffing)", RegexOptions.IgnoreCase), + (R(@"^(what'?s|what\s+is)\s+(my\s+)?(status|staffing)"), "my_status", null), - (new Regex(@"^(check|read|show)\s+(my\s+)?(messages?|inbox)", RegexOptions.IgnoreCase), + (R(@"^(check|read|show)\s+(my\s+)?(messages?|inbox)"), "list_messages", null), - (new Regex(@"^(show|list|get|what'?s)\s+(on\s+)?(the\s+)?(calendar|schedule|agenda)", RegexOptions.IgnoreCase), + (R(@"^(show|list|get|what'?s)\s+(on\s+)?(the\s+)?(calendar|schedule|agenda)"), "list_calendar", null), - (new Regex(@"^(show|list|get|my)\s+shifts?", RegexOptions.IgnoreCase), + (R(@"^(show|list|get|my)\s+shifts?"), "list_shifts", null), - (new Regex(@"^(weather\s+)?(alerts?|warnings?)", RegexOptions.IgnoreCase), + (R(@"^(weather\s+)?(alerts?|warnings?)"), "weather_alert", null), // === Send Message === - (new Regex(@"^send\s+message\s+to\s+(.+?):?\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^send\s+message\s+to\s+(.+?):?\s+(.+)"), "send_message", m => P2("recipient", m.Groups[1].Value.Trim(), "body", m.Groups[2].Value.Trim())), - (new Regex(@"^(msg|message)\s+to\s+(.+?):?\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^(msg|message)\s+to\s+(.+?):?\s+(.+)"), "send_message", m => P2("recipient", m.Groups[2].Value.Trim(), "body", m.Groups[3].Value.Trim())), - (new Regex(@"^tell\s+(.+?)\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^tell\s+(.+?)\s+(.+)"), "send_message", m => P2("recipient", m.Groups[1].Value.Trim(), "body", m.Groups[2].Value.Trim())), // === Dispatch === - (new Regex(@"^(dispatch|create\s+call|new\s+call)\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^(dispatch|create\s+call|new\s+call)\s+(.+)"), "dispatch_call", m => P("description", m.Groups[2].Value.Trim())), - (new Regex(@"^report\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^report\s+(.+)"), "dispatch_call", m => P("description", m.Groups[1].Value.Trim())), // === Close Call === - (new Regex(@"^(close|end|cancel)\s+call\s+c?(\d+)", RegexOptions.IgnoreCase), + (R(@"^(close|end|cancel)\s+call\s+c?(\d+)"), "close_call", m => P("callId", m.Groups[2].Value)), - (new Regex(@"^(close|end|cancel)\s+c(\d+)", RegexOptions.IgnoreCase), + (R(@"^(close|end|cancel)\s+c(\d+)"), "close_call", m => P("callId", m.Groups[2].Value)), // === Respond to Call === - (new Regex(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)", RegexOptions.IgnoreCase), + (R(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)$"), "respond_to_call", m => P("callId", m.Groups[2].Value)), + // Responder shorthand: "omw to 26-1", "omw to fire", "enroute to c1445", "headed to Main St". + // The reference can be a call id, a call number (yy-N), or a term matched against active + // calls — resolved by the handler. + (R(@"^(omw|on\s+my\s+way|respond(?:ing)?|going|headed|enroute|en\s+route)\s+(?:to\s+)?(.+)$"), + "respond_to_call", m => P("callRef", m.Groups[2].Value.Trim())), // === Shift Drop (must precede shift signup/detail so 'drop shift 5' isn't misread) === - (new Regex(@"^(drop|cancel|release)\s+(my\s+)?shift\s+#?(\d+)", RegexOptions.IgnoreCase), + (R(@"^(drop|cancel|release)\s+(my\s+)?shift\s+#?(\d+)"), "shift_drop", m => P("shiftId", m.Groups[3].Value)), // === Shift Signup === - (new Regex(@"^(sign\s*up|take)\s+shift\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^(sign\s*up|take)\s+shift\s+(.+)"), "shift_signup", m => P("shiftId", m.Groups[2].Value.Trim())), // === RSVP Calendar === - (new Regex(@"^rsvp\s+(yes|no|maybe)\s+to\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^rsvp\s+(yes|no|maybe)\s+to\s+(.+)"), "rsvp_calendar", m => P2("response", m.Groups[1].Value, "eventId", m.Groups[2].Value.Trim())), // === Calendar / Shift Detail (query suffix) === - (new Regex(@"^(calendar|events?)\s+(.+)$", RegexOptions.IgnoreCase), + (R(@"^(calendar|events?)\s+(.+)$"), "calendar_detail", m => P("query", m.Groups[2].Value.Trim())), - (new Regex(@"^shifts?\s+(.+)$", RegexOptions.IgnoreCase), + (R(@"^shifts?\s+(.+)$"), "shift_detail", m => P("query", m.Groups[1].Value.Trim())), // === Set Unit Status === - (new Regex(@"^set\s+unit\s+(.+?)\s+to\s+(.+)", RegexOptions.IgnoreCase), + (R(@"^set\s+unit\s+(.+?)\s+to\s+(.+)"), "set_unit_status", m => P2("unitName", m.Groups[1].Value.Trim(), "status", m.Groups[2].Value.Trim())), // === Department Management === - (new Regex(@"^(departments|depts|my\s+departments|my\s+depts|which\s+departments)$", RegexOptions.IgnoreCase), + (R(@"^(departments|depts|my\s+departments|my\s+depts|which\s+departments)$"), "list_departments", null), - (new Regex(@"^(show|list|get|what|what'?s)\s+(my\s+)?(departments?|depts?)$", RegexOptions.IgnoreCase), + (R(@"^(show|list|get|what|what'?s)\s+(my\s+)?(departments?|depts?)$"), "list_departments", null), - (new Regex(@"^(active\s+department|current\s+department|which\s+department|what\s+department)\s*(am\s+i\s+in)?\??$", RegexOptions.IgnoreCase), + (R(@"^(active\s+department|current\s+department|which\s+department|what\s+department)\s*(am\s+i\s+in)?\??$"), "get_active_department", null), - (new Regex(@"^(switch|change|set)\s+(to\s+)?(department|dept)\s+(.+)$", RegexOptions.IgnoreCase), + (R(@"^(switch|change|set)\s+(to\s+)?(department|dept)\s+(.+)$"), "switch_department", m => P("departmentIdentifier", m.Groups[4].Value.Trim())), - (new Regex(@"^(switch|change|set)\s+(my\s+)?(active\s+)?(department|dept)\s*$", RegexOptions.IgnoreCase), + (R(@"^(switch|change|set)\s+(my\s+)?(active\s+)?(department|dept)\s*$"), + "list_departments", null), + + // SMS-style short forms — parity with the legacy SWITCH text command ("SWITCH" lists the + // options, "SWITCH " switches). Placed after the wordier forms above so + // "switch department X" keeps binding the identifier without the "department" word in it. + (R(@"^switch$"), "list_departments", null), + (R(@"^switch\s+(to\s+)?(.+)$"), + "switch_department", m => P("departmentIdentifier", m.Groups[2].Value.Trim())), }; public Task ClassifyAsync(string text, string context = null, int departmentId = 0) @@ -156,19 +192,31 @@ public Task ClassifyAsync(string text, string context = null, int dep var trimmed = text.Trim(); + // Texters end questions/commands with punctuation ("My status?", "omw to 26-1."). The + // patterns are anchored, so a trailing-punctuation-stripped copy is also tried — but only as + // a FALLBACK: the original input goes first so free-form parameters (message bodies, dispatch + // descriptions) are extracted with their punctuation intact. + var normalized = trimmed.TrimEnd('?', '!', '.', ',', ' '); + var candidates = normalized.Length > 0 && !string.Equals(normalized, trimmed, StringComparison.Ordinal) + ? new[] { trimmed, normalized } + : new[] { trimmed }; + // Check all patterns in priority order - foreach (var (pattern, intent, extractor) in _patterns) + foreach (var candidate in candidates) { - var match = pattern.Match(trimmed); - if (match.Success) + foreach (var (pattern, intent, extractor) in _patterns) { - return Task.FromResult(new NLUResult + var match = pattern.Match(candidate); + if (match.Success) { - IntentName = intent, - Parameters = extractor?.Invoke(match) ?? new Dictionary(), - Confidence = 1.0, - ProviderName = ProviderName - }); + return Task.FromResult(new NLUResult + { + IntentName = intent, + Parameters = extractor?.Invoke(match) ?? new Dictionary(), + Confidence = 1.0, + ProviderName = ProviderName + }); + } } } @@ -202,6 +250,16 @@ public Task IsAvailableAsync() return Task.FromResult(true); } + // All patterns run against untrusted inbound SMS text, so every one gets a match timeout to + // bound pathological backtracking (same convention as WebhookUrlValidator/UdfValidationHelper). + // The timeout is inlined rather than a static field: _patterns is declared above and static + // field initializers run in declaration order, so a field here would still be zero when the + // pattern list is built. + private static Regex R(string pattern) + { + return new Regex(pattern, RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(200)); + } + private static Dictionary P(string key, string value) { return new Dictionary { [key] = value }; @@ -212,32 +270,34 @@ private static Dictionary P2(string k1, string v1, string k2, st return new Dictionary { [k1] = v1, [k2] = v2 }; } + // ActionTypes enum values: Responding=2, NotResponding=1, OnScene=3, StandingBy/Available=0. private static string MapStatusWord(string word) { var w = word.ToLowerInvariant().Replace(" ", ""); return w switch { - "responding" => "1", - "notresponding" => "2", + "responding" => "2", + "notresponding" => "1", "onscene" => "3", - "standingby" => "4", - "enroute" => "1", - "available" => "4", - _ => "1" + "standingby" => "0", + "enroute" => "2", + "available" => "0", + _ => "2" }; } + // UserStateTypes enum values: Available=0, Delayed=1, Unavailable=2, Committed=3, OnShift=4. private static string MapStaffingWord(string word) { var w = word.ToLowerInvariant().Replace(" ", ""); return w switch { - "available" => "1", - "delayed" => "2", - "unavailable" => "3", - "committed" => "4", - "onshift" => "5", - _ => "1" + "available" => "0", + "delayed" => "1", + "unavailable" => "2", + "committed" => "3", + "onshift" => "4", + _ => "0" }; } } diff --git a/Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs index b118463f3..4496ec575 100644 --- a/Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs @@ -30,18 +30,22 @@ public async Task 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. diff --git a/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs index e98275dc5..69001cff9 100644 --- a/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs @@ -196,7 +196,12 @@ private async Task 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]; diff --git a/Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs index 64c8cdf7b..b6e125a75 100644 --- a/Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs @@ -9,6 +9,11 @@ namespace Resgrid.Chatbot.Handlers { + /// + /// 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 <topic> returns the commands for that + /// operation. Status/staffing topics reflect the department's custom states when configured. + /// public class HelpActionHandler : IChatbotActionHandler { private readonly ICustomStateService _customStateService; @@ -24,71 +29,27 @@ public async Task 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) { @@ -96,5 +57,138 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn return new ChatbotResponse { Text = "Error generating help text.", Processed = false }; } } + + private static string BuildTopicMenu() + { + return "Resgrid Chatbot. Text a command, or HELP for details.\n" + + "Topics: STATUS, STAFFING, CALLS, MESSAGES, UNITS, SHIFTS, CALENDAR, PERSONNEL, DEPARTMENTS, STOP"; + } + + private async Task 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 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 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 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: call detail (e.g. C1445)\n" + + "RESPOND TO C: mark responding to a call\n" + + "DISPATCH
: create a new call\n" + + "CLOSE CALL C: close a call"; + } + + private static string BuildMessagesHelp() + { + return "Messages:\n" + + "MESSAGES or MSG: list your messages\n" + + "#: read a message\n" + + "REPLY YES/NO TO #: respond\n" + + "DELETE MSG : delete\n" + + "SEND MESSAGE TO : "; + } + + private static string BuildUnitsHelp() + { + return "Units:\n" + + "UNITS: list unit statuses\n" + + "SET UNIT TO "; + } + + private static string BuildShiftsHelp() + { + return "Shifts:\n" + + "SHIFTS: list your shifts\n" + + "SIGNUP SHIFT : take a shift\n" + + "DROP SHIFT : release a shift"; + } + + private static string BuildCalendarHelp() + { + return "Calendar:\n" + + "CALENDAR: upcoming events\n" + + "RSVP YES/NO/MAYBE TO "; + } + + private static string BuildPersonnelHelp() + { + return "Personnel:\n" + + "PERSONNEL: personnel status list\n" + + "WHO IS / WHERE IS "; + } + + private static string BuildDepartmentsHelp() + { + return "Departments:\n" + + "DEPARTMENTS: list your departments\n" + + "SWITCH : 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 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; + } } } diff --git a/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs b/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs index 1a50ff769..858b7dc77 100644 --- a/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs @@ -32,12 +32,18 @@ public async Task 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); diff --git a/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs index 0ab2885ce..81c9dd130 100644 --- a/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Localization; @@ -34,6 +35,17 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn { staffingId = staffingTypeId; } + else if (intent.Parameters.TryGetValue("staffingName", out var staffingNameInput) && !string.IsNullOrWhiteSpace(staffingNameInput)) + { + // "SET STAFFING TO ": 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 }; @@ -56,5 +68,33 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn return new ChatbotResponse { Text = ChatbotResources.Get("Staffing_Error", session.Culture), Processed = false }; } } + + private async Task 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 + }; + } } } diff --git a/Core/Resgrid.Chatbot/Handlers/StatusActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/StatusActionHandler.cs index 9b1df6d41..522ccd39c 100644 --- a/Core/Resgrid.Chatbot/Handlers/StatusActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/StatusActionHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Localization; @@ -34,6 +35,17 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn { statusId = actionTypeId; } + else if (intent.Parameters.TryGetValue("statusName", out var statusNameInput) && !string.IsNullOrWhiteSpace(statusNameInput)) + { + // "SET STATUS TO ": resolve against the department's custom personnel states + // first (this is the only text form that can reach custom statuses), then the + // standard status words. + var resolved = await ResolveStatusNameAsync(session.DepartmentId, statusNameInput); + if (resolved == null) + return new ChatbotResponse { Text = ChatbotResources.Get("Status_CouldNotDetermine", session.Culture), Processed = false }; + + statusId = resolved.Value; + } else { return new ChatbotResponse { Text = ChatbotResources.Get("Status_CouldNotDetermine", session.Culture), Processed = false }; @@ -57,5 +69,33 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn return new ChatbotResponse { Text = ChatbotResources.Get("Status_Error", session.Culture), Processed = false }; } } + + private async Task ResolveStatusNameAsync(int departmentId, string statusName) + { + var name = statusName.Trim().TrimEnd('?', '!', '.', ','); + + var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId); + var customActions = customStates?.FirstOrDefault(x => x.Type == (int)Model.CustomStateTypes.Personnel); + if (customActions != null && !customActions.IsDeleted && customActions.GetActiveDetails()?.Any() == true) + { + var detail = customActions.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 status words (spaces optional). + return name.Replace(" ", "").ToLowerInvariant() switch + { + "responding" => (int)Model.ActionTypes.Responding, + "notresponding" => (int)Model.ActionTypes.NotResponding, + "onscene" => (int)Model.ActionTypes.OnScene, + "standingby" => (int)Model.ActionTypes.StandingBy, + "available" => (int)Model.ActionTypes.AvailableStation, + _ => (int?)null + }; + } } } diff --git a/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs b/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs index 76de087f5..909de86e7 100644 --- a/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs +++ b/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs @@ -509,6 +509,18 @@ private static Dictionary EnOnly(string en) "Виклик #{0} не знайдено.", "لم يتم العثور على البلاغ #{0}."), + // Reference can be an id, a call number (26-1), or a shorthand term ("fire"). + ["Call_NoMatch"] = L( + "No active call found matching \"{0}\".", + "No se encontró ninguna llamada activa que coincida con \"{0}\".", + "Inget aktivt larm hittades som matchar \"{0}\".", + "Kein aktiver Einsatz gefunden, der \"{0}\" entspricht.", + "Aucun appel actif trouvé correspondant à \"{0}\".", + "Nessuna chiamata attiva trovata corrispondente a \"{0}\".", + "Nie znaleziono aktywnego zgłoszenia pasującego do \"{0}\".", + "Не знайдено активного виклику, що відповідає \"{0}\".", + "لم يتم العثور على بلاغ نشط يطابق \"{0}\"."), + ["Call_CloseWhich"] = L( "Which call would you like to close? Reply with the call number (e.g., C1445).", "¿Qué llamada quieres cerrar? Responde con el número de llamada (p. ej., C1445).", diff --git a/Core/Resgrid.Chatbot/Services/CallReferenceResolver.cs b/Core/Resgrid.Chatbot/Services/CallReferenceResolver.cs new file mode 100644 index 000000000..5e32742ce --- /dev/null +++ b/Core/Resgrid.Chatbot/Services/CallReferenceResolver.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Services +{ + /// + /// Resolves a user-supplied call reference to a call in the department. First responders reference + /// calls three ways: the raw call id ("1445", "C1445"), the call number ("26-1" — two-digit year + /// plus sequence), or a shorthand description ("fire") matched against the ACTIVE calls' name, type + /// and nature with the most recent call winning. Department scoping is enforced here (anti-IDOR): + /// a call from another department resolves to null, indistinguishable from not-found. + /// + public static class CallReferenceResolver + { + private static readonly Regex CallNumberRegex = new Regex(@"^\d{2,4}-\d+$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(200)); + + public static async Task ResolveAsync(ICallsService callsService, int departmentId, string reference) + { + if (string.IsNullOrWhiteSpace(reference)) + return null; + + // Trailing punctuation is never part of a call reference ("omw to 26-1.", "respond to fire?"). + var text = reference.Trim().TrimEnd('?', '!', '.', ','); + if (text.Length == 0) + return null; + + // "C1445" style prefix ahead of a digit. + if (text.Length > 1 && (text[0] == 'c' || text[0] == 'C') && char.IsDigit(text[1])) + text = text.Substring(1); + + // 1. Raw call id — direct lookup (works for closed calls too, e.g. detail requests). + if (int.TryParse(text, out var callId)) + { + var call = await callsService.GetCallByIdAsync(callId); + return (call != null && call.DepartmentId == departmentId) ? call : null; + } + + var activeCalls = await callsService.GetActiveCallsByDepartmentAsync(departmentId); + if (activeCalls == null || activeCalls.Count == 0) + return null; + + // 2. Call number ("26-1"). + if (CallNumberRegex.IsMatch(text)) + { + return activeCalls + .Where(c => string.Equals(c.Number?.Trim(), text, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(c => c.LoggedOn) + .FirstOrDefault(); + } + + // 3. Shorthand ("fire"): term appears in the name, type, or nature of an active call; + // most recent match wins. + return activeCalls + .Where(c => ContainsTerm(c.Name, text) + || ContainsTerm(c.Type, text) + || ContainsTerm(c.NatureOfCall == null ? null : StringHelpers.StripHtmlTagsCharArray(c.NatureOfCall), text)) + .OrderByDescending(c => c.LoggedOn) + .FirstOrDefault(); + } + + private static bool ContainsTerm(string haystack, string term) + => !string.IsNullOrWhiteSpace(haystack) && haystack.IndexOf(term, StringComparison.OrdinalIgnoreCase) >= 0; + } +} diff --git a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs index 4d3307b74..04db97031 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs @@ -256,6 +256,30 @@ await _userIdentityService.LinkUserAsync( if (!isAuthorized) { var restrictedIntent = await _intentRouter.ClassifyIntentAsync(message, session); + + // STOP always works, even in restricted mode — same semantics as the normal-mode + // branch: SMS opts out of all outbound texts; other platforms get the profile guidance. + if (restrictedIntent.Type == ChatbotIntentType.Stop) + { + if (IsSmsPlatform(message.Platform)) + { + await _userProfileService.DisableTextMessagesForUserAsync(identity.UserId); + return new ChatbotResponse + { + Text = "Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile.", + Processed = true, + Intent = restrictedIntent + }; + } + + return new ChatbotResponse + { + Text = "To manage your notification settings, update your profile in Resgrid.", + Processed = true, + Intent = restrictedIntent + }; + } + if (restrictedIntent.Type == ChatbotIntentType.ListDepartments || restrictedIntent.Type == ChatbotIntentType.SwitchDepartment || restrictedIntent.Type == ChatbotIntentType.GetActiveDepartment) @@ -282,6 +306,37 @@ await _userIdentityService.LinkUserAsync( return new ChatbotResponse { Text = optionsBuilder.ToString(), Processed = true }; } + // STOP (the telecom opt-out) must be honored BEFORE any session state processing — a user + // parked mid-confirmation or awaiting a PIN who texts STOP is opting out, not answering + // the prompt. Deliberately a plain string check against the explicit opt-out words (the + // classifier's stop set — STOP/UNSUBSCRIBE only, per policy) and NOT ClassifyIntentAsync: + // running the classifier on every mid-dialog reply would send confirmation replies and + // 4-digit PINs to the cloud NLU in the hybrid-cloud modes. + var optOutText = message.Text.Trim().TrimEnd('?', '!', '.', ','); + if (string.Equals(optOutText, "stop", StringComparison.OrdinalIgnoreCase) + || string.Equals(optOutText, "unsubscribe", StringComparison.OrdinalIgnoreCase)) + { + var stopIntent = new ChatbotIntent { Type = ChatbotIntentType.Stop, Confidence = 1.0f }; + + if (IsSmsPlatform(message.Platform)) + { + await _userProfileService.DisableTextMessagesForUserAsync(identity.UserId); + return new ChatbotResponse + { + Text = "Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile.", + Processed = true, + Intent = stopIntent + }; + } + + return new ChatbotResponse + { + Text = "To manage your notification settings, update your profile in Resgrid.", + Processed = true, + Intent = stopIntent + }; + } + // 5. Handle session state machine via ConversationEngine (Phase 2) // 5a. Confirmation of a destructive action (CloseCall, DispatchCall, SetUnitStatus, ...). @@ -400,16 +455,27 @@ await _userIdentityService.LinkUserAsync( } } - // 6. Classify intent + // 6. Classify intent (explicit STOP/UNSUBSCRIBE already handled before the state machine). var intent = await _intentRouter.ClassifyIntentAsync(message, session); - // 7. Special handling: Stop command ends session + // 7. STOP intent from non-verbatim phrasing (cloud NLU can classify e.g. "please stop + // texting me"): same opt-out semantics as the explicit-word check above. if (intent.Type == ChatbotIntentType.Stop) { - await _sessionManager.EndSessionAsync(session.SessionId); + if (IsSmsPlatform(message.Platform)) + { + await _userProfileService.DisableTextMessagesForUserAsync(identity.UserId); + return new ChatbotResponse + { + Text = "Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile.", + Processed = true, + Intent = intent + }; + } + return new ChatbotResponse { - Text = "Chatbot session ended. Text HELP to start a new session.", + Text = "To manage your notification settings, update your profile in Resgrid.", Processed = true, Intent = intent }; diff --git a/Core/Resgrid.Config/ChatbotConfig.cs b/Core/Resgrid.Config/ChatbotConfig.cs index 057f65aae..734626794 100644 --- a/Core/Resgrid.Config/ChatbotConfig.cs +++ b/Core/Resgrid.Config/ChatbotConfig.cs @@ -29,6 +29,12 @@ public static class ChatbotConfig public static int MessagesPerUserPerMinute = 30; public static int MessagesPerDepartmentPerMinute = 120; + // Outbound SMS replies from the chatbot are interactive content (help/command lists, call + // details) that users act on without opening the app, so they get a higher length cap than the + // notification default (SystemBehaviorConfig.SmsMaxLength). Kept under Twilio's 1600-char hard + // reject (error 21617); carriers deliver it as a concatenated message. + public static int SmsReplyMaxLength = 1500; + // Session Store public static bool UseRedisSessionStore = false; public static string RedisConnectionString = ""; diff --git a/Core/Resgrid.Model/Providers/ITextMessageProvider.cs b/Core/Resgrid.Model/Providers/ITextMessageProvider.cs index 2bccbcd1b..bc3d75c75 100644 --- a/Core/Resgrid.Model/Providers/ITextMessageProvider.cs +++ b/Core/Resgrid.Model/Providers/ITextMessageProvider.cs @@ -4,6 +4,9 @@ namespace Resgrid.Model.Providers { public interface ITextMessageProvider { - Task SendTextMessage(string number, string message, string departmentNumber, MobileCarriers carrier, int departmentId, bool forceGateway = false, bool isCall = false); + /// When greater than zero, overrides the default outbound SMS + /// length cap (SystemBehaviorConfig.SmsMaxLength) for this message — used by interactive chatbot + /// replies that must not be cut short. Still bounded by the provider's hard limit. + Task SendTextMessage(string number, string message, string departmentNumber, MobileCarriers carrier, int departmentId, bool forceGateway = false, bool isCall = false, int maxLengthOverride = 0); } } diff --git a/Core/Resgrid.Services/TextCommandService.cs b/Core/Resgrid.Services/TextCommandService.cs index b8a4e1b75..c72f8728b 100644 --- a/Core/Resgrid.Services/TextCommandService.cs +++ b/Core/Resgrid.Services/TextCommandService.cs @@ -113,8 +113,9 @@ public TextCommandPayload DetermineType(string message, CustomState customAction if (message.Trim().ToLower() == "help" || message.Trim().ToLower() == "info") payload.Type = TextCommandTypes.Help; - // Wanting to stop recieving text messages - if (message.Trim().ToLower() == "stop" || message.Trim().ToLower() == "end" || message.Trim().ToLower() == "quit" || message.Trim().ToLower() == "cancel" || message.Trim().ToLower() == "unsubscribe") + // Wanting to stop receiving text messages. Explicit words only (STOP/UNSUBSCRIBE) — + // END/QUIT/CANCEL must not trigger the opt-out flow; they carry other meanings. + if (message.Trim().ToLower() == "stop" || message.Trim().ToLower() == "unsubscribe") payload.Type = TextCommandTypes.Stop; if (message.Trim().ToLower() == "calls") diff --git a/Providers/Resgrid.Providers.Number/TextMessageProvider.cs b/Providers/Resgrid.Providers.Number/TextMessageProvider.cs index 3bed5a3f5..c0de3ebfc 100644 --- a/Providers/Resgrid.Providers.Number/TextMessageProvider.cs +++ b/Providers/Resgrid.Providers.Number/TextMessageProvider.cs @@ -26,11 +26,13 @@ public class TextMessageProvider : ITextMessageProvider private static int _maxZone = 4; private static IEnumerable _areaCodes; - public async Task SendTextMessage(string number, string message, string departmentNumber, MobileCarriers carrier, int departmentId, bool forceGateway = false, bool isCall = false) + public async Task SendTextMessage(string number, string message, string departmentNumber, MobileCarriers carrier, int departmentId, bool forceGateway = false, bool isCall = false, int maxLengthOverride = 0) { // Single chokepoint for every outbound SMS path below (Twilio + SignalWire fallback): strip non-allow-listed // URLs (carrier deliverability) and cap length (cost + avoid Twilio error 21617 at 1600 chars) before sending. - message = SmsContentHelper.PrepareForSms(message, Config.SystemBehaviorConfig.SmsMaxLength, + // Interactive senders (chatbot replies) can raise the cap per message via maxLengthOverride. + var maxLength = maxLengthOverride > 0 ? maxLengthOverride : Config.SystemBehaviorConfig.SmsMaxLength; + message = SmsContentHelper.PrepareForSms(message, maxLength, (Config.SystemBehaviorConfig.SmsAllowedUrlDomains ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries)); var wasTwillioSuccessful = await SendTextMessageViaTwillio(number, message, departmentNumber); diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs index 4bba96727..f06a17213 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs @@ -339,7 +339,8 @@ public async Task RespondToCall_CallInDifferentDepartment_ReturnsNotFound_AndDoe var handler = new RespondToCallHandler(calls.Object, actionLogs.Object); var response = await handler.HandleAsync(Msg("respond to c7"), Intent(ChatbotIntentType.RespondToCall, ("callId", "7")), Session(departmentId: 1)); - response.Text.Should().Contain("not found"); + // Cross-department call resolves to the same no-match reply as a nonexistent one (anti-IDOR). + response.Text.Should().Contain("No active call found matching"); actionLogs.Verify(a => a.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotSecurityTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotSecurityTests.cs index d307c7a02..f4bfaa164 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotSecurityTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotSecurityTests.cs @@ -48,7 +48,8 @@ public async Task CallDetail_CallInDifferentDepartment_ReturnsNotFound_AndDoesNo var response = await handler.HandleAsync(new ChatbotMessage { Text = "C5" }, CallDetailIntent("5"), Session(departmentId: 1)); - response.Text.Should().Contain("not found"); + // Cross-department call resolves to the same no-match reply as a nonexistent one (anti-IDOR). + response.Text.Should().Contain("No active call found matching"); response.Text.Should().NotContain("Secret"); // Cross-department access must short-circuit before any permission check. authz.Verify(a => a.CanUserViewCallAsync(It.IsAny(), It.IsAny()), Times.Never); diff --git a/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs index b7f4ef187..9b7374d62 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ChatbotMessageLogic.cs @@ -22,26 +22,38 @@ public static async Task ProcessChatbotMessageQueueItem(ChatbotMessageQueu if (item == null || string.IsNullOrWhiteSpace(item.From) || string.IsNullOrWhiteSpace(item.Body)) return true; - var chatbotIngressService = Bootstrapper.GetKernel().Resolve(); - var textMessageProvider = Bootstrapper.GetKernel().Resolve(); - - var message = new ChatbotMessage + try { - MessageId = item.MessageId, - From = item.From, - To = item.To, - Text = item.Body, - Platform = (ChatbotPlatform)item.Platform, - Timestamp = DateTime.UtcNow - }; + var chatbotIngressService = Bootstrapper.GetKernel().Resolve(); + var textMessageProvider = Bootstrapper.GetKernel().Resolve(); + + var message = new ChatbotMessage + { + MessageId = item.MessageId, + From = item.From, + To = item.To, + Text = item.Body, + Platform = (ChatbotPlatform)item.Platform, + Timestamp = DateTime.UtcNow + }; - var response = await chatbotIngressService.ProcessMessageAsync(message); + var response = await chatbotIngressService.ProcessMessageAsync(message); - if (response != null && !string.IsNullOrWhiteSpace(response.Text)) + if (response != null && !string.IsNullOrWhiteSpace(response.Text)) + { + // Reply from the department's text number (To) back to the sender (From). Twilio is the + // primary transport; carrier only governs gateway fallback, so the default is fine here. + // Chatbot replies are interactive (help/command lists the user acts on over SMS), so they + // use the higher chatbot length cap instead of the notification default. + await textMessageProvider.SendTextMessage(item.From, response.Text, item.To, default(MobileCarriers), item.DepartmentId, + maxLengthOverride: Resgrid.Config.ChatbotConfig.SmsReplyMaxLength); + } + } + catch (Exception ex) { - // Reply from the department's text number (To) back to the sender (From). Twilio is the - // primary transport; carrier only governs gateway fallback, so the default is fine here. - await textMessageProvider.SendTextMessage(item.From, response.Text, item.To, default(MobileCarriers), item.DepartmentId); + // Same convention as AuditQueueLogic: a failed item must not take down the queue + // processor — log it and move on (the sender simply gets no reply for this message). + Logging.LogException(ex); } return true;