mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 15:54:03 +00:00
feat(bot): persist locale preferences across mini app and replies
This commit is contained in:
@@ -95,7 +95,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
telegramChatId: '-100222333',
|
telegramChatId: '-100222333',
|
||||||
telegramChatType: 'supergroup',
|
telegramChatType: 'supergroup',
|
||||||
title: 'Kojori House'
|
title: 'Kojori House',
|
||||||
|
defaultLocale: 'ru'
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
getTelegramHouseholdChat: async () => ({
|
getTelegramHouseholdChat: async () => ({
|
||||||
@@ -103,14 +104,16 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
telegramChatId: '-100222333',
|
telegramChatId: '-100222333',
|
||||||
telegramChatType: 'supergroup',
|
telegramChatType: 'supergroup',
|
||||||
title: 'Kojori House'
|
title: 'Kojori House',
|
||||||
|
defaultLocale: 'ru'
|
||||||
}),
|
}),
|
||||||
getHouseholdChatByHouseholdId: async () => ({
|
getHouseholdChatByHouseholdId: async () => ({
|
||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
telegramChatId: '-100222333',
|
telegramChatId: '-100222333',
|
||||||
telegramChatType: 'supergroup',
|
telegramChatType: 'supergroup',
|
||||||
title: 'Kojori House'
|
title: 'Kojori House',
|
||||||
|
defaultLocale: 'ru'
|
||||||
}),
|
}),
|
||||||
bindHouseholdTopic: async (input) => ({
|
bindHouseholdTopic: async (input) => ({
|
||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
@@ -143,7 +146,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
username: input.username?.trim() || null,
|
username: input.username?.trim() || null,
|
||||||
languageCode: input.languageCode?.trim() || null
|
languageCode: input.languageCode?.trim() || null,
|
||||||
|
householdDefaultLocale: 'ru'
|
||||||
}),
|
}),
|
||||||
getPendingHouseholdMember: async () => null,
|
getPendingHouseholdMember: async () => null,
|
||||||
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||||
@@ -152,6 +156,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
householdId: input.householdId,
|
householdId: input.householdId,
|
||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
|
preferredLocale: input.preferredLocale ?? null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}),
|
}),
|
||||||
getHouseholdMember: async () => null,
|
getHouseholdMember: async () => null,
|
||||||
@@ -162,11 +168,30 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
|||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
telegramUserId: '123456',
|
telegramUserId: '123456',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
isAdmin: false
|
isAdmin: false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-100222333',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House',
|
||||||
|
defaultLocale: locale
|
||||||
|
}),
|
||||||
|
updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => ({
|
||||||
|
id: `member-${telegramUserId}`,
|
||||||
|
householdId: 'household-1',
|
||||||
|
telegramUserId,
|
||||||
|
displayName: 'Stan',
|
||||||
|
preferredLocale: locale,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
|
isAdmin: false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,10 +261,10 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
expect(calls[0]?.payload).toMatchObject({
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
chat_id: '-100222333',
|
chat_id: '-100222333',
|
||||||
message_thread_id: 77,
|
message_thread_id: 77,
|
||||||
text: 'Anonymous household note\n\nPlease clean the kitchen tonight.'
|
text: 'Анонимное сообщение по дому\n\nPlease clean the kitchen tonight.'
|
||||||
})
|
})
|
||||||
expect(calls[1]?.payload).toMatchObject({
|
expect(calls[1]?.payload).toMatchObject({
|
||||||
text: 'Anonymous feedback delivered.'
|
text: 'Анонимное сообщение отправлено.'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -303,7 +328,7 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
|
|
||||||
expect(calls).toHaveLength(1)
|
expect(calls).toHaveLength(1)
|
||||||
expect(calls[0]?.payload).toMatchObject({
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
text: 'Use /anon in a private chat with the bot.'
|
text: 'Используйте /anon в личном чате с ботом.'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -377,15 +402,15 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
|
|
||||||
expect(submit).toHaveBeenCalledTimes(1)
|
expect(submit).toHaveBeenCalledTimes(1)
|
||||||
expect(calls[0]?.payload).toMatchObject({
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
text: 'Send me the anonymous message in your next reply, or tap Cancel.'
|
text: 'Отправьте анонимное сообщение следующим сообщением или нажмите «Отменить».'
|
||||||
})
|
})
|
||||||
expect(calls[1]?.payload).toMatchObject({
|
expect(calls[1]?.payload).toMatchObject({
|
||||||
chat_id: '-100222333',
|
chat_id: '-100222333',
|
||||||
message_thread_id: 77,
|
message_thread_id: 77,
|
||||||
text: 'Anonymous household note\n\nPlease clean the kitchen tonight.'
|
text: 'Анонимное сообщение по дому\n\nPlease clean the kitchen tonight.'
|
||||||
})
|
})
|
||||||
expect(calls[2]?.payload).toMatchObject({
|
expect(calls[2]?.payload).toMatchObject({
|
||||||
text: 'Anonymous feedback delivered.'
|
text: 'Анонимное сообщение отправлено.'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -620,7 +645,7 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
|
|
||||||
expect(calls).toHaveLength(1)
|
expect(calls).toHaveLength(1)
|
||||||
expect(calls[0]?.payload).toMatchObject({
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
text: 'Anonymous feedback cooldown is active. You can send the next message in 6 hours.'
|
text: 'Сейчас действует пауза на анонимные сообщения. Следующее сообщение можно отправить через 6 часов.'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n'
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
|
import { resolveReplyLocale } from './bot-locale'
|
||||||
|
|
||||||
const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const
|
const ANONYMOUS_FEEDBACK_ACTION = 'anonymous_feedback' as const
|
||||||
const CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback'
|
const CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback'
|
||||||
@@ -114,9 +115,13 @@ async function clearPendingAnonymousFeedbackPrompt(
|
|||||||
|
|
||||||
async function startPendingAnonymousFeedbackPrompt(
|
async function startPendingAnonymousFeedbackPrompt(
|
||||||
repository: TelegramPendingActionRepository,
|
repository: TelegramPendingActionRepository,
|
||||||
|
householdConfigurationRepository: HouseholdConfigurationRepository,
|
||||||
ctx: Context
|
ctx: Context
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale).anonymousFeedback
|
const t = getBotTranslations(locale).anonymousFeedback
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
const telegramChatId = ctx.chat?.id?.toString()
|
const telegramChatId = ctx.chat?.id?.toString()
|
||||||
@@ -152,11 +157,15 @@ async function submitAnonymousFeedback(options: {
|
|||||||
const telegramMessageId = options.ctx.msg?.message_id?.toString()
|
const telegramMessageId = options.ctx.msg?.message_id?.toString()
|
||||||
const telegramUpdateId =
|
const telegramUpdateId =
|
||||||
'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined
|
'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined
|
||||||
const locale = botLocaleFromContext(options.ctx)
|
const fallbackLocale = await resolveReplyLocale({
|
||||||
const t = getBotTranslations(locale).anonymousFeedback
|
ctx: options.ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
|
||||||
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
||||||
await options.ctx.reply(t.unableToIdentifyMessage)
|
await options.ctx.reply(
|
||||||
|
getBotTranslations(fallbackLocale).anonymousFeedback.unableToIdentifyMessage
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,17 +176,19 @@ async function submitAnonymousFeedback(options: {
|
|||||||
|
|
||||||
if (memberships.length === 0) {
|
if (memberships.length === 0) {
|
||||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
await options.ctx.reply(t.notMember)
|
await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.notMember)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (memberships.length > 1) {
|
if (memberships.length > 1) {
|
||||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
await options.ctx.reply(t.multipleHouseholds)
|
await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.multipleHouseholds)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const member = memberships[0]!
|
const member = memberships[0]!
|
||||||
|
const locale = member.preferredLocale ?? member.householdDefaultLocale
|
||||||
|
const t = getBotTranslations(locale).anonymousFeedback
|
||||||
const householdChat =
|
const householdChat =
|
||||||
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId)
|
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId)
|
||||||
const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding(
|
const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding(
|
||||||
@@ -273,7 +284,10 @@ export function registerAnonymousFeedback(options: {
|
|||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): void {
|
||||||
options.bot.command('cancel', async (ctx) => {
|
options.bot.command('cancel', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale).anonymousFeedback
|
const t = getBotTranslations(locale).anonymousFeedback
|
||||||
if (!isPrivateChat(ctx)) {
|
if (!isPrivateChat(ctx)) {
|
||||||
return
|
return
|
||||||
@@ -297,7 +311,10 @@ export function registerAnonymousFeedback(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('anon', async (ctx) => {
|
options.bot.command('anon', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale).anonymousFeedback
|
const t = getBotTranslations(locale).anonymousFeedback
|
||||||
if (!isPrivateChat(ctx)) {
|
if (!isPrivateChat(ctx)) {
|
||||||
await ctx.reply(t.useInPrivateChat)
|
await ctx.reply(t.useInPrivateChat)
|
||||||
@@ -306,7 +323,11 @@ export function registerAnonymousFeedback(options: {
|
|||||||
|
|
||||||
const rawText = commandArgText(ctx)
|
const rawText = commandArgText(ctx)
|
||||||
if (rawText.length === 0) {
|
if (rawText.length === 0) {
|
||||||
await startPendingAnonymousFeedbackPrompt(options.promptRepository, ctx)
|
await startPendingAnonymousFeedbackPrompt(
|
||||||
|
options.promptRepository,
|
||||||
|
options.householdConfigurationRepository,
|
||||||
|
ctx
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +372,10 @@ export function registerAnonymousFeedback(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
options.bot.callbackQuery(CANCEL_ANONYMOUS_FEEDBACK_CALLBACK, async (ctx) => {
|
options.bot.callbackQuery(CANCEL_ANONYMOUS_FEEDBACK_CALLBACK, async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale).anonymousFeedback
|
const t = getBotTranslations(locale).anonymousFeedback
|
||||||
if (!isPrivateChat(ctx)) {
|
if (!isPrivateChat(ctx)) {
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
|
|||||||
45
apps/bot/src/bot-locale.ts
Normal file
45
apps/bot/src/bot-locale.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { normalizeSupportedLocale } from '@household/domain'
|
||||||
|
import type { HouseholdConfigurationRepository, HouseholdMemberRecord } from '@household/ports'
|
||||||
|
import type { Context } from 'grammy'
|
||||||
|
|
||||||
|
import { resolveBotLocale, type BotLocale } from './i18n'
|
||||||
|
|
||||||
|
function localeFromMember(member: HouseholdMemberRecord, fallback: BotLocale): BotLocale {
|
||||||
|
return member.preferredLocale ?? member.householdDefaultLocale ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveReplyLocale(options: {
|
||||||
|
ctx: Pick<Context, 'chat' | 'from'>
|
||||||
|
repository: HouseholdConfigurationRepository | undefined
|
||||||
|
householdId?: string
|
||||||
|
}): Promise<BotLocale> {
|
||||||
|
const fallback = resolveBotLocale(options.ctx.from?.language_code)
|
||||||
|
const telegramUserId = options.ctx.from?.id?.toString()
|
||||||
|
const telegramChatId = options.ctx.chat?.id?.toString()
|
||||||
|
|
||||||
|
if (!options.repository) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.ctx.chat && options.ctx.chat.type !== 'private' && telegramChatId) {
|
||||||
|
const household = await options.repository.getTelegramHouseholdChat(telegramChatId)
|
||||||
|
return household?.defaultLocale ?? fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!telegramUserId) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.householdId) {
|
||||||
|
const member = await options.repository.getHouseholdMember(options.householdId, telegramUserId)
|
||||||
|
return member ? localeFromMember(member, fallback) : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = await options.repository.listHouseholdMembersByTelegramUserId(telegramUserId)
|
||||||
|
if (members.length === 1) {
|
||||||
|
return localeFromMember(members[0]!, fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = normalizeSupportedLocale(options.ctx.from?.language_code)
|
||||||
|
return normalized ?? fallback
|
||||||
|
}
|
||||||
@@ -1,19 +1,31 @@
|
|||||||
import { Bot } from 'grammy'
|
import { Bot } from 'grammy'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
|
import type { HouseholdConfigurationRepository } from '@household/ports'
|
||||||
|
|
||||||
import { botLocaleFromContext, getBotTranslations } from './i18n'
|
import { getBotTranslations } from './i18n'
|
||||||
|
import { resolveReplyLocale } from './bot-locale'
|
||||||
import { formatTelegramHelpText } from './telegram-commands'
|
import { formatTelegramHelpText } from './telegram-commands'
|
||||||
|
|
||||||
export function createTelegramBot(token: string, logger?: Logger): Bot {
|
export function createTelegramBot(
|
||||||
|
token: string,
|
||||||
|
logger?: Logger,
|
||||||
|
householdConfigurationRepository?: HouseholdConfigurationRepository
|
||||||
|
): Bot {
|
||||||
const bot = new Bot(token)
|
const bot = new Bot(token)
|
||||||
|
|
||||||
bot.command('help', async (ctx) => {
|
bot.command('help', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: householdConfigurationRepository
|
||||||
|
})
|
||||||
await ctx.reply(formatTelegramHelpText(locale))
|
await ctx.reply(formatTelegramHelpText(locale))
|
||||||
})
|
})
|
||||||
|
|
||||||
bot.command('household_status', async (ctx) => {
|
bot.command('household_status', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: householdConfigurationRepository
|
||||||
|
})
|
||||||
await ctx.reply(getBotTranslations(locale).bot.householdStatusPending)
|
await ctx.reply(getBotTranslations(locale).bot.householdStatusPending)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { FinanceCommandService } from '@household/application'
|
|||||||
import type { HouseholdConfigurationRepository } from '@household/ports'
|
import type { HouseholdConfigurationRepository } from '@household/ports'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
import { botLocaleFromContext, getBotTranslations } from './i18n'
|
import { getBotTranslations } from './i18n'
|
||||||
|
import { resolveReplyLocale } from './bot-locale'
|
||||||
|
|
||||||
function commandArgs(ctx: Context): string[] {
|
function commandArgs(ctx: Context): string[] {
|
||||||
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||||
@@ -24,10 +25,10 @@ export function createFinanceCommandsService(options: {
|
|||||||
register: (bot: Bot) => void
|
register: (bot: Bot) => void
|
||||||
} {
|
} {
|
||||||
function formatStatement(
|
function formatStatement(
|
||||||
ctx: Context,
|
locale: Parameters<typeof getBotTranslations>[0],
|
||||||
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
|
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
|
||||||
): string {
|
): string {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const t = getBotTranslations(locale).finance
|
||||||
|
|
||||||
return [
|
return [
|
||||||
t.statementTitle(dashboard.period),
|
t.statementTitle(dashboard.period),
|
||||||
@@ -42,7 +43,11 @@ export function createFinanceCommandsService(options: {
|
|||||||
service: FinanceCommandService
|
service: FinanceCommandService
|
||||||
householdId: string
|
householdId: string
|
||||||
} | null> {
|
} | null> {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale).finance
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply(t.useInGroup)
|
await ctx.reply(t.useInGroup)
|
||||||
return null
|
return null
|
||||||
@@ -63,7 +68,11 @@ export function createFinanceCommandsService(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requireMember(ctx: Context) {
|
async function requireMember(ctx: Context) {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale).finance
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
if (!telegramUserId) {
|
if (!telegramUserId) {
|
||||||
await ctx.reply(t.unableToIdentifySender)
|
await ctx.reply(t.unableToIdentifySender)
|
||||||
@@ -89,7 +98,11 @@ export function createFinanceCommandsService(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requireAdmin(ctx: Context) {
|
async function requireAdmin(ctx: Context) {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale).finance
|
||||||
const resolved = await requireMember(ctx)
|
const resolved = await requireMember(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return null
|
return null
|
||||||
@@ -105,7 +118,11 @@ export function createFinanceCommandsService(options: {
|
|||||||
|
|
||||||
function register(bot: Bot): void {
|
function register(bot: Bot): void {
|
||||||
bot.command('cycle_open', async (ctx) => {
|
bot.command('cycle_open', async (ctx) => {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale).finance
|
||||||
const resolved = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -126,7 +143,11 @@ export function createFinanceCommandsService(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot.command('cycle_close', async (ctx) => {
|
bot.command('cycle_close', async (ctx) => {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale).finance
|
||||||
const resolved = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -146,7 +167,11 @@ export function createFinanceCommandsService(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot.command('rent_set', async (ctx) => {
|
bot.command('rent_set', async (ctx) => {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale).finance
|
||||||
const resolved = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -172,7 +197,11 @@ export function createFinanceCommandsService(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot.command('utility_add', async (ctx) => {
|
bot.command('utility_add', async (ctx) => {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale).finance
|
||||||
const resolved = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -205,7 +234,11 @@ export function createFinanceCommandsService(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
bot.command('statement', async (ctx) => {
|
bot.command('statement', async (ctx) => {
|
||||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
const t = getBotTranslations(locale).finance
|
||||||
const resolved = await requireMember(ctx)
|
const resolved = await requireMember(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -218,7 +251,7 @@ export function createFinanceCommandsService(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(formatStatement(ctx, dashboard))
|
await ctx.reply(formatStatement(locale, dashboard))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(t.statementFailed((error as Error).message))
|
await ctx.reply(t.statementFailed((error as Error).message))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,8 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
household: {
|
household: {
|
||||||
id: 'household-1',
|
id: 'household-1',
|
||||||
name: 'Kojori House'
|
name: 'Kojori House',
|
||||||
|
defaultLocale: 'ru'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +237,8 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
status: 'pending',
|
status: 'pending',
|
||||||
household: {
|
household: {
|
||||||
id: 'household-1',
|
id: 'household-1',
|
||||||
name: 'Kojori House'
|
name: 'Kojori House',
|
||||||
|
defaultLocale: 'ru'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import type {
|
import type {
|
||||||
HouseholdAdminService,
|
HouseholdAdminService,
|
||||||
HouseholdOnboardingService,
|
HouseholdOnboardingService,
|
||||||
HouseholdSetupService
|
HouseholdSetupService,
|
||||||
|
HouseholdMiniAppAccess
|
||||||
} from '@household/application'
|
} from '@household/application'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
|
import type { HouseholdConfigurationRepository } from '@household/ports'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n'
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
|
import { resolveReplyLocale } from './bot-locale'
|
||||||
|
|
||||||
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
||||||
|
|
||||||
@@ -175,12 +178,17 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
householdSetupService: HouseholdSetupService
|
householdSetupService: HouseholdSetupService
|
||||||
householdOnboardingService: HouseholdOnboardingService
|
householdOnboardingService: HouseholdOnboardingService
|
||||||
householdAdminService: HouseholdAdminService
|
householdAdminService: HouseholdAdminService
|
||||||
|
householdConfigurationRepository?: HouseholdConfigurationRepository
|
||||||
miniAppUrl?: string
|
miniAppUrl?: string
|
||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): void {
|
||||||
options.bot.command('start', async (ctx) => {
|
options.bot.command('start', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const fallbackLocale = await resolveReplyLocale({
|
||||||
const t = getBotTranslations(locale)
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
let locale = fallbackLocale
|
||||||
|
let t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (ctx.chat?.type !== 'private') {
|
if (ctx.chat?.type !== 'private') {
|
||||||
return
|
return
|
||||||
@@ -231,6 +239,18 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.status === 'active') {
|
||||||
|
locale = result.member.preferredLocale ?? result.member.householdDefaultLocale
|
||||||
|
t = getBotTranslations(locale)
|
||||||
|
} else {
|
||||||
|
const access = await options.householdOnboardingService.getMiniAppAccess({
|
||||||
|
identity,
|
||||||
|
joinToken
|
||||||
|
})
|
||||||
|
locale = localeFromAccess(access, fallbackLocale)
|
||||||
|
t = getBotTranslations(locale)
|
||||||
|
}
|
||||||
|
|
||||||
if (result.status === 'active') {
|
if (result.status === 'active') {
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
t.setup.alreadyActiveMember(result.member.displayName),
|
t.setup.alreadyActiveMember(result.member.displayName),
|
||||||
@@ -246,8 +266,12 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('setup', async (ctx) => {
|
options.bot.command('setup', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const fallbackLocale = await resolveReplyLocale({
|
||||||
const t = getBotTranslations(locale)
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
|
let locale = fallbackLocale
|
||||||
|
let t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply(t.setup.useSetupInGroup)
|
await ctx.reply(t.setup.useSetupInGroup)
|
||||||
@@ -278,6 +302,9 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
locale = result.household.defaultLocale
|
||||||
|
t = getBotTranslations(locale)
|
||||||
|
|
||||||
options.logger?.info(
|
options.logger?.info(
|
||||||
{
|
{
|
||||||
event: 'household_setup.chat_registered',
|
event: 'household_setup.chat_registered',
|
||||||
@@ -326,7 +353,10 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('bind_purchase_topic', async (ctx) => {
|
options.bot.command('bind_purchase_topic', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale)
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
@@ -373,7 +403,10 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('bind_feedback_topic', async (ctx) => {
|
options.bot.command('bind_feedback_topic', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale)
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
@@ -420,7 +453,10 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('pending_members', async (ctx) => {
|
options.bot.command('pending_members', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale)
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
@@ -456,7 +492,10 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('approve_member', async (ctx) => {
|
options.bot.command('approve_member', async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale)
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
@@ -493,7 +532,10 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
options.bot.callbackQuery(
|
options.bot.callbackQuery(
|
||||||
new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`),
|
new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`),
|
||||||
async (ctx) => {
|
async (ctx) => {
|
||||||
const locale = botLocaleFromContext(ctx)
|
const locale = await resolveReplyLocale({
|
||||||
|
ctx,
|
||||||
|
repository: options.householdConfigurationRepository
|
||||||
|
})
|
||||||
const t = getBotTranslations(locale)
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
@@ -554,3 +596,15 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function localeFromAccess(access: HouseholdMiniAppAccess, fallback: BotLocale): BotLocale {
|
||||||
|
switch (access.status) {
|
||||||
|
case 'active':
|
||||||
|
return access.member.preferredLocale ?? access.member.householdDefaultLocale
|
||||||
|
case 'pending':
|
||||||
|
case 'join_required':
|
||||||
|
return access.household.defaultLocale
|
||||||
|
case 'open_from_group':
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createHouseholdAdminService,
|
createHouseholdAdminService,
|
||||||
createFinanceCommandService,
|
createFinanceCommandService,
|
||||||
createHouseholdOnboardingService,
|
createHouseholdOnboardingService,
|
||||||
|
createLocalePreferenceService,
|
||||||
createMiniAppAdminService,
|
createMiniAppAdminService,
|
||||||
createHouseholdSetupService,
|
createHouseholdSetupService,
|
||||||
createReminderJobService
|
createReminderJobService
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
createMiniAppApproveMemberHandler,
|
createMiniAppApproveMemberHandler,
|
||||||
createMiniAppPendingMembersHandler
|
createMiniAppPendingMembersHandler
|
||||||
} from './miniapp-admin'
|
} from './miniapp-admin'
|
||||||
|
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
||||||
|
|
||||||
const runtime = getBotRuntimeConfig()
|
const runtime = getBotRuntimeConfig()
|
||||||
configureLogger({
|
configureLogger({
|
||||||
@@ -45,13 +47,16 @@ configureLogger({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const logger = getLogger('runtime')
|
const logger = getLogger('runtime')
|
||||||
const bot = createTelegramBot(runtime.telegramBotToken, getLogger('telegram'))
|
|
||||||
const webhookHandler = webhookCallback(bot, 'std/http')
|
|
||||||
|
|
||||||
const shutdownTasks: Array<() => Promise<void>> = []
|
const shutdownTasks: Array<() => Promise<void>> = []
|
||||||
const householdConfigurationRepositoryClient = runtime.databaseUrl
|
const householdConfigurationRepositoryClient = runtime.databaseUrl
|
||||||
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
|
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
|
||||||
: null
|
: null
|
||||||
|
const bot = createTelegramBot(
|
||||||
|
runtime.telegramBotToken,
|
||||||
|
getLogger('telegram'),
|
||||||
|
householdConfigurationRepositoryClient?.repository
|
||||||
|
)
|
||||||
|
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||||
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
|
const financeRepositoryClients = new Map<string, ReturnType<typeof createDbFinanceRepository>>()
|
||||||
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
const financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
||||||
const householdOnboardingService = householdConfigurationRepositoryClient
|
const householdOnboardingService = householdConfigurationRepositoryClient
|
||||||
@@ -62,6 +67,9 @@ const householdOnboardingService = householdConfigurationRepositoryClient
|
|||||||
const miniAppAdminService = householdConfigurationRepositoryClient
|
const miniAppAdminService = householdConfigurationRepositoryClient
|
||||||
? createMiniAppAdminService(householdConfigurationRepositoryClient.repository)
|
? createMiniAppAdminService(householdConfigurationRepositoryClient.repository)
|
||||||
: null
|
: null
|
||||||
|
const localePreferenceService = householdConfigurationRepositoryClient
|
||||||
|
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
|
||||||
|
: null
|
||||||
const telegramPendingActionRepositoryClient =
|
const telegramPendingActionRepositoryClient =
|
||||||
runtime.databaseUrl && runtime.anonymousFeedbackEnabled
|
runtime.databaseUrl && runtime.anonymousFeedbackEnabled
|
||||||
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
|
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
|
||||||
@@ -168,6 +176,7 @@ if (householdConfigurationRepositoryClient) {
|
|||||||
householdConfigurationRepositoryClient.repository
|
householdConfigurationRepositoryClient.repository
|
||||||
),
|
),
|
||||||
householdOnboardingService: householdOnboardingService!,
|
householdOnboardingService: householdOnboardingService!,
|
||||||
|
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||||
...(runtime.miniAppAllowedOrigins[0]
|
...(runtime.miniAppAllowedOrigins[0]
|
||||||
? {
|
? {
|
||||||
miniAppUrl: runtime.miniAppAllowedOrigins[0]
|
miniAppUrl: runtime.miniAppAllowedOrigins[0]
|
||||||
@@ -279,6 +288,15 @@ const server = createBotWebhookServer({
|
|||||||
logger: getLogger('miniapp-admin')
|
logger: getLogger('miniapp-admin')
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
|
miniAppLocalePreference: householdOnboardingService
|
||||||
|
? createMiniAppLocalePreferenceHandler({
|
||||||
|
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||||
|
botToken: runtime.telegramBotToken,
|
||||||
|
onboardingService: householdOnboardingService,
|
||||||
|
localePreferenceService: localePreferenceService!,
|
||||||
|
logger: getLogger('miniapp-admin')
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
scheduler:
|
scheduler:
|
||||||
reminderJobs && runtime.schedulerSharedSecret
|
reminderJobs && runtime.schedulerSharedSecret
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
telegramChatId: '-100123',
|
telegramChatId: '-100123',
|
||||||
telegramChatType: 'supergroup',
|
telegramChatType: 'supergroup',
|
||||||
title: 'Kojori House'
|
title: 'Kojori House',
|
||||||
|
defaultLocale: 'ru' as const
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -52,7 +53,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
username: input.username?.trim() || null,
|
username: input.username?.trim() || null,
|
||||||
languageCode: input.languageCode?.trim() || null
|
languageCode: input.languageCode?.trim() || null,
|
||||||
|
householdDefaultLocale: household.defaultLocale
|
||||||
}),
|
}),
|
||||||
getPendingHouseholdMember: async () => null,
|
getPendingHouseholdMember: async () => null,
|
||||||
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||||
@@ -61,6 +63,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
|
preferredLocale: input.preferredLocale ?? null,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}),
|
}),
|
||||||
getHouseholdMember: async () => null,
|
getHouseholdMember: async () => null,
|
||||||
@@ -73,7 +77,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
telegramUserId: '555777',
|
telegramUserId: '555777',
|
||||||
displayName: 'Mia',
|
displayName: 'Mia',
|
||||||
username: 'mia',
|
username: 'mia',
|
||||||
languageCode: 'ru'
|
languageCode: 'ru',
|
||||||
|
householdDefaultLocale: household.defaultLocale
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
approvePendingHouseholdMember: async (input) =>
|
approvePendingHouseholdMember: async (input) =>
|
||||||
@@ -83,6 +88,24 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
telegramUserId: '555777',
|
telegramUserId: '555777',
|
||||||
displayName: 'Mia',
|
displayName: 'Mia',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
|
...household,
|
||||||
|
defaultLocale: locale
|
||||||
|
}),
|
||||||
|
updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) =>
|
||||||
|
telegramUserId === '555777'
|
||||||
|
? {
|
||||||
|
id: 'member-555777',
|
||||||
|
householdId: household.householdId,
|
||||||
|
telegramUserId,
|
||||||
|
displayName: 'Mia',
|
||||||
|
preferredLocale: locale,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
isAdmin: false
|
isAdmin: false
|
||||||
}
|
}
|
||||||
: null
|
: null
|
||||||
@@ -99,6 +122,8 @@ describe('createMiniAppPendingMembersHandler', () => {
|
|||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
telegramUserId: '123456',
|
telegramUserId: '123456',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -141,7 +166,8 @@ describe('createMiniAppPendingMembersHandler', () => {
|
|||||||
telegramUserId: '555777',
|
telegramUserId: '555777',
|
||||||
displayName: 'Mia',
|
displayName: 'Mia',
|
||||||
username: 'mia',
|
username: 'mia',
|
||||||
languageCode: 'ru'
|
languageCode: 'ru',
|
||||||
|
householdDefaultLocale: 'ru'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
@@ -158,6 +184,8 @@ describe('createMiniAppApproveMemberHandler', () => {
|
|||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
telegramUserId: '123456',
|
telegramUserId: '123456',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -199,6 +227,8 @@ describe('createMiniAppApproveMemberHandler', () => {
|
|||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
telegramUserId: '555777',
|
telegramUserId: '555777',
|
||||||
displayName: 'Mia',
|
displayName: 'Mia',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
isAdmin: false
|
isAdmin: false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
telegramChatId: '-100123',
|
telegramChatId: '-100123',
|
||||||
telegramChatType: 'supergroup',
|
telegramChatType: 'supergroup',
|
||||||
title: 'Kojori House'
|
title: 'Kojori House',
|
||||||
|
defaultLocale: 'ru' as const
|
||||||
}
|
}
|
||||||
let joinToken: string | null = 'join-token'
|
let joinToken: string | null = 'join-token'
|
||||||
const members = new Map<
|
const members = new Map<
|
||||||
@@ -25,6 +26,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdId: string
|
householdId: string
|
||||||
telegramUserId: string
|
telegramUserId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
|
preferredLocale: 'en' | 'ru' | null
|
||||||
|
householdDefaultLocale: 'en' | 'ru'
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}
|
}
|
||||||
>()
|
>()
|
||||||
@@ -35,6 +38,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
displayName: string
|
displayName: string
|
||||||
username: string | null
|
username: string | null
|
||||||
languageCode: string | null
|
languageCode: string | null
|
||||||
|
householdDefaultLocale: 'ru'
|
||||||
} | null = null
|
} | null = null
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -77,7 +81,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
username: input.username?.trim() || null,
|
username: input.username?.trim() || null,
|
||||||
languageCode: input.languageCode?.trim() || null
|
languageCode: input.languageCode?.trim() || null,
|
||||||
|
householdDefaultLocale: household.defaultLocale
|
||||||
}
|
}
|
||||||
return pending
|
return pending
|
||||||
},
|
},
|
||||||
@@ -89,6 +94,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
|
preferredLocale: input.preferredLocale ?? null,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}
|
}
|
||||||
members.set(input.telegramUserId, member)
|
members.set(input.telegramUserId, member)
|
||||||
@@ -112,11 +119,26 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
telegramUserId: pending.telegramUserId,
|
telegramUserId: pending.telegramUserId,
|
||||||
displayName: pending.displayName,
|
displayName: pending.displayName,
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}
|
}
|
||||||
members.set(pending.telegramUserId, member)
|
members.set(pending.telegramUserId, member)
|
||||||
pending = null
|
pending = null
|
||||||
return member
|
return member
|
||||||
|
},
|
||||||
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
|
...household,
|
||||||
|
defaultLocale: locale
|
||||||
|
}),
|
||||||
|
updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => {
|
||||||
|
const member = members.get(telegramUserId)
|
||||||
|
return member
|
||||||
|
? {
|
||||||
|
...member,
|
||||||
|
preferredLocale: locale
|
||||||
|
}
|
||||||
|
: null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +186,9 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
authorized: true,
|
authorized: true,
|
||||||
member: {
|
member: {
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
isAdmin: true
|
isAdmin: true,
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru'
|
||||||
},
|
},
|
||||||
telegramUser: {
|
telegramUser: {
|
||||||
id: '123456',
|
id: '123456',
|
||||||
@@ -208,7 +232,8 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
authorized: false,
|
authorized: false,
|
||||||
onboarding: {
|
onboarding: {
|
||||||
status: 'join_required',
|
status: 'join_required',
|
||||||
householdName: 'Kojori House'
|
householdName: 'Kojori House',
|
||||||
|
householdDefaultLocale: 'ru'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -246,7 +271,8 @@ describe('createMiniAppAuthHandler', () => {
|
|||||||
authorized: false,
|
authorized: false,
|
||||||
onboarding: {
|
onboarding: {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
householdName: 'Kojori House'
|
householdName: 'Kojori House',
|
||||||
|
householdDefaultLocale: 'ru'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HouseholdOnboardingService } from '@household/application'
|
import type { HouseholdOnboardingService } from '@household/application'
|
||||||
|
import type { SupportedLocale } from '@household/domain'
|
||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
|
|
||||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||||
@@ -101,11 +102,14 @@ export interface MiniAppSessionResult {
|
|||||||
householdId: string
|
householdId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
preferredLocale: SupportedLocale | null
|
||||||
|
householdDefaultLocale: SupportedLocale
|
||||||
}
|
}
|
||||||
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
|
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
|
||||||
onboarding?: {
|
onboarding?: {
|
||||||
status: 'join_required' | 'pending' | 'open_from_group'
|
status: 'join_required' | 'pending' | 'open_from_group'
|
||||||
householdName?: string
|
householdName?: string
|
||||||
|
householdDefaultLocale?: SupportedLocale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +158,8 @@ export function createMiniAppSessionService(options: {
|
|||||||
telegramUser,
|
telegramUser,
|
||||||
onboarding: {
|
onboarding: {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
householdName: access.household.name
|
householdName: access.household.name,
|
||||||
|
householdDefaultLocale: access.household.defaultLocale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'join_required':
|
case 'join_required':
|
||||||
@@ -163,7 +168,8 @@ export function createMiniAppSessionService(options: {
|
|||||||
telegramUser,
|
telegramUser,
|
||||||
onboarding: {
|
onboarding: {
|
||||||
status: 'join_required',
|
status: 'join_required',
|
||||||
householdName: access.household.name
|
householdName: access.household.name,
|
||||||
|
householdDefaultLocale: access.household.defaultLocale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 'open_from_group':
|
case 'open_from_group':
|
||||||
@@ -334,7 +340,8 @@ export function createMiniAppJoinHandler(options: {
|
|||||||
authorized: false,
|
authorized: false,
|
||||||
onboarding: {
|
onboarding: {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
householdName: result.household.name
|
householdName: result.household.name,
|
||||||
|
householdDefaultLocale: result.household.defaultLocale
|
||||||
},
|
},
|
||||||
telegramUser
|
telegramUser
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdName: 'Kojori House',
|
householdName: 'Kojori House',
|
||||||
telegramChatId: '-100123',
|
telegramChatId: '-100123',
|
||||||
telegramChatType: 'supergroup',
|
telegramChatType: 'supergroup',
|
||||||
title: 'Kojori House'
|
title: 'Kojori House',
|
||||||
|
defaultLocale: 'ru' as const
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -110,7 +111,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
username: input.username?.trim() || null,
|
username: input.username?.trim() || null,
|
||||||
languageCode: input.languageCode?.trim() || null
|
languageCode: input.languageCode?.trim() || null,
|
||||||
|
householdDefaultLocale: household.defaultLocale
|
||||||
}),
|
}),
|
||||||
getPendingHouseholdMember: async () => null,
|
getPendingHouseholdMember: async () => null,
|
||||||
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||||
@@ -119,13 +121,20 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
|||||||
householdId: household.householdId,
|
householdId: household.householdId,
|
||||||
telegramUserId: input.telegramUserId,
|
telegramUserId: input.telegramUserId,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
|
preferredLocale: input.preferredLocale ?? null,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
isAdmin: input.isAdmin === true
|
isAdmin: input.isAdmin === true
|
||||||
}),
|
}),
|
||||||
getHouseholdMember: async () => null,
|
getHouseholdMember: async () => null,
|
||||||
listHouseholdMembers: async () => [],
|
listHouseholdMembers: async () => [],
|
||||||
listHouseholdMembersByTelegramUserId: async () => [],
|
listHouseholdMembersByTelegramUserId: async () => [],
|
||||||
listPendingHouseholdMembers: async () => [],
|
listPendingHouseholdMembers: async () => [],
|
||||||
approvePendingHouseholdMember: async () => null
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
updateHouseholdDefaultLocale: async (_householdId, locale) => ({
|
||||||
|
...household,
|
||||||
|
defaultLocale: locale
|
||||||
|
}),
|
||||||
|
updateMemberPreferredLocale: async () => null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +156,8 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
telegramUserId: '123456',
|
telegramUserId: '123456',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -223,6 +234,8 @@ describe('createMiniAppDashboardHandler', () => {
|
|||||||
householdId: 'household-1',
|
householdId: 'household-1',
|
||||||
telegramUserId: '123456',
|
telegramUserId: '123456',
|
||||||
displayName: 'Stan',
|
displayName: 'Stan',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: 'ru',
|
||||||
isAdmin: true
|
isAdmin: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
212
apps/bot/src/miniapp-locale.test.ts
Normal file
212
apps/bot/src/miniapp-locale.test.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
createHouseholdOnboardingService,
|
||||||
|
createLocalePreferenceService
|
||||||
|
} from '@household/application'
|
||||||
|
import type {
|
||||||
|
HouseholdConfigurationRepository,
|
||||||
|
HouseholdMemberRecord,
|
||||||
|
HouseholdTopicBindingRecord
|
||||||
|
} from '@household/ports'
|
||||||
|
|
||||||
|
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
||||||
|
import { buildMiniAppInitData } from './telegram-miniapp-test-helpers'
|
||||||
|
|
||||||
|
function repository(): HouseholdConfigurationRepository {
|
||||||
|
const household = {
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
telegramChatId: '-100123',
|
||||||
|
telegramChatType: 'supergroup',
|
||||||
|
title: 'Kojori House',
|
||||||
|
defaultLocale: 'ru' as 'en' | 'ru'
|
||||||
|
}
|
||||||
|
|
||||||
|
const members = new Map<string, HouseholdMemberRecord>([
|
||||||
|
[
|
||||||
|
'123456',
|
||||||
|
{
|
||||||
|
id: 'member-123456',
|
||||||
|
householdId: household.householdId,
|
||||||
|
telegramUserId: '123456',
|
||||||
|
displayName: 'Stan',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
|
isAdmin: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'222222',
|
||||||
|
{
|
||||||
|
id: 'member-222222',
|
||||||
|
householdId: household.householdId,
|
||||||
|
telegramUserId: '222222',
|
||||||
|
displayName: 'Mia',
|
||||||
|
preferredLocale: null,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
|
isAdmin: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
registerTelegramHouseholdChat: async () => ({ status: 'existing', household }),
|
||||||
|
getTelegramHouseholdChat: async () => household,
|
||||||
|
getHouseholdChatByHouseholdId: async () => household,
|
||||||
|
bindHouseholdTopic: async (input) =>
|
||||||
|
({
|
||||||
|
householdId: input.householdId,
|
||||||
|
role: input.role,
|
||||||
|
telegramThreadId: input.telegramThreadId,
|
||||||
|
topicName: input.topicName?.trim() || null
|
||||||
|
}) satisfies HouseholdTopicBindingRecord,
|
||||||
|
getHouseholdTopicBinding: async () => null,
|
||||||
|
findHouseholdTopicByTelegramContext: async () => null,
|
||||||
|
listHouseholdTopicBindings: async () => [],
|
||||||
|
upsertHouseholdJoinToken: async () => ({
|
||||||
|
householdId: household.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
token: 'join-token',
|
||||||
|
createdByTelegramUserId: null
|
||||||
|
}),
|
||||||
|
getHouseholdJoinToken: async () => null,
|
||||||
|
getHouseholdByJoinToken: async () => null,
|
||||||
|
upsertPendingHouseholdMember: async (input) => ({
|
||||||
|
householdId: input.householdId,
|
||||||
|
householdName: household.householdName,
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
username: input.username?.trim() || null,
|
||||||
|
languageCode: input.languageCode?.trim() || null,
|
||||||
|
householdDefaultLocale: household.defaultLocale
|
||||||
|
}),
|
||||||
|
getPendingHouseholdMember: async () => null,
|
||||||
|
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||||
|
ensureHouseholdMember: async (input) =>
|
||||||
|
members.get(input.telegramUserId) ?? {
|
||||||
|
id: `member-${input.telegramUserId}`,
|
||||||
|
householdId: input.householdId,
|
||||||
|
telegramUserId: input.telegramUserId,
|
||||||
|
displayName: input.displayName,
|
||||||
|
preferredLocale: input.preferredLocale ?? null,
|
||||||
|
householdDefaultLocale: household.defaultLocale,
|
||||||
|
isAdmin: input.isAdmin === true
|
||||||
|
},
|
||||||
|
getHouseholdMember: async (_householdId, telegramUserId) => members.get(telegramUserId) ?? null,
|
||||||
|
listHouseholdMembers: async () => [...members.values()],
|
||||||
|
listHouseholdMembersByTelegramUserId: async (telegramUserId) => {
|
||||||
|
const member = members.get(telegramUserId)
|
||||||
|
return member ? [member] : []
|
||||||
|
},
|
||||||
|
listPendingHouseholdMembers: async () => [],
|
||||||
|
approvePendingHouseholdMember: async () => null,
|
||||||
|
updateHouseholdDefaultLocale: async (_householdId, locale) => {
|
||||||
|
household.defaultLocale = locale
|
||||||
|
for (const [id, member] of members.entries()) {
|
||||||
|
members.set(id, {
|
||||||
|
...member,
|
||||||
|
householdDefaultLocale: locale
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return household
|
||||||
|
},
|
||||||
|
updateMemberPreferredLocale: async (_householdId, telegramUserId, locale) => {
|
||||||
|
const member = members.get(telegramUserId)
|
||||||
|
if (!member) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
...member,
|
||||||
|
preferredLocale: locale
|
||||||
|
}
|
||||||
|
members.set(telegramUserId, next)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('createMiniAppLocalePreferenceHandler', () => {
|
||||||
|
test('updates member locale preference', async () => {
|
||||||
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
|
const householdRepository = repository()
|
||||||
|
const handler = createMiniAppLocalePreferenceHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository: householdRepository
|
||||||
|
}),
|
||||||
|
localePreferenceService: createLocalePreferenceService(householdRepository)
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/preferences/locale', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||||
|
id: 123456,
|
||||||
|
first_name: 'Stan',
|
||||||
|
language_code: 'ru'
|
||||||
|
}),
|
||||||
|
locale: 'en',
|
||||||
|
scope: 'member'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
locale: {
|
||||||
|
scope: 'member',
|
||||||
|
effectiveLocale: 'en',
|
||||||
|
memberPreferredLocale: 'en',
|
||||||
|
householdDefaultLocale: 'ru'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects household locale updates for non-admin members', async () => {
|
||||||
|
const authDate = Math.floor(Date.now() / 1000)
|
||||||
|
const householdRepository = repository()
|
||||||
|
const handler = createMiniAppLocalePreferenceHandler({
|
||||||
|
allowedOrigins: ['http://localhost:5173'],
|
||||||
|
botToken: 'test-bot-token',
|
||||||
|
onboardingService: createHouseholdOnboardingService({
|
||||||
|
repository: householdRepository
|
||||||
|
}),
|
||||||
|
localePreferenceService: createLocalePreferenceService(householdRepository)
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await handler.handler(
|
||||||
|
new Request('http://localhost/api/miniapp/preferences/locale', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
origin: 'http://localhost:5173',
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData: buildMiniAppInitData('test-bot-token', authDate, {
|
||||||
|
id: 222222,
|
||||||
|
first_name: 'Mia',
|
||||||
|
language_code: 'ru'
|
||||||
|
}),
|
||||||
|
locale: 'en',
|
||||||
|
scope: 'household'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(403)
|
||||||
|
expect(await response.json()).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'Admin access required'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
150
apps/bot/src/miniapp-locale.ts
Normal file
150
apps/bot/src/miniapp-locale.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import type { HouseholdOnboardingService, LocalePreferenceService } from '@household/application'
|
||||||
|
import { normalizeSupportedLocale } from '@household/domain'
|
||||||
|
import type { Logger } from '@household/observability'
|
||||||
|
|
||||||
|
import {
|
||||||
|
allowedMiniAppOrigin,
|
||||||
|
createMiniAppSessionService,
|
||||||
|
miniAppErrorResponse,
|
||||||
|
miniAppJsonResponse
|
||||||
|
} from './miniapp-auth'
|
||||||
|
|
||||||
|
interface LocalePreferenceRequest {
|
||||||
|
initData: string
|
||||||
|
locale: 'en' | 'ru'
|
||||||
|
scope: 'member' | 'household'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLocalePreferenceRequest(request: Request): Promise<LocalePreferenceRequest> {
|
||||||
|
const text = await request.text()
|
||||||
|
if (text.trim().length === 0) {
|
||||||
|
throw new Error('Missing initData')
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: { initData?: string; locale?: string; scope?: string }
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text) as { initData?: string; locale?: string; scope?: string }
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid JSON body')
|
||||||
|
}
|
||||||
|
|
||||||
|
const initData = parsed.initData?.trim()
|
||||||
|
if (!initData) {
|
||||||
|
throw new Error('Missing initData')
|
||||||
|
}
|
||||||
|
|
||||||
|
const locale = normalizeSupportedLocale(parsed.locale)
|
||||||
|
if (!locale) {
|
||||||
|
throw new Error('Invalid locale')
|
||||||
|
}
|
||||||
|
|
||||||
|
const scope = parsed.scope?.trim()
|
||||||
|
if (scope !== 'member' && scope !== 'household') {
|
||||||
|
throw new Error('Invalid locale scope')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
initData,
|
||||||
|
locale,
|
||||||
|
scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMiniAppLocalePreferenceHandler(options: {
|
||||||
|
allowedOrigins: readonly string[]
|
||||||
|
botToken: string
|
||||||
|
onboardingService: HouseholdOnboardingService
|
||||||
|
localePreferenceService: LocalePreferenceService
|
||||||
|
logger?: Logger
|
||||||
|
}): {
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
} {
|
||||||
|
const sessionService = createMiniAppSessionService({
|
||||||
|
botToken: options.botToken,
|
||||||
|
onboardingService: options.onboardingService
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
handler: async (request) => {
|
||||||
|
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
|
||||||
|
|
||||||
|
if (request.method === 'OPTIONS') {
|
||||||
|
return miniAppJsonResponse({ ok: true }, 204, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method !== 'POST') {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await readLocalePreferenceRequest(request)
|
||||||
|
const session = await sessionService.authenticate({
|
||||||
|
initData: payload.initData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{ ok: false, error: 'Invalid Telegram init data' },
|
||||||
|
401,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.authorized || !session.member || !session.telegramUser) {
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{ ok: false, error: 'Access limited to active household members' },
|
||||||
|
403,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let memberPreferredLocale = session.member.preferredLocale
|
||||||
|
let householdDefaultLocale = session.member.householdDefaultLocale
|
||||||
|
|
||||||
|
if (payload.scope === 'member') {
|
||||||
|
const result = await options.localePreferenceService.updateMemberLocale({
|
||||||
|
householdId: session.member.householdId,
|
||||||
|
telegramUserId: session.telegramUser.id,
|
||||||
|
locale: payload.locale
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Member not found' }, 404, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
memberPreferredLocale = result.member.preferredLocale
|
||||||
|
householdDefaultLocale = result.member.householdDefaultLocale
|
||||||
|
} else {
|
||||||
|
const result = await options.localePreferenceService.updateHouseholdLocale({
|
||||||
|
householdId: session.member.householdId,
|
||||||
|
actorIsAdmin: session.member.isAdmin,
|
||||||
|
locale: payload.locale
|
||||||
|
})
|
||||||
|
|
||||||
|
if (result.status === 'rejected') {
|
||||||
|
return miniAppJsonResponse({ ok: false, error: 'Admin access required' }, 403, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
householdDefaultLocale = result.household.defaultLocale
|
||||||
|
}
|
||||||
|
|
||||||
|
return miniAppJsonResponse(
|
||||||
|
{
|
||||||
|
ok: true,
|
||||||
|
authorized: true,
|
||||||
|
locale: {
|
||||||
|
scope: payload.scope,
|
||||||
|
effectiveLocale: memberPreferredLocale ?? householdDefaultLocale,
|
||||||
|
memberPreferredLocale,
|
||||||
|
householdDefaultLocale
|
||||||
|
}
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
origin
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
return miniAppErrorResponse(error, origin, options.logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
} from '@household/ports'
|
} from '@household/ports'
|
||||||
|
|
||||||
import { createDbClient, schema } from '@household/db'
|
import { createDbClient, schema } from '@household/db'
|
||||||
import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n'
|
import { getBotTranslations, type BotLocale } from './i18n'
|
||||||
|
|
||||||
export interface PurchaseTopicIngestionConfig {
|
export interface PurchaseTopicIngestionConfig {
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -325,7 +325,7 @@ export function registerPurchaseTopicIngestion(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await repository.save(record, options.llmFallback)
|
const status = await repository.save(record, options.llmFallback)
|
||||||
const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx))
|
const acknowledgement = buildPurchaseAcknowledgement(status, 'en')
|
||||||
|
|
||||||
if (status.status === 'created') {
|
if (status.status === 'created') {
|
||||||
options.logger?.info(
|
options.logger?.info(
|
||||||
@@ -395,7 +395,13 @@ export function registerConfiguredPurchaseTopicIngestion(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await repository.save(record, options.llmFallback)
|
const status = await repository.save(record, options.llmFallback)
|
||||||
const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx))
|
const householdChat = await householdConfigurationRepository.getHouseholdChatByHouseholdId(
|
||||||
|
record.householdId
|
||||||
|
)
|
||||||
|
const acknowledgement = buildPurchaseAcknowledgement(
|
||||||
|
status,
|
||||||
|
householdChat?.defaultLocale ?? 'en'
|
||||||
|
)
|
||||||
|
|
||||||
if (status.status === 'created') {
|
if (status.status === 'created') {
|
||||||
options.logger?.info(
|
options.logger?.info(
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export interface BotWebhookServerOptions {
|
|||||||
handler: (request: Request) => Promise<Response>
|
handler: (request: Request) => Promise<Response>
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
|
miniAppLocalePreference?:
|
||||||
|
| {
|
||||||
|
path?: string
|
||||||
|
handler: (request: Request) => Promise<Response>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
scheduler?:
|
scheduler?:
|
||||||
| {
|
| {
|
||||||
pathPrefix?: string
|
pathPrefix?: string
|
||||||
@@ -69,6 +75,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members'
|
options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members'
|
||||||
const miniAppApproveMemberPath =
|
const miniAppApproveMemberPath =
|
||||||
options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member'
|
options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member'
|
||||||
|
const miniAppLocalePreferencePath =
|
||||||
|
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
|
||||||
const schedulerPathPrefix = options.scheduler
|
const schedulerPathPrefix = options.scheduler
|
||||||
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||||
: null
|
: null
|
||||||
@@ -101,6 +109,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
|||||||
return await options.miniAppApproveMember.handler(request)
|
return await options.miniAppApproveMember.handler(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) {
|
||||||
|
return await options.miniAppLocalePreference.handler(request)
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname !== normalizedWebhookPath) {
|
if (url.pathname !== normalizedWebhookPath) {
|
||||||
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
fetchMiniAppPendingMembers,
|
fetchMiniAppPendingMembers,
|
||||||
fetchMiniAppSession,
|
fetchMiniAppSession,
|
||||||
joinMiniAppHousehold,
|
joinMiniAppHousehold,
|
||||||
|
updateMiniAppLocalePreference,
|
||||||
type MiniAppDashboard,
|
type MiniAppDashboard,
|
||||||
type MiniAppPendingMember
|
type MiniAppPendingMember
|
||||||
} from './miniapp-api'
|
} from './miniapp-api'
|
||||||
@@ -36,6 +37,8 @@ type SessionState =
|
|||||||
member: {
|
member: {
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
preferredLocale: Locale | null
|
||||||
|
householdDefaultLocale: Locale
|
||||||
}
|
}
|
||||||
telegramUser: {
|
telegramUser: {
|
||||||
firstName: string | null
|
firstName: string | null
|
||||||
@@ -51,7 +54,9 @@ const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
|||||||
mode: 'demo',
|
mode: 'demo',
|
||||||
member: {
|
member: {
|
||||||
displayName: 'Demo Resident',
|
displayName: 'Demo Resident',
|
||||||
isAdmin: false
|
isAdmin: false,
|
||||||
|
preferredLocale: 'en',
|
||||||
|
householdDefaultLocale: 'en'
|
||||||
},
|
},
|
||||||
telegramUser: {
|
telegramUser: {
|
||||||
firstName: 'Demo',
|
firstName: 'Demo',
|
||||||
@@ -112,6 +117,8 @@ function App() {
|
|||||||
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
|
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
|
||||||
const [joining, setJoining] = createSignal(false)
|
const [joining, setJoining] = createSignal(false)
|
||||||
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
|
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
|
||||||
|
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
|
||||||
|
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
|
||||||
|
|
||||||
const copy = createMemo(() => dictionary[locale()])
|
const copy = createMemo(() => dictionary[locale()])
|
||||||
const onboardingSession = createMemo(() => {
|
const onboardingSession = createMemo(() => {
|
||||||
@@ -153,7 +160,8 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
setLocale(detectLocale())
|
const fallbackLocale = detectLocale()
|
||||||
|
setLocale(fallbackLocale)
|
||||||
|
|
||||||
webApp?.ready?.()
|
webApp?.ready?.()
|
||||||
webApp?.expand?.()
|
webApp?.expand?.()
|
||||||
@@ -175,6 +183,10 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const payload = await fetchMiniAppSession(initData, joinContext().joinToken)
|
const payload = await fetchMiniAppSession(initData, joinContext().joinToken)
|
||||||
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
||||||
|
setLocale(
|
||||||
|
payload.onboarding?.householdDefaultLocale ??
|
||||||
|
((payload.telegramUser?.languageCode ?? fallbackLocale).startsWith('ru') ? 'ru' : 'en')
|
||||||
|
)
|
||||||
setSession({
|
setSession({
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
mode: payload.onboarding?.status ?? 'open_from_group',
|
mode: payload.onboarding?.status ?? 'open_from_group',
|
||||||
@@ -192,6 +204,7 @@ function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
||||||
setSession({
|
setSession({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode: 'live',
|
mode: 'live',
|
||||||
@@ -284,6 +297,7 @@ function App() {
|
|||||||
try {
|
try {
|
||||||
const payload = await joinMiniAppHousehold(initData, joinToken)
|
const payload = await joinMiniAppHousehold(initData, joinToken)
|
||||||
if (payload.authorized && payload.member && payload.telegramUser) {
|
if (payload.authorized && payload.member && payload.telegramUser) {
|
||||||
|
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
||||||
setSession({
|
setSession({
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode: 'live',
|
mode: 'live',
|
||||||
@@ -297,6 +311,10 @@ function App() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLocale(
|
||||||
|
payload.onboarding?.householdDefaultLocale ??
|
||||||
|
((payload.telegramUser?.languageCode ?? locale()).startsWith('ru') ? 'ru' : 'en')
|
||||||
|
)
|
||||||
setSession({
|
setSession({
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
mode: payload.onboarding?.status ?? 'pending',
|
mode: payload.onboarding?.status ?? 'pending',
|
||||||
@@ -339,6 +357,71 @@ function App() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleMemberLocaleChange(nextLocale: Locale) {
|
||||||
|
const initData = webApp?.initData?.trim()
|
||||||
|
const currentReady = readySession()
|
||||||
|
|
||||||
|
setLocale(nextLocale)
|
||||||
|
|
||||||
|
if (!initData || currentReady?.mode !== 'live') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingMemberLocale(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'member')
|
||||||
|
|
||||||
|
setSession((current) =>
|
||||||
|
current.status === 'ready'
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
member: {
|
||||||
|
...current.member,
|
||||||
|
preferredLocale: updated.memberPreferredLocale,
|
||||||
|
householdDefaultLocale: updated.householdDefaultLocale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
setLocale(updated.effectiveLocale)
|
||||||
|
} finally {
|
||||||
|
setSavingMemberLocale(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHouseholdLocaleChange(nextLocale: Locale) {
|
||||||
|
const initData = webApp?.initData?.trim()
|
||||||
|
const currentReady = readySession()
|
||||||
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingHouseholdLocale(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'household')
|
||||||
|
|
||||||
|
setSession((current) =>
|
||||||
|
current.status === 'ready'
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
member: {
|
||||||
|
...current.member,
|
||||||
|
householdDefaultLocale: updated.householdDefaultLocale
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: current
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!currentReady.member.preferredLocale) {
|
||||||
|
setLocale(updated.effectiveLocale)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSavingHouseholdLocale(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const renderPanel = () => {
|
const renderPanel = () => {
|
||||||
switch (activeNav()) {
|
switch (activeNav()) {
|
||||||
case 'balances':
|
case 'balances':
|
||||||
@@ -396,6 +479,34 @@ function App() {
|
|||||||
case 'house':
|
case 'house':
|
||||||
return readySession()?.member.isAdmin ? (
|
return readySession()?.member.isAdmin ? (
|
||||||
<div class="balance-list">
|
<div class="balance-list">
|
||||||
|
<article class="balance-item">
|
||||||
|
<header>
|
||||||
|
<strong>{copy().householdLanguage}</strong>
|
||||||
|
<span>{readySession()?.member.householdDefaultLocale.toUpperCase()}</span>
|
||||||
|
</header>
|
||||||
|
<div class="locale-switch__buttons">
|
||||||
|
<button
|
||||||
|
classList={{
|
||||||
|
'is-active': readySession()?.member.householdDefaultLocale === 'en'
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={savingHouseholdLocale()}
|
||||||
|
onClick={() => void handleHouseholdLocaleChange('en')}
|
||||||
|
>
|
||||||
|
EN
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
classList={{
|
||||||
|
'is-active': readySession()?.member.householdDefaultLocale === 'ru'
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
disabled={savingHouseholdLocale()}
|
||||||
|
onClick={() => void handleHouseholdLocaleChange('ru')}
|
||||||
|
>
|
||||||
|
RU
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
<article class="balance-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{copy().pendingMembersTitle}</strong>
|
<strong>{copy().pendingMembersTitle}</strong>
|
||||||
@@ -470,14 +581,16 @@ function App() {
|
|||||||
<button
|
<button
|
||||||
classList={{ 'is-active': locale() === 'en' }}
|
classList={{ 'is-active': locale() === 'en' }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLocale('en')}
|
disabled={savingMemberLocale()}
|
||||||
|
onClick={() => void handleMemberLocaleChange('en')}
|
||||||
>
|
>
|
||||||
EN
|
EN
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
classList={{ 'is-active': locale() === 'ru' }}
|
classList={{ 'is-active': locale() === 'ru' }}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setLocale('ru')}
|
disabled={savingMemberLocale()}
|
||||||
|
onClick={() => void handleMemberLocaleChange('ru')}
|
||||||
>
|
>
|
||||||
RU
|
RU
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const dictionary = {
|
|||||||
'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.',
|
'The real session gate needs Telegram mini app data. Local development falls back to a preview shell.',
|
||||||
reload: 'Retry',
|
reload: 'Retry',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
|
householdLanguage: 'Household language',
|
||||||
|
savingLanguage: 'Saving…',
|
||||||
home: 'Home',
|
home: 'Home',
|
||||||
balances: 'Balances',
|
balances: 'Balances',
|
||||||
ledger: 'Ledger',
|
ledger: 'Ledger',
|
||||||
@@ -90,6 +92,8 @@ export const dictionary = {
|
|||||||
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',
|
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',
|
||||||
reload: 'Повторить',
|
reload: 'Повторить',
|
||||||
language: 'Язык',
|
language: 'Язык',
|
||||||
|
householdLanguage: 'Язык дома',
|
||||||
|
savingLanguage: 'Сохраняем…',
|
||||||
home: 'Главная',
|
home: 'Главная',
|
||||||
balances: 'Баланс',
|
balances: 'Баланс',
|
||||||
ledger: 'Леджер',
|
ledger: 'Леджер',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface MiniAppSession {
|
|||||||
member?: {
|
member?: {
|
||||||
displayName: string
|
displayName: string
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
|
preferredLocale: 'en' | 'ru' | null
|
||||||
|
householdDefaultLocale: 'en' | 'ru'
|
||||||
}
|
}
|
||||||
telegramUser?: {
|
telegramUser?: {
|
||||||
firstName: string | null
|
firstName: string | null
|
||||||
@@ -14,9 +16,17 @@ export interface MiniAppSession {
|
|||||||
onboarding?: {
|
onboarding?: {
|
||||||
status: 'join_required' | 'pending' | 'open_from_group'
|
status: 'join_required' | 'pending' | 'open_from_group'
|
||||||
householdName?: string
|
householdName?: string
|
||||||
|
householdDefaultLocale?: 'en' | 'ru'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MiniAppLocalePreference {
|
||||||
|
scope: 'member' | 'household'
|
||||||
|
effectiveLocale: 'en' | 'ru'
|
||||||
|
memberPreferredLocale: 'en' | 'ru' | null
|
||||||
|
householdDefaultLocale: 'en' | 'ru'
|
||||||
|
}
|
||||||
|
|
||||||
export interface MiniAppPendingMember {
|
export interface MiniAppPendingMember {
|
||||||
telegramUserId: string
|
telegramUserId: string
|
||||||
displayName: string
|
displayName: string
|
||||||
@@ -219,3 +229,34 @@ export async function approveMiniAppPendingMember(
|
|||||||
throw new Error(payload.error ?? 'Failed to approve member')
|
throw new Error(payload.error ?? 'Failed to approve member')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateMiniAppLocalePreference(
|
||||||
|
initData: string,
|
||||||
|
locale: 'en' | 'ru',
|
||||||
|
scope: 'member' | 'household'
|
||||||
|
): Promise<MiniAppLocalePreference> {
|
||||||
|
const response = await fetch(`${apiBaseUrl()}/api/miniapp/preferences/locale`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
initData,
|
||||||
|
locale,
|
||||||
|
scope
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
ok: boolean
|
||||||
|
authorized?: boolean
|
||||||
|
locale?: MiniAppLocalePreference
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok || !payload.authorized || !payload.locale) {
|
||||||
|
throw new Error(payload.error ?? 'Failed to update locale preference')
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload.locale
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user