- Replace `any` types with proper interfaces (QuestDetailResponse, etc.) - Remove unused imports (createUser, findUserByTelegramId, updatePointContent) - Use `unknown` in catch blocks with instanceof narrowing - Type Express handlers with Request/Response/NextFunction - Add argsIgnorePattern for underscore-prefixed params in ESLint config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
172 lines
5.0 KiB
TypeScript
172 lines
5.0 KiB
TypeScript
import { Bot, session } from 'grammy'
|
|
import { conversations, createConversation } from '@grammyjs/conversations'
|
|
import type { BotContext, SessionData } from './types.js'
|
|
import { onboardingConversation } from './conversations/onboarding.js'
|
|
import { createUser, findUserByTelegramId, getActiveQuestForUser } from '../api/_lib/db.js'
|
|
import { t, getLang } from './i18n/index.js'
|
|
|
|
// Helper: build reply_markup for opening the Mini App
|
|
// Telegram requires HTTPS for both web_app and url buttons.
|
|
// In dev (HTTP), we skip the button entirely and append the link to the message text.
|
|
function questMarkup(label: string): { inline_keyboard: Record<string, unknown>[][] } | undefined {
|
|
const appUrl = process.env.APP_URL || 'https://guidly.example.com'
|
|
if (appUrl.startsWith('https://')) {
|
|
return { inline_keyboard: [[ { text: label, web_app: { url: appUrl } } ]] }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
function appendAppLink(text: string): string {
|
|
const appUrl = process.env.APP_URL || 'https://guidly.example.com'
|
|
if (!appUrl.startsWith('https://')) {
|
|
return text + `\n\n🔗 ${appUrl}`
|
|
}
|
|
return text
|
|
}
|
|
|
|
export function createBot(token: string) {
|
|
const bot = new Bot<BotContext>(token)
|
|
|
|
// Error handler — log but don't crash
|
|
bot.catch((err) => {
|
|
console.error('Bot error:', err.message)
|
|
})
|
|
|
|
// Session middleware (in-memory for now, can switch to DB later)
|
|
bot.use(session({
|
|
initial: (): SessionData => ({
|
|
conversationState: 'idle',
|
|
}),
|
|
}))
|
|
|
|
// Conversations plugin
|
|
bot.use(conversations())
|
|
bot.use(createConversation(onboardingConversation))
|
|
|
|
// /start command
|
|
bot.command('start', async (ctx) => {
|
|
const from = ctx.from
|
|
if (!from) return
|
|
|
|
const lang = getLang(from.language_code)
|
|
|
|
// Upsert user in DB
|
|
const user = await createUser({
|
|
telegram_id: from.id,
|
|
username: from.username,
|
|
display_name: [from.first_name, from.last_name].filter(Boolean).join(' '),
|
|
language_code: from.language_code || 'ru',
|
|
})
|
|
|
|
ctx.session.userId = user.id
|
|
|
|
// Check if user has an active quest
|
|
const activeQuest = await getActiveQuestForUser(user.id)
|
|
|
|
if (activeQuest) {
|
|
// Resume active quest
|
|
await ctx.reply(appendAppLink(t(lang, 'welcome_back', { city: activeQuest.city_name })), {
|
|
reply_markup: questMarkup(t(lang, 'open_quest')),
|
|
})
|
|
return
|
|
}
|
|
|
|
// New user or no active quest — start onboarding
|
|
await ctx.reply(t(lang, 'welcome'))
|
|
await ctx.conversation.enter('onboardingConversation')
|
|
})
|
|
|
|
// /help command
|
|
bot.command('help', async (ctx) => {
|
|
const lang = getLang(ctx.from?.language_code)
|
|
await ctx.reply(t(lang, 'help'))
|
|
})
|
|
|
|
// /status command
|
|
bot.command('status', async (ctx) => {
|
|
const from = ctx.from
|
|
if (!from) return
|
|
|
|
const lang = getLang(from.language_code)
|
|
const user = await findUserByTelegramId(from.id)
|
|
if (!user) {
|
|
await ctx.reply(t(lang, 'no_profile'))
|
|
return
|
|
}
|
|
|
|
const quest = await getActiveQuestForUser(user.id)
|
|
if (!quest) {
|
|
await ctx.reply(t(lang, 'no_active_quest'))
|
|
return
|
|
}
|
|
|
|
await ctx.reply(t(lang, 'quest_status', {
|
|
title: quest.title || quest.city_name,
|
|
city: quest.city_name,
|
|
status: quest.status,
|
|
}))
|
|
})
|
|
|
|
// /stop command — terminate quest early
|
|
bot.command('stop', async (ctx) => {
|
|
const lang = getLang(ctx.from?.language_code)
|
|
await ctx.reply(t(lang, 'stop_confirm'), {
|
|
reply_markup: {
|
|
inline_keyboard: [
|
|
[
|
|
{ text: t(lang, 'yes_stop'), callback_data: 'quest_stop_confirm' },
|
|
{ text: t(lang, 'no_continue'), callback_data: 'quest_stop_cancel' },
|
|
],
|
|
],
|
|
},
|
|
})
|
|
})
|
|
|
|
// Callback: quest stop confirm
|
|
bot.callbackQuery('quest_stop_confirm', async (ctx) => {
|
|
const from = ctx.from
|
|
const lang = getLang(from.language_code)
|
|
const user = await findUserByTelegramId(from.id)
|
|
|
|
if (user) {
|
|
const quest = await getActiveQuestForUser(user.id)
|
|
if (quest) {
|
|
// Import dynamically to avoid circular deps
|
|
const { updateQuestStatus } = await import('../api/_lib/db.js')
|
|
await updateQuestStatus(quest.id, 'completed')
|
|
await ctx.editMessageText(t(lang, 'quest_stopped'))
|
|
return
|
|
}
|
|
}
|
|
|
|
await ctx.editMessageText(t(lang, 'no_active_quest'))
|
|
})
|
|
|
|
bot.callbackQuery('quest_stop_cancel', async (ctx) => {
|
|
const lang = getLang(ctx.from.language_code)
|
|
await ctx.editMessageText(t(lang, 'quest_continue'))
|
|
})
|
|
|
|
// Handle any text when no conversation is active
|
|
bot.on('message:text', async (ctx) => {
|
|
const lang = getLang(ctx.from?.language_code)
|
|
const user = await findUserByTelegramId(ctx.from.id)
|
|
|
|
if (!user) {
|
|
await ctx.reply(t(lang, 'use_start'))
|
|
return
|
|
}
|
|
|
|
const quest = await getActiveQuestForUser(user.id)
|
|
if (quest) {
|
|
await ctx.reply(appendAppLink(t(lang, 'quest_in_progress')), {
|
|
reply_markup: questMarkup(t(lang, 'open_quest')),
|
|
})
|
|
} else {
|
|
await ctx.reply(t(lang, 'no_active_quest_start'))
|
|
}
|
|
})
|
|
|
|
return bot
|
|
}
|