guidly/bot/conversations/onboarding.ts
Jaymiesh 8033377cdf
All checks were successful
CI/CD / lint (push) Successful in 7s
CI/CD / build (push) Successful in 18s
CI/CD / deploy (push) Successful in 40s
Switch onboarding city selection to manual text input
2026-02-11 16:35:20 +07:00

210 lines
6.0 KiB
TypeScript

import type { Conversation } from '@grammyjs/conversations'
import type { BotContext } from '../types.js'
type BotConversation = Conversation<BotContext, BotContext>
import { getOrCreateCityByName, findUserByTelegramId } from '../../api/_lib/db.js'
import { t, getLang } from '../i18n/index.js'
import { createQuestFromOnboarding } from '../services/quest.service.js'
const COMPANIONS_MAP: Record<string, string> = {
solo: 'solo',
couple: 'couple',
family: 'family',
friends: 'friends',
}
const PACE_MAP: Record<string, string> = {
slow: 'slow',
normal: 'normal',
active: 'active',
}
export async function onboardingConversation(conversation: BotConversation, ctx: BotContext) {
const lang = getLang(ctx.from?.language_code)
// Step 1: City input
await ctx.reply(t(lang, 'choose_city'))
let cityName = ''
while (true) {
const cityResponse = await conversation.waitFor('message:text')
const input = cityResponse.message.text.trim().replace(/\s+/g, ' ')
if (input.length >= 2 && !input.startsWith('/')) {
cityName = input
break
}
await ctx.reply(t(lang, 'invalid_city'))
}
const city = await conversation.external(() => getOrCreateCityByName(cityName))
const cityId = city.id
cityName = city.name
// Step 2: Number of days
await ctx.reply(t(lang, 'choose_days'))
let days: number = 0
while (true) {
const daysResponse = await conversation.waitFor('message:text')
const parsed = parseInt(daysResponse.message.text)
if (parsed >= 1 && parsed <= 30) {
days = parsed
break
}
await ctx.reply(t(lang, 'invalid_days'))
}
// Step 3: Companions
await ctx.reply(t(lang, 'choose_companions'), {
reply_markup: {
inline_keyboard: [
[
{ text: t(lang, 'companions_solo'), callback_data: 'comp_solo' },
{ text: t(lang, 'companions_couple'), callback_data: 'comp_couple' },
],
[
{ text: t(lang, 'companions_family'), callback_data: 'comp_family' },
{ text: t(lang, 'companions_friends'), callback_data: 'comp_friends' },
],
],
},
})
const compResponse = await conversation.waitForCallbackQuery(/^comp_/)
const companions = COMPANIONS_MAP[compResponse.callbackQuery!.data!.replace('comp_', '')] || 'solo'
await compResponse.answerCallbackQuery()
// Step 4: Pace
await ctx.reply(t(lang, 'choose_pace'), {
reply_markup: {
inline_keyboard: [
[{ text: t(lang, 'pace_slow'), callback_data: 'pace_slow' }],
[{ text: t(lang, 'pace_normal'), callback_data: 'pace_normal' }],
[{ text: t(lang, 'pace_active'), callback_data: 'pace_active' }],
],
},
})
const paceResponse = await conversation.waitForCallbackQuery(/^pace_/)
const pace = PACE_MAP[paceResponse.callbackQuery!.data!.replace('pace_', '')] || 'normal'
await paceResponse.answerCallbackQuery()
// Step 5: Wishes (optional)
await ctx.reply(t(lang, 'add_wishes'), {
reply_markup: {
inline_keyboard: [
[{ text: t(lang, 'skip'), callback_data: 'wishes_skip' }],
],
},
})
let userComment: string | undefined
// Wait for either text or callback
const wishesCtx = await conversation.wait()
if (wishesCtx.callbackQuery?.data === 'wishes_skip') {
await wishesCtx.answerCallbackQuery()
userComment = undefined
} else if (wishesCtx.message?.text) {
userComment = wishesCtx.message.text
}
// Step 6: Confirmation
const companionsLabel = t(lang, `companions_${companions}`)
const paceLabel = t(lang, `pace_${pace}`)
const wishesLine = userComment ? t(lang, 'wishes_line', { comment: userComment }) : ''
await ctx.reply(
t(lang, 'onboarding_summary', {
city: cityName,
days: days,
companions: companionsLabel,
pace: paceLabel,
wishes: wishesLine,
}),
{
reply_markup: {
inline_keyboard: [
[
{ text: t(lang, 'confirm_yes'), callback_data: 'onboarding_confirm' },
{ text: t(lang, 'confirm_restart'), callback_data: 'onboarding_restart' },
],
],
},
}
)
const confirmResponse = await conversation.waitForCallbackQuery(/^onboarding_/)
await confirmResponse.answerCallbackQuery()
if (confirmResponse.callbackQuery!.data === 'onboarding_restart') {
// Restart the conversation
await ctx.reply(t(lang, 'welcome'))
return
}
// Step 7: Create quest
await ctx.reply(t(lang, 'creating_quest'))
const userId = await conversation.external(async () => {
const user = await findUserByTelegramId(ctx.from!.id)
return user?.id
})
if (!userId) {
await ctx.reply(t(lang, 'use_start'))
return
}
const chatId = ctx.chat?.id
if (!chatId) {
await ctx.reply('Something went wrong. Please try /start again.')
return
}
// Run heavy generation in background so webhook handlers can return quickly.
void (async () => {
try {
const result = await createQuestFromOnboarding({
userId,
cityId,
cityName,
days,
companions,
pace,
userComment,
lang,
})
if (!result) {
await ctx.api.sendMessage(chatId, 'Something went wrong. Please try /start again.')
return
}
const appUrl = process.env.APP_URL || 'https://guidly.example.com'
const isHttps = appUrl.startsWith('https://')
const questText = t(lang, 'quest_created', {
title: result.title,
description: result.description,
}) + (isHttps ? '' : `\n\n🔗 ${appUrl}`)
await ctx.api.sendMessage(
chatId,
questText,
isHttps ? {
reply_markup: {
inline_keyboard: [[{ text: t(lang, 'open_quest'), web_app: { url: appUrl } }]],
},
} : {}
)
} catch (error) {
console.error('Background quest creation failed:', error)
await ctx.api.sendMessage(chatId, 'Something went wrong. Please try /start again.')
}
})()
// Conversation finishes immediately; final quest message will be sent by background task.
return
}