-
-
Notifications
You must be signed in to change notification settings - Fork 82
RG-T117 Chatbot fixes #427
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -153,19 +153,43 @@ 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<Model.DepartmentMember> 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 }); | ||
| // A switch target must be one the chatbot can actually serve on this platform (plan | ||
| // supports SMS AND chatbot enabled per department config) — the same filter | ||
| // DepartmentActionHandler applies, so the numbered options shown below map to the | ||
| // memberships its switch handler resolves. Plan-only filtering could strand the user in | ||
| // a department whose chatbot config blocks everything, with no in-band way back. | ||
| var smsSupported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(identity.UserId) | ||
| ?? new List<Model.DepartmentMember>(); | ||
|
|
||
| switchableDepartments = new List<Model.DepartmentMember>(); | ||
| foreach (var membership in smsSupported) | ||
| { | ||
| if (await _departmentConfigService.IsChatbotUsableForDepartmentAsync(membership.DepartmentId, message.Platform)) | ||
| switchableDepartments.Add(membership); | ||
| } | ||
|
|
||
| if (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) | ||
|
Comment on lines
+156
to
+192
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift Use chatbot eligibility, not only SMS plan eligibility, for restricted switching.
Otherwise users can switch into a department that immediately rejects every subsequent chatbot request. 📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| { | ||
| if (!deptConfig.IsEnabled) | ||
| { | ||
|
|
@@ -224,6 +248,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 <number or name> 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, ...). | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| using System.Threading; | ||
| using System.Threading.Tasks; | ||
|
|
||
| namespace Resgrid.Model.Services | ||
| { | ||
| /// <summary> | ||
| /// Shared handling for the inbound-SMS department SWITCH flow — used by both the Twilio and | ||
| /// SignalWire controllers so the plan-gate/STOP/switch behavior and reply wording stay identical | ||
| /// across providers. Methods return the reply body text; the caller wraps it in the | ||
| /// provider-specific response (TwiML or LaML). | ||
| /// </summary> | ||
| public interface ITextDepartmentSwitchService | ||
| { | ||
| /// <summary> | ||
| /// The identified user's ACTIVE department failed the inbound-text plan gate. STOP is honored | ||
| /// first (opt-out must always work), a SWITCH command is processed, any other text gets the | ||
| /// switch options, and with no supported alternatives the plan message. Never returns null. | ||
| /// </summary> | ||
| Task<string> BuildUnsupportedActiveDepartmentResponseAsync(string messageText, int departmentId, UserProfile profile, | ||
| string logPrefix, CancellationToken cancellationToken = default(CancellationToken)); | ||
|
|
||
| /// <summary> | ||
| /// Processes a SWITCH [name/number] command: changes the user's active department when the | ||
| /// identifier resolves to one of their SMS-supported memberships (list index, department id, | ||
| /// then exact/partial name or code). Empty identifier returns the options list. Never returns null. | ||
| /// </summary> | ||
| Task<string> BuildSwitchCommandResponseAsync(UserProfile profile, string departmentIdentifier, | ||
| string logPrefix, CancellationToken cancellationToken = default(CancellationToken)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -443,32 +443,46 @@ public async Task<Department> 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<List<DepartmentMember>> GetSmsSupportedMembershipsForUserAsync(string userId) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var allMembers = await GetAllDepartmentsForUserAsync(userId); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (allMembers == null || allMembers.Count == 0) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return new List<DepartmentMember>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| // 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. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // Disabled memberships (IsDisabled is nullable; null means not disabled) are excluded — a user | ||||||||||||||||||||||||||||||||||||||||||||||||||
| // deactivated in a department must not be offered it as a switch target. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var candidates = allMembers | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .Where(m => !m.IsDeleted && m.IsDisabled != true) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .OrderByDescending(m => m.IsActive) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .ThenBy(m => m.DepartmentId) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .ToList(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| for (var i = 0; i < ordered.Count; i++) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| var supported = new List<DepartmentMember>(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| foreach (var membership in candidates) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (canProvisionNumber[i]) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return await GetDepartmentByIdAsync(ordered[i].DepartmentId, bypassCache); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) | ||||||||||||||||||||||||||||||||||||||||||||||||||
| supported.Add(membership); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+472
to
+482
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Exclude disabled memberships from switch authorization. Every SMS/chatbot switch handler trusts this list. With only Proposed fix var candidates = allMembers
- .Where(m => !m.IsDeleted)
+ .Where(m => !m.IsDeleted && !m.IsDisabled.GetValueOrDefault())
.OrderByDescending(m => m.IsActive)📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| return await GetDepartmentByIdAsync(preferred.DepartmentId, bypassCache); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return supported; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicated logic in the
IsPlatformAllowedmethod (lines 66-79) verbatim copies the identical private static method inChatbotIngressService.cs(lines 556-569). Extract this parsing logic into a shared utility or extension method so both services reference a single implementation.Kody rule violation: Extract duplicated logic into functions
Prompt for LLM
Talk to Kody by mentioning @kody
Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.