From a78eb88fa437467d121516919a6e93e7c6e04850 Mon Sep 17 00:00:00 2001 From: whekin Date: Wed, 11 Mar 2026 22:36:43 +0400 Subject: [PATCH] fix(bot): hand off reminder dashboard opens through dm --- apps/bot/src/household-setup.test.ts | 80 +++++++++++++++++++++++- apps/bot/src/household-setup.ts | 79 ++++++++++++++++++++--- apps/bot/src/i18n/locales/en.ts | 2 + apps/bot/src/i18n/locales/ru.ts | 2 + apps/bot/src/i18n/types.ts | 2 + apps/bot/src/index.ts | 6 ++ apps/bot/src/reminder-jobs.test.ts | 9 ++- apps/bot/src/reminder-jobs.ts | 14 ++++- apps/bot/src/reminder-topic-utilities.ts | 12 ++-- apps/bot/src/telegram-deep-links.ts | 13 ++++ apps/miniapp/src/App.tsx | 2 +- 11 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 apps/bot/src/telegram-deep-links.ts diff --git a/apps/bot/src/household-setup.test.ts b/apps/bot/src/household-setup.test.ts index 25b4a26..1c39c15 100644 --- a/apps/bot/src/household-setup.test.ts +++ b/apps/bot/src/household-setup.test.ts @@ -572,7 +572,7 @@ describe('buildJoinMiniAppUrl', () => { 'join-token' ) - expect(url).toBe('https://household-dev-mini-app.example.app/?join=join-token&bot=kojori_bot') + expect(url).toBe('https://household-dev-mini-app.example.app/?bot=kojori_bot&join=join-token') }) test('returns null when no mini app url is configured', () => { @@ -581,6 +581,80 @@ describe('buildJoinMiniAppUrl', () => { }) describe('registerHouseholdSetupCommands', () => { + test('opens the mini app from a dashboard start payload in private chat', 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 + }) + + registerHouseholdSetupCommands({ + bot, + householdSetupService: createRejectedHouseholdSetupService(), + householdOnboardingService: { + async ensureHouseholdJoinToken() { + throw new Error('not used') + }, + async getMiniAppAccess() { + throw new Error('not used') + }, + async joinHousehold() { + throw new Error('not used') + } + }, + householdAdminService: createHouseholdAdminService(), + miniAppUrl: 'https://miniapp.example.app' + }) + + await bot.handleUpdate(startUpdate('/start dashboard') as never) + + expect(calls).toHaveLength(1) + expect(calls[0]?.payload).toMatchObject({ + chat_id: 123456, + text: 'Open the mini app from the button below.', + reply_markup: { + inline_keyboard: [ + [ + { + text: 'Open mini app', + web_app: { + url: 'https://miniapp.example.app/?bot=household_test_bot' + } + } + ] + ] + } + }) + }) + test('offers an Open mini app button after a DM join request', async () => { const bot = createTelegramBot('000000:test-token') const calls: Array<{ method: string; payload: unknown }> = [] @@ -662,7 +736,7 @@ describe('registerHouseholdSetupCommands', () => { { text: 'Open mini app', web_app: { - url: 'https://miniapp.example.app/?join=join-token&bot=household_test_bot' + url: 'https://miniapp.example.app/?bot=household_test_bot&join=join-token' } } ] @@ -750,7 +824,7 @@ describe('registerHouseholdSetupCommands', () => { { text: 'Открыть мини-приложение', web_app: { - url: 'https://miniapp.example.app/?join=join-token&bot=household_test_bot' + url: 'https://miniapp.example.app/?bot=household_test_bot&join=join-token' } } ] diff --git a/apps/bot/src/household-setup.ts b/apps/bot/src/household-setup.ts index 09d708f..d37c9e0 100644 --- a/apps/bot/src/household-setup.ts +++ b/apps/bot/src/household-setup.ts @@ -17,6 +17,7 @@ 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:' @@ -442,18 +443,15 @@ function buildGroupInviteDeepLink( 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}` + return buildBotStartDeepLink( + botUsername, + `${GROUP_INVITE_START_PREFIX}${telegramChatId}_${targetTelegramUserId}` + ) } -export function buildJoinMiniAppUrl( +function buildMiniAppBaseUrl( miniAppUrl: string | undefined, - botUsername: string | undefined, - joinToken: string + botUsername?: string | undefined ): string | null { const normalizedMiniAppUrl = miniAppUrl?.trim() if (!normalizedMiniAppUrl) { @@ -461,7 +459,6 @@ export function buildJoinMiniAppUrl( } const url = new URL(normalizedMiniAppUrl) - url.searchParams.set('join', joinToken) if (botUsername && botUsername.trim().length > 0) { url.searchParams.set('bot', botUsername.trim()) @@ -470,6 +467,29 @@ export function buildJoinMiniAppUrl( return url.toString() } +export function buildJoinMiniAppUrl( + miniAppUrl: string | undefined, + botUsername: string | undefined, + joinToken: string +): string | null { + const baseUrl = buildMiniAppBaseUrl(miniAppUrl, botUsername) + if (!baseUrl) { + return null + } + + const url = new URL(baseUrl) + url.searchParams.set('join', joinToken) + + return url.toString() +} + +function buildOpenMiniAppUrl( + miniAppUrl: string | undefined, + botUsername: string | undefined +): string | null { + return buildMiniAppBaseUrl(miniAppUrl, botUsername) +} + function miniAppReplyMarkup( locale: BotLocale, miniAppUrl: string | undefined, @@ -497,6 +517,32 @@ function miniAppReplyMarkup( } } +function openMiniAppReplyMarkup( + locale: BotLocale, + miniAppUrl: string | undefined, + botUsername: string | undefined +) { + const webAppUrl = buildOpenMiniAppUrl(miniAppUrl, botUsername) + if (!webAppUrl) { + return {} + } + + return { + reply_markup: { + inline_keyboard: [ + [ + { + text: getBotTranslations(locale).setup.openMiniAppButton, + web_app: { + url: webAppUrl + } + } + ] + ] + } + } +} + export function registerHouseholdSetupCommands(options: { bot: Bot householdSetupService: HouseholdSetupService @@ -895,6 +941,19 @@ export function registerHouseholdSetupCommands(options: { } if (!startPayload.startsWith('join_')) { + if (startPayload === 'dashboard') { + if (!options.miniAppUrl) { + await ctx.reply(t.setup.openMiniAppUnavailable) + return + } + + await ctx.reply( + t.setup.openMiniAppFromPrivateChat, + openMiniAppReplyMarkup(locale, options.miniAppUrl, ctx.me.username) + ) + return + } + await ctx.reply(t.common.useHelp) return } diff --git a/apps/bot/src/i18n/locales/en.ts b/apps/bot/src/i18n/locales/en.ts index 299e7fa..47a5775 100644 --- a/apps/bot/src/i18n/locales/en.ts +++ b/apps/bot/src/i18n/locales/en.ts @@ -42,6 +42,8 @@ export const enBotTranslations: BotTranslationCatalog = { pendingMemberLine: (member, index) => `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`, openMiniAppButton: 'Open mini app', + openMiniAppFromPrivateChat: 'Open the mini app from the button below.', + openMiniAppUnavailable: 'The mini app is not configured right now.', joinHouseholdButton: 'Join household', approveMemberButton: (displayName) => `Approve ${displayName}`, telegramIdentityRequired: 'Telegram user identity is required to join a household.', diff --git a/apps/bot/src/i18n/locales/ru.ts b/apps/bot/src/i18n/locales/ru.ts index 2fa5085..8a03e0b 100644 --- a/apps/bot/src/i18n/locales/ru.ts +++ b/apps/bot/src/i18n/locales/ru.ts @@ -44,6 +44,8 @@ export const ruBotTranslations: BotTranslationCatalog = { pendingMemberLine: (member, index) => `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`, openMiniAppButton: 'Открыть мини-приложение', + openMiniAppFromPrivateChat: 'Откройте мини-приложение по кнопке ниже.', + openMiniAppUnavailable: 'Мини-приложение сейчас не настроено.', joinHouseholdButton: 'Вступить в дом', approveMemberButton: (displayName) => `Подтвердить ${displayName}`, telegramIdentityRequired: 'Чтобы вступить в дом, нужна Telegram-учётка пользователя.', diff --git a/apps/bot/src/i18n/types.ts b/apps/bot/src/i18n/types.ts index 5ae7fb1..5f348c9 100644 --- a/apps/bot/src/i18n/types.ts +++ b/apps/bot/src/i18n/types.ts @@ -65,6 +65,8 @@ export interface BotTranslationCatalog { pendingMembersEmpty: (householdName: string) => string pendingMemberLine: (member: PendingMemberSummary, index: number) => string openMiniAppButton: string + openMiniAppFromPrivateChat: string + openMiniAppUnavailable: string joinHouseholdButton: string approveMemberButton: (displayName: string) => string telegramIdentityRequired: string diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index b8199aa..6d624cd 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -92,6 +92,7 @@ const bot = createTelegramBot( getLogger('telegram'), householdConfigurationRepositoryClient?.repository ) +bot.botInfo = await bot.api.getMe() const webhookHandler = webhookCallback(bot, 'std/http', { onTimeout: 'return' }) @@ -359,6 +360,11 @@ const reminderJobs = runtime.reminderJobsEnabled miniAppUrl: runtime.miniAppAllowedOrigins[0] } : {}), + ...(bot.botInfo?.username + ? { + botUsername: bot.botInfo.username + } + : {}), logger: getLogger('scheduler') }) })() diff --git a/apps/bot/src/reminder-jobs.test.ts b/apps/bot/src/reminder-jobs.test.ts index a29df74..e52baeb 100644 --- a/apps/bot/src/reminder-jobs.test.ts +++ b/apps/bot/src/reminder-jobs.test.ts @@ -42,7 +42,8 @@ describe('createReminderJobsHandler', () => { releaseReminderDispatch: mock(async () => {}), sendReminderMessage, reminderService, - now: () => fixedNow + now: () => fixedNow, + botUsername: 'household_test_bot' }) const response = await handler.handle( @@ -72,6 +73,12 @@ describe('createReminderJobsHandler', () => { text: 'Шаблон', callback_data: 'reminder_util:template' } + ], + [ + { + text: 'Открыть дашборд', + url: 'https://t.me/household_test_bot?start=dashboard' + } ] ] } diff --git a/apps/bot/src/reminder-jobs.ts b/apps/bot/src/reminder-jobs.ts index 096c818..13928ea 100644 --- a/apps/bot/src/reminder-jobs.ts +++ b/apps/bot/src/reminder-jobs.ts @@ -94,6 +94,7 @@ export function createReminderJobsHandler(options: { forceDryRun?: boolean now?: () => Temporal.Instant miniAppUrl?: string + botUsername?: string logger?: Logger }): { handle: (request: Request, rawReminderType: string) => Promise @@ -109,7 +110,18 @@ export function createReminderJobsHandler(options: { case 'utilities': return { text: t.utilities(period), - replyMarkup: buildUtilitiesReminderReplyMarkup(target.locale, options.miniAppUrl) + replyMarkup: buildUtilitiesReminderReplyMarkup(target.locale, { + ...(options.miniAppUrl + ? { + miniAppUrl: options.miniAppUrl + } + : {}), + ...(options.botUsername + ? { + botUsername: options.botUsername + } + : {}) + }) } case 'rent-warning': return { diff --git a/apps/bot/src/reminder-topic-utilities.ts b/apps/bot/src/reminder-topic-utilities.ts index adfbb0d..061b520 100644 --- a/apps/bot/src/reminder-topic-utilities.ts +++ b/apps/bot/src/reminder-topic-utilities.ts @@ -10,6 +10,7 @@ import type { InlineKeyboardMarkup } from 'grammy/types' import { getBotTranslations, type BotLocale } from './i18n' import { resolveReplyLocale } from './bot-locale' +import { buildBotStartDeepLink } from './telegram-deep-links' export const REMINDER_UTILITY_GUIDED_CALLBACK = 'reminder_util:guided' export const REMINDER_UTILITY_TEMPLATE_CALLBACK = 'reminder_util:template' @@ -358,10 +359,13 @@ async function resolveReminderContext( export function buildUtilitiesReminderReplyMarkup( locale: BotLocale, - miniAppUrl?: string + options?: { + miniAppUrl?: string + botUsername?: string + } ): InlineKeyboardMarkup { const t = getBotTranslations(locale).reminders - const dashboardUrl = miniAppUrl?.trim() + const dashboardUrl = buildBotStartDeepLink(options?.botUsername, 'dashboard') return { inline_keyboard: [ @@ -380,9 +384,7 @@ export function buildUtilitiesReminderReplyMarkup( [ { text: t.openDashboardButton, - web_app: { - url: dashboardUrl - } + url: dashboardUrl } ] ] diff --git a/apps/bot/src/telegram-deep-links.ts b/apps/bot/src/telegram-deep-links.ts new file mode 100644 index 0000000..c1a59ce --- /dev/null +++ b/apps/bot/src/telegram-deep-links.ts @@ -0,0 +1,13 @@ +export function buildBotStartDeepLink( + botUsername: string | undefined, + payload: string +): string | null { + const normalizedBotUsername = botUsername?.trim() + const normalizedPayload = payload.trim() + + if (!normalizedBotUsername || !normalizedPayload) { + return null + } + + return `https://t.me/${normalizedBotUsername}?start=${encodeURIComponent(normalizedPayload)}` +} diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 75543b8..61165a3 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -2437,7 +2437,7 @@ function App() {