From e24c53dce283c0307b21d38d38cd228d0f98cd59 Mon Sep 17 00:00:00 2001 From: whekin Date: Sat, 14 Mar 2026 23:46:32 +0400 Subject: [PATCH] feat(bot): replace /invite with /join_link command - Remove /invite command and targeted invite flow - Add /join_link command to generate shareable household join link - Update i18n translations for en and ru - Update command registration to include join_link in admin commands --- apps/bot/src/household-setup.test.ts | 549 ++++++--------------------- apps/bot/src/household-setup.ts | 390 +------------------ apps/bot/src/i18n/locales/en.ts | 24 +- apps/bot/src/i18n/locales/ru.ts | 24 +- apps/bot/src/i18n/types.ts | 18 +- apps/bot/src/telegram-commands.ts | 2 +- 6 files changed, 151 insertions(+), 856 deletions(-) diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 287218e..75c014c 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -133,52 +133,6 @@ 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, @@ -969,394 +923,6 @@ 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 }> = [] @@ -1853,4 +1419,119 @@ describe('registerHouseholdSetupCommands', () => { } }) }) + + test('generates a join link with /join_link command', async () => { + const bot = createTelegramBot('000000:test-token') + const calls: Array<{ method: string; payload: unknown }> = [] + const repository = createHouseholdConfigurationRepository() + + 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: 500, + 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: 'test-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: createPromptRepository() + }) + + await bot.handleUpdate(groupCommandUpdate('/setup Kojori House') as never) + calls.length = 0 + + await bot.handleUpdate(groupCommandUpdate('/join_link') as never) + + expect(calls[1]).toMatchObject({ + method: 'sendMessage', + payload: { + chat_id: -100123456, + text: 'Join link for Kojori House:\nhttps://t.me/household_test_bot?start=join_test-join-token\n\nAnyone with this link can join the household. Share it carefully.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Join household', + url: 'https://t.me/household_test_bot?start=join_test-join-token' + } + ] + ] + } + } + }) + }) }) diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 411a28b..ac102a8 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -17,14 +17,10 @@ import type { Bot, Context } from 'grammy' import { getBotTranslations, type BotLocale } from './i18n' import { resolveReplyLocale } from './bot-locale' -import { buildBotStartDeepLink } from './telegram-deep-links' 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[] = [ @@ -170,45 +166,6 @@ 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) { @@ -389,75 +346,6 @@ 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 { - return buildBotStartDeepLink( - botUsername, - `${GROUP_INVITE_START_PREFIX}${telegramChatId}_${targetTelegramUserId}` - ) -} - function buildMiniAppBaseUrl( miniAppUrl: string | undefined, botUsername?: string | undefined @@ -562,46 +450,6 @@ 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 @@ -806,148 +654,6 @@ 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_')) { if (startPayload === 'dashboard') { @@ -1235,7 +941,7 @@ export function registerHouseholdSetupCommands(options: { await ctx.reply(t.setup.approvedMember(result.member.displayName, result.householdName)) }) - options.bot.command('invite', async (ctx) => { + options.bot.command('join_link', async (ctx) => { const locale = await resolveReplyLocale({ ctx, repository: options.householdConfigurationRepository @@ -1243,12 +949,12 @@ export function registerHouseholdSetupCommands(options: { const t = getBotTranslations(locale) if (!isGroupChat(ctx)) { - await ctx.reply(t.setup.useInviteInGroup) + await ctx.reply(t.setup.useJoinLinkInGroup) return } - if (!options.promptRepository || !options.householdConfigurationRepository) { - await ctx.reply(t.setup.inviteJoinExpired) + if (!options.householdConfigurationRepository) { + await ctx.reply(t.setup.householdNotConfigured) return } @@ -1265,35 +971,6 @@ export function registerHouseholdSetupCommands(options: { 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 @@ -1303,57 +980,26 @@ export function registerHouseholdSetupCommands(options: { : {}) }) - 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 joinDeepLink = ctx.me.username + ? `https://t.me/${ctx.me.username}?start=join_${encodeURIComponent(joinToken.token)}` + : null - const deepLink = buildGroupInviteDeepLink( - ctx.me.username, - ctx.chat.id.toString(), - target.telegramUserId - ) - if (!deepLink) { - await ctx.reply(t.setup.inviteJoinExpired) + if (!joinDeepLink) { + await ctx.reply(t.setup.joinLinkUnavailable) return } - const inviteMessage = await ctx.reply( - t.setup.invitePrepared(target.displayName, household.householdName), - { - reply_markup: { - inline_keyboard: [ - [ - { - text: t.setup.joinHouseholdButton, - url: deepLink - } - ] + await ctx.reply(t.setup.joinLinkReady(joinDeepLink, household.householdName), { + reply_markup: { + inline_keyboard: [ + [ + { + text: t.setup.joinHouseholdButton, + url: joinDeepLink + } ] - } + ] } - ) - - 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 }) }) }) diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 0cd20a0..f4d9ab8 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -14,7 +14,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', + join_link: 'Get a shareable link for new members to join', payment_add: 'Record your rent or utilities payment', pending_members: 'List pending household join requests', approve_member: 'Approve a pending household member' @@ -126,28 +126,16 @@ 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.', unableToIdentifySelectedMember: 'Unable to identify the selected member.', - approvedMemberToast: (displayName) => `Approved ${displayName}.` + approvedMemberToast: (displayName) => `Approved ${displayName}.`, + useJoinLinkInGroup: 'Use /join_link inside the household group.', + joinLinkUnavailable: 'Could not generate join link.', + joinLinkReady: (link, householdName) => + `Join link for ${householdName}:\n${link}\n\nAnyone with this link can join the household. Share it carefully.` }, anonymousFeedback: { title: 'Anonymous household note', diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 3ee553b..02c0405 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -14,7 +14,7 @@ export const ruBotTranslations: BotTranslationCatalog = { bind_feedback_topic: 'Назначить текущий топик для анонимных сообщений', bind_reminders_topic: 'Назначить текущий топик для напоминаний', bind_payments_topic: 'Назначить текущий топик для оплат', - invite: 'Пригласить пользователя из сообщения в этот дом', + join_link: 'Получить ссылку для приглашения новых участников', payment_add: 'Подтвердить оплату аренды или коммуналки', pending_members: 'Показать ожидающие заявки на вступление', approve_member: 'Подтвердить участника дома' @@ -128,28 +128,16 @@ 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: 'Используйте эту кнопку в группе дома.', unableToIdentifySelectedMember: 'Не удалось определить выбранного участника.', - approvedMemberToast: (displayName) => `${displayName} подтверждён.` + approvedMemberToast: (displayName) => `${displayName} подтверждён.`, + useJoinLinkInGroup: 'Используйте /join_link внутри группы дома.', + joinLinkUnavailable: 'Не удалось сгенерировать ссылку для вступления.', + joinLinkReady: (link, householdName) => + `Поделитесь этой ссылкой, чтобы пригласить участников в ${householdName}:\n\n${link}\n\nЛюбой, у кого есть эта ссылка, может подать заявку на вступление.` }, anonymousFeedback: { title: 'Анонимное сообщение по дому', diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index e74ca84..781dd41 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -12,7 +12,7 @@ export type TelegramCommandName = | 'bind_feedback_topic' | 'bind_reminders_topic' | 'bind_payments_topic' - | 'invite' + | 'join_link' | 'payment_add' | 'pending_members' | 'approve_member' @@ -29,7 +29,7 @@ export interface BotCommandDescriptions { bind_feedback_topic: string bind_reminders_topic: string bind_payments_topic: string - invite: string + join_link: string payment_add: string pending_members: string approve_member: string @@ -115,22 +115,14 @@ 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 approvedMemberToast: (displayName: string) => string + useJoinLinkInGroup: string + joinLinkUnavailable: string + joinLinkReady: (link: string, householdName: string) => string } anonymousFeedback: { title: string diff --git a/apps/bot/src/telegram-commands.ts b/apps/bot/src/telegram-commands.ts index 9bea27f..d12a108 100644 --- a/apps/bot/src/telegram-commands.ts +++ b/apps/bot/src/telegram-commands.ts @@ -40,7 +40,7 @@ const GROUP_ADMIN_COMMAND_NAMES = [ 'bind_feedback_topic', 'bind_reminders_topic', 'bind_payments_topic', - 'invite', + 'join_link', 'pending_members', 'approve_member' ] as const satisfies readonly TelegramCommandName[]