guidly/bot/bot.ts
laruevin c61e6ad9cd fix: resolve all 32 ESLint errors across codebase
- 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>
2026-02-11 12:27:42 +07:00

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
}