Initial commit: party-mix-app with prefetch cache, audio preload optimizations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 12:40:22 +03:00
commit 0097fb5183
83 changed files with 11788 additions and 0 deletions

View File

@@ -0,0 +1,54 @@
import axios from 'axios'
import { config } from '../config'
import type { Party, Participant } from '../types'
const api = axios.create({ baseURL: config.backendUrl, timeout: 10_000 })
export async function createParty(name: string, telegramId: number): Promise<Party> {
const { data } = await api.post<Party>('/api/parties', { name, telegram_id: telegramId })
return data
}
export async function getPartyByCode(code: string): Promise<Party | null> {
try {
const { data } = await api.get<Party>(`/api/parties/code/${code}`)
return data
} catch {
return null
}
}
export async function getParty(id: string): Promise<Party | null> {
try {
const { data } = await api.get<Party>(`/api/parties/${id}`)
return data
} catch {
return null
}
}
const COLORS = [
{ bg: 'rgba(200,255,0,0.1)', text: '#c8ff00' },
{ bg: 'rgba(255,60,172,0.1)', text: '#ff3cac' },
{ bg: 'rgba(0,212,255,0.1)', text: '#00d4ff' },
{ bg: 'rgba(255,165,0,0.1)', text: '#ffa500' },
{ bg: 'rgba(140,100,255,0.1)', text: '#8c64ff' },
{ bg: 'rgba(0,255,140,0.1)', text: '#00ff8c' },
{ bg: 'rgba(255,80,80,0.1)', text: '#ff5050' },
]
export async function addParticipant(
partyId: string,
name: string,
tracks: string[],
colorIndex: number,
): Promise<Participant> {
const color = COLORS[colorIndex % COLORS.length]
const { data } = await api.post<Participant>(`/api/parties/${partyId}/participants`, {
name,
tracks,
color_bg: color.bg,
color_text: color.text,
})
return data
}

46
apps/bot/src/bot.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Bot, session } from 'grammy'
import { config } from './config'
import type { SessionData } from './types'
import { handleStart } from './commands/start'
import { handleHelp } from './commands/help'
import {
handleNewParty,
handleJoinParty,
handleAddMe,
handlePartyInfo,
handleTracksMessage,
} from './commands/party'
type SessionContext = Parameters<typeof handleTracksMessage>[0]
function createBot() {
const bot = new Bot<SessionContext>(config.botToken)
bot.use(
session({
initial: (): SessionData => ({}),
}),
)
bot.command('start', handleStart)
bot.command('help', handleHelp)
bot.command('newparty', handleNewParty)
bot.command('joinparty', handleJoinParty)
bot.command('addme', handleAddMe)
bot.command('party', handlePartyInfo)
bot.on('message:text', async (ctx) => {
const handled = await handleTracksMessage(ctx)
if (!handled) {
await ctx.reply('Неизвестная команда. /help для справки.')
}
})
bot.catch((err) => {
console.error('Bot error:', err)
})
return bot
}
export default createBot

View File

@@ -0,0 +1,17 @@
import { CommandContext, Context } from 'grammy'
export async function handleHelp(ctx: CommandContext<Context>) {
await ctx.reply(
`*Как использовать Party Mix:*\n\n` +
`1\\. Создай вечеринку: /newparty Моя вечеринка\n` +
`2\\. Поделись кодом с друзьями\n` +
`3\\. Каждый добавляет себя: /addme Имя\n` +
`4\\. После \\— список треков строчкой за строчкой\n` +
`5\\. Открой веб\\-ссылку и нажми *Перемешать*\\!\n\n` +
`*Формат треков:*\n` +
`\`Исполнитель — Название\`\n` +
`\`Тараканы — Пойдём на улицу\`\n` +
`\`PALC — Залип\``,
{ parse_mode: 'MarkdownV2' },
)
}

View File

@@ -0,0 +1,152 @@
import { CommandContext, Context } from 'grammy'
import { createParty, getPartyByCode, addParticipant, getParty } from '../api/client'
import type { SessionData } from '../types'
type SessionContext = Context & { session: SessionData }
export async function handleNewParty(ctx: CommandContext<SessionContext>) {
const name = ctx.match?.trim() || `Вечеринка ${new Date().toLocaleDateString('ru')}`
const telegramId = ctx.from?.id ?? 0
try {
const party = await createParty(name, telegramId)
ctx.session.partyId = party.id
ctx.session.partyCode = party.code
ctx.session.partyName = party.name
await ctx.reply(
`🎉 Вечеринка *${escapeMarkdown(party.name)}* создана\\!\n\n` +
`🔑 Код: \`${party.code}\`\n\n` +
`Поделись кодом с друзьями — они введут /joinparty ${party.code}\n\n` +
`Затем каждый добавляет себя командой /addme Имя`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при создании вечеринки. Попробуй ещё раз.')
}
}
export async function handleJoinParty(ctx: CommandContext<SessionContext>) {
const code = ctx.match?.trim().toUpperCase()
if (!code) {
await ctx.reply('Укажи код: /joinparty КОД')
return
}
try {
const party = await getPartyByCode(code)
if (!party) {
await ctx.reply(`Вечеринка с кодом \`${code}\` не найдена.`, { parse_mode: 'MarkdownV2' })
return
}
ctx.session.partyId = party.id
ctx.session.partyCode = party.code
ctx.session.partyName = party.name
const count = party.participants?.length ?? 0
await ctx.reply(
`✅ Подключился к вечеринке *${escapeMarkdown(party.name)}*\\!\n` +
`Участников: ${count}\n\n` +
`Добавь себя: /addme Имя`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при подключении. Проверь код и попробуй снова.')
}
}
export async function handleAddMe(ctx: CommandContext<SessionContext>) {
const name = ctx.match?.trim()
if (!name) {
await ctx.reply('Укажи своё имя: /addme Твоё Имя')
return
}
if (!ctx.session.partyId) {
await ctx.reply('Сначала создай или присоединись к вечеринке: /newparty или /joinparty КОД')
return
}
ctx.session.awaitingTracks = true
ctx.session.pendingParticipantName = name
await ctx.reply(
`Отлично, *${escapeMarkdown(name)}*\\! 🎵\n\n` +
`Отправь список своих треков — каждый с новой строки:\n\n` +
`\`Тараканы — Пойдём на улицу\n` +
`PALC — Залип\n` +
`Дора — Втюрилась\``,
{ parse_mode: 'MarkdownV2' },
)
}
export async function handlePartyInfo(ctx: CommandContext<SessionContext>) {
if (!ctx.session.partyId) {
await ctx.reply('У тебя нет активной вечеринки. Создай: /newparty или присоединись: /joinparty КОД')
return
}
try {
const party = await getParty(ctx.session.partyId)
if (!party) {
ctx.session.partyId = undefined
await ctx.reply('Вечеринка не найдена. Возможно она была удалена.')
return
}
const participants = party.participants ?? []
const list = participants.length
? participants.map((p) => `${p.name}${p.tracks?.length ?? 0} тр\\.`).join('\n')
: ' _Пока никого_'
await ctx.reply(
`🎉 *${escapeMarkdown(party.name)}*\n` +
`Код: \`${party.code}\`\n\n` +
`*Участники:*\n${list}`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при получении информации о вечеринке.')
}
}
export async function handleTracksMessage(ctx: SessionContext) {
if (!ctx.session.awaitingTracks || !ctx.session.pendingParticipantName || !ctx.session.partyId) {
return false
}
const text = ctx.message?.text ?? ''
const tracks = text
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 1)
.slice(0, 50)
if (!tracks.length) {
await ctx.reply('Не нашёл треков. Пришли список заново.')
return true
}
try {
const party = await getParty(ctx.session.partyId)
const colorIndex = party?.participants?.length ?? 0
await addParticipant(ctx.session.partyId, ctx.session.pendingParticipantName, tracks, colorIndex)
ctx.session.awaitingTracks = false
const name = ctx.session.pendingParticipantName
ctx.session.pendingParticipantName = undefined
await ctx.reply(
`✅ *${escapeMarkdown(name)}* добавлен с ${tracks.length} треками\\!\n\n` +
`Используй /party чтобы посмотреть всех участников\\.`,
{ parse_mode: 'MarkdownV2' },
)
} catch {
await ctx.reply('Ошибка при добавлении участника. Попробуй ещё раз.')
}
return true
}
function escapeMarkdown(text: string): string {
return text.replace(/[_*[\]()~`>#+=|{}.!\\-]/g, '\\$&')
}

View File

@@ -0,0 +1,14 @@
import { CommandContext, Context } from 'grammy'
export async function handleStart(ctx: CommandContext<Context>) {
await ctx.reply(
`🎉 *Party Mix Bot*\n\nСоздавай вечеринки с общими плейлистами!\n\n` +
`*Команды:*\n` +
`/newparty \\[название\\] — создать новую вечеринку\n` +
`/joinparty \\[код\\] — присоединиться к вечеринке\n` +
`/addme \\[имя\\] — добавить себя как участника\n` +
`/party — информация о текущей вечеринке\n` +
`/help — показать эту справку`,
{ parse_mode: 'MarkdownV2' },
)
}

8
apps/bot/src/config.ts Normal file
View File

@@ -0,0 +1,8 @@
export const config = {
botToken: process.env.BOT_TOKEN ?? '',
backendUrl: process.env.BACKEND_URL ?? 'http://localhost:8080',
} as const
if (!config.botToken) {
throw new Error('BOT_TOKEN environment variable is required')
}

12
apps/bot/src/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import createBot from './bot'
const bot = createBot()
process.once('SIGINT', () => bot.stop())
process.once('SIGTERM', () => bot.stop())
bot.start({
onStart: (info) => {
console.log(`Party Mix Bot started: @${info.username}`)
},
})

View File

@@ -0,0 +1,32 @@
export interface Party {
id: string
name: string
code: string
telegram_id?: number
created_at: string
participants?: Participant[]
}
export interface Participant {
id: string
party_id: string
name: string
color_bg: string
color_text: string
tracks?: Track[]
}
export interface Track {
id: string
participant_id: string
title: string
position: number
}
export interface SessionData {
partyId?: string
partyCode?: string
partyName?: string
awaitingTracks?: boolean
pendingParticipantName?: string
}