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:
commit
d5ed7fdcf9
25
.env.example
Normal file
25
.env.example
Normal 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
42
.gitignore
vendored
Normal 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
70
.gitlab-ci.yml
Normal 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
116
api/_lib/auth.ts
Normal 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
368
api/_lib/db.ts
Normal 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
16
api/_lib/types.ts
Normal 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
23
api/_lib/validation.ts
Normal 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
127
api/bot/internal.ts
Normal 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
34
api/quest/current.ts
Normal 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
122
api/quest/events.ts
Normal 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
79
api/quest/history.ts
Normal 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
24
api/telegram/webhook.ts
Normal 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
171
bot/bot.ts
Normal 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
|
||||
}
|
||||
191
bot/conversations/onboarding.ts
Normal file
191
bot/conversations/onboarding.ts
Normal 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
78
bot/i18n/en.ts
Normal 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
25
bot/i18n/index.ts
Normal 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
78
bot/i18n/ru.ts
Normal 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
42
bot/index.ts
Normal 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()
|
||||
})
|
||||
41
bot/prompts/point-content.ts
Normal file
41
bot/prompts/point-content.ts
Normal 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
36
bot/prompts/reaction.ts
Normal 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.`
|
||||
}
|
||||
48
bot/prompts/step-generation.ts
Normal file
48
bot/prompts/step-generation.ts
Normal 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
57
bot/prompts/system.ts
Normal 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
179
bot/services/ai.service.ts
Normal 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()
|
||||
209
bot/services/event-processor.ts
Normal file
209
bot/services/event-processor.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
150
bot/services/quest.service.ts
Normal file
150
bot/services/quest.service.ts
Normal 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
40
bot/types.ts
Normal 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
|
||||
}
|
||||
170
db/migrations/001_initial.sql
Normal file
170
db/migrations/001_initial.sql
Normal 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
57
deploy/deploy.sh
Normal 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
43
deploy/nginx-guidly.conf
Normal 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
49
deploy/setup-server.sh
Normal 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
30
ecosystem.config.cjs
Normal 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
28
eslint.config.js
Normal 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
14
index.html
Normal 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
5459
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
48
package.json
Normal file
48
package.json
Normal 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
66
scripts/migrate.ts
Normal 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
117
server/index.ts
Normal 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
17
src/App.tsx
Normal 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
|
||||
16
src/components/quest/ActionButtons.module.css
Normal file
16
src/components/quest/ActionButtons.module.css
Normal 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;
|
||||
}
|
||||
95
src/components/quest/ActionButtons.tsx
Normal file
95
src/components/quest/ActionButtons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
src/components/quest/ContentView.module.css
Normal file
17
src/components/quest/ContentView.module.css
Normal 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;
|
||||
}
|
||||
20
src/components/quest/ContentView.tsx
Normal file
20
src/components/quest/ContentView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/quest/MapLink.module.css
Normal file
26
src/components/quest/MapLink.module.css
Normal 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;
|
||||
}
|
||||
37
src/components/quest/MapLink.tsx
Normal file
37
src/components/quest/MapLink.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/components/quest/StepCard.module.css
Normal file
19
src/components/quest/StepCard.module.css
Normal 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);
|
||||
}
|
||||
30
src/components/quest/StepCard.tsx
Normal file
30
src/components/quest/StepCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
src/components/quest/StepProgress.module.css
Normal file
34
src/components/quest/StepProgress.module.css
Normal 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;
|
||||
}
|
||||
32
src/components/quest/StepProgress.tsx
Normal file
32
src/components/quest/StepProgress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
5
src/components/quest/index.ts
Normal file
5
src/components/quest/index.ts
Normal 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'
|
||||
75
src/components/ui/Button.module.css
Normal file
75
src/components/ui/Button.module.css
Normal 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); }
|
||||
}
|
||||
31
src/components/ui/Button.tsx
Normal file
31
src/components/ui/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
15
src/components/ui/ErrorState.module.css
Normal file
15
src/components/ui/ErrorState.module.css
Normal 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;
|
||||
}
|
||||
15
src/components/ui/ErrorState.tsx
Normal file
15
src/components/ui/ErrorState.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/components/ui/Loader.module.css
Normal file
26
src/components/ui/Loader.module.css
Normal 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); }
|
||||
}
|
||||
10
src/components/ui/Loader.tsx
Normal file
10
src/components/ui/Loader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
src/components/ui/index.ts
Normal file
3
src/components/ui/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { Button } from './Button'
|
||||
export { Loader } from './Loader'
|
||||
export { ErrorState } from './ErrorState'
|
||||
54
src/index.css
Normal file
54
src/index.css
Normal 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
16
src/main.tsx
Normal 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>
|
||||
)
|
||||
93
src/pages/HistoryPage.module.css
Normal file
93
src/pages/HistoryPage.module.css
Normal 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
106
src/pages/HistoryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/pages/QuestPage.module.css
Normal file
52
src/pages/QuestPage.module.css
Normal 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
107
src/pages/QuestPage.tsx
Normal 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
90
src/store/questStore.ts
Normal 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
60
src/types/index.ts
Normal 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
48
src/utils/api.ts
Normal 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
24
src/utils/telegram.ts
Normal 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
56
src/vite-env.d.ts
vendored
Normal 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
17
tsconfig.bot.json
Normal 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
26
tsconfig.json
Normal 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
16
tsconfig.server.json
Normal 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
22
vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user