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>
192 lines
5.4 KiB
TypeScript
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 } } ]],
|
|
},
|
|
} : {}
|
|
)
|
|
}
|