diff --git a/packages/botonic-core/src/core-bot.ts b/packages/botonic-core/src/core-bot.ts index da655ebe6a..b7040a05f1 100644 --- a/packages/botonic-core/src/core-bot.ts +++ b/packages/botonic-core/src/core-bot.ts @@ -97,7 +97,7 @@ export class CoreBot { } private getBotContext(request: BotRequest): BotContext { - const { input, session, lastRoutePath } = request + const { input, session, lastRoutePath, settings, secrets } = request return { input, session, @@ -114,8 +114,8 @@ export class CoreBot { setUserLocale: (locale: string) => this.setUserLocale(locale, session), setSystemLocale: (locale: string) => this.setSystemLocale(locale, session), - settings: request.settings, - secrets: request.secrets, + settings, + secrets, } } diff --git a/packages/botonic-core/src/models/legacy-types.ts b/packages/botonic-core/src/models/legacy-types.ts index 49119013a2..1f0a713a63 100644 --- a/packages/botonic-core/src/models/legacy-types.ts +++ b/packages/botonic-core/src/models/legacy-types.ts @@ -241,6 +241,7 @@ export interface SessionUser { locale: string country: string system_locale: string + system_locale_updated?: boolean } export interface HubtypeCaseContactReason { @@ -344,7 +345,7 @@ export interface BotSettings { LITELLM_API_URL: string AZURE_OPENAI_API_BASE: string AZURE_OPENAI_API_VERSION: string - LANGUAGE_DETECTION_ENABLED: string + LANGUAGE_DETECTION_ENABLED?: boolean CUSTOM_SHORT_URL_HOST: string | null custom: Record } diff --git a/packages/botonic-core/src/testing/index.ts b/packages/botonic-core/src/testing/index.ts index 1abfd26faa..6e5c130b8d 100644 --- a/packages/botonic-core/src/testing/index.ts +++ b/packages/botonic-core/src/testing/index.ts @@ -23,7 +23,7 @@ export const TEST_DEFAULTS = { LITELLM_API_URL: 'https://api.litellm.com', AZURE_OPENAI_API_BASE: 'https://api.openai.com', AZURE_OPENAI_API_VERSION: '2026-02-01', - LANGUAGE_DETECTION_ENABLED: 'true', + LANGUAGE_DETECTION_ENABLED: true, HUBTYPE_ACCESS_TOKEN: 'testAccessToken', // pragma: allowlist secret LITELLM_API_KEY: 'testLiteLLMAPIKey', // pragma: allowlist secret AZURE_OPENAI_API_KEY: 'testAzureOpenAIAPIKey', // pragma: allowlist secret diff --git a/packages/botonic-plugin-flow-builder/src/action/index.tsx b/packages/botonic-plugin-flow-builder/src/action/index.tsx index e80ab2416e..9b8a9efce9 100644 --- a/packages/botonic-plugin-flow-builder/src/action/index.tsx +++ b/packages/botonic-plugin-flow-builder/src/action/index.tsx @@ -73,13 +73,36 @@ export class FlowBuilderAction extends React.Component { return filteredContents } - render(): JSX.Element | JSX.Element[] { - const { contents, webchatSettingsParams } = this.props - const botContext = this.context as BotContext + protected getWebchatSettingsParams(botContext: BotContext): { + shouldSendWebchatSettings: boolean + webchatSettingsParams?: WebchatSettingsProps + } { + let { webchatSettingsParams } = this.props + if (botContext.session.user.system_locale_updated) { + webchatSettingsParams = { + ...webchatSettingsParams, + user: { + ...webchatSettingsParams?.user, + system_locale: botContext.session.user.system_locale, + }, + } + } const shouldSendWebchatSettings = (isWebchat(botContext.session) || isDev(botContext.session)) && !!webchatSettingsParams + return { + shouldSendWebchatSettings, + webchatSettingsParams, + } + } + + render(): JSX.Element | JSX.Element[] { + const { contents } = this.props + const botContext = this.context as BotContext + const { shouldSendWebchatSettings, webchatSettingsParams } = + this.getWebchatSettingsParams(botContext) + return ( <> {shouldSendWebchatSettings && ( @@ -93,11 +116,10 @@ export class FlowBuilderAction extends React.Component { export class FlowBuilderMultichannelAction extends FlowBuilderAction { render(): JSX.Element | JSX.Element[] { - const { contents, webchatSettingsParams } = this.props + const { contents } = this.props const botContext = this.context as BotContext - const shouldSendWebchatSettings = - (isWebchat(botContext.session) || isDev(botContext.session)) && - !!webchatSettingsParams + const { shouldSendWebchatSettings, webchatSettingsParams } = + this.getWebchatSettingsParams(botContext) return ( diff --git a/packages/botonic-plugin-flow-builder/src/api.ts b/packages/botonic-plugin-flow-builder/src/api.ts index d21b98a882..c8be4cf141 100644 --- a/packages/botonic-plugin-flow-builder/src/api.ts +++ b/packages/botonic-plugin-flow-builder/src/api.ts @@ -25,6 +25,7 @@ import { type HtSmartIntentNode, } from './content-fields/hubtype-fields' import { type FlowBuilderApiOptions, ProcessEnvNodeEnvs } from './types' +import { FlowLocale } from './utils/flow-locale' export class FlowBuilderApi { url: string @@ -310,47 +311,13 @@ export class FlowBuilderApi { } getResolvedLocale(): string { - const systemLocale = this.request.getSystemLocale() - - const locale = this.resolveAsLocale(systemLocale) - if (locale) { - return locale - } - - const language = this.resolveAsLanguage(systemLocale) - if (language) { - this.request.setSystemLocale(language) - return language - } - - const defaultLocale = this.resolveAsDefaultLocale() - this.request.setSystemLocale(defaultLocale) - return defaultLocale - } - - private resolveAsLocale(locale: string): string | undefined { - if (this.flow.locales.find(flowLocale => flowLocale === locale)) { - return locale - } - return undefined - } - - private resolveAsLanguage(locale?: string): string | undefined { - const language = locale?.split('-')[0] - if ( - language && - this.flow.locales.find(flowLocale => flowLocale === language) - ) { - console.log(`locale: ${locale} has been resolved as ${language}`) - return language - } - return undefined - } - - private resolveAsDefaultLocale(): string { - console.log( - `Resolve locale with default locale: ${this.flow.default_locale_code}` - ) - return this.flow.default_locale_code || 'en' + const flowLocales = this.flow.locales + const defaultLocaleCode = this.flow.default_locale_code + + return new FlowLocale( + this.request, + flowLocales, + defaultLocaleCode + ).resolve() } } diff --git a/packages/botonic-plugin-flow-builder/src/index.ts b/packages/botonic-plugin-flow-builder/src/index.ts index cf59200267..8135c48912 100644 --- a/packages/botonic-plugin-flow-builder/src/index.ts +++ b/packages/botonic-plugin-flow-builder/src/index.ts @@ -167,6 +167,7 @@ export default class BotonicPluginFlowBuilder implements Plugin { post(request: PluginPreRequest): void { request.input.nluResolution = undefined + delete request.session.user.system_locale_updated } async getContentsByContentID( diff --git a/packages/botonic-plugin-flow-builder/src/utils/flow-locale.ts b/packages/botonic-plugin-flow-builder/src/utils/flow-locale.ts new file mode 100644 index 0000000000..0079a55296 --- /dev/null +++ b/packages/botonic-plugin-flow-builder/src/utils/flow-locale.ts @@ -0,0 +1,93 @@ +import type { BotContext } from '@botonic/core' + +export class FlowLocale { + constructor( + private readonly botContext: BotContext, + private readonly flowLocales: string[], + private readonly defaultLocaleCode: string + ) {} + + resolve(): string { + const priorityLocale = this.isLanguageDetectionEnabled() + ? this.getPriorityLocale() + : this.botContext.getSystemLocale() + + if (priorityLocale) { + const exactMatch = this.matchExactLocale(priorityLocale) + if (exactMatch) { + return this.applyLocale(exactMatch) + } + + const languageMatch = this.matchLanguage(priorityLocale) + if (languageMatch) { + return this.applyLocale(languageMatch) + } + } + + return this.applyLocale(this.getDefaultLocale()) + } + + private isLanguageDetectionEnabled(): boolean { + return !!this.botContext.settings.LANGUAGE_DETECTION_ENABLED + } + + /** + * Rules: + * - If user and system languages differ, user locale takes priority. + * - If both share the same language, the more specific locale wins. + * - If both have the same specificity, user locale wins. + */ + private getPriorityLocale(): string | undefined { + const userLocale = this.botContext.getUserLocale() + const systemLocale = this.botContext.getSystemLocale() + + if (!userLocale || !systemLocale) { + return undefined + } + + const userLanguage = this.getLanguage(userLocale) + const systemLanguage = this.getLanguage(systemLocale) + + if (userLanguage !== systemLanguage) { + return userLocale + } + const userIsSpecific = this.isSpecificLocale(userLocale) + const systemIsSpecific = this.isSpecificLocale(systemLocale) + + if (userIsSpecific && !systemIsSpecific) { + return userLocale + } + if (!userIsSpecific && systemIsSpecific) { + return systemLocale + } + + return userLocale + } + + private matchExactLocale(locale: string): string | undefined { + return this.flowLocales.includes(locale) ? locale : undefined + } + + private matchLanguage(locale: string): string | undefined { + const language = this.getLanguage(locale) + return this.flowLocales.includes(language) ? language : undefined + } + + private getDefaultLocale(): string { + return this.defaultLocaleCode || 'en' + } + + private applyLocale(locale: string): string { + this.botContext.setSystemLocale(locale) + this.botContext.session.user.system_locale_updated = true + return locale + } + + private getLanguage(locale: string): string { + return locale.split('-')[0] + } + + private isSpecificLocale(locale: string): boolean { + return locale.includes('-') + } +} diff --git a/packages/botonic-plugin-flow-builder/tests/flow-locale.test.ts b/packages/botonic-plugin-flow-builder/tests/flow-locale.test.ts new file mode 100644 index 0000000000..e708bba990 --- /dev/null +++ b/packages/botonic-plugin-flow-builder/tests/flow-locale.test.ts @@ -0,0 +1,427 @@ +import type { BotContext } from '@botonic/core' +import { + createTestBotContext, + createTestSession, + createTestSettings, + TEST_DEFAULTS, +} from '@botonic/core/testing' +import { describe, expect, test } from '@jest/globals' + +import { FlowLocale } from '../src/utils/flow-locale' + +type FlowLocaleBotContextOptions = { + /** When false, `LANGUAGE_DETECTION_ENABLED` is cleared so `!!value` is falsy in FlowLocale. */ + languageDetectionEnabled?: boolean +} + +/** + * Builds a BotContext using @botonic/core/testing factories. Overrides locale getters/setters so + * `setSystemLocale` updates state (the stock `createTestBotContext` uses no-op setters). + */ +function createFlowLocaleBotContext( + userLocale: string | undefined, + systemLocale: string | undefined, + options?: FlowLocaleBotContextOptions +): BotContext { + const session = createTestSession({ + user: { + locale: userLocale !== undefined ? userLocale : TEST_DEFAULTS.LOCALE, + systemLocale: + systemLocale !== undefined ? systemLocale : TEST_DEFAULTS.LOCALE, + }, + }) + + const user = session.user + + if (userLocale === undefined) { + Object.assign(user, { locale: undefined }) + } + if (systemLocale === undefined) { + Object.assign(user, { system_locale: undefined }) + } + + let currentSystemLocale = user.system_locale as string | undefined + + const base = createTestBotContext({ + session, + ...(options?.languageDetectionEnabled === false + ? { settings: createTestSettings({ LANGUAGE_DETECTION_ENABLED: false }) } + : {}), + }) + + return { + ...base, + getUserLocale: () => user.locale as string | undefined, + getSystemLocale: () => currentSystemLocale, + setSystemLocale: (locale: string) => { + currentSystemLocale = locale + user.system_locale = locale + }, + } as BotContext +} + +describe('FlowLocale.resolve()', () => { + describe('when user locale matches exactly', () => { + test('should return the user locale when it matches a flow locale', () => { + const botContext = createFlowLocaleBotContext('es', 'en') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('es') + }) + + test('should set system locale when user locale is resolved', () => { + const botContext = createFlowLocaleBotContext('fr', 'en') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + flowLocale.resolve() + + expect(botContext.getSystemLocale()).toBe('fr') + }) + }) + + describe('when user locale has language-region format', () => { + test('should resolve to language code when exact locale not found but language is available', () => { + const botContext = createFlowLocaleBotContext('es-ES', 'en') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('es') + }) + + test('should set system locale to resolved language', () => { + const botContext = createFlowLocaleBotContext('fr-CA', 'en') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + flowLocale.resolve() + + expect(botContext.getSystemLocale()).toBe('fr') + }) + + test('should prefer exact match over language extraction', () => { + const botContext = createFlowLocaleBotContext('pt-BR', 'en') + const flowLocales = ['en', 'pt-BR', 'pt'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('pt-BR') + }) + }) + + describe('when system locale matches', () => { + test('should return default locale when user locale does not match and is different from system locale', () => { + const botContext = createFlowLocaleBotContext('de', 'es') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + }) + + describe('when falling back to default locale', () => { + test('should return default locale when no match found', () => { + const botContext = createFlowLocaleBotContext('de', 'it') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + + test('should set system locale to default when falling back', () => { + const botContext = createFlowLocaleBotContext('de', 'it') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'fr' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + flowLocale.resolve() + + expect(botContext.getSystemLocale()).toBe('fr') + }) + + test('should return "en" when default locale code is empty', () => { + const botContext = createFlowLocaleBotContext('de', 'it') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = '' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + }) + + describe('edge cases', () => { + test('should handle empty flow locales array', () => { + const botContext = createFlowLocaleBotContext('es', 'en') + const flowLocales: string[] = [] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + + test('should handle undefined user locale', () => { + const botContext = createFlowLocaleBotContext(undefined, 'es') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + + test('should handle undefined system locale', () => { + const botContext = createFlowLocaleBotContext('de', undefined) + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'fr' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('fr') + }) + + test('should handle locale with multiple dashes', () => { + const botContext = createFlowLocaleBotContext('zh-Hans-CN', 'en') + const flowLocales = ['en', 'zh'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('zh') + }) + + test('should be case sensitive when matching locales', () => { + const botContext = createFlowLocaleBotContext('ES', 'EN') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('en') + }) + }) + + describe('when locales share language but differ in specificity', () => { + test('should prefer system locale with region over generic user locale', () => { + const botContext = createFlowLocaleBotContext('es', 'es-MX') + const flowLocales = ['es', 'es-MX'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es-MX') + }) + + test('should fall back to generic locale when specific system locale not in flow', () => { + const botContext = createFlowLocaleBotContext('es', 'es-MX') + const flowLocales = ['es'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es') + }) + + test('should prefer user locale with region over generic system locale', () => { + const botContext = createFlowLocaleBotContext('es-ES', 'es') + const flowLocales = ['es-ES', 'es'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es-ES') + }) + + test('should use user locale when both have regions for the same language', () => { + const botContext = createFlowLocaleBotContext('es-MX', 'es-CO') + const flowLocales = ['es-MX', 'es-CO'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es-MX') + }) + + test('should not apply specificity logic when languages differ', () => { + const botContext = createFlowLocaleBotContext('fr', 'es-MX') + const flowLocales = ['fr', 'es-MX'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('fr') + }) + + test('should set system locale to the more specific resolved locale', () => { + const botContext = createFlowLocaleBotContext('es', 'es-MX') + const flowLocales = ['es-MX'] + const defaultLocaleCode = 'en' + + new FlowLocale(botContext, flowLocales, defaultLocaleCode).resolve() + + expect(botContext.getSystemLocale()).toBe('es-MX') + }) + }) + + describe('priority order', () => { + test('should prioritize user locale over system locale', () => { + const botContext = createFlowLocaleBotContext('fr', 'es') + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const flowLocale = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ) + + const result = flowLocale.resolve() + + expect(result).toBe('fr') + }) + }) + + describe('when LANGUAGE_DETECTION_ENABLED is falsy', () => { + test('should resolve using system locale only, ignoring user vs system priority', () => { + const botContext = createFlowLocaleBotContext('fr', 'es', { + languageDetectionEnabled: false, + }) + const flowLocales = ['en', 'es', 'fr'] + const defaultLocaleCode = 'en' + + const result = new FlowLocale( + botContext, + flowLocales, + defaultLocaleCode + ).resolve() + + expect(result).toBe('es') + }) + + test('should set system locale from flow match derived from current system locale', () => { + const botContext = createFlowLocaleBotContext('es', 'fr', { + languageDetectionEnabled: false, + }) + const flowLocales = ['en', 'fr', 'de'] + const defaultLocaleCode = 'en' + + new FlowLocale(botContext, flowLocales, defaultLocaleCode).resolve() + + expect(botContext.getSystemLocale()).toBe('fr') + }) + }) +})