Skip to content

Commit c74752b

Browse files
committed
RE1-T117 PR#393 fixes
1 parent 2d52129 commit c74752b

8 files changed

Lines changed: 264 additions & 69 deletions

File tree

Core/Resgrid.Chatbot.NLU/Services/EntityExtractor.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ private static void ExtractDates(string text, List<ChatbotEntity> entities)
109109

110110
private static void ExtractTimes(string text, List<ChatbotEntity> entities)
111111
{
112-
var timePattern = new Regex(@"\b(\d{1,2})(:\d{2})?\s*(am|pm|AM|PM)?\b");
112+
var timePattern = new Regex(@"\b(\d{1,2})(?::\d{2}(?:\s*(?:am|pm|AM|PM))?|\s*(?:am|pm|AM|PM))\b");
113113
var match = timePattern.Match(text);
114114
if (match.Success)
115115
{

Core/Resgrid.Chatbot/Handlers/DispatchCallHandler.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
5959

6060
var priorities = await _callsService.GetActiveCallPrioritiesForDepartmentAsync(session.DepartmentId);
6161
var priority = priorities?.FirstOrDefault(p => p.IsDefault) ?? priorities?.FirstOrDefault();
62+
if (priority == null)
63+
Framework.Logging.LogError($"DispatchCallHandler: No active call priorities for department {session.DepartmentId}; falling back to {nameof(CallPriority.Low)}.");
6264

6365
var call = new Call
6466
{
@@ -67,9 +69,9 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
6769
LoggedOn = DateTime.UtcNow,
6870
ReportingUserId = session.UserId,
6971
DepartmentId = session.DepartmentId,
70-
Priority = priority?.DepartmentCallPriorityId ?? 0,
72+
Priority = priority?.DepartmentCallPriorityId ?? (int)CallPriority.Low,
7173
State = (int)CallStates.Active,
72-
CallSource = 0
74+
CallSource = (int)CallSources.Chatbot
7375
};
7476

7577
var saved = await _callsService.SaveCallAsync(call);

Core/Resgrid.Chatbot/Localization/ChatbotResources.cs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,31 @@ public static string Get(string key, string culture, params object[] args)
5454
return value;
5555
}
5656

57+
/// <summary>True if <paramref name="text"/> is an affirmative ("yes") confirmation in the given culture.</summary>
58+
public static bool IsAffirmative(string text, string culture) => MatchesConfirmationToken(text, culture, "Confirm_YesTokens");
59+
60+
/// <summary>True if <paramref name="text"/> is a negative ("no"/"cancel") confirmation in the given culture.</summary>
61+
public static bool IsNegative(string text, string culture) => MatchesConfirmationToken(text, culture, "Confirm_NoTokens");
62+
63+
// Matches the trimmed input against the comma-separated token list for the key. The English tokens
64+
// are always accepted (commands are English today, so "yes"/"no" must keep working in every locale)
65+
// alongside the locale's own words, letting non-English users confirm in their language too.
66+
private static bool MatchesConfirmationToken(string text, string culture, string key)
67+
{
68+
if (string.IsNullOrWhiteSpace(text))
69+
return false;
70+
71+
var input = text.Trim();
72+
foreach (var token in (Get(key, "en") + "," + Get(key, culture)).Split(','))
73+
{
74+
var t = token.Trim();
75+
if (t.Length > 0 && string.Equals(t, input, StringComparison.OrdinalIgnoreCase))
76+
return true;
77+
}
78+
79+
return false;
80+
}
81+
5782
private static Dictionary<string, string> L(string en, string es, string sv, string de, string fr, string it, string pl, string uk, string ar)
5883
=> new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
5984
{
@@ -68,6 +93,166 @@ private static Dictionary<string, string> EnOnly(string en)
6893
private static readonly Dictionary<string, Dictionary<string, string>> _table =
6994
new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase)
7095
{
96+
// ---- ConversationEngine (multi-turn continuation, confirmation, parameter prompts) ----
97+
98+
// Comma-separated confirmation tokens. Command words double as classifier tokens, so the
99+
// English set is always honored (see MatchesConfirmationToken); the locale entries add native words.
100+
["Confirm_YesTokens"] = L(
101+
"yes,y,confirm,ok",
102+
"sí,si,s,confirmar,vale,ok",
103+
"ja,j,bekräfta,ok",
104+
"ja,j,bestätigen,ok",
105+
"oui,o,confirmer,ok",
106+
"sì,si,s,conferma,ok",
107+
"tak,t,potwierdzam,ok",
108+
"так,т,підтвердити,ok",
109+
"نعم,تأكيد,موافق,حسنا"),
110+
111+
["Confirm_NoTokens"] = L(
112+
"no,n,cancel",
113+
"no,n,cancelar",
114+
"nej,n,avbryt",
115+
"nein,n,abbrechen",
116+
"non,n,annuler",
117+
"no,n,annulla",
118+
"nie,n,anuluj",
119+
"ні,н,скасувати",
120+
"لا,إلغاء"),
121+
122+
["Conv_Confirmed"] = L(
123+
"Confirmed.",
124+
"Confirmado.",
125+
"Bekräftat.",
126+
"Bestätigt.",
127+
"Confirmé.",
128+
"Confermato.",
129+
"Potwierdzono.",
130+
"Підтверджено.",
131+
"تم التأكيد."),
132+
133+
["Conv_Cancelled"] = L(
134+
"Cancelled.",
135+
"Cancelado.",
136+
"Avbruten.",
137+
"Abgebrochen.",
138+
"Annulé.",
139+
"Annullato.",
140+
"Anulowano.",
141+
"Скасовано.",
142+
"تم الإلغاء."),
143+
144+
["Conv_ConfirmPrompt"] = L(
145+
"Please reply YES to confirm or NO to cancel.",
146+
"Responde SÍ para confirmar o NO para cancelar.",
147+
"Svara JA för att bekräfta eller NEJ för att avbryta.",
148+
"Antworten Sie mit JA zum Bestätigen oder NEIN zum Abbrechen.",
149+
"Répondez OUI pour confirmer ou NON pour annuler.",
150+
"Rispondi SÌ per confermare o NO per annullare.",
151+
"Odpowiedz TAK, aby potwierdzić, lub NIE, aby anulować.",
152+
"Відповідайте ТАК, щоб підтвердити, або НІ, щоб скасувати.",
153+
"أرسل نعم للتأكيد أو لا للإلغاء."),
154+
155+
["Conv_ProcessingLinkingCode"] = L(
156+
"Processing your linking code...",
157+
"Procesando tu código de vinculación...",
158+
"Bearbetar din länkningskod...",
159+
"Ihr Verknüpfungscode wird verarbeitet...",
160+
"Traitement de votre code de liaison...",
161+
"Elaborazione del codice di collegamento...",
162+
"Przetwarzanie kodu łączenia...",
163+
"Обробка вашого коду прив'язки...",
164+
"جارٍ معالجة رمز الربط الخاص بك..."),
165+
166+
["Conv_ReceivedProcessing"] = L(
167+
"Received. Processing your request...",
168+
"Recibido. Procesando tu solicitud...",
169+
"Mottaget. Bearbetar din begäran...",
170+
"Empfangen. Ihre Anfrage wird verarbeitet...",
171+
"Reçu. Traitement de votre demande...",
172+
"Ricevuto. Elaborazione della tua richiesta...",
173+
"Odebrano. Przetwarzanie żądania...",
174+
"Отримано. Обробка вашого запиту...",
175+
"تم الاستلام. جارٍ معالجة طلبك..."),
176+
177+
["Conv_PromptSendMessageTo"] = L(
178+
"What message should I send to {0}?",
179+
"¿Qué mensaje debo enviar a {0}?",
180+
"Vilket meddelande ska jag skicka till {0}?",
181+
"Welche Nachricht soll ich an {0} senden?",
182+
"Quel message dois-je envoyer à {0} ?",
183+
"Quale messaggio devo inviare a {0}?",
184+
"Jaką wiadomość mam wysłać do {0}?",
185+
"Яке повідомлення надіслати {0}?",
186+
"ما الرسالة التي يجب أن أرسلها إلى {0}؟"),
187+
188+
["Conv_PromptSendMessage"] = L(
189+
"Who would you like to send a message to, and what should it say?",
190+
"¿A quién quieres enviar un mensaje y qué debe decir?",
191+
"Vem vill du skicka ett meddelande till och vad ska det stå?",
192+
"An wen möchten Sie eine Nachricht senden und was soll darin stehen?",
193+
"À qui souhaitez-vous envoyer un message et que doit-il dire ?",
194+
"A chi vuoi inviare un messaggio e cosa deve dire?",
195+
"Do kogo chcesz wysłać wiadomość i co ma zawierać?",
196+
"Кому ви хочете надіслати повідомлення і що в ньому написати?",
197+
"إلى من تريد إرسال رسالة، وماذا يجب أن تقول؟"),
198+
199+
// Status/staffing option lines stay in English: the numbers and English names are the
200+
// command tokens the classifier/handlers match; only the leading question is translated.
201+
["Conv_PromptSetStatus"] = L(
202+
"Which status? Text a number or name:\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By",
203+
"¿Qué estado? Escribe un número o nombre:\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By",
204+
"Vilken status? Skriv en siffra eller ett namn:\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By",
205+
"Welcher Status? Senden Sie eine Nummer oder einen Namen:\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By",
206+
"Quel statut ? Envoyez un numéro ou un nom :\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By",
207+
"Quale stato? Scrivi un numero o un nome:\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By",
208+
"Jaki status? Wpisz numer lub nazwę:\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By",
209+
"Який статус? Надішліть номер або назву:\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By",
210+
"ما الحالة؟ أرسل رقمًا أو اسمًا:\n(1) Responding\n(2) Not Responding\n(3) On Scene\n(4) Standing By"),
211+
212+
["Conv_PromptSetStaffing"] = L(
213+
"Which staffing level? Text a number or name:\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift",
214+
"¿Qué nivel de personal? Escribe un número o nombre:\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift",
215+
"Vilken bemanningsnivå? Skriv en siffra eller ett namn:\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift",
216+
"Welche Personalstufe? Senden Sie eine Nummer oder einen Namen:\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift",
217+
"Quel niveau d'effectif ? Envoyez un numéro ou un nom :\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift",
218+
"Quale livello di personale? Scrivi un numero o un nome:\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift",
219+
"Jaki poziom obsady? Wpisz numer lub nazwę:\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift",
220+
"Який рівень укомплектування? Надішліть номер або назву:\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift",
221+
"ما مستوى التوظيف؟ أرسل رقمًا أو اسمًا:\n(S1) Available\n(S2) Delayed\n(S3) Unavailable\n(S4) Committed\n(S5) On Shift"),
222+
223+
["Conv_PromptDispatchCall"] = L(
224+
"Please provide the call details (e.g., 'Structure fire at 123 Main St')",
225+
"Proporciona los detalles de la llamada (p. ej., 'Structure fire at 123 Main St')",
226+
"Ange samtalsuppgifterna (t.ex. 'Structure fire at 123 Main St')",
227+
"Bitte geben Sie die Einsatzdetails an (z. B. 'Structure fire at 123 Main St')",
228+
"Veuillez fournir les détails de l'appel (p. ex. 'Structure fire at 123 Main St')",
229+
"Fornisci i dettagli della chiamata (es. 'Structure fire at 123 Main St')",
230+
"Podaj szczegóły zgłoszenia (np. 'Structure fire at 123 Main St')",
231+
"Вкажіть деталі виклику (напр., 'Structure fire at 123 Main St')",
232+
"يرجى تقديم تفاصيل النداء (مثال: 'Structure fire at 123 Main St')"),
233+
234+
["Conv_PromptCloseCall"] = L(
235+
"Which call would you like to close? Reply with the call number (e.g., C1445)",
236+
"¿Qué llamada quieres cerrar? Responde con el número de llamada (p. ej., C1445)",
237+
"Vilket samtal vill du avsluta? Svara med samtalsnumret (t.ex. C1445)",
238+
"Welchen Einsatz möchten Sie schließen? Antworten Sie mit der Einsatznummer (z. B. C1445)",
239+
"Quel appel souhaitez-vous clôturer ? Répondez avec le numéro d'appel (p. ex. C1445)",
240+
"Quale chiamata vuoi chiudere? Rispondi con il numero della chiamata (es. C1445)",
241+
"Które zgłoszenie chcesz zamknąć? Odpowiedz numerem zgłoszenia (np. C1445)",
242+
"Який виклик ви хочете закрити? Відповідайте номером виклику (напр., C1445)",
243+
"أي نداء تريد إغلاقه؟ أرسل رقم النداء (مثال: C1445)"),
244+
245+
["Conv_PromptDefault"] = L(
246+
"Please provide more details.",
247+
"Proporciona más detalles.",
248+
"Ange fler detaljer.",
249+
"Bitte geben Sie weitere Details an.",
250+
"Veuillez fournir plus de détails.",
251+
"Fornisci maggiori dettagli.",
252+
"Podaj więcej szczegółów.",
253+
"Надайте більше деталей.",
254+
"يرجى تقديم مزيد من التفاصيل."),
255+
71256
["Msg_NoUnread"] = L(
72257
"You have no unread messages.",
73258
"No tienes mensajes sin leer.",

Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ public async Task<ChatbotResponse> ProcessMessageAsync(ChatbotMessage message)
104104

105105
identity.LastUsedAt = DateTime.UtcNow;
106106

107+
// Persist the touch: LinkUserAsync upserts the existing identity (bumping LastUsedAt
108+
// and preserving LinkingMethod/PlatformUserName when passed their current values).
109+
await _userIdentityService.LinkUserAsync(
110+
identity.UserId, identity.Platform, identity.PlatformUserId, identity.PlatformUserName, identity.LinkingMethod);
111+
107112
// 2. Get active department for this user (respects IsActive flag for multi-dept users)
108113
var department = await ResolveActiveDepartmentAsync(identity.UserId);
109114
if (department == null)
@@ -207,23 +212,33 @@ public async Task<ChatbotResponse> ProcessMessageAsync(ChatbotMessage message)
207212
if (reply == "YES" || reply == "Y" || reply == "CONFIRM" || reply == "OK")
208213
{
209214
var pendingType = session.PendingIntent.Value;
215+
216+
// Locate the owning handler BEFORE mutating session state. If none can handle
217+
// the pending intent, leave the session parked in AwaitingConfirmation and return
218+
// an explicit error rather than silently dropping the confirmation.
219+
var confirmHandler = _actionHandlers.FirstOrDefault(h => h.CanHandle(pendingType));
220+
if (confirmHandler == null)
221+
{
222+
return new ChatbotResponse
223+
{
224+
Text = "Unable to complete confirmation — please try again or contact support.",
225+
Processed = false
226+
};
227+
}
228+
210229
var confirmedIntent = new ChatbotIntent
211230
{
212231
Type = pendingType,
213232
Parameters = new Dictionary<string, string>(session.Context) { ["__confirmed"] = "true" }
214233
};
234+
235+
var confirmResponse = await confirmHandler.HandleAsync(message, confirmedIntent, session);
236+
confirmResponse.Intent = confirmedIntent;
237+
session.Context.Clear();
215238
session.State = ChatbotDialogState.Idle;
216239
session.PendingIntent = null;
217-
218-
var confirmHandler = _actionHandlers.FirstOrDefault(h => h.CanHandle(pendingType));
219-
if (confirmHandler != null)
220-
{
221-
var confirmResponse = await confirmHandler.HandleAsync(message, confirmedIntent, session);
222-
confirmResponse.Intent = confirmedIntent;
223-
session.Context.Clear();
224-
await _sessionManager.SaveSessionAsync(session);
225-
return confirmResponse;
226-
}
240+
await _sessionManager.SaveSessionAsync(session);
241+
return confirmResponse;
227242
}
228243
else if (reply == "NO" || reply == "N" || reply == "CANCEL")
229244
{

Core/Resgrid.Chatbot/Services/ChatbotUserIdentityService.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ public async Task<ChatbotUserIdentity> GetIdentityByPhoneAsync(string phoneNumbe
3737
if (string.IsNullOrWhiteSpace(clean))
3838
return null;
3939

40-
var entity = await _identityRepository.GetByPlatformUserIdAsync(clean);
40+
// A phone number only ever identifies an SMS-platform identity, so scope the lookup to the
41+
// SMS platforms (Twilio / SignalWire). This prevents a cleaned number from coincidentally
42+
// matching a platformUserId stored by a non-SMS platform (Telegram/Discord/OAuth ids, etc.).
43+
var entity = await _identityRepository.GetByPlatformAndUserAsync((int)ChatbotPlatform.SmsTwilio, clean)
44+
?? await _identityRepository.GetByPlatformAndUserAsync((int)ChatbotPlatform.SmsSignalWire, clean);
4145
return ToModel(entity);
4246
}
4347

0 commit comments

Comments
 (0)