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>
This commit is contained in:
laruevin 2026-02-11 11:42:42 +07:00
commit d5ed7fdcf9
71 changed files with 9782 additions and 0 deletions

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
# PostgreSQL
DB_HOST=localhost
DB_PORT=5432
DB_NAME=guidly
DB_USER=guidly
DB_PASSWORD=
DATABASE_SSL=false
# Telegram Bot
TELEGRAM_BOT_TOKEN=
BOT_WEBHOOK_URL=https://guidly.example.com/api/telegram/webhook
BOT_WEBHOOK_SECRET=
# DeepSeek LLM (OpenAI-compatible API)
LLM_BASE_URL=https://api.deepseek.com
LLM_API_KEY=
LLM_MODEL=deepseek-chat
# Internal API (shared secret between bot and API server)
INTERNAL_API_KEY=
# Application
NODE_ENV=development
PORT=3000
APP_URL=http://localhost:5173

42
.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
# Dependencies
node_modules/
# Build output
dist/
dist-server/
dist-bot/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Env files
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
# Temp files
~$*
# Docs
*.docx
*.xlsx
# Deploy secrets & artifacts
deploy/.env.production
deploy/.env.*
deploy.tar.gz
# Local files
.claude/
Инструкция.txt

70
.gitlab-ci.yml Normal file
View File

@ -0,0 +1,70 @@
stages:
- lint
- build
- deploy
# Шаблон: Node.js 22 + кэш node_modules
.node_setup:
image: node:22-alpine
before_script:
- npm ci --prefer-offline
cache:
key:
files:
- package-lock.json
paths:
- node_modules/
# Lint — на каждый push и MR
lint:
extends: .node_setup
stage: lint
script:
- npm run lint
# Build — компиляция TS + Vite сборка
build:
extends: .node_setup
stage: build
script:
- npm run build
artifacts:
paths:
- dist/
- dist-server/
- dist-bot/
expire_in: 1 hour
# Deploy — только при push в main
deploy_production:
stage: deploy
image: node:22-alpine
dependencies:
- build
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- tar -czf deploy.tar.gz dist/ dist-server/ dist-bot/ package.json package-lock.json ecosystem.config.cjs deploy/nginx-guidly.conf
- scp deploy.tar.gz "${DEPLOY_USER}@${DEPLOY_HOST}:/tmp/"
- |
ssh "${DEPLOY_USER}@${DEPLOY_HOST}" << 'ENDSSH'
set -e
cd /root/guidly
tar -xzf /tmp/deploy.tar.gz
rm /tmp/deploy.tar.gz
npm install --omit=dev
pm2 delete guidly-server 2>/dev/null || true
pm2 start ecosystem.config.cjs
pm2 save
echo "=== Deploy complete ==="
ENDSSH
rules:
- if: $CI_COMMIT_BRANCH == "main"
environment:
name: production
url: https://gidli.ru

116
api/_lib/auth.ts Normal file
View File

@ -0,0 +1,116 @@
import crypto from 'crypto'
import type { ApiRequest, ApiResponse } from './types.js'
const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN || ''
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || 'dev-internal-key'
const ALLOWED_ORIGINS = [
'https://web.telegram.org',
process.env.APP_URL || null,
process.env.NODE_ENV === 'development' ? 'http://localhost:5173' : null,
].filter(Boolean) as string[]
// Validate Telegram Mini App initData
// https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
export function validateTelegramInitData(initData: string): { id: number; username?: string; first_name?: string; last_name?: string; language_code?: string } | null {
if (!initData || !TELEGRAM_BOT_TOKEN) return null
try {
const params = new URLSearchParams(initData)
const hash = params.get('hash')
if (!hash) return null
params.delete('hash')
// Sort and create data-check-string
const dataCheckString = Array.from(params.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}=${value}`)
.join('\n')
// Create secret key
const secretKey = crypto
.createHmac('sha256', 'WebAppData')
.update(TELEGRAM_BOT_TOKEN)
.digest()
// Compute HMAC
const computedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex')
if (computedHash !== hash) return null
// Check auth_date is not too old (24 hours)
const authDate = params.get('auth_date')
if (authDate) {
const authTimestamp = parseInt(authDate) * 1000
const now = Date.now()
if (now - authTimestamp > 24 * 60 * 60 * 1000) return null
}
// Parse user data
const userStr = params.get('user')
if (!userStr) return null
return JSON.parse(userStr)
} catch {
return null
}
}
// Middleware: authenticate via Telegram initData
export function withTelegramAuth(handler: Function) {
return async (req: ApiRequest, res: ApiResponse) => {
const initData = req.headers['x-telegram-init-data'] as string
const user = validateTelegramInitData(initData)
if (!user) {
// In development, allow bypass with query param
if (process.env.NODE_ENV === 'development' && req.query.telegram_id) {
req.telegramUser = {
id: parseInt(req.query.telegram_id as string),
first_name: 'Dev',
language_code: 'ru',
}
return handler(req, res)
}
return res.status(401).json({ error: 'Invalid Telegram authentication' })
}
req.telegramUser = user
return handler(req, res)
}
}
// Middleware: authenticate internal bot-to-API calls
export function withInternalAuth(handler: Function) {
return async (req: ApiRequest, res: ApiResponse) => {
const apiKey = req.headers['x-internal-api-key'] as string
if (apiKey !== INTERNAL_API_KEY) {
return res.status(401).json({ error: 'Invalid internal API key' })
}
return handler(req, res)
}
}
// CORS handler
export function cors(req: ApiRequest, res: ApiResponse) {
const origin = req.headers.origin
if (origin && ALLOWED_ORIGINS.includes(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin)
} else if (process.env.NODE_ENV === 'development' && origin) {
if (origin.startsWith('http://localhost:') || origin.startsWith('http://127.0.0.1:')) {
res.setHeader('Access-Control-Allow-Origin', origin)
}
}
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Telegram-Init-Data, X-Internal-Api-Key')
res.setHeader('Access-Control-Allow-Credentials', 'true')
}

368
api/_lib/db.ts Normal file
View File

@ -0,0 +1,368 @@
import { Pool } from 'pg'
const pool = new Pool({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false },
})
// ============================================
// Users
// ============================================
export async function findUserByTelegramId(telegramId: number) {
const { rows } = await pool.query(
'SELECT * FROM users WHERE telegram_id = $1',
[telegramId]
)
return rows[0] || null
}
export async function createUser(data: {
telegram_id: number
username?: string
display_name?: string
language_code?: string
}) {
const { rows } = await pool.query(
`INSERT INTO users (telegram_id, username, display_name, language_code)
VALUES ($1, $2, $3, $4)
ON CONFLICT (telegram_id) DO UPDATE SET
username = COALESCE($2, users.username),
display_name = COALESCE($3, users.display_name),
language_code = COALESCE($4, users.language_code),
updated_at = NOW()
RETURNING *`,
[data.telegram_id, data.username || null, data.display_name || null, data.language_code || 'ru']
)
return rows[0]
}
// ============================================
// Cities
// ============================================
export async function getActiveCities() {
const { rows } = await pool.query(
"SELECT * FROM cities WHERE status = 'active' ORDER BY name"
)
return rows
}
export async function getCityById(id: string) {
const { rows } = await pool.query('SELECT * FROM cities WHERE id = $1', [id])
return rows[0] || null
}
// ============================================
// Quests
// ============================================
export async function getActiveQuestForUser(userId: string) {
const { rows } = await pool.query(
`SELECT q.*, c.name as city_name, c.country as city_country
FROM quests q
JOIN cities c ON q.city_id = c.id
WHERE q.user_id = $1 AND q.status = 'in_progress'
LIMIT 1`,
[userId]
)
return rows[0] || null
}
export async function createQuest(data: {
city_id: string
user_id: string
title?: string
description?: string
number_of_days: number
pace: string
companions: string
user_comment?: string
}) {
const { rows } = await pool.query(
`INSERT INTO quests (city_id, user_id, title, description, number_of_days, pace, companions, user_comment)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[
data.city_id, data.user_id, data.title || null, data.description || null,
data.number_of_days, data.pace, data.companions, data.user_comment || null
]
)
return rows[0]
}
export async function updateQuestStatus(questId: string, status: string) {
const extra = status === 'in_progress'
? ", started_at = NOW()"
: status === 'completed'
? ", completed_at = NOW()"
: ""
const { rows } = await pool.query(
`UPDATE quests SET status = $1, updated_at = NOW()${extra} WHERE id = $2 RETURNING *`,
[status, questId]
)
return rows[0] || null
}
// ============================================
// Quest Days
// ============================================
export async function createQuestDay(data: {
quest_id: string
day_number: number
theme?: string
}) {
const { rows } = await pool.query(
`INSERT INTO quest_days (quest_id, day_number, theme)
VALUES ($1, $2, $3)
RETURNING *`,
[data.quest_id, data.day_number, data.theme || null]
)
return rows[0]
}
export async function getQuestDays(questId: string) {
const { rows } = await pool.query(
'SELECT * FROM quest_days WHERE quest_id = $1 ORDER BY day_number',
[questId]
)
return rows
}
export async function getActiveDayForQuest(questId: string) {
const { rows } = await pool.query(
`SELECT * FROM quest_days
WHERE quest_id = $1 AND status = 'in_progress'
LIMIT 1`,
[questId]
)
return rows[0] || null
}
export async function updateDayStatus(dayId: string, status: string) {
const extra = status === 'in_progress'
? ", started_at = NOW()"
: status === 'completed'
? ", completed_at = NOW()"
: ""
const { rows } = await pool.query(
`UPDATE quest_days SET status = $1${extra} WHERE id = $2 RETURNING *`,
[status, dayId]
)
return rows[0] || null
}
// ============================================
// Quest Points
// ============================================
export async function createQuestPoint(data: {
day_id: string
title: string
location_lat?: number
location_lon?: number
teaser_text?: string
content_text?: string
order_in_day: number
status?: string
}) {
const { rows } = await pool.query(
`INSERT INTO quest_points (day_id, title, location_lat, location_lon, teaser_text, content_text, order_in_day, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (day_id, order_in_day) DO NOTHING
RETURNING *`,
[
data.day_id, data.title, data.location_lat || null, data.location_lon || null,
data.teaser_text || null, data.content_text || null, data.order_in_day,
data.status || 'hidden'
]
)
return rows[0] || null
}
export async function getActivePointForDay(dayId: string) {
const { rows } = await pool.query(
`SELECT * FROM quest_points
WHERE day_id = $1 AND status = 'active'
LIMIT 1`,
[dayId]
)
return rows[0] || null
}
export async function getPointsForDay(dayId: string) {
const { rows } = await pool.query(
'SELECT * FROM quest_points WHERE day_id = $1 ORDER BY order_in_day',
[dayId]
)
return rows
}
export async function getPointById(pointId: string) {
const { rows } = await pool.query(
'SELECT * FROM quest_points WHERE id = $1',
[pointId]
)
return rows[0] || null
}
export async function updatePointStatus(pointId: string, status: string) {
const extra = status === 'active'
? ""
: status === 'completed'
? ", completed_at = NOW()"
: ""
const arrivedExtra = status === 'active' ? "" : ""
const { rows } = await pool.query(
`UPDATE quest_points SET status = $1${extra} WHERE id = $2 RETURNING *`,
[status, pointId]
)
return rows[0] || null
}
export async function markPointArrived(pointId: string) {
const { rows } = await pool.query(
`UPDATE quest_points SET arrived_at = NOW() WHERE id = $1 RETURNING *`,
[pointId]
)
return rows[0] || null
}
export async function updatePointContent(pointId: string, contentText: string) {
const { rows } = await pool.query(
`UPDATE quest_points SET content_text = $1 WHERE id = $2 RETURNING *`,
[contentText, pointId]
)
return rows[0] || null
}
// ============================================
// Quest Events
// ============================================
export async function createQuestEvent(data: {
quest_id: string
user_id: string
point_id?: string
event_type: string
payload?: Record<string, unknown>
}) {
const { rows } = await pool.query(
`INSERT INTO quest_events (quest_id, user_id, point_id, event_type, payload)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[data.quest_id, data.user_id, data.point_id || null, data.event_type, JSON.stringify(data.payload || {})]
)
return rows[0]
}
export async function getUnprocessedEvents() {
const { rows } = await pool.query(
`SELECT * FROM quest_events
WHERE processed = false
ORDER BY created_at ASC
LIMIT 20`
)
return rows
}
export async function markEventProcessed(eventId: string) {
await pool.query(
'UPDATE quest_events SET processed = true WHERE id = $1',
[eventId]
)
}
// ============================================
// Chat History
// ============================================
export async function saveChatMessage(data: {
user_id: string
quest_id: string
point_id?: string
message_type: string
content: string
}) {
const { rows } = await pool.query(
`INSERT INTO chat_history (user_id, quest_id, point_id, message_type, content)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[data.user_id, data.quest_id, data.point_id || null, data.message_type, data.content]
)
return rows[0]
}
export async function getChatHistory(questId: string) {
const { rows } = await pool.query(
'SELECT * FROM chat_history WHERE quest_id = $1 ORDER BY created_at',
[questId]
)
return rows
}
// ============================================
// Achievements
// ============================================
export async function createAchievement(data: {
user_id: string
type: string
city_id?: string
}) {
const { rows } = await pool.query(
`INSERT INTO achievements (user_id, type, city_id)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, type, city_id) DO NOTHING
RETURNING *`,
[data.user_id, data.type, data.city_id || null]
)
return rows[0] || null
}
export async function getUserAchievements(userId: string) {
const { rows } = await pool.query(
'SELECT * FROM achievements WHERE user_id = $1 ORDER BY achieved_at',
[userId]
)
return rows
}
// ============================================
// Full quest state (for API responses)
// ============================================
export async function getFullQuestState(userId: string) {
const quest = await getActiveQuestForUser(userId)
if (!quest) return null
const day = await getActiveDayForQuest(quest.id)
if (!day) {
return { quest, day: null, point: null, dayProgress: null }
}
const points = await getPointsForDay(day.id)
const activePoint = points.find((p: { status: string }) => p.status === 'active') || null
const completedCount = points.filter((p: { status: string }) => p.status === 'completed').length
return {
quest,
day,
point: activePoint,
dayProgress: {
totalPoints: points.length,
completedPoints: completedCount,
}
}
}
export { pool }

16
api/_lib/types.ts Normal file
View File

@ -0,0 +1,16 @@
import type { Request, Response } from 'express'
export interface ApiRequest extends Request {
query: Record<string, string | string[] | undefined>
telegramUser?: {
id: number
username?: string
first_name?: string
last_name?: string
language_code?: string
}
}
export interface ApiResponse extends Response {}
export type ApiHandler = (req: ApiRequest, res: ApiResponse) => Promise<void | ApiResponse> | void | ApiResponse

23
api/_lib/validation.ts Normal file
View File

@ -0,0 +1,23 @@
import { z } from 'zod'
export const questEventSchema = z.object({
questId: z.string().uuid(),
pointId: z.string().uuid().optional(),
eventType: z.enum([
'arrived', 'photo_uploaded', 'skipped',
'finished_today', 'point_completed',
'quest_started', 'quest_completed',
'day_started', 'day_completed',
'peek_next'
]),
payload: z.record(z.unknown()).optional(),
})
export const internalActionSchema = z.object({
action: z.enum([
'create_quest', 'generate_step', 'update_point_content',
'update_state', 'get_user_state', 'save_chat_message',
'check_achievements'
]),
data: z.record(z.unknown()),
})

127
api/bot/internal.ts Normal file
View File

@ -0,0 +1,127 @@
import type { ApiRequest, ApiResponse } from '../_lib/types.js'
import { withInternalAuth } from '../_lib/auth.js'
import {
createQuest, createQuestDay, createQuestPoint,
updateQuestStatus, updateDayStatus, updatePointStatus,
updatePointContent, markPointArrived,
getActiveQuestForUser, getActiveDayForQuest,
getPointsForDay, getFullQuestState,
saveChatMessage, createAchievement, getUserAchievements,
findUserByTelegramId,
} from '../_lib/db.js'
async function handler(req: ApiRequest, res: ApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const { action, data } = req.body
try {
switch (action) {
case 'create_quest': {
const quest = await createQuest({
city_id: data.city_id,
user_id: data.user_id,
title: data.title,
description: data.description,
number_of_days: data.number_of_days,
pace: data.pace,
companions: data.companions,
user_comment: data.user_comment,
})
// Create day skeletons
const days = []
for (let i = 1; i <= data.number_of_days; i++) {
const day = await createQuestDay({
quest_id: quest.id,
day_number: i,
theme: data.day_themes?.[i - 1] || null,
})
days.push(day)
}
return res.json({ quest, days })
}
case 'generate_step': {
const point = await createQuestPoint({
day_id: data.day_id,
title: data.title,
location_lat: data.location_lat,
location_lon: data.location_lon,
teaser_text: data.teaser_text,
order_in_day: data.order_in_day,
status: data.status || 'active',
})
return res.json({ point })
}
case 'update_point_content': {
const point = await updatePointContent(data.point_id, data.content_text)
return res.json({ point })
}
case 'update_state': {
let result = null
if (data.entity === 'quest') {
result = await updateQuestStatus(data.id, data.status)
} else if (data.entity === 'day') {
result = await updateDayStatus(data.id, data.status)
} else if (data.entity === 'point') {
result = await updatePointStatus(data.id, data.status)
if (data.status === 'active' && data.arrived) {
await markPointArrived(data.id)
}
}
return res.json({ result })
}
case 'get_user_state': {
const user = await findUserByTelegramId(data.telegram_id)
if (!user) {
return res.json({ state: null })
}
const state = await getFullQuestState(user.id)
return res.json({ state })
}
case 'save_chat_message': {
const message = await saveChatMessage({
user_id: data.user_id,
quest_id: data.quest_id,
point_id: data.point_id,
message_type: data.message_type,
content: data.content,
})
return res.json({ message })
}
case 'check_achievements': {
const existing = await getUserAchievements(data.user_id)
const newAchievements = []
// First quest completed
if (!existing.find((a: any) => a.type === 'first_quest')) {
const ach = await createAchievement({
user_id: data.user_id,
type: 'first_quest',
city_id: data.city_id,
})
if (ach) newAchievements.push(ach)
}
return res.json({ achievements: newAchievements })
}
default:
return res.status(400).json({ error: `Unknown action: ${action}` })
}
} catch (error: any) {
console.error(`Internal API error (${action}):`, error)
return res.status(500).json({ error: error.message })
}
}
export default withInternalAuth(handler)

34
api/quest/current.ts Normal file
View File

@ -0,0 +1,34 @@
import type { ApiRequest, ApiResponse } from '../_lib/types.js'
import { cors, withTelegramAuth } from '../_lib/auth.js'
import { findUserByTelegramId, getFullQuestState } from '../_lib/db.js'
async function handler(req: ApiRequest, res: ApiResponse) {
cors(req, res)
if (req.method === 'OPTIONS') {
return res.status(200).end()
}
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' })
}
const telegramUser = req.telegramUser
if (!telegramUser) {
return res.status(401).json({ error: 'Not authenticated' })
}
const user = await findUserByTelegramId(telegramUser.id)
if (!user) {
return res.status(404).json({ error: 'User not found', code: 'USER_NOT_FOUND' })
}
const state = await getFullQuestState(user.id)
if (!state) {
return res.status(404).json({ error: 'No active quest', code: 'NO_ACTIVE_QUEST' })
}
return res.json(state)
}
export default withTelegramAuth(handler)

122
api/quest/events.ts Normal file
View File

@ -0,0 +1,122 @@
import type { ApiRequest, ApiResponse } from '../_lib/types.js'
import { cors, withTelegramAuth } from '../_lib/auth.js'
import { questEventSchema } from '../_lib/validation.js'
import {
findUserByTelegramId,
createQuestEvent,
getPointById,
updatePointStatus,
markPointArrived,
updatePointContent,
getActivePointForDay,
getActiveDayForQuest,
getActiveQuestForUser,
updateDayStatus,
getPointsForDay,
} from '../_lib/db.js'
async function handler(req: ApiRequest, res: ApiResponse) {
cors(req, res)
if (req.method === 'OPTIONS') {
return res.status(200).end()
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
const telegramUser = req.telegramUser
if (!telegramUser) {
return res.status(401).json({ error: 'Not authenticated' })
}
const parsed = questEventSchema.safeParse(req.body)
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request', details: parsed.error.issues })
}
const { questId, pointId, eventType, payload } = parsed.data
const user = await findUserByTelegramId(telegramUser.id)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
// Write event to DB (bot will process it asynchronously)
await createQuestEvent({
quest_id: questId,
user_id: user.id,
point_id: pointId,
event_type: eventType,
payload,
})
// Immediate state updates for UI responsiveness
let nextAction: string = 'wait'
let updatedPoint = null
switch (eventType) {
case 'arrived': {
if (pointId) {
await markPointArrived(pointId)
updatedPoint = await getPointById(pointId)
nextAction = 'show_content'
}
break
}
case 'point_completed': {
if (pointId) {
await updatePointStatus(pointId, 'completed')
// Check if there are more points in the day
const quest = await getActiveQuestForUser(user.id)
if (quest) {
const day = await getActiveDayForQuest(quest.id)
if (day) {
const points = await getPointsForDay(day.id)
const nextPoint = points.find((p: { status: string }) => p.status === 'hidden' || p.status === 'preview')
if (nextPoint) {
nextAction = 'next_point'
} else {
nextAction = 'day_complete'
}
}
}
}
break
}
case 'skipped': {
if (pointId) {
await updatePointStatus(pointId, 'skipped')
nextAction = 'next_point'
}
break
}
case 'finished_today': {
const quest = await getActiveQuestForUser(user.id)
if (quest) {
const day = await getActiveDayForQuest(quest.id)
if (day) {
// Mark all remaining points as skipped
const points = await getPointsForDay(day.id)
for (const point of points) {
if (point.status !== 'completed' && point.status !== 'skipped') {
await updatePointStatus(point.id, 'skipped')
}
}
await updateDayStatus(day.id, 'completed')
nextAction = 'day_complete'
}
}
break
}
}
return res.json({
success: true,
updatedPoint,
nextAction,
})
}
export default withTelegramAuth(handler)

79
api/quest/history.ts Normal file
View File

@ -0,0 +1,79 @@
import type { ApiRequest, ApiResponse } from '../_lib/types.js'
import { cors, withTelegramAuth } from '../_lib/auth.js'
import { findUserByTelegramId, pool } from '../_lib/db.js'
async function handler(req: ApiRequest, res: ApiResponse) {
cors(req, res)
if (req.method === 'OPTIONS') {
return res.status(200).end()
}
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' })
}
const telegramUser = req.telegramUser
if (!telegramUser) {
return res.status(401).json({ error: 'Not authenticated' })
}
const user = await findUserByTelegramId(telegramUser.id)
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
const questId = req.query.id as string | undefined
if (questId) {
// Get specific quest with days and points
const { rows: quests } = await pool.query(
`SELECT q.*, c.name as city_name, c.country as city_country
FROM quests q
JOIN cities c ON q.city_id = c.id
WHERE q.id = $1 AND q.user_id = $2`,
[questId, user.id]
)
if (quests.length === 0) {
return res.status(404).json({ error: 'Quest not found' })
}
const { rows: days } = await pool.query(
`SELECT d.*,
COALESCE(
json_agg(
jsonb_build_object(
'id', p.id, 'title', p.title, 'teaser_text', p.teaser_text,
'content_text', p.content_text, 'status', p.status,
'order_in_day', p.order_in_day, 'location_lat', p.location_lat,
'location_lon', p.location_lon
) ORDER BY p.order_in_day
) FILTER (WHERE p.id IS NOT NULL),
'[]'::json
) as points
FROM quest_days d
LEFT JOIN quest_points p ON d.id = p.day_id
WHERE d.quest_id = $1
GROUP BY d.id
ORDER BY d.day_number`,
[questId]
)
return res.json({ quest: quests[0], days })
}
// List all completed quests
const { rows: quests } = await pool.query(
`SELECT q.*, c.name as city_name, c.country as city_country
FROM quests q
JOIN cities c ON q.city_id = c.id
WHERE q.user_id = $1 AND q.status = 'completed'
ORDER BY q.completed_at DESC`,
[user.id]
)
return res.json({ quests })
}
export default withTelegramAuth(handler)

24
api/telegram/webhook.ts Normal file
View File

@ -0,0 +1,24 @@
import type { ApiRequest, ApiResponse } from '../_lib/types.js'
// Telegram webhook endpoint
// In production, the bot process handles updates via grammY's built-in webhook support
// This endpoint serves as a passthrough for webhook mode
async function handler(req: ApiRequest, res: ApiResponse) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' })
}
// Verify webhook secret
const secret = req.headers['x-telegram-bot-api-secret-token']
if (secret !== process.env.BOT_WEBHOOK_SECRET) {
return res.status(401).json({ error: 'Invalid secret' })
}
// The bot process will handle updates directly in webhook mode
// This is a placeholder for when we integrate webhook into the server
console.log('Received Telegram webhook update')
return res.status(200).json({ ok: true })
}
export default handler

171
bot/bot.ts Normal file
View File

@ -0,0 +1,171 @@
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: any[][] } | 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
}

View File

@ -0,0 +1,191 @@
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 } } ]],
},
} : {}
)
}

78
bot/i18n/en.ts Normal file
View File

@ -0,0 +1,78 @@
export const en: Record<string, string> = {
// Welcome & general
welcome: `Hi! I'm Guidly — your personal city guide and adventure companion.
I'll turn your walk into a real adventure step by step, like a quest.
Let's begin! Where are you headed?`,
welcome_back: `Welcome back! You have an active quest in {city}. Open the app to continue.`,
// Onboarding
choose_city: 'Choose a city for your adventure:',
choose_days: 'How many days are you planning? (1-30)',
invalid_days: 'Please enter a number from 1 to 30.',
choose_companions: 'Who are you traveling with?',
companions_solo: 'Solo',
companions_couple: 'As a couple',
companions_family: 'With family',
companions_friends: 'With friends',
choose_pace: 'What pace do you prefer?',
pace_slow: 'Relaxed — no rush, enjoying every moment',
pace_normal: 'Moderate — a nice balance',
pace_active: 'Active — I want to see as much as possible',
add_wishes: 'Any special wishes? What interests you most? (or tap "Skip")',
skip: 'Skip',
onboarding_summary: `Great! Here's your plan:
City: {city}
Days: {days}
Company: {companions}
Pace: {pace}
{wishes}
All good?`,
wishes_line: 'Wishes: {comment}',
confirm_yes: "Yes, let's go!",
confirm_restart: 'Start over',
// Quest creation
creating_quest: "Awesome! Creating your quest... Please wait a moment.",
quest_created: `Your quest "{title}" is ready!
{description}
Open the app to see your first step.`,
// Commands
help: `Available commands:
/start start or continue a quest
/status current progress
/stop end quest early
/help show this help`,
// Status
no_profile: 'Tap /start to begin.',
no_active_quest: "You don't have an active quest. Tap /start to create one.",
no_active_quest_start: "You don't have an active quest. Tap /start to create a new one.",
quest_status: 'Quest: {title}\nCity: {city}\nStatus: in progress',
quest_in_progress: 'You have an active quest! Open the app to continue.',
use_start: 'Tap /start to begin.',
// Stop
stop_confirm: 'Are you sure you want to end the quest early? Your progress will be saved.',
yes_stop: 'Yes, end it',
no_continue: 'No, continue',
quest_stopped: 'Quest ended. You can start a new one with /start.',
quest_continue: "Great! Let's keep going.",
// Quest buttons
open_quest: 'Open Quest',
// Event reactions
reaction_arrived: "You're here! Here's what awaits you...",
reaction_skipped: "No worries, let's move on!",
reaction_finished_today: 'Great day! Rest up, we continue tomorrow.',
reaction_point_completed: "Excellent! Let's head to the next spot...",
reaction_day_completed: "Day complete! Well done. See you tomorrow!",
reaction_quest_completed: "Congratulations! Quest complete! You've discovered the city in a whole new way.",
}

25
bot/i18n/index.ts Normal file
View File

@ -0,0 +1,25 @@
import { ru } from './ru.js'
import { en } from './en.js'
const translations: Record<string, Record<string, string>> = { ru, en }
export function getLang(languageCode?: string): string {
if (!languageCode) return 'ru'
if (languageCode.startsWith('ru') || languageCode.startsWith('uk') || languageCode.startsWith('be')) {
return 'ru'
}
return 'en'
}
export function t(lang: string, key: string, params?: Record<string, string | number>): string {
const dict = translations[lang] || translations['ru']
let text = dict[key] || translations['ru'][key] || key
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, String(v))
}
}
return text
}

78
bot/i18n/ru.ts Normal file
View File

@ -0,0 +1,78 @@
export const ru: Record<string, string> = {
// Welcome & general
welcome: `Привет! Я Guidly — твой персональный гид-проводник по городам мира.
Я превращу твою прогулку в настоящее приключение шаг за шагом, как квест.
Давай начнём! Расскажи, куда ты собираешься?`,
welcome_back: `С возвращением! У тебя есть активный квест по городу {city}. Открой приложение, чтобы продолжить.`,
// Onboarding
choose_city: 'Выбери город для приключения:',
choose_days: 'На сколько дней планируешь поездку? (1-30)',
invalid_days: 'Введи число от 1 до 30.',
choose_companions: 'С кем путешествуешь?',
companions_solo: 'Один',
companions_couple: 'Вдвоём',
companions_family: 'С семьёй',
companions_friends: 'С друзьями',
choose_pace: 'В каком темпе хочешь гулять?',
pace_slow: 'Спокойный — без спешки, наслаждаюсь',
pace_normal: 'Умеренный — золотая середина',
pace_active: 'Активный — хочу увидеть максимум',
add_wishes: 'Есть пожелания? Что тебе особенно интересно? (или нажми "Пропустить")',
skip: 'Пропустить',
onboarding_summary: `Отлично! Вот твой план:
Город: {city}
Дней: {days}
Компания: {companions}
Темп: {pace}
{wishes}
Всё верно?`,
wishes_line: 'Пожелания: {comment}',
confirm_yes: 'Да, начинаем!',
confirm_restart: 'Начать сначала',
// Quest creation
creating_quest: 'Отлично! Создаю твой квест... Подожди немного.',
quest_created: `Твой квест "{title}" готов!
{description}
Открой приложение, чтобы увидеть первый шаг.`,
// Commands
help: `Доступные команды:
/start начать или продолжить квест
/status текущий прогресс
/stop завершить квест досрочно
/help показать эту справку`,
// Status
no_profile: 'Нажми /start, чтобы начать.',
no_active_quest: 'У тебя нет активного квеста. Нажми /start, чтобы создать.',
no_active_quest_start: 'У тебя нет активного квеста. Нажми /start, чтобы создать новый.',
quest_status: 'Квест: {title}\nГород: {city}\nСтатус: в процессе',
quest_in_progress: 'У тебя есть активный квест! Открой приложение, чтобы продолжить.',
use_start: 'Нажми /start, чтобы начать.',
// Stop
stop_confirm: 'Ты уверен, что хочешь завершить квест досрочно? Прогресс будет сохранён.',
yes_stop: 'Да, завершить',
no_continue: 'Нет, продолжить',
quest_stopped: 'Квест завершён. Ты можешь начать новый квест с помощью /start.',
quest_continue: 'Отлично! Продолжаем приключение.',
// Quest buttons
open_quest: 'Открыть квест',
// Event reactions
reaction_arrived: 'Ты на месте! Вот что тебя здесь ждёт...',
reaction_skipped: 'Ничего страшного, пропускаем. Идём дальше!',
reaction_finished_today: 'Хороший день! Отдыхай, завтра продолжим.',
reaction_point_completed: 'Отлично! Идём к следующей точке...',
reaction_day_completed: 'День завершён! Ты молодец. До завтра!',
reaction_quest_completed: 'Поздравляю! Квест пройден! Ты открыл для себя город по-новому.',
}

42
bot/index.ts Normal file
View File

@ -0,0 +1,42 @@
import 'dotenv/config'
import { createBot } from './bot.js'
import { startEventProcessor } from './services/event-processor.js'
const token = process.env.TELEGRAM_BOT_TOKEN
if (!token) {
console.error('TELEGRAM_BOT_TOKEN is not set!')
process.exit(1)
}
const bot = createBot(token)
// Start event processing loop (polls quest_events table)
startEventProcessor(bot)
// Start bot
if (process.env.NODE_ENV === 'production' && process.env.BOT_WEBHOOK_URL) {
// Webhook mode for production
bot.api.setWebhook(process.env.BOT_WEBHOOK_URL, {
secret_token: process.env.BOT_WEBHOOK_SECRET,
})
console.log('Guidly bot started in webhook mode')
} else {
// Polling mode for development
bot.start({
onStart: () => {
console.log('Guidly bot started in polling mode')
},
})
}
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('Shutting down bot...')
bot.stop()
})
process.on('SIGINT', () => {
console.log('Shutting down bot...')
bot.stop()
})

View File

@ -0,0 +1,41 @@
export function getPointContentPrompt(params: {
city: string
pointTitle: string
dayNumber: number
companions: string
pace: string
lang: string
}): string {
const langInstruction = params.lang === 'ru'
? 'Write the content entirely in Russian.'
: 'Write the content entirely in English.'
const toneMap: Record<string, string> = {
solo: params.lang === 'ru' ? 'задумчивый, философский' : 'introspective, philosophical',
couple: params.lang === 'ru' ? 'романтический, с нотками приватности' : 'romantic, intimate',
family: params.lang === 'ru' ? 'познавательный, увлекательный' : 'educational, engaging',
friends: params.lang === 'ru' ? 'весёлый, энергичный' : 'fun, energetic',
}
return `The traveler has arrived at: ${params.pointTitle}
City: ${params.city}, Day ${params.dayNumber}
Companions: ${params.companions}, Pace: ${params.pace}
Generate a FULL guide content for this location.
This is the moment of revelation the traveler is standing here right now.
${langInstruction}
Content must include:
- Opening that acknowledges their arrival (e.g., "You're standing in front of...")
- Historical context (2-3 paragraphs)
- Cultural significance
- Interesting facts and hidden details
- What to look for right now
- A personal tip or recommendation
Length: 300-500 words
Tone: atmospheric, discovery-oriented, ${toneMap[params.companions] || 'balanced'}
Do NOT include JSON formatting. Write natural flowing text with paragraphs.`
}

36
bot/prompts/reaction.ts Normal file
View File

@ -0,0 +1,36 @@
export function getReactionPrompt(params: {
eventType: string
pointTitle?: string
city: string
lang: string
companions: string
pace: string
}): string {
const langInstruction = params.lang === 'ru'
? 'Write the reaction in Russian.'
: 'Write the reaction in English.'
const eventDescriptions: Record<string, string> = {
arrived: `The traveler has arrived at "${params.pointTitle}" in ${params.city}.`,
skipped: `The traveler decided to skip "${params.pointTitle}".`,
finished_today: `The traveler decided to finish for today in ${params.city}.`,
point_completed: `The traveler has finished exploring "${params.pointTitle}" and is ready for the next step.`,
day_completed: `The traveler has completed all points for today in ${params.city}.`,
quest_completed: `The traveler has completed the entire quest in ${params.city}!`,
}
return `${eventDescriptions[params.eventType] || 'The traveler performed an action.'}
Companions: ${params.companions}, Pace: ${params.pace}
${langInstruction}
Generate a short, atmospheric reaction message (2-3 sentences max).
- Keep the adventurous guide tone
- Be encouraging and warm
- If they skipped, be understanding
- If they completed, celebrate
- Make it personal and contextual
Write only the message text, no formatting, no JSON.`
}

View File

@ -0,0 +1,48 @@
export function getStepGenerationPrompt(params: {
city: string
dayNumber: number
totalDays: number
dayTheme: string
pace: string
companions: string
userComment?: string
previousPoints: string[]
lang: string
}): string {
const langInstruction = params.lang === 'ru'
? 'Write title and teaserText in Russian.'
: 'Write title and teaserText in English.'
return `Given quest context:
- City: ${params.city}
- Day: ${params.dayNumber} of ${params.totalDays}
- Day theme: ${params.dayTheme}
- Pace: ${params.pace}
- Companions: ${params.companions}
${params.userComment ? `- User wishes: ${params.userComment}` : ''}
- Points already visited today: ${params.previousPoints.length > 0 ? params.previousPoints.join(', ') : 'none (this is the first point)'}
Generate the NEXT point for today's route.
${langInstruction}
Return ONLY a valid JSON object:
{
"title": "Location name",
"teaserText": "1-2 sentences teasing what awaits, creating curiosity",
"locationLat": <number>,
"locationLon": <number>,
"isLastPointOfDay": <boolean>
}
Requirements:
- Must be a REAL location in ${params.city} with accurate coordinates
- Must be geographically close to the previous point (walkable)
- Must not repeat any visited location
- Teaser should create curiosity without spoiling
- Consider the narrative arc of the day
- For ${params.pace} pace: ${{slow: '4-5 points per day', normal: '5-7 points per day', active: '7-9 points per day'}[params.pace] || '5-7 points per day'}
- Set isLastPointOfDay=true when it makes sense to end the day
Return ONLY valid JSON, no markdown, no extra text.`
}

57
bot/prompts/system.ts Normal file
View File

@ -0,0 +1,57 @@
export function getSystemPrompt(lang: string): string {
if (lang === 'ru') {
return `Ты — Guidly, опытный и увлечённый городской гид. Ты ведёшь путешественников по городам шаг за шагом, создавая ощущение открытия и чуда.
Твой стиль:
- Атмосферный и приключенческий, как друг, который знает город изнутри
- Тёплый, но без лишнего восторга
- Ты словно раскрываешь секреты города
- Никогда не выдаёшь всю информацию сразу интригуешь, раскрываешь, удивляешь
Адаптируй стиль:
- Один: более задумчивый, философский тон
- Вдвоём: романтический, с нотками приватности
- С семьёй: познавательный, увлекательный для всех возрастов
- С друзьями: весёлый, энергичный
Темп:
- Спокойный: подробный, поэтичный
- Умеренный: сбалансированный
- Активный: энергичный, лаконичный
ВАЖНЫЕ ПРАВИЛА:
- Никогда не раскрывай весь маршрут
- Генерируй только СЛЕДУЮЩИЙ шаг, никогда не несколько сразу
- После подтверждения прибытия давай полный контент о локации
- Жди подтверждения перед генерацией следующего шага
- Поддерживай сюжетную связность между шагами
- Все ответы на русском языке`
}
return `You are Guidly, an experienced and passionate city guide. You lead travelers through cities step by step, creating a sense of discovery and wonder.
Your style:
- Atmospheric and adventurous, like a friend who knows the city inside out
- Warm but not overly enthusiastic
- You speak as if revealing the city's secrets
- Never dump all information at once tease, reveal, and surprise
Adapt your style based on companions:
- Solo: more introspective, philosophical tone
- Couple: romantic, with a sense of intimacy
- Family: educational, engaging for all ages
- Friends: fun, energetic
Pace:
- Relaxed: detailed, poetic
- Moderate: balanced
- Active: energetic, concise
CRITICAL RULES:
- Never reveal the full route
- Generate only the NEXT step, never multiple steps at once
- After arrival confirmation, provide rich, full content about the location
- Wait for confirmation before generating the next step
- Maintain narrative continuity across steps
- All responses in English`
}

179
bot/services/ai.service.ts Normal file
View File

@ -0,0 +1,179 @@
import OpenAI from 'openai'
import type { QuestPlan, GeneratedPoint, PointContent } from '../types.js'
import { getSystemPrompt } from '../prompts/system.js'
import { getStepGenerationPrompt } from '../prompts/step-generation.js'
import { getPointContentPrompt } from '../prompts/point-content.js'
import { getReactionPrompt } from '../prompts/reaction.js'
const client = new OpenAI({
baseURL: process.env.LLM_BASE_URL || 'https://api.deepseek.com',
apiKey: process.env.LLM_API_KEY || '',
})
const MODEL = process.env.LLM_MODEL || 'deepseek-chat'
class AIService {
async generateQuestPlan(params: {
city: string
days: number
companions: string
pace: string
userComment?: string
lang: string
}): Promise<QuestPlan> {
const systemPrompt = getSystemPrompt(params.lang)
const userPrompt = `Create a quest plan for a traveler:
- City: ${params.city}
- Duration: ${params.days} day(s)
- Companions: ${params.companions}
- Pace: ${params.pace}
${params.userComment ? `- Special wishes: ${params.userComment}` : ''}
Return a JSON object with:
{
"title": "Creative quest title (in ${params.lang === 'ru' ? 'Russian' : 'English'})",
"description": "1-2 sentence quest description that sets the mood (in ${params.lang === 'ru' ? 'Russian' : 'English'})",
"dayThemes": ["Theme for day 1", "Theme for day 2", ...]
}
IMPORTANT: Return ONLY valid JSON, no markdown, no extra text.`
const response = await client.chat.completions.create({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.8,
max_tokens: 1000,
})
const text = response.choices[0]?.message?.content?.trim() || '{}'
try {
// Try to extract JSON from potential markdown code blocks
const jsonMatch = text.match(/\{[\s\S]*\}/)
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : text)
return {
title: parsed.title || `Adventure in ${params.city}`,
description: parsed.description || 'Your personal city quest awaits!',
dayThemes: parsed.dayThemes || Array(params.days).fill('Exploration'),
}
} catch {
console.error('Failed to parse quest plan:', text)
return {
title: params.lang === 'ru'
? `Приключение в городе ${params.city}`
: `Adventure in ${params.city}`,
description: params.lang === 'ru'
? 'Твой персональный городской квест ждёт!'
: 'Your personal city quest awaits!',
dayThemes: Array(params.days).fill(
params.lang === 'ru' ? 'Исследование' : 'Exploration'
),
}
}
}
async generatePoint(params: {
city: string
dayNumber: number
totalDays: number
dayTheme: string
pace: string
companions: string
userComment?: string
previousPoints: string[]
lang: string
}): Promise<GeneratedPoint> {
const systemPrompt = getSystemPrompt(params.lang)
const userPrompt = getStepGenerationPrompt(params)
const response = await client.chat.completions.create({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.8,
max_tokens: 500,
})
const text = response.choices[0]?.message?.content?.trim() || '{}'
try {
const jsonMatch = text.match(/\{[\s\S]*\}/)
const parsed = JSON.parse(jsonMatch ? jsonMatch[0] : text)
return {
title: parsed.title || 'Unknown Location',
teaserText: parsed.teaserText || 'A mysterious place awaits...',
locationLat: parsed.locationLat || 0,
locationLon: parsed.locationLon || 0,
isLastPointOfDay: parsed.isLastPointOfDay || false,
}
} catch {
console.error('Failed to parse generated point:', text)
return {
title: 'Mystery Spot',
teaserText: params.lang === 'ru'
? 'Загадочное место ждёт тебя...'
: 'A mysterious place awaits...',
locationLat: 0,
locationLon: 0,
isLastPointOfDay: false,
}
}
}
async generatePointContent(params: {
city: string
pointTitle: string
dayNumber: number
companions: string
pace: string
lang: string
}): Promise<PointContent> {
const systemPrompt = getSystemPrompt(params.lang)
const userPrompt = getPointContentPrompt(params)
const response = await client.chat.completions.create({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.7,
max_tokens: 2000,
})
const text = response.choices[0]?.message?.content?.trim() || ''
return { contentText: text }
}
async generateReaction(params: {
eventType: string
pointTitle?: string
city: string
lang: string
companions: string
pace: string
}): Promise<string> {
const systemPrompt = getSystemPrompt(params.lang)
const userPrompt = getReactionPrompt(params)
const response = await client.chat.completions.create({
model: MODEL,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
temperature: 0.8,
max_tokens: 300,
})
return response.choices[0]?.message?.content?.trim() || ''
}
}
export const aiService = new AIService()

View File

@ -0,0 +1,209 @@
import type { Bot } from 'grammy'
import type { BotContext } from '../types.js'
import {
getUnprocessedEvents, markEventProcessed,
getPointById, findUserByTelegramId,
getActiveQuestForUser, getActiveDayForQuest,
updatePointContent,
} from '../../api/_lib/db.js'
import { aiService } from './ai.service.js'
import { generateNextPoint, generatePointContent } from './quest.service.js'
import { t, getLang } from '../i18n/index.js'
let processorInterval: ReturnType<typeof setInterval> | null = null
export function startEventProcessor(bot: Bot<BotContext>) {
// Poll every 3 seconds for unprocessed events
processorInterval = setInterval(async () => {
try {
await processEvents(bot)
} catch (error) {
console.error('Event processor error:', error)
}
}, 3000)
console.log('Event processor started')
}
export function stopEventProcessor() {
if (processorInterval) {
clearInterval(processorInterval)
processorInterval = null
}
}
async function processEvents(bot: Bot<BotContext>) {
const events = await getUnprocessedEvents()
for (const event of events) {
try {
await processEvent(bot, event)
await markEventProcessed(event.id)
} catch (error) {
console.error(`Failed to process event ${event.id}:`, error)
// Still mark as processed to avoid infinite retry
await markEventProcessed(event.id)
}
}
}
async function processEvent(bot: Bot<BotContext>, event: any) {
// Look up the user to get their telegram_id for sending messages
const { pool } = await import('../../api/_lib/db.js')
const { rows: users } = await pool.query('SELECT * FROM users WHERE id = $1', [event.user_id])
const user = users[0]
if (!user) return
const lang = getLang(user.language_code)
const quest = await getActiveQuestForUser(user.id)
if (!quest) return
const point = event.point_id ? await getPointById(event.point_id) : null
switch (event.event_type) {
case 'arrived': {
if (point && !point.content_text) {
// Generate full content for the point
const content = await generatePointContent(point.id, {
city: quest.city_name,
pointTitle: point.title,
dayNumber: 1, // TODO: get actual day number
companions: quest.companions,
pace: quest.pace,
lang,
})
if (content) {
// Send a reaction to the chat
const reaction = await aiService.generateReaction({
eventType: 'arrived',
pointTitle: point.title,
city: quest.city_name,
lang,
companions: quest.companions,
pace: quest.pace,
})
if (reaction) {
await bot.api.sendMessage(user.telegram_id, reaction)
}
}
}
break
}
case 'point_completed': {
// Generate next point
const day = await getActiveDayForQuest(quest.id)
if (!day) break
const result = await generateNextPoint(quest.id, {
city: quest.city_name,
dayNumber: day.day_number,
totalDays: quest.number_of_days,
dayTheme: day.theme || '',
pace: quest.pace,
companions: quest.companions,
userComment: quest.user_comment,
lang,
})
const reaction = await aiService.generateReaction({
eventType: 'point_completed',
pointTitle: point?.title,
city: quest.city_name,
lang,
companions: quest.companions,
pace: quest.pace,
})
if (reaction) {
const appUrl = process.env.APP_URL || 'https://guidly.example.com'
const isHttps = appUrl.startsWith('https://')
const msgText = isHttps ? reaction : reaction + `\n\n🔗 ${appUrl}`
await bot.api.sendMessage(user.telegram_id, msgText, isHttps ? {
reply_markup: {
inline_keyboard: [[ { text: t(lang, 'open_quest'), web_app: { url: appUrl } } ]],
},
} : {})
}
break
}
case 'skipped': {
const reaction = await aiService.generateReaction({
eventType: 'skipped',
pointTitle: point?.title,
city: quest.city_name,
lang,
companions: quest.companions,
pace: quest.pace,
})
if (reaction) {
await bot.api.sendMessage(user.telegram_id, reaction)
}
// Generate next point
const day = await getActiveDayForQuest(quest.id)
if (day) {
await generateNextPoint(quest.id, {
city: quest.city_name,
dayNumber: day.day_number,
totalDays: quest.number_of_days,
dayTheme: day.theme || '',
pace: quest.pace,
companions: quest.companions,
userComment: quest.user_comment,
lang,
})
}
break
}
case 'finished_today': {
const reaction = await aiService.generateReaction({
eventType: 'finished_today',
city: quest.city_name,
lang,
companions: quest.companions,
pace: quest.pace,
})
if (reaction) {
await bot.api.sendMessage(user.telegram_id, reaction)
}
break
}
case 'day_completed': {
const reaction = await aiService.generateReaction({
eventType: 'day_completed',
city: quest.city_name,
lang,
companions: quest.companions,
pace: quest.pace,
})
if (reaction) {
await bot.api.sendMessage(user.telegram_id, reaction)
}
break
}
case 'quest_completed': {
const reaction = await aiService.generateReaction({
eventType: 'quest_completed',
city: quest.city_name,
lang,
companions: quest.companions,
pace: quest.pace,
})
if (reaction) {
await bot.api.sendMessage(user.telegram_id, reaction)
}
break
}
}
}

View File

@ -0,0 +1,150 @@
import {
createQuest, createQuestDay, createQuestPoint,
updateQuestStatus, updateDayStatus,
getActiveDayForQuest, getPointsForDay,
} from '../../api/_lib/db.js'
import { aiService } from './ai.service.js'
interface OnboardingData {
userId: string
cityId: string
cityName: string
days: number
companions: string
pace: string
userComment?: string
lang: string
}
export async function createQuestFromOnboarding(data: OnboardingData) {
try {
// Generate quest plan via AI
const plan = await aiService.generateQuestPlan({
city: data.cityName,
days: data.days,
companions: data.companions,
pace: data.pace,
userComment: data.userComment,
lang: data.lang,
})
// Create quest in DB
const quest = await createQuest({
city_id: data.cityId,
user_id: data.userId,
title: plan.title,
description: plan.description,
number_of_days: data.days,
pace: data.pace,
companions: data.companions,
user_comment: data.userComment,
})
// Create day skeletons
const days = []
for (let i = 0; i < data.days; i++) {
const day = await createQuestDay({
quest_id: quest.id,
day_number: i + 1,
theme: plan.dayThemes[i] || undefined,
})
days.push(day)
}
// Start the quest and first day
await updateQuestStatus(quest.id, 'in_progress')
await updateDayStatus(days[0].id, 'in_progress')
// Generate first point
const firstPoint = await aiService.generatePoint({
city: data.cityName,
dayNumber: 1,
totalDays: data.days,
dayTheme: plan.dayThemes[0] || '',
pace: data.pace,
companions: data.companions,
userComment: data.userComment,
previousPoints: [],
lang: data.lang,
})
await createQuestPoint({
day_id: days[0].id,
title: firstPoint.title,
location_lat: firstPoint.locationLat,
location_lon: firstPoint.locationLon,
teaser_text: firstPoint.teaserText,
order_in_day: 1,
status: 'active',
})
return {
questId: quest.id,
title: plan.title,
description: plan.description,
}
} catch (error) {
console.error('Failed to create quest from onboarding:', error)
return null
}
}
export async function generateNextPoint(questId: string, questContext: {
city: string
dayNumber: number
totalDays: number
dayTheme: string
pace: string
companions: string
userComment?: string
lang: string
}) {
try {
const day = await getActiveDayForQuest(questId)
if (!day) return null
const existingPoints = await getPointsForDay(day.id)
const previousPoints = existingPoints.map((p: any) => p.title)
const maxOrder = existingPoints.reduce((max: number, p: any) => Math.max(max, p.order_in_day || 0), 0)
const nextOrder = maxOrder + 1
const point = await aiService.generatePoint({
...questContext,
previousPoints,
})
const created = await createQuestPoint({
day_id: day.id,
title: point.title,
location_lat: point.locationLat,
location_lon: point.locationLon,
teaser_text: point.teaserText,
order_in_day: nextOrder,
status: 'active',
})
return { point: created, isLastOfDay: point.isLastPointOfDay }
} catch (error) {
console.error('Failed to generate next point:', error)
return null
}
}
export async function generatePointContent(pointId: string, context: {
city: string
pointTitle: string
dayNumber: number
companions: string
pace: string
lang: string
}) {
try {
const content = await aiService.generatePointContent(context)
const { updatePointContent } = await import('../../api/_lib/db.js')
await updatePointContent(pointId, content.contentText)
return content
} catch (error) {
console.error('Failed to generate point content:', error)
return null
}
}

40
bot/types.ts Normal file
View File

@ -0,0 +1,40 @@
import type { Context, SessionFlavor } from 'grammy'
import type { ConversationFlavor } from '@grammyjs/conversations'
export interface SessionData {
conversationState: 'idle' | 'onboarding' | 'quest_active' | 'waiting_confirmation'
userId?: string // DB user ID
onboarding?: {
cityId?: string
cityName?: string
days?: number
companions?: 'solo' | 'couple' | 'family' | 'friends'
pace?: 'slow' | 'normal' | 'active'
comment?: string
step: number
}
activeQuestId?: string
activePointId?: string
}
export type BotContext = ConversationFlavor<Context & SessionFlavor<SessionData>>
export interface QuestPlan {
title: string
description: string
dayThemes: string[]
}
export interface GeneratedPoint {
title: string
teaserText: string
locationLat: number
locationLon: number
isLastPointOfDay: boolean
}
export interface PointContent {
contentText: string
}

View File

@ -0,0 +1,170 @@
-- Guidly: Initial database schema
-- Run with: npm run migrate
-- Enable UUID generation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ============================================
-- Cities
-- ============================================
CREATE TABLE cities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
country VARCHAR(100) NOT NULL,
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'inactive')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- Users
-- ============================================
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
telegram_id BIGINT NOT NULL UNIQUE,
username VARCHAR(255),
display_name VARCHAR(255),
language_code VARCHAR(10) DEFAULT 'ru',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_users_telegram_id ON users(telegram_id);
-- ============================================
-- Quests
-- ============================================
CREATE TABLE quests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
city_id UUID NOT NULL REFERENCES cities(id),
user_id UUID NOT NULL REFERENCES users(id),
title TEXT,
description TEXT,
number_of_days INT NOT NULL,
pace VARCHAR(20) NOT NULL CHECK (pace IN ('slow', 'normal', 'active')),
companions VARCHAR(20) NOT NULL CHECK (companions IN ('solo', 'couple', 'family', 'friends')),
user_comment TEXT,
status VARCHAR(30) NOT NULL DEFAULT 'not_started'
CHECK (status IN ('not_started', 'in_progress', 'completed')),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_quests_user_id ON quests(user_id);
CREATE INDEX idx_quests_user_status ON quests(user_id, status);
-- ============================================
-- Quest Days
-- ============================================
CREATE TABLE quest_days (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quest_id UUID NOT NULL REFERENCES quests(id) ON DELETE CASCADE,
day_number INT NOT NULL,
theme TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'not_started'
CHECK (status IN ('not_started', 'in_progress', 'completed')),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (quest_id, day_number)
);
CREATE INDEX idx_quest_days_quest ON quest_days(quest_id);
-- ============================================
-- Quest Points (individual stops/locations)
-- ============================================
CREATE TABLE quest_points (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
day_id UUID NOT NULL REFERENCES quest_days(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
location_lat DECIMAL(10, 7),
location_lon DECIMAL(10, 7),
teaser_text TEXT,
content_text TEXT,
order_in_day INT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'hidden'
CHECK (status IN ('hidden', 'preview', 'active', 'completed', 'skipped')),
arrived_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (day_id, order_in_day)
);
CREATE INDEX idx_quest_points_day ON quest_points(day_id);
-- ============================================
-- Quest Events (Mini App -> Bot communication)
-- ============================================
CREATE TABLE quest_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quest_id UUID NOT NULL REFERENCES quests(id),
user_id UUID NOT NULL REFERENCES users(id),
point_id UUID REFERENCES quest_points(id),
event_type VARCHAR(30) NOT NULL
CHECK (event_type IN (
'arrived', 'photo_uploaded', 'skipped',
'finished_today', 'point_completed',
'quest_started', 'quest_completed',
'day_started', 'day_completed',
'peek_next'
)),
payload JSONB DEFAULT '{}',
processed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_quest_events_quest ON quest_events(quest_id);
CREATE INDEX idx_quest_events_unprocessed ON quest_events(processed) WHERE processed = false;
-- ============================================
-- Chat History
-- ============================================
CREATE TABLE chat_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
quest_id UUID NOT NULL REFERENCES quests(id),
point_id UUID REFERENCES quest_points(id),
message_type VARCHAR(20) NOT NULL
CHECK (message_type IN ('user_text', 'user_voice', 'ai_text', 'ai_audio')),
content TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_chat_history_quest ON chat_history(quest_id);
-- ============================================
-- Achievements
-- ============================================
CREATE TABLE achievements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
type VARCHAR(50) NOT NULL
CHECK (type IN (
'first_quest', 'quest_completed',
'cities_2', 'cities_5', 'cities_10',
'steps_50', 'steps_100'
)),
city_id UUID REFERENCES cities(id),
achieved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, type, city_id)
);
CREATE INDEX idx_achievements_user ON achievements(user_id);
-- ============================================
-- Seed: Test cities
-- ============================================
INSERT INTO cities (name, country, description) VALUES
('Novosibirsk', 'Russia', 'The cultural and scientific capital of Siberia'),
('Moscow', 'Russia', 'The heart of Russia with centuries of history'),
('Saint Petersburg', 'Russia', 'The cultural capital with imperial grandeur'),
('Istanbul', 'Turkey', 'Where East meets West across the Bosphorus'),
('Barcelona', 'Spain', 'A city of art, architecture and Mediterranean spirit');

57
deploy/deploy.sh Normal file
View File

@ -0,0 +1,57 @@
#!/bin/bash
set -e
SERVER="${DEPLOY_SERVER:-root@85.239.58.182}"
REMOTE_DIR="/root/guidly"
DOMAIN="gidli.ru"
echo "=== Building project ==="
npm run build
echo "=== Creating deployment archive ==="
tar -czf deploy.tar.gz \
dist/ \
dist-server/ \
dist-bot/ \
package.json \
package-lock.json \
ecosystem.config.cjs \
deploy/nginx-guidly.conf
echo "=== Uploading to server ==="
scp deploy.tar.gz "$SERVER:/tmp/"
echo "=== Deploying on server ==="
ssh "$SERVER" << ENDSSH
set -e
mkdir -p $REMOTE_DIR
cd $REMOTE_DIR
# Extract new files
tar -xzf /tmp/deploy.tar.gz
rm /tmp/deploy.tar.gz
# Install production dependencies
npm install --omit=dev
# Copy nginx config if not already linked
if [ ! -f /etc/nginx/sites-available/$DOMAIN ]; then
cp deploy/nginx-guidly.conf /etc/nginx/sites-available/$DOMAIN
ln -sf /etc/nginx/sites-available/$DOMAIN /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
echo "Nginx config installed. Run 'certbot --nginx -d $DOMAIN' if SSL not yet set up."
fi
# Restart app via PM2
pm2 delete guidly-server 2>/dev/null || true
pm2 start ecosystem.config.cjs
pm2 save
echo "=== Deployment complete ==="
echo "App: https://$DOMAIN"
ENDSSH
echo "=== Cleanup ==="
rm deploy.tar.gz
echo "=== Deploy complete ==="

43
deploy/nginx-guidly.conf Normal file
View File

@ -0,0 +1,43 @@
server {
listen 80;
server_name gidli.ru;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name gidli.ru;
# SSL certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/gidli.ru/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gidli.ru/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Security headers
add_header X-Frame-Allow "tg-embed";
add_header X-Content-Type-Options nosniff;
# Proxy API and webhook requests to Express
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
}
# Serve static files (Vite build output)
location / {
root /root/guidly/dist;
try_files $uri $uri/ /index.html;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
}

49
deploy/setup-server.sh Normal file
View File

@ -0,0 +1,49 @@
#!/bin/bash
set -e
echo "=== Guidly VPS Setup ==="
echo "Run this script once on a fresh Ubuntu 22/24 server"
# 1. Update system
echo ">>> Updating system..."
apt update && apt upgrade -y
# 2. Install Node.js 22 (LTS)
echo ">>> Installing Node.js 22..."
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt install -y nodejs
# 3. Install nginx
echo ">>> Installing nginx..."
apt install -y nginx
# 4. Install certbot for Let's Encrypt SSL
echo ">>> Installing certbot..."
apt install -y certbot python3-certbot-nginx
# 5. Install PM2 globally
echo ">>> Installing PM2..."
npm install -g pm2
# 6. Create app directory
echo ">>> Creating app directory..."
mkdir -p /root/guidly
# 7. Configure firewall (if ufw is active)
if command -v ufw &> /dev/null; then
echo ">>> Configuring firewall..."
ufw allow 'Nginx Full'
ufw allow OpenSSH
fi
echo ""
echo "=== Setup complete ==="
echo ""
echo "Next steps:"
echo " 1. Point DNS: gidli.ru → $(curl -s ifconfig.me)"
echo " 2. Copy nginx config: cp /root/guidly/deploy/nginx-guidly.conf /etc/nginx/sites-available/gidli.ru"
echo " 3. Enable site: ln -s /etc/nginx/sites-available/gidli.ru /etc/nginx/sites-enabled/"
echo " 4. Remove default: rm -f /etc/nginx/sites-enabled/default"
echo " 5. Get SSL cert: certbot --nginx -d gidli.ru"
echo " 6. Test nginx: nginx -t && systemctl reload nginx"
echo " 7. Deploy app: run deploy.sh from your local machine"

30
ecosystem.config.cjs Normal file
View File

@ -0,0 +1,30 @@
module.exports = {
apps: [
{
name: 'guidly-server',
script: 'dist-server/server/index.js',
instances: 1,
autorestart: true,
watch: false,
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: 3000,
},
},
// Bot is integrated into the server process in webhook mode (production).
// This separate bot process is only needed for polling mode (development/fallback).
// Uncomment if you need to run bot separately:
// {
// name: 'guidly-bot',
// script: 'dist-bot/bot/index.js',
// instances: 1,
// autorestart: true,
// watch: false,
// max_memory_restart: '300M',
// env: {
// NODE_ENV: 'production',
// },
// },
],
}

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist', 'dist-server', 'dist-bot', 'node_modules', 'api'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#1a1a2e" />
<title>Guidly</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5459
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "guidly",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "concurrently \"vite\" \"tsx watch bot/index.ts\" \"tsx watch server/index.ts\"",
"dev:bot": "tsx watch bot/index.ts",
"dev:app": "vite",
"build": "tsc -b && tsc -p tsconfig.server.json && tsc -p tsconfig.bot.json && vite build",
"build:bot": "tsc -p tsconfig.bot.json",
"lint": "eslint .",
"preview": "vite preview",
"migrate": "tsx scripts/migrate.ts"
},
"dependencies": {
"@grammyjs/conversations": "^2.0.3",
"cookie-parser": "^1.4.7",
"dotenv": "^17.2.4",
"express": "^5.2.1",
"grammy": "^1.35.0",
"openai": "^4.80.0",
"pg": "^8.17.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"zod": "^3.24.0",
"zustand": "^5.0.0"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.6",
"@types/pg": "^8.16.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.2.1",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.13.0",
"tsx": "^4.21.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.0"
}
}

66
scripts/migrate.ts Normal file
View File

@ -0,0 +1,66 @@
import 'dotenv/config'
import { readFileSync, readdirSync } from 'fs'
import { join, dirname } from 'path'
import { fileURLToPath } from 'url'
import pg from 'pg'
const __dirname = dirname(fileURLToPath(import.meta.url))
const pool = new pg.Pool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'guidly',
user: process.env.DB_USER || 'guidly',
password: process.env.DB_PASSWORD || '',
ssl: process.env.DATABASE_SSL === 'false' ? false : undefined,
})
async function migrate() {
console.log('Running migrations...')
// Create migrations tracking table
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
`)
// Get already executed migrations
const { rows: executed } = await pool.query('SELECT name FROM _migrations ORDER BY name')
const executedNames = new Set(executed.map((r: { name: string }) => r.name))
// Read migration files
const migrationsDir = join(__dirname, '..', 'db', 'migrations')
const files = readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort()
for (const file of files) {
if (executedNames.has(file)) {
console.log(` [skip] ${file} (already executed)`)
continue
}
const sql = readFileSync(join(migrationsDir, file), 'utf-8')
console.log(` [run] ${file}`)
try {
await pool.query(sql)
await pool.query('INSERT INTO _migrations (name) VALUES ($1)', [file])
console.log(` [ok] ${file}`)
} catch (error: any) {
console.error(` [fail] ${file}:`, error.message)
process.exit(1)
}
}
console.log('Migrations complete!')
await pool.end()
}
migrate().catch((error) => {
console.error('Migration failed:', error)
process.exit(1)
})

117
server/index.ts Normal file
View File

@ -0,0 +1,117 @@
import 'dotenv/config'
import express from 'express'
import cookieParser from 'cookie-parser'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const app = express()
const PORT = process.env.PORT || 3000
// Middleware
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true }))
app.use(cookieParser())
// Wrap handler to catch errors and merge params
function wrapHandler(handler: Function) {
return async (req: any, res: any, next: any) => {
const mergedQuery = { ...req.query, ...req.params }
Object.defineProperty(req, 'query', {
value: mergedQuery,
writable: true,
configurable: true,
})
try {
await handler(req, res)
} catch (error) {
next(error)
}
}
}
async function setupRoutes() {
// Quest API (Mini App)
const questCurrent = (await import('../api/quest/current.js')).default
const questEvents = (await import('../api/quest/events.js')).default
const questHistory = (await import('../api/quest/history.js')).default
// Internal API (Bot)
const botInternal = (await import('../api/bot/internal.js')).default
// Quest routes (Mini App)
app.all('/api/quest/current', wrapHandler(questCurrent))
app.all('/api/quest/events', wrapHandler(questEvents))
app.all('/api/quest/history', wrapHandler(questHistory))
app.all('/api/quest/history/:id', wrapHandler(questHistory))
// Internal bot routes
app.all('/api/bot/internal', wrapHandler(botInternal))
// In production: start bot in webhook mode inside this server process
const isProduction = process.env.NODE_ENV === 'production' && process.env.BOT_WEBHOOK_URL
if (isProduction) {
const { webhookCallback } = await import('grammy')
const { createBot } = await import('../bot/bot.js')
const { startEventProcessor } = await import('../bot/services/event-processor.js')
const token = process.env.TELEGRAM_BOT_TOKEN
if (!token) {
console.error('TELEGRAM_BOT_TOKEN is not set!')
process.exit(1)
}
const bot = createBot(token)
await bot.init()
// Register grammY webhook handler
app.post('/api/telegram/webhook', webhookCallback(bot, 'express'))
// Set webhook with Telegram
await bot.api.setWebhook(process.env.BOT_WEBHOOK_URL!, {
secret_token: process.env.BOT_WEBHOOK_SECRET,
})
// Start event processing loop
startEventProcessor(bot)
console.log('Bot started in webhook mode')
} else {
// In development: register a placeholder webhook route
const telegramWebhook = (await import('../api/telegram/webhook.js')).default
app.all('/api/telegram/webhook', wrapHandler(telegramWebhook))
}
// Serve static files from dist/ in production
const distPath = path.join(__dirname, '..', '..', 'dist')
app.use(express.static(distPath))
// SPA fallback
app.use((req: any, res: any, next: any) => {
if (req.path.startsWith('/api/')) {
return next()
}
res.sendFile(path.join(distPath, 'index.html'))
})
}
// Error handler
app.use((err: any, _req: any, res: any, _next: any) => {
console.error('Server error:', err)
res.status(500).json({ error: 'Internal server error' })
})
async function start() {
try {
await setupRoutes()
app.listen(PORT, () => {
console.log(`Guidly server running on http://localhost:${PORT}`)
})
} catch (error) {
console.error('Failed to start server:', error)
process.exit(1)
}
}
start()

17
src/App.tsx Normal file
View File

@ -0,0 +1,17 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { QuestPage } from './pages/QuestPage'
import { HistoryPage } from './pages/HistoryPage'
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<QuestPage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/history/:questId" element={<HistoryPage />} />
</Routes>
</BrowserRouter>
)
}
export default App

View File

@ -0,0 +1,16 @@
.container {
padding: 16px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-bg);
border-top: 1px solid var(--color-surface);
}
.secondary {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 10px;
}

View File

@ -0,0 +1,95 @@
import { useState } from 'react'
import styles from './ActionButtons.module.css'
import { Button } from '@/components/ui/Button'
import { getLanguage } from '@/utils/telegram'
interface ActionButtonsProps {
hasArrived: boolean
hasContent: boolean
onArrived: () => void
onCompleted: () => void
onSkipped: () => void
onFinishedToday: () => void
}
export function ActionButtons({
hasArrived,
hasContent,
onArrived,
onCompleted,
onSkipped,
onFinishedToday,
}: ActionButtonsProps) {
const lang = getLanguage()
const [loading, setLoading] = useState<string | null>(null)
const handleAction = async (action: string, handler: () => void) => {
setLoading(action)
try {
await handler()
} finally {
setLoading(null)
}
}
if (hasArrived && hasContent) {
// After arrival and content is shown — "Next" button
return (
<div className={styles.container}>
<Button
fullWidth
size="lg"
loading={loading === 'completed'}
onClick={() => handleAction('completed', onCompleted)}
>
{lang === 'ru' ? 'Дальше' : 'Next'}
</Button>
</div>
)
}
if (hasArrived && !hasContent) {
// Arrived but content loading
return (
<div className={styles.container}>
<Button fullWidth size="lg" loading disabled>
{lang === 'ru' ? 'Загрузка...' : 'Loading...'}
</Button>
</div>
)
}
// Before arrival
return (
<div className={styles.container}>
<Button
fullWidth
size="lg"
loading={loading === 'arrived'}
onClick={() => handleAction('arrived', onArrived)}
>
{lang === 'ru' ? 'Я на месте!' : "I'm here!"}
</Button>
<div className={styles.secondary}>
<Button
variant="secondary"
size="sm"
loading={loading === 'skipped'}
onClick={() => handleAction('skipped', onSkipped)}
>
{lang === 'ru' ? 'Пропустить' : 'Skip'}
</Button>
<Button
variant="ghost"
size="sm"
loading={loading === 'finished'}
onClick={() => handleAction('finished', onFinishedToday)}
>
{lang === 'ru' ? 'Закончить на сегодня' : 'Done for today'}
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,17 @@
.container {
padding: 20px;
margin: 0 16px;
background: var(--color-surface);
border-radius: var(--radius);
}
.paragraph {
font-size: 15px;
line-height: 1.7;
margin-bottom: 16px;
color: var(--color-text);
}
.paragraph:last-child {
margin-bottom: 0;
}

View File

@ -0,0 +1,20 @@
import styles from './ContentView.module.css'
interface ContentViewProps {
content: string
}
export function ContentView({ content }: ContentViewProps) {
// Split content into paragraphs
const paragraphs = content.split('\n').filter(p => p.trim())
return (
<div className={styles.container}>
{paragraphs.map((paragraph, index) => (
<p key={index} className={styles.paragraph}>
{paragraph}
</p>
))}
</div>
)
}

View File

@ -0,0 +1,26 @@
.container {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0;
}
.link {
color: var(--color-link);
font-size: 14px;
font-weight: 500;
text-decoration: none;
padding: 6px 12px;
background: var(--color-surface);
border-radius: var(--radius-sm);
transition: opacity 0.2s;
}
.link:active {
opacity: 0.7;
}
.separator {
color: var(--color-hint);
font-size: 12px;
}

View File

@ -0,0 +1,37 @@
import styles from './MapLink.module.css'
import { getLanguage } from '@/utils/telegram'
interface MapLinkProps {
lat: number
lon: number
title: string
}
export function MapLink({ lat, lon }: MapLinkProps) {
const lang = getLanguage()
const googleMapsUrl = `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`
const yandexMapsUrl = `https://yandex.ru/maps/?pt=${lon},${lat}&z=17&l=map`
return (
<div className={styles.container}>
<a
href={googleMapsUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
{lang === 'ru' ? 'Google Maps' : 'Google Maps'}
</a>
<span className={styles.separator}>|</span>
<a
href={yandexMapsUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.link}
>
{lang === 'ru' ? 'Яндекс Карты' : 'Yandex Maps'}
</a>
</div>
)
}

View File

@ -0,0 +1,19 @@
.card {
padding: 20px;
background: var(--color-surface);
border-radius: var(--radius);
margin: 0 16px;
}
.title {
font-size: 22px;
font-weight: 700;
line-height: 1.3;
margin-bottom: 12px;
}
.teaser {
font-size: 15px;
line-height: 1.6;
color: var(--color-hint);
}

View File

@ -0,0 +1,30 @@
import styles from './StepCard.module.css'
import type { QuestPoint } from '@/types'
import { MapLink } from './MapLink'
interface StepCardProps {
point: QuestPoint
showTeaser?: boolean
}
export function StepCard({ point, showTeaser = true }: StepCardProps) {
const hasCoords = point.location_lat && point.location_lon
return (
<div className={styles.card}>
<h2 className={styles.title}>{point.title}</h2>
{showTeaser && point.teaser_text && (
<p className={styles.teaser}>{point.teaser_text}</p>
)}
{hasCoords && (
<MapLink
lat={point.location_lat!}
lon={point.location_lon!}
title={point.title}
/>
)}
</div>
)
}

View File

@ -0,0 +1,34 @@
.container {
padding: 16px 20px;
}
.info {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.day {
font-weight: 600;
font-size: 14px;
color: var(--color-primary);
}
.step {
font-size: 13px;
color: var(--color-hint);
}
.bar {
height: 4px;
background: var(--color-surface);
border-radius: 2px;
overflow: hidden;
}
.fill {
height: 100%;
background: var(--color-primary);
border-radius: 2px;
transition: width 0.5s ease;
}

View File

@ -0,0 +1,32 @@
import styles from './StepProgress.module.css'
import { getLanguage } from '@/utils/telegram'
interface StepProgressProps {
current: number
total: number
dayNumber: number
}
export function StepProgress({ current, total, dayNumber }: StepProgressProps) {
const lang = getLanguage()
const progress = total > 0 ? (current / total) * 100 : 0
return (
<div className={styles.container}>
<div className={styles.info}>
<span className={styles.day}>
{lang === 'ru' ? `День ${dayNumber}` : `Day ${dayNumber}`}
</span>
<span className={styles.step}>
{lang === 'ru'
? `Точка ${current} из ${total}`
: `Point ${current} of ${total}`
}
</span>
</div>
<div className={styles.bar}>
<div className={styles.fill} style={{ width: `${progress}%` }} />
</div>
</div>
)
}

View File

@ -0,0 +1,5 @@
export { StepCard } from './StepCard'
export { StepProgress } from './StepProgress'
export { ActionButtons } from './ActionButtons'
export { MapLink } from './MapLink'
export { ContentView } from './ContentView'

View File

@ -0,0 +1,75 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: var(--radius-sm);
font-weight: 600;
transition: opacity 0.2s, transform 0.1s;
white-space: nowrap;
}
.button:active {
transform: scale(0.97);
}
.button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Variants */
.primary {
background: var(--color-primary);
color: var(--color-primary-text);
}
.secondary {
background: var(--color-surface);
color: var(--color-text);
}
.danger {
background: #ef4444;
color: #fff;
}
.ghost {
background: transparent;
color: var(--color-hint);
}
/* Sizes */
.sm {
padding: 6px 12px;
font-size: 13px;
}
.md {
padding: 10px 20px;
font-size: 15px;
}
.lg {
padding: 14px 28px;
font-size: 16px;
}
.fullWidth {
width: 100%;
}
/* Spinner */
.spinner {
width: 18px;
height: 18px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@ -0,0 +1,31 @@
import styles from './Button.module.css'
interface ButtonProps {
children: React.ReactNode
onClick?: () => void
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
loading?: boolean
fullWidth?: boolean
}
export function Button({
children,
onClick,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
fullWidth = false,
}: ButtonProps) {
return (
<button
className={`${styles.button} ${styles[variant]} ${styles[size]} ${fullWidth ? styles.fullWidth : ''}`}
onClick={onClick}
disabled={disabled || loading}
>
{loading ? <span className={styles.spinner} /> : children}
</button>
)
}

View File

@ -0,0 +1,15 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
text-align: center;
}
.message {
color: var(--color-hint);
font-size: 15px;
line-height: 1.5;
}

View File

@ -0,0 +1,15 @@
import styles from './ErrorState.module.css'
import { Button } from './Button'
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
return (
<div className={styles.container}>
<p className={styles.message}>{message}</p>
{onRetry && (
<Button variant="secondary" onClick={onRetry}>
Try again
</Button>
)}
</div>
)
}

View File

@ -0,0 +1,26 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--color-surface);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.text {
color: var(--color-hint);
font-size: 14px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

View File

@ -0,0 +1,10 @@
import styles from './Loader.module.css'
export function Loader({ text }: { text?: string }) {
return (
<div className={styles.container}>
<div className={styles.spinner} />
{text && <p className={styles.text}>{text}</p>}
</div>
)
}

View File

@ -0,0 +1,3 @@
export { Button } from './Button'
export { Loader } from './Loader'
export { ErrorState } from './ErrorState'

54
src/index.css Normal file
View File

@ -0,0 +1,54 @@
:root {
--tg-theme-bg-color: var(--tg-theme-bg-color, #1a1a2e);
--tg-theme-text-color: var(--tg-theme-text-color, #e0e0e0);
--tg-theme-hint-color: var(--tg-theme-hint-color, #999);
--tg-theme-link-color: var(--tg-theme-link-color, #64b5f6);
--tg-theme-button-color: var(--tg-theme-button-color, #5865f2);
--tg-theme-button-text-color: var(--tg-theme-button-text-color, #fff);
--tg-theme-secondary-bg-color: var(--tg-theme-secondary-bg-color, #16213e);
--color-bg: var(--tg-theme-bg-color);
--color-text: var(--tg-theme-text-color);
--color-hint: var(--tg-theme-hint-color);
--color-link: var(--tg-theme-link-color);
--color-primary: var(--tg-theme-button-color);
--color-primary-text: var(--tg-theme-button-text-color);
--color-surface: var(--tg-theme-secondary-bg-color);
--radius: 12px;
--radius-sm: 8px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
overflow-x: hidden;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
a {
color: var(--color-link);
text-decoration: none;
}
button {
cursor: pointer;
border: none;
outline: none;
font-family: inherit;
}

16
src/main.tsx Normal file
View File

@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
// Initialize Telegram Web App
if (window.Telegram?.WebApp) {
window.Telegram.WebApp.ready()
window.Telegram.WebApp.expand()
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@ -0,0 +1,93 @@
.page {
padding: 20px;
min-height: 100vh;
}
.title {
font-size: 24px;
font-weight: 700;
margin-bottom: 8px;
}
.subtitle {
color: var(--color-hint);
font-size: 14px;
margin-bottom: 24px;
}
.empty {
color: var(--color-hint);
font-size: 15px;
text-align: center;
margin-top: 40px;
}
.questCard {
display: block;
padding: 16px;
background: var(--color-surface);
border-radius: var(--radius);
margin-bottom: 12px;
text-decoration: none;
color: var(--color-text);
}
.questCard h3 {
font-size: 16px;
font-weight: 600;
margin-bottom: 4px;
}
.questCard p {
color: var(--color-hint);
font-size: 13px;
}
.date {
font-size: 12px;
color: var(--color-hint);
}
.daySection {
margin-bottom: 24px;
}
.dayTitle {
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
color: var(--color-primary);
}
.pointCard {
display: flex;
gap: 12px;
padding: 12px;
background: var(--color-surface);
border-radius: var(--radius-sm);
margin-bottom: 8px;
}
.pointStatus {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: var(--color-primary);
flex-shrink: 0;
}
.pointTitle {
font-size: 14px;
font-weight: 600;
margin-bottom: 4px;
}
.pointContent {
font-size: 13px;
color: var(--color-hint);
line-height: 1.4;
}

106
src/pages/HistoryPage.tsx Normal file
View File

@ -0,0 +1,106 @@
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import styles from './HistoryPage.module.css'
import { fetchQuestHistory, fetchQuestDetail } from '@/utils/api'
import { Loader, ErrorState } from '@/components/ui'
import { getLanguage } from '@/utils/telegram'
import type { QuestHistoryItem } from '@/types'
export function HistoryPage() {
const { questId } = useParams()
const lang = getLanguage()
const [quests, setQuests] = useState<QuestHistoryItem[]>([])
const [detail, setDetail] = useState<any>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
async function load() {
setIsLoading(true)
try {
if (questId) {
const data = await fetchQuestDetail(questId)
setDetail(data)
} else {
const data = await fetchQuestHistory()
setQuests(data.quests)
}
} catch (err: any) {
setError(err.message)
} finally {
setIsLoading(false)
}
}
load()
}, [questId])
if (isLoading) {
return <Loader text={lang === 'ru' ? 'Загрузка...' : 'Loading...'} />
}
if (error) {
return <ErrorState message={error} />
}
if (questId && detail) {
return (
<div className={styles.page}>
<h1 className={styles.title}>{detail.quest.title || detail.quest.city_name}</h1>
<p className={styles.subtitle}>
{detail.quest.city_name}, {detail.quest.city_country}
</p>
{detail.days.map((day: any) => (
<div key={day.id} className={styles.daySection}>
<h3 className={styles.dayTitle}>
{lang === 'ru' ? `День ${day.day_number}` : `Day ${day.day_number}`}
{day.theme && `${day.theme}`}
</h3>
{day.points.map((point: any) => (
<div key={point.id} className={styles.pointCard}>
<div className={styles.pointStatus}>
{point.status === 'completed' ? 'V' : point.status === 'skipped' ? '-' : 'o'}
</div>
<div>
<h4 className={styles.pointTitle}>{point.title}</h4>
{point.content_text && (
<p className={styles.pointContent}>
{point.content_text.substring(0, 100)}...
</p>
)}
</div>
</div>
))}
</div>
))}
</div>
)
}
// Quest list
return (
<div className={styles.page}>
<h1 className={styles.title}>
{lang === 'ru' ? 'Мои квесты' : 'My Quests'}
</h1>
{quests.length === 0 ? (
<p className={styles.empty}>
{lang === 'ru' ? 'Пока нет завершённых квестов.' : 'No completed quests yet.'}
</p>
) : (
quests.map((quest) => (
<a key={quest.id} href={`/history/${quest.id}`} className={styles.questCard}>
<h3>{quest.title || quest.city_name}</h3>
<p>{quest.city_name}, {quest.city_country}</p>
<span className={styles.date}>
{quest.completed_at
? new Date(quest.completed_at).toLocaleDateString()
: ''}
</span>
</a>
))
)}
</div>
)
}

View File

@ -0,0 +1,52 @@
.page {
min-height: 100vh;
padding-bottom: 120px;
}
.header {
padding: 20px 20px 8px;
}
.questTitle {
font-size: 18px;
font-weight: 600;
color: var(--color-hint);
}
.contentSection {
margin-top: 16px;
}
.contentLoading {
margin-top: 16px;
padding: 20px;
}
.actionsSpace {
height: 100px;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40px 20px;
text-align: center;
gap: 12px;
}
.empty h2 {
font-size: 20px;
font-weight: 600;
}
.empty p {
color: var(--color-hint);
font-size: 15px;
}
.waiting {
padding: 40px 20px;
}

107
src/pages/QuestPage.tsx Normal file
View File

@ -0,0 +1,107 @@
import { useEffect } from 'react'
import styles from './QuestPage.module.css'
import { useQuestStore } from '@/store/questStore'
import { StepCard, StepProgress, ActionButtons, ContentView } from '@/components/quest'
import { Loader, ErrorState } from '@/components/ui'
import { getLanguage } from '@/utils/telegram'
export function QuestPage() {
const { state, isLoading, error, loadCurrentQuest, sendEvent } = useQuestStore()
const lang = getLanguage()
useEffect(() => {
loadCurrentQuest()
}, [loadCurrentQuest])
// Periodically reload to pick up bot-generated content
useEffect(() => {
if (state?.point?.arrived_at && !state.point.content_text) {
const interval = setInterval(() => {
loadCurrentQuest()
}, 3000)
return () => clearInterval(interval)
}
}, [state?.point?.arrived_at, state?.point?.content_text, loadCurrentQuest])
if (isLoading && !state) {
return <Loader text={lang === 'ru' ? 'Загрузка квеста...' : 'Loading quest...'} />
}
if (error) {
return <ErrorState message={error} onRetry={loadCurrentQuest} />
}
if (!state || !state.quest) {
return (
<div className={styles.empty}>
<h2>{lang === 'ru' ? 'Нет активного квеста' : 'No active quest'}</h2>
<p>
{lang === 'ru'
? 'Напиши боту в чат, чтобы создать новый квест!'
: 'Message the bot in chat to create a new quest!'}
</p>
</div>
)
}
const { quest, day, point, dayProgress } = state
const hasArrived = !!point?.arrived_at
const hasContent = !!point?.content_text
return (
<div className={styles.page}>
{/* Header */}
<div className={styles.header}>
<h1 className={styles.questTitle}>{quest.title || quest.city_name}</h1>
</div>
{/* Progress */}
{day && dayProgress && (
<StepProgress
current={point ? point.order_in_day : dayProgress.completedPoints}
total={dayProgress.totalPoints || point?.order_in_day || 1}
dayNumber={day.day_number}
/>
)}
{/* Current step */}
{point ? (
<>
<StepCard point={point} showTeaser={!hasArrived} />
{/* Content after arrival */}
{hasArrived && hasContent && (
<div className={styles.contentSection}>
<ContentView content={point.content_text!} />
</div>
)}
{hasArrived && !hasContent && (
<div className={styles.contentLoading}>
<Loader text={lang === 'ru' ? 'Готовлю рассказ...' : 'Preparing the story...'} />
</div>
)}
{/* Action buttons */}
<div className={styles.actionsSpace} />
<ActionButtons
hasArrived={hasArrived}
hasContent={hasContent}
onArrived={() => sendEvent('arrived')}
onCompleted={() => sendEvent('point_completed')}
onSkipped={() => sendEvent('skipped')}
onFinishedToday={() => sendEvent('finished_today')}
/>
</>
) : (
<div className={styles.waiting}>
<Loader text={
lang === 'ru'
? 'Готовлю следующий шаг...'
: 'Preparing the next step...'
} />
</div>
)}
</div>
)
}

90
src/store/questStore.ts Normal file
View File

@ -0,0 +1,90 @@
import { create } from 'zustand'
import type { QuestState, QuestPoint } from '../types'
import { fetchCurrentQuest, sendQuestEvent } from '../utils/api'
interface QuestStore {
state: QuestState | null
isLoading: boolean
error: string | null
showContent: boolean
loadCurrentQuest: () => Promise<void>
sendEvent: (eventType: string, payload?: Record<string, unknown>) => Promise<string>
setShowContent: (show: boolean) => void
updatePoint: (point: QuestPoint) => void
reset: () => void
}
export const useQuestStore = create<QuestStore>((set, get) => ({
state: null,
isLoading: false,
error: null,
showContent: false,
loadCurrentQuest: async () => {
set({ isLoading: true, error: null })
try {
const state = await fetchCurrentQuest()
const showContent = state.point?.arrived_at !== null && state.point?.content_text !== null
set({ state, isLoading: false, showContent })
} catch (error: any) {
if (error.message?.includes('NO_ACTIVE_QUEST') || error.message?.includes('User not found')) {
set({ state: null, isLoading: false, error: null })
} else {
set({ isLoading: false, error: error.message })
}
}
},
sendEvent: async (eventType: string, payload?: Record<string, unknown>) => {
const state = get().state
if (!state?.quest) return 'wait'
try {
const response = await sendQuestEvent({
questId: state.quest.id,
pointId: state.point?.id,
eventType,
payload,
})
if (response.updatedPoint) {
set((s) => ({
state: s.state ? {
...s.state,
point: response.updatedPoint || s.state.point,
} : null,
}))
}
// Handle next actions
if (response.nextAction === 'show_content') {
// Reload to get full content (generated by bot)
setTimeout(() => get().loadCurrentQuest(), 2000)
set({ showContent: true })
} else if (response.nextAction === 'next_point') {
set({ showContent: false })
// Wait for bot to generate next point, then reload
setTimeout(() => get().loadCurrentQuest(), 3000)
} else if (response.nextAction === 'day_complete' || response.nextAction === 'quest_complete') {
set({ showContent: false })
setTimeout(() => get().loadCurrentQuest(), 2000)
}
return response.nextAction
} catch (error: any) {
console.error('Failed to send event:', error)
return 'wait'
}
},
setShowContent: (show: boolean) => set({ showContent: show }),
updatePoint: (point: QuestPoint) => {
set((s) => ({
state: s.state ? { ...s.state, point } : null,
}))
},
reset: () => set({ state: null, isLoading: false, error: null, showContent: false }),
}))

60
src/types/index.ts Normal file
View File

@ -0,0 +1,60 @@
export interface QuestInfo {
id: string
title: string | null
city_name: string
city_country: string
number_of_days: number
pace: string
companions: string
status: string
}
export interface QuestDay {
id: string
quest_id: string
day_number: number
theme: string | null
status: string
started_at: string | null
completed_at: string | null
}
export interface QuestPoint {
id: string
day_id: string
title: string
location_lat: number | null
location_lon: number | null
teaser_text: string | null
content_text: string | null
order_in_day: number
status: string
arrived_at: string | null
completed_at: string | null
}
export interface QuestState {
quest: QuestInfo
day: QuestDay | null
point: QuestPoint | null
dayProgress: {
totalPoints: number
completedPoints: number
} | null
}
export interface EventResponse {
success: boolean
updatedPoint: QuestPoint | null
nextAction: 'wait' | 'show_content' | 'next_point' | 'day_complete' | 'quest_complete' | 'close_app'
}
export interface QuestHistoryItem {
id: string
title: string | null
city_name: string
city_country: string
number_of_days: number
status: string
completed_at: string | null
}

48
src/utils/api.ts Normal file
View File

@ -0,0 +1,48 @@
import { getTelegramInitData } from './telegram'
import type { QuestState, EventResponse } from '../types'
const API_BASE = '/api'
async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
const initData = getTelegramInitData()
const response = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
'X-Telegram-Init-Data': initData,
...options.headers,
},
})
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Unknown error' }))
throw new Error(error.error || `HTTP ${response.status}`)
}
return response.json()
}
export async function fetchCurrentQuest(): Promise<QuestState> {
return apiFetch<QuestState>('/quest/current')
}
export async function sendQuestEvent(data: {
questId: string
pointId?: string
eventType: string
payload?: Record<string, unknown>
}): Promise<EventResponse> {
return apiFetch<EventResponse>('/quest/events', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function fetchQuestHistory() {
return apiFetch<{ quests: any[] }>('/quest/history')
}
export async function fetchQuestDetail(questId: string) {
return apiFetch<{ quest: any; days: any[] }>(`/quest/history/${questId}`)
}

24
src/utils/telegram.ts Normal file
View File

@ -0,0 +1,24 @@
export function getTelegramWebApp() {
return window.Telegram?.WebApp
}
export function getTelegramUser() {
return window.Telegram?.WebApp?.initDataUnsafe?.user
}
export function getTelegramInitData(): string {
return window.Telegram?.WebApp?.initData || ''
}
export function getLanguage(): string {
const langCode = getTelegramUser()?.language_code
if (!langCode) return 'ru'
if (langCode.startsWith('ru') || langCode.startsWith('uk') || langCode.startsWith('be')) {
return 'ru'
}
return 'en'
}
export function closeMiniApp() {
window.Telegram?.WebApp?.close()
}

56
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,56 @@
/// <reference types="vite/client" />
interface TelegramWebApp {
ready(): void
expand(): void
close(): void
initData: string
initDataUnsafe: {
user?: {
id: number
first_name: string
last_name?: string
username?: string
language_code?: string
}
auth_date: number
hash: string
}
themeParams: {
bg_color?: string
text_color?: string
hint_color?: string
link_color?: string
button_color?: string
button_text_color?: string
secondary_bg_color?: string
}
colorScheme: 'light' | 'dark'
MainButton: {
text: string
color: string
textColor: string
isVisible: boolean
isActive: boolean
show(): void
hide(): void
onClick(callback: () => void): void
offClick(callback: () => void): void
setText(text: string): void
enable(): void
disable(): void
}
BackButton: {
isVisible: boolean
show(): void
hide(): void
onClick(callback: () => void): void
offClick(callback: () => void): void
}
}
interface Window {
Telegram?: {
WebApp?: TelegramWebApp
}
}

17
tsconfig.bot.json Normal file
View File

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"outDir": "./dist-bot",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"noEmitOnError": false,
"resolveJsonModule": true
},
"include": ["bot/**/*"]
}

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

16
tsconfig.server.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"outDir": "./dist-server",
"rootDir": ".",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": false,
"noEmitOnError": false
},
"include": ["api/**/*", "server/**/*", "bot/**/*"]
}

22
vite.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
})