mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 11:54:03 +00:00
feat(bot): localize household flows and finance replies
This commit is contained in:
@@ -14,6 +14,7 @@ function anonUpdate(params: {
|
|||||||
updateId: number
|
updateId: number
|
||||||
chatType: 'private' | 'supergroup'
|
chatType: 'private' | 'supergroup'
|
||||||
text: string
|
text: string
|
||||||
|
languageCode?: string
|
||||||
}) {
|
}) {
|
||||||
const commandToken = params.text.split(' ')[0] ?? params.text
|
const commandToken = params.text.split(' ')[0] ?? params.text
|
||||||
|
|
||||||
@@ -29,7 +30,12 @@ function anonUpdate(params: {
|
|||||||
from: {
|
from: {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
is_bot: false,
|
is_bot: false,
|
||||||
first_name: 'Stan'
|
first_name: 'Stan',
|
||||||
|
...(params.languageCode
|
||||||
|
? {
|
||||||
|
language_code: params.languageCode
|
||||||
|
}
|
||||||
|
: {})
|
||||||
},
|
},
|
||||||
text: params.text,
|
text: params.text,
|
||||||
entities: [
|
entities: [
|
||||||
@@ -383,6 +389,81 @@ describe('registerAnonymousFeedback', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('prompts in Russian for Russian-speaking users', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.botInfo = {
|
||||||
|
id: 999000,
|
||||||
|
is_bot: true,
|
||||||
|
first_name: 'Household Test Bot',
|
||||||
|
username: 'household_test_bot',
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false,
|
||||||
|
can_connect_to_business: false,
|
||||||
|
has_main_web_app: false,
|
||||||
|
has_topics_enabled: true,
|
||||||
|
allows_users_to_create_topics: false
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: 1,
|
||||||
|
type: 'private'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
registerAnonymousFeedback({
|
||||||
|
bot,
|
||||||
|
anonymousFeedbackServiceForHousehold: () => ({
|
||||||
|
submit: mock(async () => ({
|
||||||
|
status: 'accepted' as const,
|
||||||
|
submissionId: 'submission-1',
|
||||||
|
sanitizedText: 'irrelevant'
|
||||||
|
})),
|
||||||
|
markPosted: mock(async () => {}),
|
||||||
|
markFailed: mock(async () => {})
|
||||||
|
}),
|
||||||
|
householdConfigurationRepository: createHouseholdConfigurationRepository(),
|
||||||
|
promptRepository: createPromptRepository()
|
||||||
|
})
|
||||||
|
|
||||||
|
await bot.handleUpdate(
|
||||||
|
anonUpdate({
|
||||||
|
updateId: 8001,
|
||||||
|
chatType: 'private',
|
||||||
|
text: '/anon',
|
||||||
|
languageCode: 'ru'
|
||||||
|
}) as never
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
|
chat_id: 123456,
|
||||||
|
text: 'Отправьте анонимное сообщение следующим сообщением или нажмите «Отменить».',
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Отменить',
|
||||||
|
callback_data: 'cancel_prompt:anonymous_feedback'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test('cancels the pending anonymous feedback prompt', async () => {
|
test('cancels the pending anonymous feedback prompt', async () => {
|
||||||
const bot = createTelegramBot('000000:test-token')
|
const bot = createTelegramBot('000000:test-token')
|
||||||
const calls: Array<{ method: string; payload: unknown }> = []
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|||||||
@@ -7,6 +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'
|
||||||
|
|
||||||
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'
|
||||||
const PENDING_ACTION_TTL_MS = 24 * 60 * 60 * 1000
|
const PENDING_ACTION_TTL_MS = 24 * 60 * 60 * 1000
|
||||||
@@ -19,16 +21,16 @@ function commandArgText(ctx: Context): string {
|
|||||||
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
return typeof ctx.match === 'string' ? ctx.match.trim() : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function feedbackText(sanitizedText: string): string {
|
function feedbackText(locale: BotLocale, sanitizedText: string): string {
|
||||||
return ['Anonymous household note', '', sanitizedText].join('\n')
|
return [getBotTranslations(locale).anonymousFeedback.title, '', sanitizedText].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelReplyMarkup() {
|
function cancelReplyMarkup(locale: BotLocale) {
|
||||||
return {
|
return {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'Cancel',
|
text: getBotTranslations(locale).anonymousFeedback.cancelButton,
|
||||||
callback_data: CANCEL_ANONYMOUS_FEEDBACK_CALLBACK
|
callback_data: CANCEL_ANONYMOUS_FEEDBACK_CALLBACK
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -44,9 +46,10 @@ function shouldKeepPrompt(reason: string): boolean {
|
|||||||
return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted'
|
return reason === 'too_short' || reason === 'too_long' || reason === 'blocklisted'
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatRetryDelay(now: Instant, nextAllowedAt: Instant): string {
|
function formatRetryDelay(locale: BotLocale, now: Instant, nextAllowedAt: Instant): string {
|
||||||
|
const t = getBotTranslations(locale).anonymousFeedback
|
||||||
if (Temporal.Instant.compare(nextAllowedAt, now) <= 0) {
|
if (Temporal.Instant.compare(nextAllowedAt, now) <= 0) {
|
||||||
return 'now'
|
return t.retryNow
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = now.until(nextAllowedAt, {
|
const duration = now.until(nextAllowedAt, {
|
||||||
@@ -59,34 +62,40 @@ function formatRetryDelay(now: Instant, nextAllowedAt: Instant): string {
|
|||||||
const hours = duration.hours % 24
|
const hours = duration.hours % 24
|
||||||
|
|
||||||
const parts = [
|
const parts = [
|
||||||
days > 0 ? `${days} day${days === 1 ? '' : 's'}` : null,
|
days > 0 ? t.day(days) : null,
|
||||||
hours > 0 ? `${hours} hour${hours === 1 ? '' : 's'}` : null,
|
hours > 0 ? t.hour(hours) : null,
|
||||||
duration.minutes > 0 ? `${duration.minutes} minute${duration.minutes === 1 ? '' : 's'}` : null
|
duration.minutes > 0 ? t.minute(duration.minutes) : null
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
|
|
||||||
return parts.length > 0 ? `in ${parts.join(' ')}` : 'in less than a minute'
|
return parts.length > 0 ? t.retryIn(parts.join(' ')) : t.retryInLessThanMinute
|
||||||
}
|
}
|
||||||
|
|
||||||
function rejectionMessage(reason: string, nextAllowedAt?: Instant, now = nowInstant()): string {
|
function rejectionMessage(
|
||||||
|
locale: BotLocale,
|
||||||
|
reason: string,
|
||||||
|
nextAllowedAt?: Instant,
|
||||||
|
now = nowInstant()
|
||||||
|
): string {
|
||||||
|
const t = getBotTranslations(locale).anonymousFeedback
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'not_member':
|
case 'not_member':
|
||||||
return 'You are not a member of this household.'
|
return t.notMember
|
||||||
case 'too_short':
|
case 'too_short':
|
||||||
return 'Anonymous feedback is too short. Add a little more detail.'
|
return t.tooShort
|
||||||
case 'too_long':
|
case 'too_long':
|
||||||
return 'Anonymous feedback is too long. Keep it under 500 characters.'
|
return t.tooLong
|
||||||
case 'cooldown':
|
case 'cooldown':
|
||||||
return nextAllowedAt
|
return nextAllowedAt
|
||||||
? `Anonymous feedback cooldown is active. You can send the next message ${formatRetryDelay(now, nextAllowedAt)}.`
|
? t.cooldown(formatRetryDelay(locale, now, nextAllowedAt))
|
||||||
: 'Anonymous feedback cooldown is active. Try again later.'
|
: t.cooldown(t.retryInLessThanMinute)
|
||||||
case 'daily_cap':
|
case 'daily_cap':
|
||||||
return nextAllowedAt
|
return nextAllowedAt
|
||||||
? `Daily anonymous feedback limit reached. You can send the next message ${formatRetryDelay(now, nextAllowedAt)}.`
|
? t.dailyCap(formatRetryDelay(locale, now, nextAllowedAt))
|
||||||
: 'Daily anonymous feedback limit reached. Try again tomorrow.'
|
: t.dailyCap(t.retryInLessThanMinute)
|
||||||
case 'blocklisted':
|
case 'blocklisted':
|
||||||
return 'Message rejected by moderation. Rewrite it in calmer, non-abusive language.'
|
return t.blocklisted
|
||||||
default:
|
default:
|
||||||
return 'Anonymous feedback could not be submitted.'
|
return t.submitFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,10 +116,12 @@ async function startPendingAnonymousFeedbackPrompt(
|
|||||||
repository: TelegramPendingActionRepository,
|
repository: TelegramPendingActionRepository,
|
||||||
ctx: Context
|
ctx: Context
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const locale = botLocaleFromContext(ctx)
|
||||||
|
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()
|
||||||
if (!telegramUserId || !telegramChatId) {
|
if (!telegramUserId || !telegramChatId) {
|
||||||
await ctx.reply('Unable to start anonymous feedback right now.')
|
await ctx.reply(t.unableToStart)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,8 +133,8 @@ async function startPendingAnonymousFeedbackPrompt(
|
|||||||
expiresAt: nowInstant().add({ milliseconds: PENDING_ACTION_TTL_MS })
|
expiresAt: nowInstant().add({ milliseconds: PENDING_ACTION_TTL_MS })
|
||||||
})
|
})
|
||||||
|
|
||||||
await ctx.reply('Send me the anonymous message in your next reply, or tap Cancel.', {
|
await ctx.reply(t.prompt, {
|
||||||
reply_markup: cancelReplyMarkup()
|
reply_markup: cancelReplyMarkup(locale)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +152,11 @@ 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 t = getBotTranslations(locale).anonymousFeedback
|
||||||
|
|
||||||
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
if (!telegramUserId || !telegramChatId || !telegramMessageId || !telegramUpdateId) {
|
||||||
await options.ctx.reply('Unable to identify this message for anonymous feedback.')
|
await options.ctx.reply(t.unableToIdentifyMessage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,15 +167,13 @@ 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('You are not a member of this household.')
|
await options.ctx.reply(t.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(
|
await options.ctx.reply(t.multipleHouseholds)
|
||||||
'You belong to multiple households. Open the target household from its group until household selection is added.'
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,9 +187,7 @@ async function submitAnonymousFeedback(options: {
|
|||||||
|
|
||||||
if (!householdChat || !feedbackTopic) {
|
if (!householdChat || !feedbackTopic) {
|
||||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
await options.ctx.reply(
|
await options.ctx.reply(t.feedbackTopicMissing)
|
||||||
'Anonymous feedback is not configured for your household yet. Ask an admin to run /bind_feedback_topic.'
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +203,7 @@ async function submitAnonymousFeedback(options: {
|
|||||||
|
|
||||||
if (result.status === 'duplicate') {
|
if (result.status === 'duplicate') {
|
||||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
await options.ctx.reply('This anonymous feedback message was already processed.')
|
await options.ctx.reply(t.duplicate)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,15 +212,18 @@ async function submitAnonymousFeedback(options: {
|
|||||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectionText = rejectionMessage(result.reason, result.nextAllowedAt, nowInstant())
|
const rejectionText = rejectionMessage(
|
||||||
|
locale,
|
||||||
|
result.reason,
|
||||||
|
result.nextAllowedAt,
|
||||||
|
nowInstant()
|
||||||
|
)
|
||||||
|
|
||||||
await options.ctx.reply(
|
await options.ctx.reply(
|
||||||
shouldKeepPrompt(result.reason)
|
shouldKeepPrompt(result.reason) ? `${rejectionText} ${t.keepPromptSuffix}` : rejectionText,
|
||||||
? `${rejectionText} Send a revised message, or tap Cancel.`
|
|
||||||
: rejectionText,
|
|
||||||
shouldKeepPrompt(result.reason)
|
shouldKeepPrompt(result.reason)
|
||||||
? {
|
? {
|
||||||
reply_markup: cancelReplyMarkup()
|
reply_markup: cancelReplyMarkup(locale)
|
||||||
}
|
}
|
||||||
: {}
|
: {}
|
||||||
)
|
)
|
||||||
@@ -221,7 +233,7 @@ async function submitAnonymousFeedback(options: {
|
|||||||
try {
|
try {
|
||||||
const posted = await options.ctx.api.sendMessage(
|
const posted = await options.ctx.api.sendMessage(
|
||||||
householdChat.telegramChatId,
|
householdChat.telegramChatId,
|
||||||
feedbackText(result.sanitizedText),
|
feedbackText(locale, result.sanitizedText),
|
||||||
{
|
{
|
||||||
message_thread_id: Number(feedbackTopic.telegramThreadId)
|
message_thread_id: Number(feedbackTopic.telegramThreadId)
|
||||||
}
|
}
|
||||||
@@ -235,7 +247,7 @@ async function submitAnonymousFeedback(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
await options.ctx.reply('Anonymous feedback delivered.')
|
await options.ctx.reply(t.delivered)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown Telegram send failure'
|
const message = error instanceof Error ? error.message : 'Unknown Telegram send failure'
|
||||||
options.logger?.error(
|
options.logger?.error(
|
||||||
@@ -249,7 +261,7 @@ async function submitAnonymousFeedback(options: {
|
|||||||
'Anonymous feedback posting failed'
|
'Anonymous feedback posting failed'
|
||||||
)
|
)
|
||||||
await anonymousFeedbackService.markFailed(result.submissionId, message)
|
await anonymousFeedbackService.markFailed(result.submissionId, message)
|
||||||
await options.ctx.reply('Anonymous feedback was saved, but posting failed. Try again later.')
|
await options.ctx.reply(t.savedButPostFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,6 +273,8 @@ 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 t = getBotTranslations(locale).anonymousFeedback
|
||||||
if (!isPrivateChat(ctx)) {
|
if (!isPrivateChat(ctx)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -268,23 +282,25 @@ export function registerAnonymousFeedback(options: {
|
|||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
const telegramChatId = ctx.chat?.id?.toString()
|
const telegramChatId = ctx.chat?.id?.toString()
|
||||||
if (!telegramUserId || !telegramChatId) {
|
if (!telegramUserId || !telegramChatId) {
|
||||||
await ctx.reply('Nothing to cancel right now.')
|
await ctx.reply(t.nothingToCancel)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId)
|
const pending = await options.promptRepository.getPendingAction(telegramChatId, telegramUserId)
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
await ctx.reply('Nothing to cancel right now.')
|
await ctx.reply(t.nothingToCancel)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
await options.promptRepository.clearPendingAction(telegramChatId, telegramUserId)
|
||||||
await ctx.reply('Cancelled.')
|
await ctx.reply(t.cancelled)
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('anon', async (ctx) => {
|
options.bot.command('anon', async (ctx) => {
|
||||||
|
const locale = botLocaleFromContext(ctx)
|
||||||
|
const t = getBotTranslations(locale).anonymousFeedback
|
||||||
if (!isPrivateChat(ctx)) {
|
if (!isPrivateChat(ctx)) {
|
||||||
await ctx.reply('Use /anon in a private chat with the bot.')
|
await ctx.reply(t.useInPrivateChat)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,9 +351,11 @@ 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 t = getBotTranslations(locale).anonymousFeedback
|
||||||
if (!isPrivateChat(ctx)) {
|
if (!isPrivateChat(ctx)) {
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
text: 'Use this in a private chat with the bot.',
|
text: t.useThisInPrivateChat,
|
||||||
show_alert: true
|
show_alert: true
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -345,11 +363,11 @@ export function registerAnonymousFeedback(options: {
|
|||||||
|
|
||||||
await clearPendingAnonymousFeedbackPrompt(options.promptRepository, ctx)
|
await clearPendingAnonymousFeedbackPrompt(options.promptRepository, ctx)
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
text: 'Cancelled.'
|
text: t.cancelled
|
||||||
})
|
})
|
||||||
|
|
||||||
if (ctx.msg) {
|
if (ctx.msg) {
|
||||||
await ctx.editMessageText('Anonymous feedback cancelled.')
|
await ctx.editMessageText(t.cancelledMessage)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +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'
|
||||||
|
|
||||||
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() : ''
|
||||||
if (raw.length === 0) {
|
if (raw.length === 0) {
|
||||||
@@ -21,12 +23,28 @@ export function createFinanceCommandsService(options: {
|
|||||||
}): {
|
}): {
|
||||||
register: (bot: Bot) => void
|
register: (bot: Bot) => void
|
||||||
} {
|
} {
|
||||||
|
function formatStatement(
|
||||||
|
ctx: Context,
|
||||||
|
dashboard: NonNullable<Awaited<ReturnType<FinanceCommandService['generateDashboard']>>>
|
||||||
|
): string {
|
||||||
|
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||||
|
|
||||||
|
return [
|
||||||
|
t.statementTitle(dashboard.period),
|
||||||
|
...dashboard.members.map((line) =>
|
||||||
|
t.statementLine(line.displayName, line.netDue.toMajorString(), dashboard.currency)
|
||||||
|
),
|
||||||
|
t.statementTotal(dashboard.totalDue.toMajorString(), dashboard.currency)
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveGroupFinanceService(ctx: Context): Promise<{
|
async function resolveGroupFinanceService(ctx: Context): Promise<{
|
||||||
service: FinanceCommandService
|
service: FinanceCommandService
|
||||||
householdId: string
|
householdId: string
|
||||||
} | null> {
|
} | null> {
|
||||||
|
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply('Use this command inside a household group.')
|
await ctx.reply(t.useInGroup)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +52,7 @@ export function createFinanceCommandsService(options: {
|
|||||||
ctx.chat!.id.toString()
|
ctx.chat!.id.toString()
|
||||||
)
|
)
|
||||||
if (!household) {
|
if (!household) {
|
||||||
await ctx.reply('Household is not configured for this chat yet. Run /setup first.')
|
await ctx.reply(t.householdNotConfigured)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,9 +63,10 @@ export function createFinanceCommandsService(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requireMember(ctx: Context) {
|
async function requireMember(ctx: Context) {
|
||||||
|
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||||
const telegramUserId = ctx.from?.id?.toString()
|
const telegramUserId = ctx.from?.id?.toString()
|
||||||
if (!telegramUserId) {
|
if (!telegramUserId) {
|
||||||
await ctx.reply('Unable to identify sender for this command.')
|
await ctx.reply(t.unableToIdentifySender)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +77,7 @@ export function createFinanceCommandsService(options: {
|
|||||||
|
|
||||||
const member = await scoped.service.getMemberByTelegramUserId(telegramUserId)
|
const member = await scoped.service.getMemberByTelegramUserId(telegramUserId)
|
||||||
if (!member) {
|
if (!member) {
|
||||||
await ctx.reply('You are not a member of this household.')
|
await ctx.reply(t.notMember)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,13 +89,14 @@ export function createFinanceCommandsService(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function requireAdmin(ctx: Context) {
|
async function requireAdmin(ctx: Context) {
|
||||||
|
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||||
const resolved = await requireMember(ctx)
|
const resolved = await requireMember(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resolved.member.isAdmin) {
|
if (!resolved.member.isAdmin) {
|
||||||
await ctx.reply('Only household admins can use this command.')
|
await ctx.reply(t.adminOnly)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +105,7 @@ 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 resolved = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -92,19 +113,20 @@ export function createFinanceCommandsService(options: {
|
|||||||
|
|
||||||
const args = commandArgs(ctx)
|
const args = commandArgs(ctx)
|
||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
await ctx.reply('Usage: /cycle_open <YYYY-MM> [USD|GEL]')
|
await ctx.reply(t.cycleOpenUsage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cycle = await resolved.service.openCycle(args[0]!, args[1])
|
const cycle = await resolved.service.openCycle(args[0]!, args[1])
|
||||||
await ctx.reply(`Cycle opened: ${cycle.period} (${cycle.currency})`)
|
await ctx.reply(t.cycleOpened(cycle.period, cycle.currency))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to open cycle: ${(error as Error).message}`)
|
await ctx.reply(t.cycleOpenFailed((error as Error).message))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
bot.command('cycle_close', async (ctx) => {
|
bot.command('cycle_close', async (ctx) => {
|
||||||
|
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||||
const resolved = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -113,17 +135,18 @@ export function createFinanceCommandsService(options: {
|
|||||||
try {
|
try {
|
||||||
const cycle = await resolved.service.closeCycle(commandArgs(ctx)[0])
|
const cycle = await resolved.service.closeCycle(commandArgs(ctx)[0])
|
||||||
if (!cycle) {
|
if (!cycle) {
|
||||||
await ctx.reply('No cycle found to close.')
|
await ctx.reply(t.noCycleToClose)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(`Cycle closed: ${cycle.period}`)
|
await ctx.reply(t.cycleClosed(cycle.period))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to close cycle: ${(error as Error).message}`)
|
await ctx.reply(t.cycleCloseFailed((error as Error).message))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
bot.command('rent_set', async (ctx) => {
|
bot.command('rent_set', async (ctx) => {
|
||||||
|
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||||
const resolved = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -131,26 +154,25 @@ export function createFinanceCommandsService(options: {
|
|||||||
|
|
||||||
const args = commandArgs(ctx)
|
const args = commandArgs(ctx)
|
||||||
if (args.length === 0) {
|
if (args.length === 0) {
|
||||||
await ctx.reply('Usage: /rent_set <amount> [USD|GEL] [YYYY-MM]')
|
await ctx.reply(t.rentSetUsage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await resolved.service.setRent(args[0]!, args[1], args[2])
|
const result = await resolved.service.setRent(args[0]!, args[1], args[2])
|
||||||
if (!result) {
|
if (!result) {
|
||||||
await ctx.reply('No period provided and no open cycle found.')
|
await ctx.reply(t.rentNoPeriod)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(t.rentSaved(result.amount.toMajorString(), result.currency, result.period))
|
||||||
`Rent rule saved: ${result.amount.toMajorString()} ${result.currency} starting ${result.period}`
|
|
||||||
)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to save rent rule: ${(error as Error).message}`)
|
await ctx.reply(t.rentSaveFailed((error as Error).message))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
bot.command('utility_add', async (ctx) => {
|
bot.command('utility_add', async (ctx) => {
|
||||||
|
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||||
const resolved = await requireAdmin(ctx)
|
const resolved = await requireAdmin(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
@@ -158,7 +180,7 @@ export function createFinanceCommandsService(options: {
|
|||||||
|
|
||||||
const args = commandArgs(ctx)
|
const args = commandArgs(ctx)
|
||||||
if (args.length < 2) {
|
if (args.length < 2) {
|
||||||
await ctx.reply('Usage: /utility_add <name> <amount> [USD|GEL]')
|
await ctx.reply(t.utilityAddUsage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,34 +192,35 @@ export function createFinanceCommandsService(options: {
|
|||||||
args[2]
|
args[2]
|
||||||
)
|
)
|
||||||
if (!result) {
|
if (!result) {
|
||||||
await ctx.reply('No open cycle found. Use /cycle_open first.')
|
await ctx.reply(t.utilityNoOpenCycle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`Utility bill added: ${args[0]} ${result.amount.toMajorString()} ${result.currency} for ${result.period}`
|
t.utilityAdded(args[0]!, result.amount.toMajorString(), result.currency, result.period)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to add utility bill: ${(error as Error).message}`)
|
await ctx.reply(t.utilityAddFailed((error as Error).message))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
bot.command('statement', async (ctx) => {
|
bot.command('statement', async (ctx) => {
|
||||||
|
const t = getBotTranslations(botLocaleFromContext(ctx)).finance
|
||||||
const resolved = await requireMember(ctx)
|
const resolved = await requireMember(ctx)
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statement = await resolved.service.generateStatement(commandArgs(ctx)[0])
|
const dashboard = await resolved.service.generateDashboard(commandArgs(ctx)[0])
|
||||||
if (!statement) {
|
if (!dashboard) {
|
||||||
await ctx.reply('No cycle found for statement.')
|
await ctx.reply(t.noStatementCycle)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(statement)
|
await ctx.reply(formatStatement(ctx, dashboard))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.reply(`Failed to generate statement: ${(error as Error).message}`)
|
await ctx.reply(t.statementFailed((error as Error).message))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type {
|
|||||||
import { createTelegramBot } from './bot'
|
import { createTelegramBot } from './bot'
|
||||||
import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup'
|
import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup'
|
||||||
|
|
||||||
function startUpdate(text: string) {
|
function startUpdate(text: string, languageCode?: string) {
|
||||||
const commandToken = text.split(' ')[0] ?? text
|
const commandToken = text.split(' ')[0] ?? text
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -24,7 +24,12 @@ function startUpdate(text: string) {
|
|||||||
from: {
|
from: {
|
||||||
id: 123456,
|
id: 123456,
|
||||||
is_bot: false,
|
is_bot: false,
|
||||||
first_name: 'Stan'
|
first_name: 'Stan',
|
||||||
|
...(languageCode
|
||||||
|
? {
|
||||||
|
language_code: languageCode
|
||||||
|
}
|
||||||
|
: {})
|
||||||
},
|
},
|
||||||
text,
|
text,
|
||||||
entities: [
|
entities: [
|
||||||
@@ -177,4 +182,91 @@ describe('registerHouseholdSetupCommands', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('localizes the DM join response for Russian users', async () => {
|
||||||
|
const bot = createTelegramBot('000000:test-token')
|
||||||
|
const calls: Array<{ method: string; payload: unknown }> = []
|
||||||
|
|
||||||
|
bot.botInfo = {
|
||||||
|
id: 999000,
|
||||||
|
is_bot: true,
|
||||||
|
first_name: 'Household Test Bot',
|
||||||
|
username: 'household_test_bot',
|
||||||
|
can_join_groups: true,
|
||||||
|
can_read_all_group_messages: false,
|
||||||
|
supports_inline_queries: false,
|
||||||
|
can_connect_to_business: false,
|
||||||
|
has_main_web_app: false,
|
||||||
|
has_topics_enabled: true,
|
||||||
|
allows_users_to_create_topics: false
|
||||||
|
}
|
||||||
|
|
||||||
|
bot.api.config.use(async (_prev, method, payload) => {
|
||||||
|
calls.push({ method, payload })
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
result: {
|
||||||
|
message_id: calls.length,
|
||||||
|
date: Math.floor(Date.now() / 1000),
|
||||||
|
chat: {
|
||||||
|
id: 123456,
|
||||||
|
type: 'private'
|
||||||
|
},
|
||||||
|
text: 'ok'
|
||||||
|
}
|
||||||
|
} as never
|
||||||
|
})
|
||||||
|
|
||||||
|
const householdOnboardingService: HouseholdOnboardingService = {
|
||||||
|
async ensureHouseholdJoinToken() {
|
||||||
|
return {
|
||||||
|
householdId: 'household-1',
|
||||||
|
householdName: 'Kojori House',
|
||||||
|
token: 'join-token'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async getMiniAppAccess() {
|
||||||
|
return {
|
||||||
|
status: 'open_from_group'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async joinHousehold() {
|
||||||
|
return {
|
||||||
|
status: 'pending',
|
||||||
|
household: {
|
||||||
|
id: 'household-1',
|
||||||
|
name: 'Kojori House'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerHouseholdSetupCommands({
|
||||||
|
bot,
|
||||||
|
householdSetupService: createHouseholdSetupService(),
|
||||||
|
householdOnboardingService,
|
||||||
|
householdAdminService: createHouseholdAdminService(),
|
||||||
|
miniAppUrl: 'https://miniapp.example.app'
|
||||||
|
})
|
||||||
|
|
||||||
|
await bot.handleUpdate(startUpdate('/start join_join-token', 'ru') as never)
|
||||||
|
|
||||||
|
expect(calls[0]?.payload).toMatchObject({
|
||||||
|
chat_id: 123456,
|
||||||
|
text: 'Заявка на вступление в Kojori House отправлена. Дождитесь подтверждения от админа дома.',
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'Открыть мини-приложение',
|
||||||
|
web_app: {
|
||||||
|
url: 'https://miniapp.example.app/?join=join-token&bot=household_test_bot'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import type {
|
|||||||
import type { Logger } from '@household/observability'
|
import type { Logger } from '@household/observability'
|
||||||
import type { Bot, Context } from 'grammy'
|
import type { Bot, Context } from 'grammy'
|
||||||
|
|
||||||
|
import { botLocaleFromContext, getBotTranslations, type BotLocale } from './i18n'
|
||||||
|
|
||||||
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
|
||||||
|
|
||||||
function commandArgText(ctx: Context): string {
|
function commandArgText(ctx: Context): string {
|
||||||
@@ -32,38 +34,46 @@ async function isGroupAdmin(ctx: Context): Promise<boolean> {
|
|||||||
return member.status === 'creator' || member.status === 'administrator'
|
return member.status === 'creator' || member.status === 'administrator'
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupRejectionMessage(reason: 'not_admin' | 'invalid_chat_type'): string {
|
function setupRejectionMessage(
|
||||||
|
locale: BotLocale,
|
||||||
|
reason: 'not_admin' | 'invalid_chat_type'
|
||||||
|
): string {
|
||||||
|
const t = getBotTranslations(locale).setup
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'not_admin':
|
case 'not_admin':
|
||||||
return 'Only Telegram group admins can run /setup.'
|
return t.onlyTelegramAdmins
|
||||||
case 'invalid_chat_type':
|
case 'invalid_chat_type':
|
||||||
return 'Use /setup inside a group or supergroup.'
|
return t.useSetupInGroup
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindRejectionMessage(
|
function bindRejectionMessage(
|
||||||
|
locale: BotLocale,
|
||||||
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
|
reason: 'not_admin' | 'household_not_found' | 'not_topic_message'
|
||||||
): string {
|
): string {
|
||||||
|
const t = getBotTranslations(locale).setup
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'not_admin':
|
case 'not_admin':
|
||||||
return 'Only Telegram group admins can bind household topics.'
|
return t.onlyTelegramAdminsBindTopics
|
||||||
case 'household_not_found':
|
case 'household_not_found':
|
||||||
return 'Household is not configured for this chat yet. Run /setup first.'
|
return t.householdNotConfigured
|
||||||
case 'not_topic_message':
|
case 'not_topic_message':
|
||||||
return 'Run this command inside the target topic thread.'
|
return t.useCommandInTopic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adminRejectionMessage(
|
function adminRejectionMessage(
|
||||||
|
locale: BotLocale,
|
||||||
reason: 'not_admin' | 'household_not_found' | 'pending_not_found'
|
reason: 'not_admin' | 'household_not_found' | 'pending_not_found'
|
||||||
): string {
|
): string {
|
||||||
|
const t = getBotTranslations(locale).setup
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'not_admin':
|
case 'not_admin':
|
||||||
return 'Only household admins can manage pending members.'
|
return t.onlyHouseholdAdmins
|
||||||
case 'household_not_found':
|
case 'household_not_found':
|
||||||
return 'Household is not configured for this chat yet. Run /setup first.'
|
return t.householdNotConfigured
|
||||||
case 'pending_not_found':
|
case 'pending_not_found':
|
||||||
return 'Pending member not found. Use /pending_members to inspect the queue.'
|
return t.pendingNotFound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,27 +94,28 @@ function buildPendingMemberLabel(displayName: string): string {
|
|||||||
return `${normalized.slice(0, 29)}...`
|
return `${normalized.slice(0, 29)}...`
|
||||||
}
|
}
|
||||||
|
|
||||||
function pendingMembersReply(result: {
|
function pendingMembersReply(
|
||||||
householdName: string
|
locale: BotLocale,
|
||||||
members: readonly {
|
result: {
|
||||||
telegramUserId: string
|
householdName: string
|
||||||
displayName: string
|
members: readonly {
|
||||||
username?: string | null
|
telegramUserId: string
|
||||||
}[]
|
displayName: string
|
||||||
}) {
|
username?: string | null
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const t = getBotTranslations(locale).setup
|
||||||
return {
|
return {
|
||||||
text: [
|
text: [
|
||||||
`Pending members for ${result.householdName}:`,
|
t.pendingMembersHeading(result.householdName),
|
||||||
...result.members.map(
|
...result.members.map((member, index) => t.pendingMemberLine(member, index)),
|
||||||
(member, index) =>
|
t.pendingMembersHint
|
||||||
`${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`
|
|
||||||
),
|
|
||||||
'Tap a button below to approve, or use /approve_member <telegram_user_id>.'
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: result.members.map((member) => [
|
inline_keyboard: result.members.map((member) => [
|
||||||
{
|
{
|
||||||
text: `Approve ${buildPendingMemberLabel(member.displayName)}`,
|
text: t.approveMemberButton(buildPendingMemberLabel(member.displayName)),
|
||||||
callback_data: `${APPROVE_MEMBER_CALLBACK_PREFIX}${member.telegramUserId}`
|
callback_data: `${APPROVE_MEMBER_CALLBACK_PREFIX}${member.telegramUserId}`
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
@@ -133,6 +144,7 @@ export function buildJoinMiniAppUrl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function miniAppReplyMarkup(
|
function miniAppReplyMarkup(
|
||||||
|
locale: BotLocale,
|
||||||
miniAppUrl: string | undefined,
|
miniAppUrl: string | undefined,
|
||||||
botUsername: string | undefined,
|
botUsername: string | undefined,
|
||||||
joinToken: string
|
joinToken: string
|
||||||
@@ -147,7 +159,7 @@ function miniAppReplyMarkup(
|
|||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'Open mini app',
|
text: getBotTranslations(locale).setup.openMiniAppButton,
|
||||||
web_app: {
|
web_app: {
|
||||||
url: webAppUrl
|
url: webAppUrl
|
||||||
}
|
}
|
||||||
@@ -167,24 +179,27 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
logger?: Logger
|
logger?: Logger
|
||||||
}): void {
|
}): void {
|
||||||
options.bot.command('start', async (ctx) => {
|
options.bot.command('start', async (ctx) => {
|
||||||
|
const locale = botLocaleFromContext(ctx)
|
||||||
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (ctx.chat?.type !== 'private') {
|
if (ctx.chat?.type !== 'private') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ctx.from) {
|
if (!ctx.from) {
|
||||||
await ctx.reply('Telegram user identity is required to join a household.')
|
await ctx.reply(t.setup.telegramIdentityRequired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const startPayload = commandArgText(ctx)
|
const startPayload = commandArgText(ctx)
|
||||||
if (!startPayload.startsWith('join_')) {
|
if (!startPayload.startsWith('join_')) {
|
||||||
await ctx.reply('Send /help to see available commands.')
|
await ctx.reply(t.common.useHelp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const joinToken = startPayload.slice('join_'.length).trim()
|
const joinToken = startPayload.slice('join_'.length).trim()
|
||||||
if (!joinToken) {
|
if (!joinToken) {
|
||||||
await ctx.reply('Invalid household invite link.')
|
await ctx.reply(t.setup.invalidJoinLink)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,27 +227,30 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'invalid_token') {
|
if (result.status === 'invalid_token') {
|
||||||
await ctx.reply('This household invite link is invalid or expired.')
|
await ctx.reply(t.setup.joinLinkInvalidOrExpired)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status === 'active') {
|
if (result.status === 'active') {
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`You are already an active member. Open the mini app to view ${result.member.displayName}.`,
|
t.setup.alreadyActiveMember(result.member.displayName),
|
||||||
miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken)
|
miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, joinToken)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`Join request sent for ${result.household.name}. Wait for a household admin to confirm you.`,
|
t.setup.joinRequestSent(result.household.name),
|
||||||
miniAppReplyMarkup(options.miniAppUrl, ctx.me.username, joinToken)
|
miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, joinToken)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('setup', async (ctx) => {
|
options.bot.command('setup', async (ctx) => {
|
||||||
|
const locale = botLocaleFromContext(ctx)
|
||||||
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply('Use /setup inside the household group.')
|
await ctx.reply(t.setup.useSetupInGroup)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,7 +274,7 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
await ctx.reply(setupRejectionMessage(result.reason))
|
await ctx.reply(setupRejectionMessage(locale, result.reason))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,19 +303,18 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}`
|
? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}`
|
||||||
: null
|
: null
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
[
|
t.setup.setupSummary({
|
||||||
`Household ${action}: ${result.household.householdName}`,
|
householdName: result.household.householdName,
|
||||||
`Chat ID: ${result.household.telegramChatId}`,
|
telegramChatId: result.household.telegramChatId,
|
||||||
'Next: open the purchase topic and run /bind_purchase_topic, then open the feedback topic and run /bind_feedback_topic.',
|
created: action === 'created'
|
||||||
'Members should open the bot chat from the button below and confirm the join request there.'
|
}),
|
||||||
].join('\n'),
|
|
||||||
joinDeepLink
|
joinDeepLink
|
||||||
? {
|
? {
|
||||||
reply_markup: {
|
reply_markup: {
|
||||||
inline_keyboard: [
|
inline_keyboard: [
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
text: 'Join household',
|
text: t.setup.joinHouseholdButton,
|
||||||
url: joinDeepLink
|
url: joinDeepLink
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -309,8 +326,11 @@ 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 t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply('Use /bind_purchase_topic inside the household group topic.')
|
await ctx.reply(t.setup.useBindPurchaseTopicInGroup)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +351,7 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
await ctx.reply(bindRejectionMessage(result.reason))
|
await ctx.reply(bindRejectionMessage(locale, result.reason))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,13 +368,16 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`Purchase topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).`
|
t.setup.purchaseTopicSaved(result.household.householdName, result.binding.telegramThreadId)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('bind_feedback_topic', async (ctx) => {
|
options.bot.command('bind_feedback_topic', async (ctx) => {
|
||||||
|
const locale = botLocaleFromContext(ctx)
|
||||||
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply('Use /bind_feedback_topic inside the household group topic.')
|
await ctx.reply(t.setup.useBindFeedbackTopicInGroup)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,7 +398,7 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
await ctx.reply(bindRejectionMessage(result.reason))
|
await ctx.reply(bindRejectionMessage(locale, result.reason))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,19 +415,22 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(
|
||||||
`Feedback topic saved for ${result.household.householdName} (thread ${result.binding.telegramThreadId}).`
|
t.setup.feedbackTopicSaved(result.household.householdName, result.binding.telegramThreadId)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('pending_members', async (ctx) => {
|
options.bot.command('pending_members', async (ctx) => {
|
||||||
|
const locale = botLocaleFromContext(ctx)
|
||||||
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply('Use /pending_members inside the household group.')
|
await ctx.reply(t.setup.usePendingMembersInGroup)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const actorTelegramUserId = ctx.from?.id?.toString()
|
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||||
if (!actorTelegramUserId) {
|
if (!actorTelegramUserId) {
|
||||||
await ctx.reply('Unable to identify sender for this command.')
|
await ctx.reply(t.common.unableToIdentifySender)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,36 +440,39 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
await ctx.reply(adminRejectionMessage(result.reason))
|
await ctx.reply(adminRejectionMessage(locale, result.reason))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.members.length === 0) {
|
if (result.members.length === 0) {
|
||||||
await ctx.reply(`No pending members for ${result.householdName}.`)
|
await ctx.reply(t.setup.pendingMembersEmpty(result.householdName))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const reply = pendingMembersReply(result)
|
const reply = pendingMembersReply(locale, result)
|
||||||
await ctx.reply(reply.text, {
|
await ctx.reply(reply.text, {
|
||||||
reply_markup: reply.reply_markup
|
reply_markup: reply.reply_markup
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
options.bot.command('approve_member', async (ctx) => {
|
options.bot.command('approve_member', async (ctx) => {
|
||||||
|
const locale = botLocaleFromContext(ctx)
|
||||||
|
const t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.reply('Use /approve_member inside the household group.')
|
await ctx.reply(t.setup.useApproveMemberInGroup)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const actorTelegramUserId = ctx.from?.id?.toString()
|
const actorTelegramUserId = ctx.from?.id?.toString()
|
||||||
if (!actorTelegramUserId) {
|
if (!actorTelegramUserId) {
|
||||||
await ctx.reply('Unable to identify sender for this command.')
|
await ctx.reply(t.common.unableToIdentifySender)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingTelegramUserId = commandArgText(ctx)
|
const pendingTelegramUserId = commandArgText(ctx)
|
||||||
if (!pendingTelegramUserId) {
|
if (!pendingTelegramUserId) {
|
||||||
await ctx.reply('Usage: /approve_member <telegram_user_id>')
|
await ctx.reply(t.setup.approveMemberUsage)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,21 +483,22 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
await ctx.reply(adminRejectionMessage(result.reason))
|
await ctx.reply(adminRejectionMessage(locale, result.reason))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName))
|
||||||
`Approved ${result.member.displayName} as an active member of ${result.householdName}.`
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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 t = getBotTranslations(locale)
|
||||||
|
|
||||||
if (!isGroupChat(ctx)) {
|
if (!isGroupChat(ctx)) {
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
text: 'Use this button in the household group.',
|
text: t.setup.useButtonInGroup,
|
||||||
show_alert: true
|
show_alert: true
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -478,7 +508,7 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
const pendingTelegramUserId = ctx.match[1]
|
const pendingTelegramUserId = ctx.match[1]
|
||||||
if (!actorTelegramUserId || !pendingTelegramUserId) {
|
if (!actorTelegramUserId || !pendingTelegramUserId) {
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
text: 'Unable to identify the selected member.',
|
text: t.setup.unableToIdentifySelectedMember,
|
||||||
show_alert: true
|
show_alert: true
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -492,14 +522,14 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
|
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
text: adminRejectionMessage(result.reason),
|
text: adminRejectionMessage(locale, result.reason),
|
||||||
show_alert: true
|
show_alert: true
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.answerCallbackQuery({
|
await ctx.answerCallbackQuery({
|
||||||
text: `Approved ${result.member.displayName}.`
|
text: t.setup.approvedMemberToast(result.member.displayName)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (ctx.msg) {
|
if (ctx.msg) {
|
||||||
@@ -510,9 +540,9 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
|
|
||||||
if (refreshed.status === 'ok') {
|
if (refreshed.status === 'ok') {
|
||||||
if (refreshed.members.length === 0) {
|
if (refreshed.members.length === 0) {
|
||||||
await ctx.editMessageText(`No pending members for ${refreshed.householdName}.`)
|
await ctx.editMessageText(t.setup.pendingMembersEmpty(refreshed.householdName))
|
||||||
} else {
|
} else {
|
||||||
const reply = pendingMembersReply(refreshed)
|
const reply = pendingMembersReply(locale, refreshed)
|
||||||
await ctx.editMessageText(reply.text, {
|
await ctx.editMessageText(reply.text, {
|
||||||
reply_markup: reply.reply_markup
|
reply_markup: reply.reply_markup
|
||||||
})
|
})
|
||||||
@@ -520,9 +550,7 @@ export function registerHouseholdSetupCommands(options: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.reply(
|
await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName))
|
||||||
`Approved ${result.member.displayName} as an active member of ${result.householdName}.`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,23 @@ describe('buildPurchaseAcknowledgement', () => {
|
|||||||
})
|
})
|
||||||
).toBeNull()
|
).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('returns Russian acknowledgement when requested', () => {
|
||||||
|
const result = buildPurchaseAcknowledgement(
|
||||||
|
{
|
||||||
|
status: 'created',
|
||||||
|
processingStatus: 'parsed',
|
||||||
|
parsedAmountMinor: 3000n,
|
||||||
|
parsedCurrency: 'GEL',
|
||||||
|
parsedItemDescription: 'туалетная бумага',
|
||||||
|
parserConfidence: 92,
|
||||||
|
parserMode: 'rules'
|
||||||
|
},
|
||||||
|
'ru'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toBe('Покупка сохранена: туалетная бумага - 30.00 GEL')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('registerPurchaseTopicIngestion', () => {
|
describe('registerPurchaseTopicIngestion', () => {
|
||||||
|
|||||||
@@ -9,6 +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'
|
||||||
|
|
||||||
export interface PurchaseTopicIngestionConfig {
|
export interface PurchaseTopicIngestionConfig {
|
||||||
householdId: string
|
householdId: string
|
||||||
@@ -216,6 +217,7 @@ export function createPurchaseMessageRepository(databaseUrl: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatPurchaseSummary(
|
function formatPurchaseSummary(
|
||||||
|
locale: BotLocale,
|
||||||
result: Extract<PurchaseMessageIngestionResult, { status: 'created' }>
|
result: Extract<PurchaseMessageIngestionResult, { status: 'created' }>
|
||||||
): string {
|
): string {
|
||||||
if (
|
if (
|
||||||
@@ -223,7 +225,7 @@ function formatPurchaseSummary(
|
|||||||
result.parsedCurrency === null ||
|
result.parsedCurrency === null ||
|
||||||
result.parsedItemDescription === null
|
result.parsedItemDescription === null
|
||||||
) {
|
) {
|
||||||
return 'shared purchase'
|
return getBotTranslations(locale).purchase.sharedPurchaseFallback
|
||||||
}
|
}
|
||||||
|
|
||||||
const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency)
|
const amount = Money.fromMinor(result.parsedAmountMinor, result.parsedCurrency)
|
||||||
@@ -231,19 +233,22 @@ function formatPurchaseSummary(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildPurchaseAcknowledgement(
|
export function buildPurchaseAcknowledgement(
|
||||||
result: PurchaseMessageIngestionResult
|
result: PurchaseMessageIngestionResult,
|
||||||
|
locale: BotLocale = 'en'
|
||||||
): string | null {
|
): string | null {
|
||||||
if (result.status === 'duplicate') {
|
if (result.status === 'duplicate') {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const t = getBotTranslations(locale).purchase
|
||||||
|
|
||||||
switch (result.processingStatus) {
|
switch (result.processingStatus) {
|
||||||
case 'parsed':
|
case 'parsed':
|
||||||
return `Recorded purchase: ${formatPurchaseSummary(result)}`
|
return t.recorded(formatPurchaseSummary(locale, result))
|
||||||
case 'needs_review':
|
case 'needs_review':
|
||||||
return `Saved for review: ${formatPurchaseSummary(result)}`
|
return t.savedForReview(formatPurchaseSummary(locale, result))
|
||||||
case 'parse_failed':
|
case 'parse_failed':
|
||||||
return "Saved for review: I couldn't parse this purchase yet."
|
return t.parseFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,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)
|
const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx))
|
||||||
|
|
||||||
if (status.status === 'created') {
|
if (status.status === 'created') {
|
||||||
options.logger?.info(
|
options.logger?.info(
|
||||||
@@ -390,7 +395,7 @@ 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)
|
const acknowledgement = buildPurchaseAcknowledgement(status, botLocaleFromContext(ctx))
|
||||||
|
|
||||||
if (status.status === 'created') {
|
if (status.status === 'created') {
|
||||||
options.logger?.info(
|
options.logger?.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user