From 728de3a3999b33c09d3598f7d681e6023cd28603 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Thu, 16 Jul 2026 14:57:25 -0700 Subject: [PATCH 1/2] RG-T117 Chatbot fixes --- .../Handlers/DepartmentActionHandler.cs | 21 +- .../Services/ChatbotIngressService.cs | 54 ++- .../Services/IDepartmentsService.cs | 17 +- Core/Resgrid.Model/TextCommandTypes.cs | 3 +- Core/Resgrid.Services/DepartmentsService.cs | 42 +- Core/Resgrid.Services/TextCommandService.cs | 9 + .../Controllers/SignalWireController.cs | 225 ++++++++-- .../Controllers/TwilioController.cs | 207 ++++++++- .../User/Views/Subscription/Index.cshtml | 422 +++++++++--------- 9 files changed, 706 insertions(+), 294 deletions(-) diff --git a/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs index 70214c011..eee617e31 100644 --- a/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs @@ -37,24 +37,9 @@ public DepartmentActionHandler( /// private async Task> GetSmsSupportingMembershipsAsync(string userId) { - var allMemberRecords = await _departmentsService.GetAllDepartmentsForUserAsync(userId); - if (allMemberRecords == null || allMemberRecords.Count == 0) - return new System.Collections.Generic.List(); - - var candidates = allMemberRecords - .Where(m => !m.IsDeleted) - .OrderByDescending(m => m.IsActive) - .ThenBy(m => m.DepartmentId) - .ToList(); - - var supported = new System.Collections.Generic.List(); - foreach (var membership in candidates) - { - if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) - supported.Add(membership); - } - - return supported; + // Shared with the legacy SMS SWITCH command and the ingress restricted mode so every surface + // shows the same departments in the same order (numeric picks stay stable across requests). + return await _departmentsService.GetSmsSupportedMembershipsForUserAsync(userId); } public ChatbotIntentType IntentType => ChatbotIntentType.ListDepartments; diff --git a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs index 4d134c444..136ce91b6 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs @@ -153,19 +153,29 @@ await _userIdentityService.LinkUserAsync( message.Platform, new ChatbotIntent { Type = ChatbotIntentType.Unknown }); } - // 3b. Check department plan supports chatbot features + // 3b. Check the ACTIVE department's plan supports chatbot features. When it doesn't but the + // user belongs to other departments that do, don't hard-block: they must still be able to + // list departments and SWITCH their active department (restricted mode, enforced after + // intent classification below). Only when there is no supported alternative is this a dead end. var isAuthorized = await _limitsService.CanDepartmentProvisionNumberAsync(department.DepartmentId); + List switchableDepartments = null; if (!isAuthorized) { - return await _templateRenderer.RenderResponseAsync("error", - new Services.ErrorModel { Message = "Your department's plan does not support chatbot features. Please upgrade your plan." }, - message.Platform, new ChatbotIntent { Type = ChatbotIntentType.Unknown }); + switchableDepartments = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(identity.UserId); + if (switchableDepartments == null || switchableDepartments.Count == 0) + { + return await _templateRenderer.RenderResponseAsync("error", + new Services.ErrorModel { Message = "Your department's plan does not support chatbot features. Please upgrade your plan." }, + message.Platform, new ChatbotIntent { Type = ChatbotIntentType.Unknown }); + } } // 3c. Per-department configuration gates (when a config row exists; otherwise the // system defaults apply and the chatbot stays enabled for backward compatibility). + // Skipped in restricted (switch-only) mode: the unsupported department's config must not + // block the user from switching OUT of it. var deptConfig = await _departmentConfigService.GetConfigAsync(department.DepartmentId); - if (deptConfig != null) + if (deptConfig != null && isAuthorized) { if (!deptConfig.IsEnabled) { @@ -224,6 +234,40 @@ await _userIdentityService.LinkUserAsync( if (session.RecentMessages.Count > 20) session.RecentMessages.RemoveAt(0); + // Restricted (switch-only) mode: the active department's plan doesn't support the chatbot + // but the user belongs to other departments that do. Only department list/switch/active + // intents are honored — so the user can move to a supported department — and anything else + // gets the switch options. Confirmation/PIN/continuation states are intentionally skipped: + // no destructive intent can be pending for a department that can't dispatch them. + if (!isAuthorized) + { + var restrictedIntent = await _intentRouter.ClassifyIntentAsync(message, session); + if (restrictedIntent.Type == ChatbotIntentType.ListDepartments + || restrictedIntent.Type == ChatbotIntentType.SwitchDepartment + || restrictedIntent.Type == ChatbotIntentType.GetActiveDepartment) + { + var restrictedHandler = _actionHandlers.FirstOrDefault(h => h.CanHandle(restrictedIntent.Type)); + if (restrictedHandler != null) + { + var restrictedResponse = await restrictedHandler.HandleAsync(message, restrictedIntent, session); + restrictedResponse.Intent = restrictedIntent; + await _sessionManager.SaveSessionAsync(session); + return restrictedResponse; + } + } + + var optionsBuilder = new System.Text.StringBuilder(); + optionsBuilder.AppendLine("Your active department's plan does not support chatbot features, but you belong to other departments that do:"); + for (int i = 0; i < switchableDepartments.Count; i++) + { + var switchableDept = await _departmentsService.GetDepartmentByIdAsync(switchableDepartments[i].DepartmentId); + optionsBuilder.AppendLine($"{i + 1}: {switchableDept?.Name ?? ("Department " + switchableDepartments[i].DepartmentId)}"); + } + optionsBuilder.Append("Reply SWITCH to change your active department."); + + return new ChatbotResponse { Text = optionsBuilder.ToString(), Processed = true }; + } + // 5. Handle session state machine via ConversationEngine (Phase 2) // 5a. Confirmation of a destructive action (CloseCall, DispatchCall, SetUnitStatus, ...). diff --git a/Core/Resgrid.Model/Services/IDepartmentsService.cs b/Core/Resgrid.Model/Services/IDepartmentsService.cs index 593926558..9e813dd51 100644 --- a/Core/Resgrid.Model/Services/IDepartmentsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentsService.cs @@ -71,15 +71,20 @@ Task AddUserToDepartmentAsync(int departmentId, string userId, Task GetDepartmentByUserIdAsync(string userId, bool bypassCache = false); /// - /// Resolves the department to use for a user's SMS/chatbot operations: their active (then default, - /// then first) membership, preferring one whose plan supports SMS (non-free). If the preferred - /// department is on a free plan but another of the user's departments supports SMS, that one is - /// returned so a user isn't dead-ended; if none support SMS the preferred one is returned so the - /// downstream plan gate can produce the proper "plan doesn't support" message. Null when the user has - /// no non-deleted memberships. + /// Resolves the department to use for a user's SMS/chatbot operations: their ACTIVE (then default, + /// then first) membership. No plan-based auto-picking — if the active department's plan doesn't + /// support SMS the downstream plan gate handles it (offering a department switch when the user has + /// other supported memberships). Null when the user has no non-deleted memberships. /// Task GetActiveSmsDepartmentForUserAsync(string userId, bool bypassCache = false); + /// + /// The user's non-deleted memberships restricted to departments whose plan supports SMS/chatbot + /// (non-free), in a stable order (active first, then by department id) so a numeric pick from a + /// displayed list maps to the same membership across stateless SMS requests. + /// + Task> GetSmsSupportedMembershipsForUserAsync(string userId); + Task GetValidateUserForDepartmentInfoAsync(string userName, bool bypassCache = true); diff --git a/Core/Resgrid.Model/TextCommandTypes.cs b/Core/Resgrid.Model/TextCommandTypes.cs index 5f54dac71..88f022ad8 100644 --- a/Core/Resgrid.Model/TextCommandTypes.cs +++ b/Core/Resgrid.Model/TextCommandTypes.cs @@ -12,6 +12,7 @@ public enum TextCommandTypes Units = 7, MyStatus = 8, CustomAction = 9, - CustomStaffing = 10 + CustomStaffing = 10, + SwitchDepartment = 11 } } \ No newline at end of file diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs index 736958c79..844d51a63 100644 --- a/Core/Resgrid.Services/DepartmentsService.cs +++ b/Core/Resgrid.Services/DepartmentsService.cs @@ -443,32 +443,44 @@ public async Task GetActiveSmsDepartmentForUserAsync(string userId, if (allMembers == null || allMembers.Count == 0) return null; - // Prefer the member marked IsActive, then IsDefault, then first. - var ordered = allMembers + // The user's ACTIVE department (IsActive, then IsDefault, then first) — no plan-based + // auto-picking. If the active department's plan doesn't support SMS the caller's plan gate + // handles it (offering a switch when the user has other supported departments) rather than + // this resolver silently acting in a department the user didn't select. + var preferred = allMembers .Where(m => !m.IsDeleted) .OrderByDescending(m => m.IsActive) .ThenByDescending(m => m.IsDefault) - .ToList(); + .FirstOrDefault(); - if (ordered.Count == 0) + if (preferred == null) return null; - var preferred = ordered[0]; + return await GetDepartmentByIdAsync(preferred.DepartmentId, bypassCache); + } - // If the preferred department supports SMS (non-free plan) use it; otherwise auto-pick the first - // of the user's departments that does, so a user whose active department is on the free plan still - // lands in an SMS-capable department. If none support SMS, fall back to the preferred one so the - // caller's plan gate returns the proper "plan doesn't support" message. - var canProvisionNumber = await Task.WhenAll(ordered.Select(membership => - _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId))); + public async Task> GetSmsSupportedMembershipsForUserAsync(string userId) + { + var allMembers = await GetAllDepartmentsForUserAsync(userId); + if (allMembers == null || allMembers.Count == 0) + return new List(); - for (var i = 0; i < ordered.Count; i++) + // Stable order (active first, then by id) so a numeric pick from a displayed list maps to the + // same membership when the follow-up "switch N" message arrives in a later, stateless request. + var candidates = allMembers + .Where(m => !m.IsDeleted) + .OrderByDescending(m => m.IsActive) + .ThenBy(m => m.DepartmentId) + .ToList(); + + var supported = new List(); + foreach (var membership in candidates) { - if (canProvisionNumber[i]) - return await GetDepartmentByIdAsync(ordered[i].DepartmentId, bypassCache); + if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) + supported.Add(membership); } - return await GetDepartmentByIdAsync(preferred.DepartmentId, bypassCache); + return supported; } diff --git a/Core/Resgrid.Services/TextCommandService.cs b/Core/Resgrid.Services/TextCommandService.cs index ce255841b..ffe4d2b62 100644 --- a/Core/Resgrid.Services/TextCommandService.cs +++ b/Core/Resgrid.Services/TextCommandService.cs @@ -20,6 +20,15 @@ public TextCommandPayload DetermineType(string message, CustomState customAction if (String.IsNullOrWhiteSpace(message)) return payload; + // SWITCH [department name/number]: change the user's active department. Checked first — the + // custom staffing branch below claims any message starting with 's' and would swallow it. + if (message.Trim().ToLower() == "switch" || message.Trim().ToLower().StartsWith("switch ")) + { + payload.Type = TextCommandTypes.SwitchDepartment; + payload.Data = message.Trim().Length > 6 ? message.Trim().Substring(6).Trim() : ""; + return payload; + } + if (customActions != null && customActions.IsDeleted == false && customActions.GetActiveDetails() != null && customActions.GetActiveDetails().Any()) { int actionId = 0; diff --git a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs index f6b651e51..f2cca8fe0 100644 --- a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs +++ b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs @@ -47,13 +47,14 @@ public class SignalWireController : ControllerBase private readonly IUnitsService _unitsService; private readonly ICommunicationTestService _communicationTestService; private readonly IChatbotIngressService _chatbotIngressService; + private readonly IUsersService _usersService; public SignalWireController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, - ICommunicationTestService communicationTestService, IChatbotIngressService chatbotIngressService) + ICommunicationTestService communicationTestService, IChatbotIngressService chatbotIngressService, IUsersService usersService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -72,6 +73,7 @@ public SignalWireController(IDepartmentSettingsService departmentSettingsService _unitsService = unitsService; _communicationTestService = communicationTestService; _chatbotIngressService = chatbotIngressService; + _usersService = usersService; } #endregion Private Readonly Properties and Constructors @@ -168,31 +170,41 @@ public async Task Receive(CancellationToken cancellationToken) try { - UserProfile profile = null; - var departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); - - if (!departmentId.HasValue) + // Parity with TwilioController: the sender's profile identifies them and their ACTIVE + // department drives routing. The TextToCallNumber lookup is a legacy path used only when no + // profile matched (dispatch centers sending text-to-call imports, unknown senders). + UserProfile profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); + + // SECURITY: an unverified mobile number may be used for ROUTING (department resolution, + // plan gate) so the sender lands in their real department, but it must never authorize + // ACTIONS. Downstream handling blocks commands for unverified senders and prompts them to + // verify. The single exception is STOP (handled below): opting out must always work. + bool senderNumberUnverified = profile != null && profile.MobileNumberVerified != true; + if (senderNumberUnverified) + Framework.Logging.LogInfo($"[SignalWire SMS] MessageSid={textMessage.MessageId} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; using profile for routing only, actions blocked until verified."); + + int? departmentId = null; + if (profile != null) { - profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); - - // SECURITY: an unverified mobile number must never identify/act as the matched user. - // Verification (MobileNumberVerified == true) is the bar for trusting an inbound sender. - if (profile != null && profile.MobileNumberVerified != true) - { - Framework.Logging.LogInfo($"[SignalWire SMS] MessageSid={textMessage.MessageId} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; not linking sender to user."); - profile = null; - } - - if (profile != null) - { - var department = await _departmentsService.GetDepartmentByUserIdAsync(profile.UserId); - - if (department != null) - departmentId = department.DepartmentId; - } + var activeDepartment = await _departmentsService.GetActiveSmsDepartmentForUserAsync(profile.UserId); + if (activeDepartment != null) + departmentId = activeDepartment.DepartmentId; + } + else + { + departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); } - if (departmentId.HasValue) + // STOP from an unverified sender: honor the opt-out immediately — turning off messages must + // always work, regardless of verification, plan or department state. + if (senderNumberUnverified && _textCommandService.DetermineType(textMessage.Text).Type == TextCommandTypes.Stop) + { + Framework.Logging.LogInfo($"[SignalWire SMS] MessageSid={textMessage.MessageId} unverified sender texted STOP; disabling SMS messaging on the matched profile."); + await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId, cancellationToken); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."); + } + else if (departmentId.HasValue) { var department = await _departmentsService.GetDepartmentByIdAsync(departmentId.Value); var textToCallEnabled = await _departmentSettingsService.GetDepartmentIsTextCallImportEnabledAsync(departmentId.Value); @@ -263,17 +275,52 @@ public async Task Receive(CancellationToken cancellationToken) var result = await _chatbotIngressService.ProcessMessageAsync(request); messageEvent.Processed = true; - response = LaMLResponse.Message.Respond(result.Text); + if (result != null && !string.IsNullOrWhiteSpace(result.Text)) + response = LaMLResponse.Message.Respond(result.Text); } } - else if (textMessage.To == Config.NumberProviderConfig.SignalWireResgridNumber.Replace("+", "")) // Resgrid master text number + else if (senderNumberUnverified) + { + // Checked BEFORE the switch offer: switching departments is an action, and an + // unverified sender may not act as the matched user (parity with TwilioController). + Framework.Logging.LogInfo($"[SignalWire SMS] DepartmentId={departmentId.Value} not authorized for inbound text (plan gate) and sender is unverified; replying with verify-number message."); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } + else if (profile != null) + { + // The user's ACTIVE department doesn't support inbound text. If they belong to other + // departments that do, process a SWITCH command or offer the switch options; + // otherwise fall through to the plan message. + response = await HandleUnsupportedActiveDepartmentAsync(textMessage, messageEvent, departmentId.Value, profile, cancellationToken); + } + else { - var payload = _textCommandService.DetermineType(textMessage.Text); + Framework.Logging.LogInfo($"[SignalWire SMS] DepartmentId={departmentId.Value} not authorized for inbound text (plan gate); replying with unsupported message."); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Resgrid: Inbound text messaging isn't available on your department's current plan. Please upgrade to a paid plan to enable text commands."); + } + } + else if (textMessage.To == Config.NumberProviderConfig.SignalWireResgridNumber.Replace("+", "")) // Resgrid master text number + { + var payload = _textCommandService.DetermineType(textMessage.Text); + // A sender whose profile mobile number is unverified can't be identified, so commands + // can't be acted on — direct them to verify instead of replying "unknown command". HELP + // and STOP still work below: neither needs a trusted identity, and STOP must always opt out. + if (profile != null && profile.MobileNumberVerified != true + && payload.Type != TextCommandTypes.Help && payload.Type != TextCommandTypes.Stop) + { + Framework.Logging.LogInfo($"[SignalWire SMS] Master number: sender {Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; replying with verify-number message."); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } + else + { switch (payload.Type) { case TextCommandTypes.None: - await _communicationService.SendTextMessageAsync(profile.UserId, "Resgrid TCI Help", "Resgrid (https://resgrid.com) Automated Text System. Unknown command, text help for supported commands.", department.DepartmentId, textMessage.To, profile); + response = LaMLResponse.Message.Respond("Resgrid (https://resgrid.com) Automated Text System. Unknown command, text help for supported commands."); break; case TextCommandTypes.Help: messageEvent.Processed = true; @@ -287,19 +334,32 @@ public async Task Receive(CancellationToken cancellationToken) help.Append("HELP: This help text" + Environment.NewLine); response = LaMLResponse.Message.Respond(help.ToString()); - //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Help", help.ToString(), department.DepartmentId, textMessage.To, profile); break; case TextCommandTypes.Stop: messageEvent.Processed = true; + + if (profile == null) + { + response = LaMLResponse.Message.Respond("Unable to locate your profile. Please log in to Resgrid to manage your text message settings."); + break; + } + await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId, cancellationToken); response = LaMLResponse.Message.Respond("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."); - //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Help", "Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile.", department.DepartmentId, textMessage.To, profile); break; } } } + else if (senderNumberUnverified) + { + // No department resolved and this isn't the master number, but the sender did match a + // profile with an unverified mobile number — tell them the actionable fix. + Framework.Logging.LogInfo($"[SignalWire SMS] No department resolved for sender {Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} (unverified profile match); replying with verify-number message."); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } //return Ok(new StringContent(response, Encoding.UTF8, "application/xml")); return new ContentResult @@ -322,5 +382,112 @@ public async Task Receive(CancellationToken cancellationToken) return Ok(); } + // Parity with TwilioController: the identified user's ACTIVE department failed the inbound-text + // plan gate. A SWITCH command is honored (the normal command path is unreachable while the active + // department is unsupported), any other text gets the switch options, and with no supported + // alternatives they get the plan message. Returns the LaML response body. + private async Task HandleUnsupportedActiveDepartmentAsync(TextMessage textMessage, InboundMessageEvent messageEvent, + int departmentId, UserProfile profile, CancellationToken cancellationToken) + { + var supported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(profile.UserId); + if (supported.Count == 0) + { + Framework.Logging.LogInfo($"[SignalWire SMS] DepartmentId={departmentId} not authorized for inbound text (plan gate); user {profile.UserId} has no SMS-supported departments; replying with unsupported message."); + messageEvent.Processed = true; + return LaMLResponse.Message.Respond("Resgrid: Inbound text messaging isn't available on your department's current plan. Please upgrade to a paid plan to enable text commands."); + } + + var payload = _textCommandService.DetermineType(textMessage.Text); + if (payload.Type == TextCommandTypes.SwitchDepartment) + return await HandleSwitchDepartmentCommandAsync(messageEvent, profile, payload.Data, cancellationToken); + + Framework.Logging.LogInfo($"[SignalWire SMS] DepartmentId={departmentId} not authorized for inbound text (plan gate); user {profile.UserId} has {supported.Count} SMS-supported department(s); replying with switch options."); + messageEvent.Processed = true; + return LaMLResponse.Message.Respond(await BuildSwitchOptionsMessageAsync(supported, + "Resgrid: Your active department's plan doesn't include inbound text messaging, but you belong to other departments that do.")); + } + + // SWITCH [name/number]: changes the user's active department. Mirrors the TwilioController + // handler — same supported-department list and ordering so numeric picks stay stable across + // providers. Returns the LaML response body. + private async Task HandleSwitchDepartmentCommandAsync(InboundMessageEvent messageEvent, + UserProfile profile, string departmentIdentifier, CancellationToken cancellationToken) + { + messageEvent.Processed = true; + + var supported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(profile.UserId); + if (supported.Count == 0) + return LaMLResponse.Message.Respond("Resgrid: None of your departments' current plans include inbound text messaging. Please upgrade to a paid plan to enable text commands."); + + if (string.IsNullOrWhiteSpace(departmentIdentifier)) + return LaMLResponse.Message.Respond(await BuildSwitchOptionsMessageAsync(supported, "Resgrid: Your departments that support text messaging:")); + + DepartmentMember target = null; + var trimmedId = departmentIdentifier.Trim(); + + // A small number is a pick from the displayed list; otherwise try a department id, then name/code. + if (int.TryParse(trimmedId, out var listIndex) && listIndex >= 1 && listIndex <= supported.Count) + target = supported[listIndex - 1]; + + if (target == null && int.TryParse(trimmedId, out var deptId)) + target = supported.FirstOrDefault(m => m.DepartmentId == deptId); + + if (target == null) + { + foreach (var membership in supported) + { + var dept = await _departmentsService.GetDepartmentByIdAsync(membership.DepartmentId); + if (dept == null) + continue; + + if (string.Equals(dept.Name, trimmedId, StringComparison.OrdinalIgnoreCase) + || string.Equals(dept.Code, trimmedId, StringComparison.OrdinalIgnoreCase)) + { + target = membership; + break; + } + + if (target == null && !string.IsNullOrWhiteSpace(dept.Name) && dept.Name.IndexOf(trimmedId, StringComparison.OrdinalIgnoreCase) >= 0) + target = membership; + } + } + + if (target == null) + return LaMLResponse.Message.Respond(await BuildSwitchOptionsMessageAsync(supported, $"Resgrid: Couldn't find a department matching \"{trimmedId}\".")); + + if (target.IsActive) + { + var alreadyActive = await _departmentsService.GetDepartmentByIdAsync(target.DepartmentId); + return LaMLResponse.Message.Respond($"Resgrid: {alreadyActive?.Name ?? "That department"} is already your active department."); + } + + var identityUser = _usersService.GetUserById(profile.UserId); + var success = await _departmentsService.SetActiveDepartmentForUserAsync(profile.UserId, target.DepartmentId, identityUser, cancellationToken); + + var targetDept = await _departmentsService.GetDepartmentByIdAsync(target.DepartmentId); + if (success) + { + Framework.Logging.LogInfo($"[SignalWire SMS] User {profile.UserId} switched active department to {target.DepartmentId} via text command."); + return LaMLResponse.Message.Respond($"Resgrid: Your active department is now {targetDept?.Name ?? ("department " + target.DepartmentId)}. Text commands now apply to this department."); + } + + return LaMLResponse.Message.Respond("Resgrid: Unable to switch departments right now. Please try again later."); + } + + private async Task BuildSwitchOptionsMessageAsync(List supported, string prefix) + { + var sb = new StringBuilder(); + sb.Append(prefix + Environment.NewLine); + + for (int i = 0; i < supported.Count; i++) + { + var dept = await _departmentsService.GetDepartmentByIdAsync(supported[i].DepartmentId); + var activeMarker = supported[i].IsActive ? " (active)" : ""; + sb.Append($"{i + 1}: {dept?.Name ?? ("Department " + supported[i].DepartmentId)}{activeMarker}" + Environment.NewLine); + } + + sb.Append("Reply SWITCH to change your active department."); + return sb.ToString(); + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 8ff89bca4..399ee8c09 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -143,27 +143,32 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques // inbound number fall back to resolving the department from that number. UserProfile userProfile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); - // SECURITY: an unverified mobile number must never identify/act as the matched user — - // anyone can type someone else's number into a profile. Verification (MobileNumberVerified - // == true) is the bar for trusting an inbound sender. + // SECURITY: an unverified mobile number may be used for ROUTING (department resolution, + // plan gate, feature flag) so the sender lands in their real department, but it must never + // authorize ACTIONS — anyone can type someone else's number into a profile. Downstream + // handlers block commands for unverified senders and prompt them to verify. The single + // exception is STOP (handled below): opting out of messages must always work. bool senderNumberUnverified = userProfile != null && userProfile.MobileNumberVerified != true; if (senderNumberUnverified) - { - Framework.Logging.LogInfo($"[Twilio SMS] MessageSid={request.MessageSid} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; not linking sender to user."); - userProfile = null; - } + Framework.Logging.LogInfo($"[Twilio SMS] MessageSid={request.MessageSid} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; using profile for routing only, actions blocked until verified."); int? departmentId = null; if (userProfile != null) { + // An identified user (verified or not) always operates in their ACTIVE department. The + // texted number must NOT override this — the TextToCallNumber mapping is a legacy path + // that can resolve a department the user isn't even a member of. var department = await _departmentsService.GetActiveSmsDepartmentForUserAsync(userProfile.UserId); if (department != null) departmentId = department.DepartmentId; } - - // Legacy fallback: a per-department provisioned inbound number identifies the department directly. - if (!departmentId.HasValue) + else + { + // Legacy fallback for senders with no usable profile only (dispatch centers sending + // text-to-call imports, unknown numbers): the provisioned inbound number identifies the + // department directly. departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); + } // Carry the resolved department onto the inbound message event so chatbot-routed events // retain the same department context the text-command path records. @@ -173,6 +178,24 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques // Diagnostic: did we resolve a department for this inbound text? If not, no reply is sent. Framework.Logging.LogInfo($"[Twilio SMS] MessageSid={request.MessageSid} To={textMessage.To} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} resolved DepartmentId={(departmentId.HasValue ? departmentId.Value.ToString() : "none")}"); + // STOP from an unverified sender: honor the opt-out immediately — turning off messages must + // always work, regardless of verification, plan or feature-flag state. + if (senderNumberUnverified && _textCommandService.DetermineType(textMessage.Text).Type == TextCommandTypes.Stop) + { + Framework.Logging.LogInfo($"[Twilio SMS] MessageSid={request.MessageSid} unverified sender texted STOP; disabling SMS messaging on the matched profile."); + await _userProfileService.DisableTextMessagesForUserAsync(userProfile.UserId); + messageEvent.Processed = true; + response.Message("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."); + + // The enclosing finally saves messageEvent and the shared tail returns the TwiML. + return new ContentResult + { + Content = response.ToString(), + ContentType = "application/xml", + StatusCode = 200 + }; + } + // Feature-flagged rollout: the chatbot ingress is the new path. When the flag is off // (globally or for this department) fall back to the original text-command handling so // existing behavior is preserved. @@ -406,6 +429,9 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Help", help.ToString(), department.DepartmentId, textMessage.To, profile); break; + case TextCommandTypes.SwitchDepartment: + await HandleSwitchDepartmentCommandAsync(messageEvent, response, profile, payload.Data); + break; case TextCommandTypes.Action: messageEvent.Processed = true; await _actionLogsService.SetUserActionAsync(profile.UserId, department.DepartmentId, (int)payload.GetActionType()); @@ -548,6 +574,22 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t } } } + else if (senderNumberUnverified) + { + // Checked BEFORE the switch offer: switching departments is an action, and an unverified + // sender may not act as the matched user. Their profile routed them to the right + // department; verifying the number is the prerequisite for everything else. + Framework.Logging.LogInfo($"[Twilio SMS] DepartmentId={departmentId.Value} not authorized for inbound text (plan gate) and sender is unverified; replying with verify-number message."); + messageEvent.Processed = true; + response.Message("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } + else if (userProfile != null) + { + // The user's ACTIVE department doesn't support inbound text. If they belong to other + // departments that do, process a SWITCH command or offer the switch options; otherwise + // fall through to the plan message. + await HandleUnsupportedActiveDepartmentAsync(textMessage, messageEvent, response, departmentId.Value, userProfile); + } else { // Department resolved but its plan doesn't include inbound text messaging (only the free @@ -562,6 +604,18 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t var profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); var payload = _textCommandService.DetermineType(textMessage.Text); + // A sender whose profile mobile number is unverified can't be identified, so commands can't + // be acted on — direct them to verify instead of replying "unknown command". HELP and STOP + // still work below: neither needs a trusted identity, and STOP must always opt out. + if (profile != null && profile.MobileNumberVerified != true + && payload.Type != TextCommandTypes.Help && payload.Type != TextCommandTypes.Stop) + { + Framework.Logging.LogInfo($"[Twilio SMS] Master number: sender {Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; replying with verify-number message."); + messageEvent.Processed = true; + response.Message("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + return; + } + switch (payload.Type) { case TextCommandTypes.None: @@ -596,6 +650,139 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t break; } } + else if (senderNumberUnverified) + { + // No department resolved and this isn't the master number, but the sender did match a + // profile with an unverified mobile number. Previously this was silence; tell them the + // actionable fix instead. + Framework.Logging.LogInfo($"[Twilio SMS] No department resolved for sender {Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} (unverified profile match); replying with verify-number message."); + messageEvent.Processed = true; + response.Message("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } + } + + // The identified user's ACTIVE department failed the inbound-text plan gate. When they belong to + // other departments that do support it, a SWITCH command is honored (it must work here — the + // normal command path is unreachable while the active department is unsupported) and any other + // text gets the switch options; with no alternatives they get the plan message. + private async System.Threading.Tasks.Task HandleUnsupportedActiveDepartmentAsync(TextMessage textMessage, InboundMessageEvent messageEvent, + MessagingResponse response, int departmentId, UserProfile userProfile) + { + var supported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(userProfile.UserId); + if (supported.Count == 0) + { + Framework.Logging.LogInfo($"[Twilio SMS] DepartmentId={departmentId} not authorized for inbound text (plan gate); user {userProfile.UserId} has no SMS-supported departments; replying with unsupported message."); + messageEvent.Processed = true; + response.Message("Resgrid: Inbound text messaging isn't available on your department's current plan. Please upgrade to a paid plan to enable text commands."); + return; + } + + var payload = _textCommandService.DetermineType(textMessage.Text); + if (payload.Type == TextCommandTypes.SwitchDepartment) + { + await HandleSwitchDepartmentCommandAsync(messageEvent, response, userProfile, payload.Data); + return; + } + + Framework.Logging.LogInfo($"[Twilio SMS] DepartmentId={departmentId} not authorized for inbound text (plan gate); user {userProfile.UserId} has {supported.Count} SMS-supported department(s); replying with switch options."); + messageEvent.Processed = true; + response.Message(await BuildSwitchOptionsMessageAsync(supported, + "Resgrid: Your active department's plan doesn't include inbound text messaging, but you belong to other departments that do.")); + } + + // SWITCH [name/number]: changes the user's active department. Only departments whose plan + // supports SMS are offered/accepted — the list order matches BuildSwitchOptionsMessageAsync so a + // numeric reply maps to what the user was shown, even though each SMS is a stateless request. + private async System.Threading.Tasks.Task HandleSwitchDepartmentCommandAsync(InboundMessageEvent messageEvent, + MessagingResponse response, UserProfile userProfile, string departmentIdentifier) + { + messageEvent.Processed = true; + + var supported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(userProfile.UserId); + if (supported.Count == 0) + { + response.Message("Resgrid: None of your departments' current plans include inbound text messaging. Please upgrade to a paid plan to enable text commands."); + return; + } + + if (string.IsNullOrWhiteSpace(departmentIdentifier)) + { + response.Message(await BuildSwitchOptionsMessageAsync(supported, "Resgrid: Your departments that support text messaging:")); + return; + } + + DepartmentMember target = null; + var trimmedId = departmentIdentifier.Trim(); + + // A small number is a pick from the displayed list; otherwise try a department id, then name/code. + if (int.TryParse(trimmedId, out var listIndex) && listIndex >= 1 && listIndex <= supported.Count) + target = supported[listIndex - 1]; + + if (target == null && int.TryParse(trimmedId, out var deptId)) + target = supported.FirstOrDefault(m => m.DepartmentId == deptId); + + if (target == null) + { + foreach (var membership in supported) + { + var dept = await _departmentsService.GetDepartmentByIdAsync(membership.DepartmentId); + if (dept == null) + continue; + + if (string.Equals(dept.Name, trimmedId, StringComparison.OrdinalIgnoreCase) + || string.Equals(dept.Code, trimmedId, StringComparison.OrdinalIgnoreCase)) + { + target = membership; + break; + } + + if (target == null && !string.IsNullOrWhiteSpace(dept.Name) && dept.Name.IndexOf(trimmedId, StringComparison.OrdinalIgnoreCase) >= 0) + target = membership; + } + } + + if (target == null) + { + response.Message(await BuildSwitchOptionsMessageAsync(supported, $"Resgrid: Couldn't find a department matching \"{trimmedId}\".")); + return; + } + + if (target.IsActive) + { + var alreadyActive = await _departmentsService.GetDepartmentByIdAsync(target.DepartmentId); + response.Message($"Resgrid: {alreadyActive?.Name ?? "That department"} is already your active department."); + return; + } + + var identityUser = _usersService.GetUserById(userProfile.UserId); + var success = await _departmentsService.SetActiveDepartmentForUserAsync(userProfile.UserId, target.DepartmentId, identityUser); + + var targetDept = await _departmentsService.GetDepartmentByIdAsync(target.DepartmentId); + if (success) + { + Framework.Logging.LogInfo($"[Twilio SMS] User {userProfile.UserId} switched active department to {target.DepartmentId} via text command."); + response.Message($"Resgrid: Your active department is now {targetDept?.Name ?? ("department " + target.DepartmentId)}. Text commands now apply to this department."); + } + else + { + response.Message("Resgrid: Unable to switch departments right now. Please try again later."); + } + } + + private async Task BuildSwitchOptionsMessageAsync(List supported, string prefix) + { + var sb = new StringBuilder(); + sb.Append(prefix + Environment.NewLine); + + for (int i = 0; i < supported.Count; i++) + { + var dept = await _departmentsService.GetDepartmentByIdAsync(supported[i].DepartmentId); + var activeMarker = supported[i].IsActive ? " (active)" : ""; + sb.Append($"{i + 1}: {dept?.Name ?? ("Department " + supported[i].DepartmentId)}{activeMarker}" + Environment.NewLine); + } + + sb.Append("Reply SWITCH to change your active department."); + return sb.ToString(); } [HttpGet("VoiceCall")] diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml index d2d3eaabc..dd9c0cd55 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml @@ -237,276 +237,278 @@
-
-
-
-
-
-
-

- @Model.Plan.Name +
+
+
+
+
+
+

+ @Model.Plan.Name - @if (Model.Payment.Cancelled) - { - @localizer["Canceled"] - } - else - { - @localizer["Active"] - } -

@if (Model.Payment.Cancelled) { - @localizer["PlanExpires"]: @Model.Expires + @localizer["Canceled"] } else { - @localizer["PlanRenews"]: @Model.Expires + @localizer["Active"] } -
+

+ @if (Model.Payment.Cancelled) + { + @localizer["PlanExpires"]: @Model.Expires + } + else + { + @localizer["PlanRenews"]: @Model.Expires + }
-
-
-

- @if (Model.Plan.Frequency == (int)PlanFrequency.Yearly) - { - - @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["Year"] - - } - else - { - - @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["month"] - - } -

+
+
+
+

+ @if (Model.Plan.Frequency == (int)PlanFrequency.Yearly) + { + + @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["Year"] + + } + else + { + + @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["month"] + + } +

+
+
+
+
+

+ @Model.PersonnelCount/@Model.PersonnelLimit + @localizer["Entities"] +

+
+
+
+ + @if (ViewBag.SubscriptionErrorMessage != null) + {
-

- @Model.PersonnelCount/@Model.PersonnelLimit - @localizer["Entities"] -

-
-
+
+

@localizer["Warning"]

+ @ViewBag.SubscriptionErrorMessage
- - @if (ViewBag.SubscriptionErrorMessage != null) - { -
-
-
-

@localizer["Warning"]

- @ViewBag.SubscriptionErrorMessage + } +
+ + +
+
+
+
+
+
-
- } -
+
+
+
+ @if (Model.Plan == null || Model.Plan.PlanId == 1) + { +

Select the number of Entities (Users + Units) you require using the slider or text box below. + @if (!isEU) { Your first 10 entities are included at no charge — each } else { Each } + additional pack of 10 entities is billed at the rate shown. Select "Buy Yearly" or "Buy Monthly" to proceed to checkout.

-
-
-
-
-
- -
-
-
-
-
- - @if (Model.Plan == null || Model.Plan.PlanId == 1) - { -

Select the number of Entities (Users + Units) you require using the slider or text box below. - @if (!isEU) { Your first 10 entities are included at no charge — each } else { Each } - additional pack of 10 entities is billed at the rate shown. Select "Buy Yearly" or "Buy Monthly" to proceed to checkout.

- @if (isEU) - { -
- European Region — Pricing includes GDPR-compliant data hosting. All prices shown in EUR (@currencySymbol) with a regional adjustment. No free tier is available. -
- } +
-
+
- - -
-

Entities

- Users or Units sold in packs of 10 -
-
-
- - - -
+
+

Entities

+ Users or Units sold in packs of 10 +
+
+
+ + +
+
-
-
-
- Entities -
+
+
+
+ Entities
-
- - Monthly billing amount -
- -

0.00

-
+
+
+ + Monthly billing amount +
+ +

0.00

-
- - Yearly (annual) billing amount -
- -

0.00

-
+
+
+ + Yearly (annual) billing amount +
+ +

0.00

-
-
-
- @if (Model.IsPaddleDepartment) { - @localizer["BuyYearly"] - } else { - @localizer["BuyYearly"] - } -
-
- @if (Model.IsPaddleDepartment) { - @localizer["BuyMonthly"] - } else { - @localizer["BuyMonthly"] - } -
+
+
+
+
+ @if (Model.IsPaddleDepartment) { + @localizer["BuyYearly"] + } else { + @localizer["BuyYearly"] + } +
+
+ @if (Model.IsPaddleDepartment) { + @localizer["BuyMonthly"] + } else { + @localizer["BuyMonthly"] + }
-
- +
-
- } - else if (Model.Plan.PlanId >= 36) - { -
-

Active Subscription

- You currently have an activate subscription, thank you! To update the amount of entities you need, update your billing information, or cancel your subscription please use the "Manage Your Subscription" button. If you are a Invoice customer, please contact us to update your subscription. + + +
+ } + else if (Model.Plan.PlanId >= 36) + { +
+

Active Subscription

+ You currently have an activate subscription, thank you! To update the amount of entities you need, update your billing information, or cancel your subscription please use the "Manage Your Subscription" button. If you are a Invoice customer, please contact us to update your subscription. +
+ +
+ @if (Model.IsPaddleDepartment) + { + @localizer["CancelSub"] + } + else if (!string.IsNullOrWhiteSpace(Model.StripeCustomer)) + { + @localizer["ManageYourSubscription"] + } +
+ } + else if (Model.Plan.PlanId != 1) + { +
+

Active Legacy Subscription

+ You currently have an active legacy subscription. To change over to the Entity based subscription, please cancel your current subscription and then sign up for a new one. If you are a Invoice customer, please contact us to update your subscription. Click on the "Legacy Plan" tab to see more information about your current plan. +
+ } +
+
+
+
+
+

@localizer["Warning"]

+ You are on a Legacy Plan. We do not offer these plans anymore. You can stay on this plan as long as you wish, but you will not be able to upgrade or downgrade your plan. If you wish to change your plan, you will need to cancel your current plan and then sign up for a new plan. We do not raise prices on departments with active billing relationships, so you can stay on this plan as long as you wish, you will need to use the "Change Billing Info" button below if you are using a Credit Card to pay. If you bill via Invoice your cost will stay the same if you renew.
- @if (Model.IsPaddleDepartment) + @if (!string.IsNullOrWhiteSpace(Model.StripeCustomer)) { + @localizer["ChangeBillingInfo"] @localizer["CancelSub"] } - else if (!string.IsNullOrWhiteSpace(Model.StripeCustomer)) - { - @localizer["ManageYourSubscription"] - } -
- } - else if (Model.Plan.PlanId != 1) - { -
-

Active Legacy Subscription

- You currently have an active legacy subscription. To change over to the Entity based subscription, please cancel your current subscription and then sign up for a new one. If you are a Invoice customer, please contact us to update your subscription. Click on the "Legacy Plan" tab to see more information about your current plan. -
- } -
-
-
-
-
-

@localizer["Warning"]

- You are on a Legacy Plan. We do not offer these plans anymore. You can stay on this plan as long as you wish, but you will not be able to upgrade or downgrade your plan. If you wish to change your plan, you will need to cancel your current plan and then sign up for a new plan. We do not raise prices on departments with active billing relationships, so you can stay on this plan as long as you wish, you will need to use the "Change Billing Info" button below if you are using a Credit Card to pay. If you bill via Invoice your cost will stay the same if you renew. -
- -
- @if (!string.IsNullOrWhiteSpace(Model.StripeCustomer)) - { - @localizer["ChangeBillingInfo"] - @localizer["CancelSub"] - } -
-
+
+
- @if (Model.HasActiveSubscription) - { -
-
-
-
-
-
-
-

Push-To-Talk Addon

-
-
-
-
-
    -
  • Two-Way IP Voice Communications
  • -
  • Up to 50 Channels
  • -
  • Radios with worldwide reach
  • -
  • Real-Time Voice, Push-To-Talk
  • -
  • Android, iOS and Web
  • -
-
-
-
- -

$35/mo

per 10 user pack -
-
-
-

The Push-To-Talk Addon is only available on paid plans. You can buy as many 10 user packs as you wish, and they are billed monthly separately from your Resgrid bill. Allowing you to size up and down as you need. You can have up to 50 active people per channel. Push-To-Talk requires devices with an active Internet (data) connection and uses VOIP technology.

- @if (Model.IsPaddleDepartment) - { - Manage PTT - } - else - { - Manage PTT - } + @if (Model.HasActiveSubscription) + { +
+
+
+
+
+
+
+

Push-To-Talk Addon

+
+
+
+
+
    +
  • Two-Way IP Voice Communications
  • +
  • Up to 50 Channels
  • +
  • Radios with worldwide reach
  • +
  • Real-Time Voice, Push-To-Talk
  • +
  • Android, iOS and Web
  • +
+
+ +

$35/mo

per 10 user pack +
+
+
+

The Push-To-Talk Addon is only available on paid plans. You can buy as many 10 user packs as you wish, and they are billed monthly separately from your Resgrid bill. Allowing you to size up and down as you need. You can have up to 50 active people per channel. Push-To-Talk requires devices with an active Internet (data) connection and uses VOIP technology.

+ @if (Model.IsPaddleDepartment) + { + Manage PTT + } + else + { + Manage PTT + } +
- } +
+ } -
+
+
+
@@ -522,7 +524,7 @@