mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 13:44: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',
|
||||
telegramChatId: '-100222333',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
title: 'Kojori House',
|
||||
defaultLocale: 'ru'
|
||||
}
|
||||
}),
|
||||
getTelegramHouseholdChat: async () => ({
|
||||
@@ -103,14 +104,16 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-100222333',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
title: 'Kojori House',
|
||||
defaultLocale: 'ru'
|
||||
}),
|
||||
getHouseholdChatByHouseholdId: async () => ({
|
||||
householdId: 'household-1',
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-100222333',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
title: 'Kojori House',
|
||||
defaultLocale: 'ru'
|
||||
}),
|
||||
bindHouseholdTopic: async (input) => ({
|
||||
householdId: input.householdId,
|
||||
@@ -143,7 +146,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
username: input.username?.trim() || null,
|
||||
languageCode: input.languageCode?.trim() || null
|
||||
languageCode: input.languageCode?.trim() || null,
|
||||
householdDefaultLocale: 'ru'
|
||||
}),
|
||||
getPendingHouseholdMember: async () => null,
|
||||
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||
@@ -152,6 +156,8 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
householdId: input.householdId,
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: 'ru',
|
||||
isAdmin: input.isAdmin === true
|
||||
}),
|
||||
getHouseholdMember: async () => null,
|
||||
@@ -162,11 +168,30 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
|
||||
householdId: 'household-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
isAdmin: false
|
||||
}
|
||||
],
|
||||
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({
|
||||
chat_id: '-100222333',
|
||||
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({
|
||||
text: 'Anonymous feedback delivered.'
|
||||
text: 'Анонимное сообщение отправлено.'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -303,7 +328,7 @@ describe('registerAnonymousFeedback', () => {
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
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(calls[0]?.payload).toMatchObject({
|
||||
text: 'Send me the anonymous message in your next reply, or tap Cancel.'
|
||||
text: 'Отправьте анонимное сообщение следующим сообщением или нажмите «Отменить».'
|
||||
})
|
||||
expect(calls[1]?.payload).toMatchObject({
|
||||
chat_id: '-100222333',
|
||||
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({
|
||||
text: 'Anonymous feedback delivered.'
|
||||
text: 'Анонимное сообщение отправлено.'
|
||||
})
|
||||
})
|
||||
|
||||
@@ -620,7 +645,7 @@ describe('registerAnonymousFeedback', () => {
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
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'
|
||||
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 CANCEL_ANONYMOUS_FEEDBACK_CALLBACK = 'cancel_prompt:anonymous_feedback'
|
||||
@@ -114,9 +115,13 @@ async function clearPendingAnonymousFeedbackPrompt(
|
||||
|
||||
async function startPendingAnonymousFeedbackPrompt(
|
||||
repository: TelegramPendingActionRepository,
|
||||
householdConfigurationRepository: HouseholdConfigurationRepository,
|
||||
ctx: Context
|
||||
): Promise<void> {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale).anonymousFeedback
|
||||
const telegramUserId = ctx.from?.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 telegramUpdateId =
|
||||
'update_id' in options.ctx.update ? options.ctx.update.update_id?.toString() : undefined
|
||||
const locale = botLocaleFromContext(options.ctx)
|
||||
const t = getBotTranslations(locale).anonymousFeedback
|
||||
const fallbackLocale = await resolveReplyLocale({
|
||||
ctx: options.ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
|
||||
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
||||
await options.ctx.reply(t.unableToIdentifyMessage)
|
||||
await options.ctx.reply(
|
||||
getBotTranslations(fallbackLocale).anonymousFeedback.unableToIdentifyMessage
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -167,17 +176,19 @@ async function submitAnonymousFeedback(options: {
|
||||
|
||||
if (memberships.length === 0) {
|
||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||
await options.ctx.reply(t.notMember)
|
||||
await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.notMember)
|
||||
return
|
||||
}
|
||||
|
||||
if (memberships.length > 1) {
|
||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||
await options.ctx.reply(t.multipleHouseholds)
|
||||
await options.ctx.reply(getBotTranslations(fallbackLocale).anonymousFeedback.multipleHouseholds)
|
||||
return
|
||||
}
|
||||
|
||||
const member = memberships[0]!
|
||||
const locale = member.preferredLocale ?? member.householdDefaultLocale
|
||||
const t = getBotTranslations(locale).anonymousFeedback
|
||||
const householdChat =
|
||||
await options.householdConfigurationRepository.getHouseholdChatByHouseholdId(member.householdId)
|
||||
const feedbackTopic = await options.householdConfigurationRepository.getHouseholdTopicBinding(
|
||||
@@ -273,7 +284,10 @@ export function registerAnonymousFeedback(options: {
|
||||
logger?: Logger
|
||||
}): void {
|
||||
options.bot.command('cancel', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale).anonymousFeedback
|
||||
if (!isPrivateChat(ctx)) {
|
||||
return
|
||||
@@ -297,7 +311,10 @@ export function registerAnonymousFeedback(options: {
|
||||
})
|
||||
|
||||
options.bot.command('anon', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale).anonymousFeedback
|
||||
if (!isPrivateChat(ctx)) {
|
||||
await ctx.reply(t.useInPrivateChat)
|
||||
@@ -306,7 +323,11 @@ export function registerAnonymousFeedback(options: {
|
||||
|
||||
const rawText = commandArgText(ctx)
|
||||
if (rawText.length === 0) {
|
||||
await startPendingAnonymousFeedbackPrompt(options.promptRepository, ctx)
|
||||
await startPendingAnonymousFeedbackPrompt(
|
||||
options.promptRepository,
|
||||
options.householdConfigurationRepository,
|
||||
ctx
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -351,7 +372,10 @@ export function registerAnonymousFeedback(options: {
|
||||
})
|
||||
|
||||
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
|
||||
if (!isPrivateChat(ctx)) {
|
||||
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 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'
|
||||
|
||||
export function createTelegramBot(token: string, logger?: Logger): Bot {
|
||||
export function createTelegramBot(
|
||||
token: string,
|
||||
logger?: Logger,
|
||||
householdConfigurationRepository?: HouseholdConfigurationRepository
|
||||
): Bot {
|
||||
const bot = new Bot(token)
|
||||
|
||||
bot.command('help', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: householdConfigurationRepository
|
||||
})
|
||||
await ctx.reply(formatTelegramHelpText(locale))
|
||||
})
|
||||
|
||||
bot.command('household_status', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: householdConfigurationRepository
|
||||
})
|
||||
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 { Bot, Context } from 'grammy'
|
||||
|
||||
import { botLocaleFromContext, getBotTranslations } from './i18n'
|
||||
import { getBotTranslations } from './i18n'
|
||||
import { resolveReplyLocale } from './bot-locale'
|
||||
|
||||
function commandArgs(ctx: Context): string[] {
|
||||
const raw = typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||
@@ -24,10 +25,10 @@ export function createFinanceCommandsService(options: {
|
||||
register: (bot: Bot) => void
|
||||
} {
|
||||
function formatStatement(
|
||||
ctx: Context,
|
||||
locale: Parameters<typeof getBotTranslations>[0],
|
||||
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
|
||||
): string {
|
||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||
const t = getBotTranslations(locale).finance
|
||||
|
||||
return [
|
||||
t.statementTitle(dashboard.period),
|
||||
@@ -42,7 +43,11 @@ export function createFinanceCommandsService(options: {
|
||||
service: FinanceCommandService
|
||||
householdId: string
|
||||
} | null> {
|
||||
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale).finance
|
||||
if (!isGroupChat(ctx)) {
|
||||
await ctx.reply(t.useInGroup)
|
||||
return null
|
||||
@@ -63,7 +68,11 @@ export function createFinanceCommandsService(options: {
|
||||
}
|
||||
|
||||
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()
|
||||
if (!telegramUserId) {
|
||||
await ctx.reply(t.unableToIdentifySender)
|
||||
@@ -89,7 +98,11 @@ export function createFinanceCommandsService(options: {
|
||||
}
|
||||
|
||||
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)
|
||||
if (!resolved) {
|
||||
return null
|
||||
@@ -105,7 +118,11 @@ export function createFinanceCommandsService(options: {
|
||||
|
||||
function register(bot: Bot): void {
|
||||
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)
|
||||
if (!resolved) {
|
||||
return
|
||||
@@ -126,7 +143,11 @@ export function createFinanceCommandsService(options: {
|
||||
})
|
||||
|
||||
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)
|
||||
if (!resolved) {
|
||||
return
|
||||
@@ -146,7 +167,11 @@ export function createFinanceCommandsService(options: {
|
||||
})
|
||||
|
||||
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)
|
||||
if (!resolved) {
|
||||
return
|
||||
@@ -172,7 +197,11 @@ export function createFinanceCommandsService(options: {
|
||||
})
|
||||
|
||||
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)
|
||||
if (!resolved) {
|
||||
return
|
||||
@@ -205,7 +234,11 @@ export function createFinanceCommandsService(options: {
|
||||
})
|
||||
|
||||
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)
|
||||
if (!resolved) {
|
||||
return
|
||||
@@ -218,7 +251,7 @@ export function createFinanceCommandsService(options: {
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.reply(formatStatement(ctx, dashboard))
|
||||
await ctx.reply(formatStatement(locale, dashboard))
|
||||
} catch (error) {
|
||||
await ctx.reply(t.statementFailed((error as Error).message))
|
||||
}
|
||||
|
||||
@@ -147,7 +147,8 @@ describe('registerHouseholdSetupCommands', () => {
|
||||
status: 'pending',
|
||||
household: {
|
||||
id: 'household-1',
|
||||
name: 'Kojori House'
|
||||
name: 'Kojori House',
|
||||
defaultLocale: 'ru'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +237,8 @@ describe('registerHouseholdSetupCommands', () => {
|
||||
status: 'pending',
|
||||
household: {
|
||||
id: 'household-1',
|
||||
name: 'Kojori House'
|
||||
name: 'Kojori House',
|
||||
defaultLocale: 'ru'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import type {
|
||||
HouseholdAdminService,
|
||||
HouseholdOnboardingService,
|
||||
HouseholdSetupService
|
||||
HouseholdSetupService,
|
||||
HouseholdMiniAppAccess
|
||||
} from '@household/application'
|
||||
import type { Logger } from '@household/observability'
|
||||
import type { HouseholdConfigurationRepository } from '@household/ports'
|
||||
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:'
|
||||
|
||||
@@ -175,12 +178,17 @@ export function registerHouseholdSetupCommands(options: {
|
||||
householdSetupService: HouseholdSetupService
|
||||
householdOnboardingService: HouseholdOnboardingService
|
||||
householdAdminService: HouseholdAdminService
|
||||
householdConfigurationRepository?: HouseholdConfigurationRepository
|
||||
miniAppUrl?: string
|
||||
logger?: Logger
|
||||
}): void {
|
||||
options.bot.command('start', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const t = getBotTranslations(locale)
|
||||
const fallbackLocale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
let locale = fallbackLocale
|
||||
let t = getBotTranslations(locale)
|
||||
|
||||
if (ctx.chat?.type !== 'private') {
|
||||
return
|
||||
@@ -231,6 +239,18 @@ export function registerHouseholdSetupCommands(options: {
|
||||
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') {
|
||||
await ctx.reply(
|
||||
t.setup.alreadyActiveMember(result.member.displayName),
|
||||
@@ -246,8 +266,12 @@ export function registerHouseholdSetupCommands(options: {
|
||||
})
|
||||
|
||||
options.bot.command('setup', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const t = getBotTranslations(locale)
|
||||
const fallbackLocale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
let locale = fallbackLocale
|
||||
let t = getBotTranslations(locale)
|
||||
|
||||
if (!isGroupChat(ctx)) {
|
||||
await ctx.reply(t.setup.useSetupInGroup)
|
||||
@@ -278,6 +302,9 @@ export function registerHouseholdSetupCommands(options: {
|
||||
return
|
||||
}
|
||||
|
||||
locale = result.household.defaultLocale
|
||||
t = getBotTranslations(locale)
|
||||
|
||||
options.logger?.info(
|
||||
{
|
||||
event: 'household_setup.chat_registered',
|
||||
@@ -326,7 +353,10 @@ export function registerHouseholdSetupCommands(options: {
|
||||
})
|
||||
|
||||
options.bot.command('bind_purchase_topic', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale)
|
||||
|
||||
if (!isGroupChat(ctx)) {
|
||||
@@ -373,7 +403,10 @@ export function registerHouseholdSetupCommands(options: {
|
||||
})
|
||||
|
||||
options.bot.command('bind_feedback_topic', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale)
|
||||
|
||||
if (!isGroupChat(ctx)) {
|
||||
@@ -420,7 +453,10 @@ export function registerHouseholdSetupCommands(options: {
|
||||
})
|
||||
|
||||
options.bot.command('pending_members', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale)
|
||||
|
||||
if (!isGroupChat(ctx)) {
|
||||
@@ -456,7 +492,10 @@ export function registerHouseholdSetupCommands(options: {
|
||||
})
|
||||
|
||||
options.bot.command('approve_member', async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale)
|
||||
|
||||
if (!isGroupChat(ctx)) {
|
||||
@@ -493,7 +532,10 @@ export function registerHouseholdSetupCommands(options: {
|
||||
options.bot.callbackQuery(
|
||||
new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`),
|
||||
async (ctx) => {
|
||||
const locale = botLocaleFromContext(ctx)
|
||||
const locale = await resolveReplyLocale({
|
||||
ctx,
|
||||
repository: options.householdConfigurationRepository
|
||||
})
|
||||
const t = getBotTranslations(locale)
|
||||
|
||||
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,
|
||||
createFinanceCommandService,
|
||||
createHouseholdOnboardingService,
|
||||
createLocalePreferenceService,
|
||||
createMiniAppAdminService,
|
||||
createHouseholdSetupService,
|
||||
createReminderJobService
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
createMiniAppApproveMemberHandler,
|
||||
createMiniAppPendingMembersHandler
|
||||
} from './miniapp-admin'
|
||||
import { createMiniAppLocalePreferenceHandler } from './miniapp-locale'
|
||||
|
||||
const runtime = getBotRuntimeConfig()
|
||||
configureLogger({
|
||||
@@ -45,13 +47,16 @@ configureLogger({
|
||||
})
|
||||
|
||||
const logger = getLogger('runtime')
|
||||
const bot = createTelegramBot(runtime.telegramBotToken, getLogger('telegram'))
|
||||
const webhookHandler = webhookCallback(bot, 'std/http')
|
||||
|
||||
const shutdownTasks: Array<() => Promise<void>> = []
|
||||
const householdConfigurationRepositoryClient = runtime.databaseUrl
|
||||
? createDbHouseholdConfigurationRepository(runtime.databaseUrl)
|
||||
: 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 financeServices = new Map<string, ReturnType<typeof createFinanceCommandService>>()
|
||||
const householdOnboardingService = householdConfigurationRepositoryClient
|
||||
@@ -62,6 +67,9 @@ const householdOnboardingService = householdConfigurationRepositoryClient
|
||||
const miniAppAdminService = householdConfigurationRepositoryClient
|
||||
? createMiniAppAdminService(householdConfigurationRepositoryClient.repository)
|
||||
: null
|
||||
const localePreferenceService = householdConfigurationRepositoryClient
|
||||
? createLocalePreferenceService(householdConfigurationRepositoryClient.repository)
|
||||
: null
|
||||
const telegramPendingActionRepositoryClient =
|
||||
runtime.databaseUrl && runtime.anonymousFeedbackEnabled
|
||||
? createDbTelegramPendingActionRepository(runtime.databaseUrl!)
|
||||
@@ -168,6 +176,7 @@ if (householdConfigurationRepositoryClient) {
|
||||
householdConfigurationRepositoryClient.repository
|
||||
),
|
||||
householdOnboardingService: householdOnboardingService!,
|
||||
householdConfigurationRepository: householdConfigurationRepositoryClient.repository,
|
||||
...(runtime.miniAppAllowedOrigins[0]
|
||||
? {
|
||||
miniAppUrl: runtime.miniAppAllowedOrigins[0]
|
||||
@@ -279,6 +288,15 @@ const server = createBotWebhookServer({
|
||||
logger: getLogger('miniapp-admin')
|
||||
})
|
||||
: undefined,
|
||||
miniAppLocalePreference: householdOnboardingService
|
||||
? createMiniAppLocalePreferenceHandler({
|
||||
allowedOrigins: runtime.miniAppAllowedOrigins,
|
||||
botToken: runtime.telegramBotToken,
|
||||
onboardingService: householdOnboardingService,
|
||||
localePreferenceService: localePreferenceService!,
|
||||
logger: getLogger('miniapp-admin')
|
||||
})
|
||||
: undefined,
|
||||
scheduler:
|
||||
reminderJobs && runtime.schedulerSharedSecret
|
||||
? {
|
||||
|
||||
@@ -18,7 +18,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
title: 'Kojori House',
|
||||
defaultLocale: 'ru' as const
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -52,7 +53,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
username: input.username?.trim() || null,
|
||||
languageCode: input.languageCode?.trim() || null
|
||||
languageCode: input.languageCode?.trim() || null,
|
||||
householdDefaultLocale: household.defaultLocale
|
||||
}),
|
||||
getPendingHouseholdMember: async () => null,
|
||||
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||
@@ -61,6 +63,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdId: household.householdId,
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
isAdmin: input.isAdmin === true
|
||||
}),
|
||||
getHouseholdMember: async () => null,
|
||||
@@ -73,7 +77,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
telegramUserId: '555777',
|
||||
displayName: 'Mia',
|
||||
username: 'mia',
|
||||
languageCode: 'ru'
|
||||
languageCode: 'ru',
|
||||
householdDefaultLocale: household.defaultLocale
|
||||
}
|
||||
],
|
||||
approvePendingHouseholdMember: async (input) =>
|
||||
@@ -83,6 +88,24 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdId: household.householdId,
|
||||
telegramUserId: '555777',
|
||||
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
|
||||
}
|
||||
: null
|
||||
@@ -99,6 +122,8 @@ describe('createMiniAppPendingMembersHandler', () => {
|
||||
householdId: 'household-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
@@ -141,7 +166,8 @@ describe('createMiniAppPendingMembersHandler', () => {
|
||||
telegramUserId: '555777',
|
||||
displayName: 'Mia',
|
||||
username: 'mia',
|
||||
languageCode: 'ru'
|
||||
languageCode: 'ru',
|
||||
householdDefaultLocale: 'ru'
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -158,6 +184,8 @@ describe('createMiniAppApproveMemberHandler', () => {
|
||||
householdId: 'household-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
@@ -199,6 +227,8 @@ describe('createMiniAppApproveMemberHandler', () => {
|
||||
householdId: 'household-1',
|
||||
telegramUserId: '555777',
|
||||
displayName: 'Mia',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
isAdmin: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,7 +15,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
title: 'Kojori House',
|
||||
defaultLocale: 'ru' as const
|
||||
}
|
||||
let joinToken: string | null = 'join-token'
|
||||
const members = new Map<
|
||||
@@ -25,6 +26,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdId: string
|
||||
telegramUserId: string
|
||||
displayName: string
|
||||
preferredLocale: 'en' | 'ru' | null
|
||||
householdDefaultLocale: 'en' | 'ru'
|
||||
isAdmin: boolean
|
||||
}
|
||||
>()
|
||||
@@ -35,6 +38,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
displayName: string
|
||||
username: string | null
|
||||
languageCode: string | null
|
||||
householdDefaultLocale: 'ru'
|
||||
} | null = null
|
||||
|
||||
return {
|
||||
@@ -77,7 +81,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
username: input.username?.trim() || null,
|
||||
languageCode: input.languageCode?.trim() || null
|
||||
languageCode: input.languageCode?.trim() || null,
|
||||
householdDefaultLocale: household.defaultLocale
|
||||
}
|
||||
return pending
|
||||
},
|
||||
@@ -89,6 +94,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdId: household.householdId,
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
members.set(input.telegramUserId, member)
|
||||
@@ -112,11 +119,26 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdId: household.householdId,
|
||||
telegramUserId: pending.telegramUserId,
|
||||
displayName: pending.displayName,
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
isAdmin: input.isAdmin === true
|
||||
}
|
||||
members.set(pending.telegramUserId, member)
|
||||
pending = null
|
||||
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,
|
||||
member: {
|
||||
displayName: 'Stan',
|
||||
isAdmin: true
|
||||
isAdmin: true,
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru'
|
||||
},
|
||||
telegramUser: {
|
||||
id: '123456',
|
||||
@@ -208,7 +232,8 @@ describe('createMiniAppAuthHandler', () => {
|
||||
authorized: false,
|
||||
onboarding: {
|
||||
status: 'join_required',
|
||||
householdName: 'Kojori House'
|
||||
householdName: 'Kojori House',
|
||||
householdDefaultLocale: 'ru'
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -246,7 +271,8 @@ describe('createMiniAppAuthHandler', () => {
|
||||
authorized: false,
|
||||
onboarding: {
|
||||
status: 'pending',
|
||||
householdName: 'Kojori House'
|
||||
householdName: 'Kojori House',
|
||||
householdDefaultLocale: 'ru'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HouseholdOnboardingService } from '@household/application'
|
||||
import type { SupportedLocale } from '@household/domain'
|
||||
import type { Logger } from '@household/observability'
|
||||
|
||||
import { verifyTelegramMiniAppInitData } from './telegram-miniapp-auth'
|
||||
@@ -101,11 +102,14 @@ export interface MiniAppSessionResult {
|
||||
householdId: string
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
preferredLocale: SupportedLocale | null
|
||||
householdDefaultLocale: SupportedLocale
|
||||
}
|
||||
telegramUser?: ReturnType<typeof verifyTelegramMiniAppInitData>
|
||||
onboarding?: {
|
||||
status: 'join_required' | 'pending' | 'open_from_group'
|
||||
householdName?: string
|
||||
householdDefaultLocale?: SupportedLocale
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +158,8 @@ export function createMiniAppSessionService(options: {
|
||||
telegramUser,
|
||||
onboarding: {
|
||||
status: 'pending',
|
||||
householdName: access.household.name
|
||||
householdName: access.household.name,
|
||||
householdDefaultLocale: access.household.defaultLocale
|
||||
}
|
||||
}
|
||||
case 'join_required':
|
||||
@@ -163,7 +168,8 @@ export function createMiniAppSessionService(options: {
|
||||
telegramUser,
|
||||
onboarding: {
|
||||
status: 'join_required',
|
||||
householdName: access.household.name
|
||||
householdName: access.household.name,
|
||||
householdDefaultLocale: access.household.defaultLocale
|
||||
}
|
||||
}
|
||||
case 'open_from_group':
|
||||
@@ -334,7 +340,8 @@ export function createMiniAppJoinHandler(options: {
|
||||
authorized: false,
|
||||
onboarding: {
|
||||
status: 'pending',
|
||||
householdName: result.household.name
|
||||
householdName: result.household.name,
|
||||
householdDefaultLocale: result.household.defaultLocale
|
||||
},
|
||||
telegramUser
|
||||
},
|
||||
|
||||
@@ -76,7 +76,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdName: 'Kojori House',
|
||||
telegramChatId: '-100123',
|
||||
telegramChatType: 'supergroup',
|
||||
title: 'Kojori House'
|
||||
title: 'Kojori House',
|
||||
defaultLocale: 'ru' as const
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -110,7 +111,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
username: input.username?.trim() || null,
|
||||
languageCode: input.languageCode?.trim() || null
|
||||
languageCode: input.languageCode?.trim() || null,
|
||||
householdDefaultLocale: household.defaultLocale
|
||||
}),
|
||||
getPendingHouseholdMember: async () => null,
|
||||
findPendingHouseholdMemberByTelegramUserId: async () => null,
|
||||
@@ -119,13 +121,20 @@ function onboardingRepository(): HouseholdConfigurationRepository {
|
||||
householdId: household.householdId,
|
||||
telegramUserId: input.telegramUserId,
|
||||
displayName: input.displayName,
|
||||
preferredLocale: input.preferredLocale ?? null,
|
||||
householdDefaultLocale: household.defaultLocale,
|
||||
isAdmin: input.isAdmin === true
|
||||
}),
|
||||
getHouseholdMember: async () => null,
|
||||
listHouseholdMembers: async () => [],
|
||||
listHouseholdMembersByTelegramUserId: 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',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
isAdmin: true
|
||||
}
|
||||
]
|
||||
@@ -223,6 +234,8 @@ describe('createMiniAppDashboardHandler', () => {
|
||||
householdId: 'household-1',
|
||||
telegramUserId: '123456',
|
||||
displayName: 'Stan',
|
||||
preferredLocale: null,
|
||||
householdDefaultLocale: 'ru',
|
||||
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'
|
||||
|
||||
import { createDbClient, schema } from '@household/db'
|
||||
import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n'
|
||||
import { getBotTranslations, type BotLocale } from './i18n'
|
||||
|
||||
export interface PurchaseTopicIngestionConfig {
|
||||
householdId: string
|
||||
@@ -325,7 +325,7 @@ export function registerPurchaseTopicIngestion(
|
||||
|
||||
try {
|
||||
const status = await repository.save(record, options.llmFallback)
|
||||
const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx))
|
||||
const acknowledgement = buildPurchaseAcknowledgement(status, 'en')
|
||||
|
||||
if (status.status === 'created') {
|
||||
options.logger?.info(
|
||||
@@ -395,7 +395,13 @@ export function registerConfiguredPurchaseTopicIngestion(
|
||||
|
||||
try {
|
||||
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') {
|
||||
options.logger?.info(
|
||||
|
||||
@@ -32,6 +32,12 @@ export interface BotWebhookServerOptions {
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
miniAppLocalePreference?:
|
||||
| {
|
||||
path?: string
|
||||
handler: (request: Request) => Promise<Response>
|
||||
}
|
||||
| undefined
|
||||
scheduler?:
|
||||
| {
|
||||
pathPrefix?: string
|
||||
@@ -69,6 +75,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
options.miniAppPendingMembers?.path ?? '/api/miniapp/admin/pending-members'
|
||||
const miniAppApproveMemberPath =
|
||||
options.miniAppApproveMember?.path ?? '/api/miniapp/admin/approve-member'
|
||||
const miniAppLocalePreferencePath =
|
||||
options.miniAppLocalePreference?.path ?? '/api/miniapp/preferences/locale'
|
||||
const schedulerPathPrefix = options.scheduler
|
||||
? (options.scheduler.pathPrefix ?? '/jobs/reminder')
|
||||
: null
|
||||
@@ -101,6 +109,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
|
||||
return await options.miniAppApproveMember.handler(request)
|
||||
}
|
||||
|
||||
if (options.miniAppLocalePreference && url.pathname === miniAppLocalePreferencePath) {
|
||||
return await options.miniAppLocalePreference.handler(request)
|
||||
}
|
||||
|
||||
if (url.pathname !== normalizedWebhookPath) {
|
||||
if (schedulerPathPrefix && url.pathname.startsWith(`${schedulerPathPrefix}/`)) {
|
||||
if (request.method !== 'POST') {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
fetchMiniAppPendingMembers,
|
||||
fetchMiniAppSession,
|
||||
joinMiniAppHousehold,
|
||||
updateMiniAppLocalePreference,
|
||||
type MiniAppDashboard,
|
||||
type MiniAppPendingMember
|
||||
} from './miniapp-api'
|
||||
@@ -36,6 +37,8 @@ type SessionState =
|
||||
member: {
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
preferredLocale: Locale | null
|
||||
householdDefaultLocale: Locale
|
||||
}
|
||||
telegramUser: {
|
||||
firstName: string | null
|
||||
@@ -51,7 +54,9 @@ const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||
mode: 'demo',
|
||||
member: {
|
||||
displayName: 'Demo Resident',
|
||||
isAdmin: false
|
||||
isAdmin: false,
|
||||
preferredLocale: 'en',
|
||||
householdDefaultLocale: 'en'
|
||||
},
|
||||
telegramUser: {
|
||||
firstName: 'Demo',
|
||||
@@ -112,6 +117,8 @@ function App() {
|
||||
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
|
||||
const [joining, setJoining] = createSignal(false)
|
||||
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
|
||||
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
|
||||
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
|
||||
|
||||
const copy = createMemo(() => dictionary[locale()])
|
||||
const onboardingSession = createMemo(() => {
|
||||
@@ -153,7 +160,8 @@ function App() {
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
setLocale(detectLocale())
|
||||
const fallbackLocale = detectLocale()
|
||||
setLocale(fallbackLocale)
|
||||
|
||||
webApp?.ready?.()
|
||||
webApp?.expand?.()
|
||||
@@ -175,6 +183,10 @@ function App() {
|
||||
try {
|
||||
const payload = await fetchMiniAppSession(initData, joinContext().joinToken)
|
||||
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
||||
setLocale(
|
||||
payload.onboarding?.householdDefaultLocale ??
|
||||
((payload.telegramUser?.languageCode ?? fallbackLocale).startsWith('ru') ? 'ru' : 'en')
|
||||
)
|
||||
setSession({
|
||||
status: 'onboarding',
|
||||
mode: payload.onboarding?.status ?? 'open_from_group',
|
||||
@@ -192,6 +204,7 @@ function App() {
|
||||
return
|
||||
}
|
||||
|
||||
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
||||
setSession({
|
||||
status: 'ready',
|
||||
mode: 'live',
|
||||
@@ -284,6 +297,7 @@ function App() {
|
||||
try {
|
||||
const payload = await joinMiniAppHousehold(initData, joinToken)
|
||||
if (payload.authorized && payload.member && payload.telegramUser) {
|
||||
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
||||
setSession({
|
||||
status: 'ready',
|
||||
mode: 'live',
|
||||
@@ -297,6 +311,10 @@ function App() {
|
||||
return
|
||||
}
|
||||
|
||||
setLocale(
|
||||
payload.onboarding?.householdDefaultLocale ??
|
||||
((payload.telegramUser?.languageCode ?? locale()).startsWith('ru') ? 'ru' : 'en')
|
||||
)
|
||||
setSession({
|
||||
status: 'onboarding',
|
||||
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 = () => {
|
||||
switch (activeNav()) {
|
||||
case 'balances':
|
||||
@@ -396,6 +479,34 @@ function App() {
|
||||
case 'house':
|
||||
return readySession()?.member.isAdmin ? (
|
||||
<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">
|
||||
<header>
|
||||
<strong>{copy().pendingMembersTitle}</strong>
|
||||
@@ -470,14 +581,16 @@ function App() {
|
||||
<button
|
||||
classList={{ 'is-active': locale() === 'en' }}
|
||||
type="button"
|
||||
onClick={() => setLocale('en')}
|
||||
disabled={savingMemberLocale()}
|
||||
onClick={() => void handleMemberLocaleChange('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
classList={{ 'is-active': locale() === 'ru' }}
|
||||
type="button"
|
||||
onClick={() => setLocale('ru')}
|
||||
disabled={savingMemberLocale()}
|
||||
onClick={() => void handleMemberLocaleChange('ru')}
|
||||
>
|
||||
RU
|
||||
</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.',
|
||||
reload: 'Retry',
|
||||
language: 'Language',
|
||||
householdLanguage: 'Household language',
|
||||
savingLanguage: 'Saving…',
|
||||
home: 'Home',
|
||||
balances: 'Balances',
|
||||
ledger: 'Ledger',
|
||||
@@ -90,6 +92,8 @@ export const dictionary = {
|
||||
'Настоящая проверка требует данные Telegram Mini App. Локально показывается демо-оболочка.',
|
||||
reload: 'Повторить',
|
||||
language: 'Язык',
|
||||
householdLanguage: 'Язык дома',
|
||||
savingLanguage: 'Сохраняем…',
|
||||
home: 'Главная',
|
||||
balances: 'Баланс',
|
||||
ledger: 'Леджер',
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface MiniAppSession {
|
||||
member?: {
|
||||
displayName: string
|
||||
isAdmin: boolean
|
||||
preferredLocale: 'en' | 'ru' | null
|
||||
householdDefaultLocale: 'en' | 'ru'
|
||||
}
|
||||
telegramUser?: {
|
||||
firstName: string | null
|
||||
@@ -14,9 +16,17 @@ export interface MiniAppSession {
|
||||
onboarding?: {
|
||||
status: 'join_required' | 'pending' | 'open_from_group'
|
||||
householdName?: string
|
||||
householdDefaultLocale?: 'en' | 'ru'
|
||||
}
|
||||
}
|
||||
|
||||
export interface MiniAppLocalePreference {
|
||||
scope: 'member' | 'household'
|
||||
effectiveLocale: 'en' | 'ru'
|
||||
memberPreferredLocale: 'en' | 'ru' | null
|
||||
householdDefaultLocale: 'en' | 'ru'
|
||||
}
|
||||
|
||||
export interface MiniAppPendingMember {
|
||||
telegramUserId: string
|
||||
displayName: string
|
||||
@@ -219,3 +229,34 @@ export async function approveMiniAppPendingMember(
|
||||
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