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'
)
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'
}
}
]

View File

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

View File

@@ -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.',

View File

@@ -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-учётка пользователя.',

View File

@@ -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

View File

@@ -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')
})
})()

View File

@@ -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'
}
]
]
}

View File

@@ -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<Response>
@@ -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 {

View File

@@ -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
}
]
]

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>
<Show when={readySession()?.mode === 'live'}>
<Button
variant="ghost"
variant="secondary"
class="app-context-row__action"
onClick={() => setProfileEditorOpen(true)}
>