diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 0ccc694..25b4a26 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -21,8 +21,16 @@ import type { import { createTelegramBot } from './bot' import { buildJoinMiniAppUrl, registerHouseholdSetupCommands } from './household-setup' -function startUpdate(text: string, languageCode?: string) { +function startUpdate( + text: string, + options: { + userId?: number + firstName?: string + languageCode?: string + } = {} +) { const commandToken = text.split(' ')[0] ?? text + const userId = options.userId ?? 123456 return { update_id: 2001, @@ -30,16 +38,16 @@ function startUpdate(text: string, languageCode?: string) { message_id: 71, date: Math.floor(Date.now() / 1000), chat: { - id: 123456, + id: userId, type: 'private' }, from: { - id: 123456, + id: userId, is_bot: false, - first_name: 'Stan', - ...(languageCode + first_name: options.firstName ?? 'Stan', + ...(options.languageCode ? { - language_code: languageCode + language_code: options.languageCode } : {}) }, @@ -125,6 +133,52 @@ function groupCommandUpdate(text: string) { } } +function groupReplyCommandUpdate(text: string, repliedUser: { id: number; firstName: string }) { + const commandToken = text.split(' ')[0] ?? text + + return { + update_id: 3004, + message: { + message_id: 82, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup', + title: 'Kojori House' + }, + from: { + id: 123456, + is_bot: false, + first_name: 'Stan', + language_code: 'en' + }, + reply_to_message: { + message_id: 80, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup', + title: 'Kojori House' + }, + from: { + id: repliedUser.id, + is_bot: false, + first_name: repliedUser.firstName + }, + text: 'hello' + }, + text, + entities: [ + { + offset: 0, + length: commandToken.length, + type: 'bot_command' + } + ] + } + } +} + function groupCallbackUpdate(data: string) { return { update_id: 3002, @@ -685,7 +739,7 @@ describe('registerHouseholdSetupCommands', () => { miniAppUrl: 'https://miniapp.example.app' }) - await bot.handleUpdate(startUpdate('/start join_join-token', 'ru') as never) + await bot.handleUpdate(startUpdate('/start join_join-token', { languageCode: 'ru' }) as never) expect(calls[0]?.payload).toMatchObject({ chat_id: 123456, @@ -836,6 +890,394 @@ describe('registerHouseholdSetupCommands', () => { }) }) + test('creates a targeted in-group invite from a replied user message', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const repository = createHouseholdConfigurationRepository() + const promptRepository = createPromptRepository() + + 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: true + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'getChatMember') { + return { + ok: true, + result: { + status: 'administrator', + user: { + id: 123456, + is_bot: false, + first_name: 'Stan' + } + } + } as never + } + + if (method === 'sendMessage') { + return { + ok: true, + result: { + message_id: 410, + date: Math.floor(Date.now() / 1000), + chat: { + id: -100123456, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } 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', + defaultLocale: 'en' + } + } + } + } + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createHouseholdSetupService(repository), + householdOnboardingService, + householdAdminService: createHouseholdAdminService(), + householdConfigurationRepository: repository, + promptRepository + }) + + await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never) + calls.length = 0 + + await bot.handleUpdate( + groupReplyCommandUpdate('/invite', { id: 654321, firstName: 'Chorbanaut' }) as never + ) + + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: -100123456, + text: 'Invitation prepared for Chorbanaut. Tap below to join Kojori House.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Join household', + url: 'https://t.me/household_test_bot?start=invite_-100123456_654321' + } + ] + ] + } + } + }) + + expect(await promptRepository.getPendingAction('invite:-100123456', '654321')).toMatchObject({ + action: 'household_group_invite', + payload: { + joinToken: 'join-token', + householdId: 'household-1', + householdName: 'Kojori House', + targetDisplayName: 'Chorbanaut', + inviteMessageId: 410 + } + }) + }) + + test('rejects household invite links for the wrong Telegram user', 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: true + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + return { + ok: true, + result: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { + id: 111111, + type: 'private' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + }) + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createRejectedHouseholdSetupService(), + householdOnboardingService: { + async ensureHouseholdJoinToken() { + return { + householdId: 'household-1', + householdName: 'Kojori House', + token: 'join-token' + } + }, + async getMiniAppAccess() { + return { + status: 'open_from_group' + } + }, + async joinHousehold() { + return { + status: 'invalid_token' + } + } + }, + householdAdminService: createHouseholdAdminService(), + promptRepository: createPromptRepository() + }) + + await bot.handleUpdate( + startUpdate('/start invite_-100123456_654321', { + userId: 111111, + firstName: 'Wrong user' + }) as never + ) + + expect(calls[0]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: 111111, + text: 'This invite is for a different Telegram user.' + } + }) + }) + + test('consumes a targeted invite for the invited user and updates the group message', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const repository = createHouseholdConfigurationRepository() + const promptRepository = createPromptRepository() + const joinCalls: string[] = [] + + 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: true + } + + const householdOnboardingService: HouseholdOnboardingService = { + async ensureHouseholdJoinToken() { + return { + householdId: 'household-1', + householdName: 'Kojori House', + token: 'join-token' + } + }, + async getMiniAppAccess(input) { + if (joinCalls.includes(input.identity.telegramUserId)) { + return { + status: 'pending', + household: { + id: 'household-1', + name: 'Kojori House', + defaultLocale: 'en' + } + } + } + + return { + status: 'open_from_group' + } + }, + async joinHousehold(input) { + joinCalls.push(input.identity.telegramUserId) + return { + status: 'pending', + household: { + id: 'household-1', + name: 'Kojori House', + defaultLocale: 'en' + } + } + } + } + + bot.api.config.use(async (_prev, method, payload) => { + calls.push({ method, payload }) + + if (method === 'getChatMember') { + return { + ok: true, + result: { + status: 'administrator', + user: { + id: 123456, + is_bot: false, + first_name: 'Stan' + } + } + } as never + } + + if (method === 'sendMessage') { + const chatId = (payload as { chat_id?: number }).chat_id ?? 0 + return { + ok: true, + result: { + message_id: chatId === -100123456 ? 411 : 1, + date: Math.floor(Date.now() / 1000), + chat: { + id: chatId, + type: chatId > 0 ? 'private' : 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + if (method === 'editMessageText') { + return { + ok: true, + result: { + message_id: (payload as { message_id?: number }).message_id ?? 411, + date: Math.floor(Date.now() / 1000), + chat: { + id: (payload as { chat_id?: number }).chat_id ?? -100123456, + type: 'supergroup' + }, + text: (payload as { text?: string }).text ?? 'ok' + } + } as never + } + + return { + ok: true, + result: true + } as never + }) + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createHouseholdSetupService(repository), + householdOnboardingService, + householdAdminService: createHouseholdAdminService(), + householdConfigurationRepository: repository, + promptRepository + }) + + await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never) + calls.length = 0 + await bot.handleUpdate( + groupReplyCommandUpdate('/invite', { id: 654321, firstName: 'Chorbanaut' }) as never + ) + + calls.length = 0 + await bot.handleUpdate( + startUpdate('/start invite_-100123456_654321', { + userId: 654321, + firstName: 'Chorbanaut' + }) as never + ) + + expect(calls[0]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: -100123456, + message_id: 411, + text: 'Chorbanaut sent a join request for Kojori House.' + } + }) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: 654321, + text: 'Join request sent for Kojori House. Wait for a household admin to confirm you.' + } + }) + + expect(await promptRepository.getPendingAction('invite:-100123456', '654321')).toMatchObject({ + action: 'household_group_invite', + payload: { + completed: true + } + }) + + calls.length = 0 + await bot.handleUpdate( + startUpdate('/start invite_-100123456_654321', { + userId: 654321, + firstName: 'Chorbanaut' + }) as never + ) + + expect(calls[0]).toMatchObject({ + method: 'editMessageText', + payload: { + chat_id: -100123456, + message_id: 411, + text: 'Chorbanaut sent a join request for Kojori House.' + } + }) + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: 654321, + text: 'Join request sent for Kojori House. Wait for a household admin to confirm you.' + } + }) + }) + test('creates and binds a missing setup topic from callback', async () => { const bot = createTelegramBot('000000:test-token') const calls: Array<{ method: string; payload: unknown }> = [] diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index d337329..09d708f 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -21,6 +21,9 @@ import { resolveReplyLocale } from './bot-locale' const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:' const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:' const SETUP_BIND_TOPIC_CALLBACK_PREFIX = 'setup_topic:bind:' +const GROUP_INVITE_START_PREFIX = 'invite_' +const GROUP_INVITE_ACTION = 'household_group_invite' as const +const GROUP_INVITE_TTL_MS = 3 * 24 * 60 * 60 * 1000 const SETUP_BIND_TOPIC_ACTION = 'setup_topic_binding' as const const SETUP_BIND_TOPIC_TTL_MS = 10 * 60 * 1000 const HOUSEHOLD_TOPIC_ROLE_ORDER: readonly HouseholdTopicRole[] = [ @@ -161,6 +164,45 @@ function actorDisplayName(ctx: Context): string | undefined { return fullName || ctx.from?.username?.trim() || undefined } +function telegramUserDisplayName(input: { + firstName: string | undefined + lastName: string | undefined + username: string | undefined + fallback: string +}): string { + const fullName = [input.firstName?.trim(), input.lastName?.trim()] + .filter(Boolean) + .join(' ') + .trim() + return fullName || input.username?.trim() || input.fallback +} + +function repliedTelegramUser(ctx: Context): { + telegramUserId: string + displayName: string + username?: string +} | null { + const replied = ctx.msg?.reply_to_message + if (!replied?.from || replied.from.is_bot) { + return null + } + + return { + telegramUserId: replied.from.id.toString(), + displayName: telegramUserDisplayName({ + firstName: replied.from.first_name, + lastName: replied.from.last_name, + username: replied.from.username, + fallback: `Telegram ${replied.from.id}` + }), + ...(replied.from.username + ? { + username: replied.from.username + } + : {}) + } +} + function buildPendingMemberLabel(displayName: string): string { const normalized = displayName.trim().replaceAll(/\s+/g, ' ') if (normalized.length <= 32) { @@ -337,6 +379,77 @@ function parseSetupBindPayload(payload: Record): { } } +function invitePendingChatId(telegramChatId: string): string { + return `invite:${telegramChatId}` +} + +function parseGroupInvitePayload(payload: Record): { + joinToken: string + householdId: string + householdName: string + targetDisplayName: string + inviteMessageId?: number + completed?: boolean +} | null { + if ( + typeof payload.joinToken !== 'string' || + payload.joinToken.trim().length === 0 || + typeof payload.householdId !== 'string' || + payload.householdId.trim().length === 0 || + typeof payload.householdName !== 'string' || + payload.householdName.trim().length === 0 || + typeof payload.targetDisplayName !== 'string' || + payload.targetDisplayName.trim().length === 0 + ) { + return null + } + + return { + joinToken: payload.joinToken, + householdId: payload.householdId, + householdName: payload.householdName, + targetDisplayName: payload.targetDisplayName, + ...(typeof payload.inviteMessageId === 'number' && Number.isInteger(payload.inviteMessageId) + ? { + inviteMessageId: payload.inviteMessageId + } + : {}), + ...(payload.completed === true + ? { + completed: true + } + : {}) + } +} + +function parseInviteStartPayload(payload: string): { + telegramChatId: string + targetTelegramUserId: string +} | null { + const match = /^invite_(-?\d+)_(\d+)$/.exec(payload) + if (!match) { + return null + } + + return { + telegramChatId: match[1]!, + targetTelegramUserId: match[2]! + } +} + +function buildGroupInviteDeepLink( + botUsername: string | undefined, + telegramChatId: string, + targetTelegramUserId: string +): string | null { + const normalizedBotUsername = botUsername?.trim() + if (!normalizedBotUsername) { + return null + } + + return `https://t.me/${normalizedBotUsername}?start=${GROUP_INVITE_START_PREFIX}${telegramChatId}_${targetTelegramUserId}` +} + export function buildJoinMiniAppUrl( miniAppUrl: string | undefined, botUsername: string | undefined, @@ -394,6 +507,64 @@ export function registerHouseholdSetupCommands(options: { miniAppUrl?: string logger?: Logger }): void { + async function editGroupInviteCompletion(input: { + locale: BotLocale + telegramChatId: string + payload: { + householdName: string + targetDisplayName: string + inviteMessageId?: number + } + status: 'active' | 'pending' + ctx: Context + }) { + if (!input.payload.inviteMessageId) { + return + } + + const t = getBotTranslations(input.locale).setup + const text = + input.status === 'active' + ? t.inviteJoinCompleted(input.payload.targetDisplayName, input.payload.householdName) + : t.inviteJoinRequestSent(input.payload.targetDisplayName, input.payload.householdName) + + try { + await input.ctx.api.editMessageText( + Number(input.telegramChatId), + input.payload.inviteMessageId, + text + ) + } catch (error) { + options.logger?.warn( + { + event: 'household_setup.invite_message_update_failed', + telegramChatId: input.telegramChatId, + inviteMessageId: input.payload.inviteMessageId, + error: error instanceof Error ? error.message : String(error) + }, + 'Failed to update household invite message' + ) + } + } + + async function isInviteAuthorized(ctx: Context, householdId: string): Promise { + if (await isGroupAdmin(ctx)) { + return true + } + + const actorTelegramUserId = ctx.from?.id?.toString() + if (!actorTelegramUserId || !options.householdConfigurationRepository) { + return false + } + + const member = await options.householdConfigurationRepository.getHouseholdMember( + householdId, + actorTelegramUserId + ) + + return member?.isAdmin === true + } + async function buildSetupReplyForHousehold(input: { ctx: Context locale: BotLocale @@ -580,6 +751,149 @@ export function registerHouseholdSetupCommands(options: { } const startPayload = commandArgText(ctx) + const inviteStart = parseInviteStartPayload(startPayload) + if (inviteStart) { + if (ctx.from.id.toString() !== inviteStart.targetTelegramUserId) { + await ctx.reply(t.setup.inviteJoinWrongUser) + return + } + + if (!options.promptRepository) { + await ctx.reply(t.setup.inviteJoinExpired) + return + } + + const invitePending = await options.promptRepository.getPendingAction( + invitePendingChatId(inviteStart.telegramChatId), + inviteStart.targetTelegramUserId + ) + const invitePayload = + invitePending?.action === GROUP_INVITE_ACTION + ? parseGroupInvitePayload(invitePending.payload) + : null + const inviteExpiresAt = invitePending?.expiresAt ?? null + + if (!invitePayload) { + await ctx.reply(t.setup.inviteJoinExpired) + return + } + + const identity = { + telegramUserId: ctx.from.id.toString(), + displayName: + [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ').trim() || + ctx.from.username || + `Telegram ${ctx.from.id}`, + ...(ctx.from.username + ? { + username: ctx.from.username + } + : {}), + ...(ctx.from.language_code + ? { + languageCode: ctx.from.language_code + } + : {}) + } + + if (invitePayload.completed) { + const access = await options.householdOnboardingService.getMiniAppAccess({ + identity, + joinToken: invitePayload.joinToken + }) + locale = localeFromAccess(access, fallbackLocale) + t = getBotTranslations(locale) + + if (access.status === 'active') { + await editGroupInviteCompletion({ + locale, + telegramChatId: inviteStart.telegramChatId, + payload: invitePayload, + status: 'active', + ctx + }) + await ctx.reply( + t.setup.alreadyActiveMember(access.member.displayName), + miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, invitePayload.joinToken) + ) + return + } + + if (access.status === 'pending') { + await editGroupInviteCompletion({ + locale, + telegramChatId: inviteStart.telegramChatId, + payload: invitePayload, + status: 'pending', + ctx + }) + await ctx.reply( + t.setup.joinRequestSent(access.household.name), + miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, invitePayload.joinToken) + ) + return + } + + await ctx.reply(t.setup.inviteJoinExpired) + return + } + + const result = await options.householdOnboardingService.joinHousehold({ + identity, + joinToken: invitePayload.joinToken + }) + + if (result.status === 'invalid_token') { + await ctx.reply(t.setup.inviteJoinExpired) + return + } + + if (result.status === 'active') { + locale = result.member.preferredLocale ?? result.member.householdDefaultLocale + t = getBotTranslations(locale) + } else { + const access = await options.householdOnboardingService.getMiniAppAccess({ + identity, + joinToken: invitePayload.joinToken + }) + locale = localeFromAccess(access, fallbackLocale) + t = getBotTranslations(locale) + } + + await options.promptRepository.upsertPendingAction({ + telegramUserId: inviteStart.targetTelegramUserId, + telegramChatId: invitePendingChatId(inviteStart.telegramChatId), + action: GROUP_INVITE_ACTION, + payload: { + ...invitePayload, + completed: true + }, + expiresAt: inviteExpiresAt + }) + + await editGroupInviteCompletion({ + locale, + telegramChatId: inviteStart.telegramChatId, + payload: invitePayload, + status: result.status, + ctx + }) + + if (result.status === 'active') { + await ctx.reply( + t.setup.alreadyActiveMember(result.member.displayName), + miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, invitePayload.joinToken) + ) + return + } + + await ctx.reply( + t.setup.joinRequestSent(result.household.name), + miniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username, invitePayload.joinToken) + ) + return + } + if (!startPayload.startsWith('join_')) { await ctx.reply(t.common.useHelp) return @@ -849,6 +1163,128 @@ export function registerHouseholdSetupCommands(options: { await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName)) }) + options.bot.command('invite', async (ctx) => { + const locale = await resolveReplyLocale({ + ctx, + repository: options.householdConfigurationRepository + }) + const t = getBotTranslations(locale) + + if (!isGroupChat(ctx)) { + await ctx.reply(t.setup.useInviteInGroup) + return + } + + if (!options.promptRepository || !options.householdConfigurationRepository) { + await ctx.reply(t.setup.inviteJoinExpired) + return + } + + const household = await options.householdConfigurationRepository.getTelegramHouseholdChat( + ctx.chat.id.toString() + ) + if (!household) { + await ctx.reply(t.setup.householdNotConfigured) + return + } + + if (!(await isInviteAuthorized(ctx, household.householdId))) { + await ctx.reply(t.setup.onlyInviteAdmins) + return + } + + const target = repliedTelegramUser(ctx) + if (!target) { + await ctx.reply(t.setup.inviteUsage) + return + } + + const existingMember = await options.householdConfigurationRepository.getHouseholdMember( + household.householdId, + target.telegramUserId + ) + if (existingMember?.status === 'active') { + await ctx.reply( + t.setup.inviteAlreadyMember(existingMember.displayName, household.householdName) + ) + return + } + + const existingPending = + await options.householdConfigurationRepository.getPendingHouseholdMember( + household.householdId, + target.telegramUserId + ) + if (existingPending) { + await ctx.reply( + t.setup.inviteAlreadyPending(existingPending.displayName, household.householdName) + ) + return + } + + const joinToken = await options.householdOnboardingService.ensureHouseholdJoinToken({ + householdId: household.householdId, + ...(ctx.from?.id + ? { + actorTelegramUserId: ctx.from.id.toString() + } + : {}) + }) + + await options.promptRepository.upsertPendingAction({ + telegramUserId: target.telegramUserId, + telegramChatId: invitePendingChatId(ctx.chat.id.toString()), + action: GROUP_INVITE_ACTION, + payload: { + joinToken: joinToken.token, + householdId: household.householdId, + householdName: household.householdName, + targetDisplayName: target.displayName + }, + expiresAt: nowInstant().add({ milliseconds: GROUP_INVITE_TTL_MS }) + }) + + const deepLink = buildGroupInviteDeepLink( + ctx.me.username, + ctx.chat.id.toString(), + target.telegramUserId + ) + if (!deepLink) { + await ctx.reply(t.setup.inviteJoinExpired) + return + } + + const inviteMessage = await ctx.reply( + t.setup.invitePrepared(target.displayName, household.householdName), + { + reply_markup: { + inline_keyboard: [ + [ + { + text: t.setup.joinHouseholdButton, + url: deepLink + } + ] + ] + } + } + ) + + await options.promptRepository.upsertPendingAction({ + telegramUserId: target.telegramUserId, + telegramChatId: invitePendingChatId(ctx.chat.id.toString()), + action: GROUP_INVITE_ACTION, + payload: { + joinToken: joinToken.token, + householdId: household.householdId, + householdName: household.householdName, + targetDisplayName: target.displayName, + inviteMessageId: inviteMessage.message_id + }, + expiresAt: nowInstant().add({ milliseconds: GROUP_INVITE_TTL_MS }) + }) + }) + options.bot.callbackQuery( new RegExp(`^${APPROVE_MEMBER_CALLBACK_PREFIX}(\\d+)$`), async (ctx) => { diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index b951c15..acf706f 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -13,6 +13,7 @@ export const enBotTranslations: BotTranslationCatalog = { bind_feedback_topic: 'Bind the current topic as feedback', bind_reminders_topic: 'Bind the current topic as reminders', bind_payments_topic: 'Bind the current topic as payments', + invite: 'Invite the replied user into this household', payment_add: 'Record your rent or utilities payment', pending_members: 'List pending household join requests', approve_member: 'Approve a pending household member' @@ -115,6 +116,23 @@ export const enBotTranslations: BotTranslationCatalog = { usePendingMembersInGroup: 'Use /pending_members inside the household group.', useApproveMemberInGroup: 'Use /approve_member inside the household group.', approveMemberUsage: 'Usage: /approve_member ', + useInviteInGroup: 'Use /invite as a reply inside the household group.', + onlyInviteAdmins: 'Only Telegram group admins or household admins can invite members.', + inviteUsage: 'Reply to a real user message with /invite.', + inviteTargetInvalid: 'I can only prepare invites for real group members.', + inviteAlreadyMember: (displayName, householdName) => + `${displayName} is already an active member of ${householdName}.`, + inviteAlreadyPending: (displayName, householdName) => + `${displayName} already has a pending join request for ${householdName}.`, + invitePrepared: (displayName, householdName) => + `Invitation prepared for ${displayName}. Tap below to join ${householdName}.`, + invitePreparedToast: (displayName) => `Invite prepared for ${displayName}.`, + inviteJoinWrongUser: 'This invite is for a different Telegram user.', + inviteJoinExpired: 'This invite is no longer available.', + inviteJoinCompleted: (displayName, householdName) => + `${displayName} completed the join flow for ${householdName}.`, + inviteJoinRequestSent: (displayName, householdName) => + `${displayName} sent a join request for ${householdName}.`, approvedMember: (displayName, householdName) => `Approved ${displayName} as an active member of ${householdName}.`, useButtonInGroup: 'Use this button in the household group.', diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 3236ad1..66e786e 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -13,6 +13,7 @@ export const ruBotTranslations: BotTranslationCatalog = { bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', bind_reminders_topic: 'Назначить текущий топик для напоминаний', bind_payments_topic: 'Назначить текущий топик для оплат', + invite: 'Пригласить пользователя из сообщения в этот дом', payment_add: 'Подтвердить оплату аренды или коммуналки', pending_members: 'Показать ожидающие заявки на вступление', approve_member: 'Подтвердить участника дома' @@ -117,6 +118,23 @@ export const ruBotTranslations: BotTranslationCatalog = { usePendingMembersInGroup: 'Используйте /pending_members внутри группы дома.', useApproveMemberInGroup: 'Используйте /approve_member внутри группы дома.', approveMemberUsage: 'Использование: /approve_member ', + useInviteInGroup: 'Используйте /invite как ответ внутри группы дома.', + onlyInviteAdmins: 'Приглашать участников могут только админы Telegram-группы или админы дома.', + inviteUsage: 'Ответьте командой /invite на сообщение реального участника.', + inviteTargetInvalid: 'Я могу подготовить приглашение только для реального участника группы.', + inviteAlreadyMember: (displayName, householdName) => + `${displayName} уже является активным участником ${householdName}.`, + inviteAlreadyPending: (displayName, householdName) => + `${displayName} уже отправил(а) заявку на вступление в ${householdName}.`, + invitePrepared: (displayName, householdName) => + `Приглашение для ${displayName} готово. Нажмите кнопку ниже, чтобы вступить в ${householdName}.`, + invitePreparedToast: (displayName) => `Приглашение для ${displayName} подготовлено.`, + inviteJoinWrongUser: 'Это приглашение предназначено для другого пользователя Telegram.', + inviteJoinExpired: 'Это приглашение больше недоступно.', + inviteJoinCompleted: (displayName, householdName) => + `${displayName} завершил(а) вступление в ${householdName}.`, + inviteJoinRequestSent: (displayName, householdName) => + `${displayName} отправил(а) заявку на вступление в ${householdName}.`, approvedMember: (displayName, householdName) => `Участник ${displayName} подтверждён как активный участник ${householdName}.`, useButtonInGroup: 'Используйте эту кнопку в группе дома.', diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 0cdb18b..5afb4f4 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -11,6 +11,7 @@ export type TelegramCommandName = | 'bind_feedback_topic' | 'bind_reminders_topic' | 'bind_payments_topic' + | 'invite' | 'payment_add' | 'pending_members' | 'approve_member' @@ -26,6 +27,7 @@ export interface BotCommandDescriptions { bind_feedback_topic: string bind_reminders_topic: string bind_payments_topic: string + invite: string payment_add: string pending_members: string approve_member: string @@ -103,6 +105,18 @@ export interface BotTranslationCatalog { usePendingMembersInGroup: string useApproveMemberInGroup: string approveMemberUsage: string + useInviteInGroup: string + onlyInviteAdmins: string + inviteUsage: string + inviteTargetInvalid: string + inviteAlreadyMember: (displayName: string, householdName: string) => string + inviteAlreadyPending: (displayName: string, householdName: string) => string + invitePrepared: (displayName: string, householdName: string) => string + invitePreparedToast: (displayName: string) => string + inviteJoinWrongUser: string + inviteJoinExpired: string + inviteJoinCompleted: (displayName: string, householdName: string) => string + inviteJoinRequestSent: (displayName: string, householdName: string) => string approvedMember: (displayName: string, householdName: string) => string useButtonInGroup: string unableToIdentifySelectedMember: string diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts index 477db25..99e15f5 100644 --- a/apps/bot/src/telegram-commands.ts +++ b/apps/bot/src/telegram-commands.ts @@ -39,6 +39,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [ 'bind_feedback_topic', 'bind_reminders_topic', 'bind_payments_topic', + 'invite', 'pending_members', 'approve_member' ] as const satisfies readonly TelegramCommandName[] diff --git a/packages/adapters-db/src/telegram-pending-action-repository.ts b/packages/adapters-db/src/telegram-pending-action-repository.ts index 2e02681..d10cfe2 100644 --- a/packages/adapters-db/src/telegram-pending-action-repository.ts +++ b/packages/adapters-db/src/telegram-pending-action-repository.ts @@ -17,6 +17,10 @@ function parsePendingActionType(raw: string): TelegramPendingActionType { return raw } + if (raw === 'household_group_invite') { + return raw + } + if (raw === 'payment_topic_clarification') { return raw } diff --git a/packages/ports/src/telegram-pending-actions.ts b/packages/ports/src/telegram-pending-actions.ts index e1c247f..2623f28 100644 --- a/packages/ports/src/telegram-pending-actions.ts +++ b/packages/ports/src/telegram-pending-actions.ts @@ -3,6 +3,7 @@ import type { Instant } from '@household/domain' export const TELEGRAM_PENDING_ACTION_TYPES = [ 'anonymous_feedback', 'assistant_payment_confirmation', + 'household_group_invite', 'payment_topic_clarification', 'payment_topic_confirmation', 'setup_topic_binding'