guidly/bot/conversations/onboarding.ts
laruevin d5ed7fdcf9 Initial commit: Guidly project with CI/CD pipeline
Telegram Bot + Mini App for city walking quests.
- React 19 + TypeScript + Vite 6 frontend
- Express 5 + PostgreSQL backend
- grammY Telegram bot with DeepSeek AI
- GitLab CI/CD: lint, build, deploy to production

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:42:42 +07:00

192 lines
5.4 KiB
TypeScript

import type { Conversation } from '@grammyjs/conversations'
import type { BotContext } from '../types.js'
type BotConversation = Conversation<BotContext, BotContext>
import { getActiveCities, createUser, 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 selection
const cities = await conversation.external(() => getActiveCities())
const cityButtons = cities.map((city: any) => ([
{ text: city.name, callback_data: `city_${city.id}` }
]))
await ctx.reply(t(lang, 'choose_city'), {
reply_markup: { inline_keyboard: cityButtons },
})
const cityResponse = await conversation.waitForCallbackQuery(/^city_/)
const cityId = cityResponse.callbackQuery!.data!.replace('city_', '')
const cityName = cities.find((c: any) => c.id === cityId)?.name || 'Unknown'
await cityResponse.answerCallbackQuery()
// 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 result = await conversation.external(() =>
createQuestFromOnboarding({
userId,
cityId,
cityName,
days,
companions,
pace,
userComment,
lang,
})
)
if (!result) {
await ctx.reply('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.reply(
questText,
isHttps ? {
reply_markup: {
inline_keyboard: [[ { text: t(lang, 'open_quest'), web_app: { url: appUrl } } ]],
},
} : {}
)
}