fix(bot): hand off reminder dashboard opens through dm

This commit is contained in:
2026-03-11 22:36:43 +04:00
parent 6b8c2fa397
commit a78eb88fa4
11 changed files with 200 additions and 21 deletions

View File

@@ -572,7 +572,7 @@ describe('buildJoinMiniAppUrl', () => {
'join-token' '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', () => { test('returns null when no mini app url is configured', () => {
@@ -581,6 +581,80 @@ describe('buildJoinMiniAppUrl', () => {
}) })
describe('registerHouseholdSetupCommands', () => { 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 () => { test('offers an Open mini app button after a DM join request', 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 }> = []
@@ -662,7 +736,7 @@ describe('registerHouseholdSetupCommands', () => {
{ {
text: 'Open mini app', text: 'Open mini app',
web_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: 'Открыть мини-приложение', text: 'Открыть мини-приложение',
web_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'
} }
} }
] ]

View File

@@ -17,6 +17,7 @@ import type { Bot, Context } from 'grammy'
import { getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
import { resolveReplyLocale } from './bot-locale' import { resolveReplyLocale } from './bot-locale'
import { buildBotStartDeepLink } from './telegram-deep-links'
const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:' const APPROVE_MEMBER_CALLBACK_PREFIX = 'approve_member:'
const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:' const SETUP_CREATE_TOPIC_CALLBACK_PREFIX = 'setup_topic:create:'
@@ -442,18 +443,15 @@ function buildGroupInviteDeepLink(
telegramChatId: string, telegramChatId: string,
targetTelegramUserId: string targetTelegramUserId: string
): string | null { ): string | null {
const normalizedBotUsername = botUsername?.trim() return buildBotStartDeepLink(
if (!normalizedBotUsername) { botUsername,
return null `${GROUP_INVITE_START_PREFIX}${telegramChatId}_${targetTelegramUserId}`
} )
return `https://t.me/${normalizedBotUsername}?start=${GROUP_INVITE_START_PREFIX}${telegramChatId}_${targetTelegramUserId}`
} }
export function buildJoinMiniAppUrl( function buildMiniAppBaseUrl(
miniAppUrl: string | undefined, miniAppUrl: string | undefined,
botUsername: string | undefined, botUsername?: string | undefined
joinToken: string
): string | null { ): string | null {
const normalizedMiniAppUrl = miniAppUrl?.trim() const normalizedMiniAppUrl = miniAppUrl?.trim()
if (!normalizedMiniAppUrl) { if (!normalizedMiniAppUrl) {
@@ -461,7 +459,6 @@ export function buildJoinMiniAppUrl(
} }
const url = new URL(normalizedMiniAppUrl) const url = new URL(normalizedMiniAppUrl)
url.searchParams.set('join', joinToken)
if (botUsername && botUsername.trim().length > 0) { if (botUsername && botUsername.trim().length > 0) {
url.searchParams.set('bot', botUsername.trim()) url.searchParams.set('bot', botUsername.trim())
@@ -470,6 +467,29 @@ export function buildJoinMiniAppUrl(
return url.toString() 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( function miniAppReplyMarkup(
locale: BotLocale, locale: BotLocale,
miniAppUrl: string | undefined, 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: { export function registerHouseholdSetupCommands(options: {
bot: Bot bot: Bot
householdSetupService: HouseholdSetupService householdSetupService: HouseholdSetupService
@@ -895,6 +941,19 @@ export function registerHouseholdSetupCommands(options: {
} }
if (!startPayload.startsWith('join_')) { 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) await ctx.reply(t.common.useHelp)
return return
} }

View File

@@ -42,6 +42,8 @@ export const enBotTranslations: BotTranslationCatalog = {
pendingMemberLine: (member, index) => pendingMemberLine: (member, index) =>
`${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`, `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`,
openMiniAppButton: 'Open mini app', 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', joinHouseholdButton: 'Join household',
approveMemberButton: (displayName) => `Approve ${displayName}`, approveMemberButton: (displayName) => `Approve ${displayName}`,
telegramIdentityRequired: 'Telegram user identity is required to join a household.', telegramIdentityRequired: 'Telegram user identity is required to join a household.',

View File

@@ -44,6 +44,8 @@ export const ruBotTranslations: BotTranslationCatalog = {
pendingMemberLine: (member, index) => pendingMemberLine: (member, index) =>
`${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`, `${index + 1}. ${member.displayName} (${member.telegramUserId})${member.username ? ` @${member.username}` : ''}`,
openMiniAppButton: 'Открыть мини-приложение', openMiniAppButton: 'Открыть мини-приложение',
openMiniAppFromPrivateChat: 'Откройте мини-приложение по кнопке ниже.',
openMiniAppUnavailable: 'Мини-приложение сейчас не настроено.',
joinHouseholdButton: 'Вступить в дом', joinHouseholdButton: 'Вступить в дом',
approveMemberButton: (displayName) => `Подтвердить ${displayName}`, approveMemberButton: (displayName) => `Подтвердить ${displayName}`,
telegramIdentityRequired: 'Чтобы вступить в дом, нужна Telegram-учётка пользователя.', telegramIdentityRequired: 'Чтобы вступить в дом, нужна Telegram-учётка пользователя.',

View File

@@ -65,6 +65,8 @@ export interface BotTranslationCatalog {
pendingMembersEmpty: (householdName: string) => string pendingMembersEmpty: (householdName: string) => string
pendingMemberLine: (member: PendingMemberSummary, index: number) => string pendingMemberLine: (member: PendingMemberSummary, index: number) => string
openMiniAppButton: string openMiniAppButton: string
openMiniAppFromPrivateChat: string
openMiniAppUnavailable: string
joinHouseholdButton: string joinHouseholdButton: string
approveMemberButton: (displayName: string) => string approveMemberButton: (displayName: string) => string
telegramIdentityRequired: string telegramIdentityRequired: string

View File

@@ -92,6 +92,7 @@ const bot = createTelegramBot(
getLogger('telegram'), getLogger('telegram'),
householdConfigurationRepositoryClient?.repository householdConfigurationRepositoryClient?.repository
) )
bot.botInfo = await bot.api.getMe()
const webhookHandler = webhookCallback(bot, 'std/http', { const webhookHandler = webhookCallback(bot, 'std/http', {
onTimeout: 'return' onTimeout: 'return'
}) })
@@ -359,6 +360,11 @@ const reminderJobs = runtime.reminderJobsEnabled
miniAppUrl: runtime.miniAppAllowedOrigins[0] miniAppUrl: runtime.miniAppAllowedOrigins[0]
} }
: {}), : {}),
...(bot.botInfo?.username
? {
botUsername: bot.botInfo.username
}
: {}),
logger: getLogger('scheduler') logger: getLogger('scheduler')
}) })
})() })()

View File

@@ -42,7 +42,8 @@ describe('createReminderJobsHandler', () => {
releaseReminderDispatch: mock(async () => {}), releaseReminderDispatch: mock(async () => {}),
sendReminderMessage, sendReminderMessage,
reminderService, reminderService,
now: () => fixedNow now: () => fixedNow,
botUsername: 'household_test_bot'
}) })
const response = await handler.handle( const response = await handler.handle(
@@ -72,6 +73,12 @@ describe('createReminderJobsHandler', () => {
text: 'Шаблон', text: 'Шаблон',
callback_data: 'reminder_util:template' callback_data: 'reminder_util:template'
} }
],
[
{
text: 'Открыть дашборд',
url: 'https://t.me/household_test_bot?start=dashboard'
}
] ]
] ]
} }

View File

@@ -94,6 +94,7 @@ export function createReminderJobsHandler(options: {
forceDryRun?: boolean forceDryRun?: boolean
now?: () => Temporal.Instant now?: () => Temporal.Instant
miniAppUrl?: string miniAppUrl?: string
botUsername?: string
logger?: Logger logger?: Logger
}): { }): {
handle: (request: Request, rawReminderType: string) => Promise<Response> handle: (request: Request, rawReminderType: string) => Promise<Response>
@@ -109,7 +110,18 @@ export function createReminderJobsHandler(options: {
case 'utilities': case 'utilities':
return { return {
text: t.utilities(period), 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': case 'rent-warning':
return { return {

View File

@@ -10,6 +10,7 @@ import type { InlineKeyboardMarkup } from 'grammy/types'
import { getBotTranslations, type BotLocale } from './i18n' import { getBotTranslations, type BotLocale } from './i18n'
import { resolveReplyLocale } from './bot-locale' import { resolveReplyLocale } from './bot-locale'
import { buildBotStartDeepLink } from './telegram-deep-links'
export const REMINDER_UTILITY_GUIDED_CALLBACK = 'reminder_util:guided' export const REMINDER_UTILITY_GUIDED_CALLBACK = 'reminder_util:guided'
export const REMINDER_UTILITY_TEMPLATE_CALLBACK = 'reminder_util:template' export const REMINDER_UTILITY_TEMPLATE_CALLBACK = 'reminder_util:template'
@@ -358,10 +359,13 @@ async function resolveReminderContext(
export function buildUtilitiesReminderReplyMarkup( export function buildUtilitiesReminderReplyMarkup(
locale: BotLocale, locale: BotLocale,
miniAppUrl?: string options?: {
miniAppUrl?: string
botUsername?: string
}
): InlineKeyboardMarkup { ): InlineKeyboardMarkup {
const t = getBotTranslations(locale).reminders const t = getBotTranslations(locale).reminders
const dashboardUrl = miniAppUrl?.trim() const dashboardUrl = buildBotStartDeepLink(options?.botUsername, 'dashboard')
return { return {
inline_keyboard: [ inline_keyboard: [
@@ -380,9 +384,7 @@ export function buildUtilitiesReminderReplyMarkup(
[ [
{ {
text: t.openDashboardButton, text: t.openDashboardButton,
web_app: { url: dashboardUrl
url: dashboardUrl
}
} }
] ]
] ]

View File

@@ -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)}`
}

View File

@@ -2437,7 +2437,7 @@ function App() {
</div> </div>
<Show when={readySession()?.mode === 'live'}> <Show when={readySession()?.mode === 'live'}>
<Button <Button
variant="ghost" variant="secondary"
class="app-context-row__action" class="app-context-row__action"
onClick={() => setProfileEditorOpen(true)} onClick={() => setProfileEditorOpen(true)}
> >