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:
54
apps/bot/src/api/client.ts
Normal file
54
apps/bot/src/api/client.ts
Normal 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
46
apps/bot/src/bot.ts
Normal 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
|
||||
17
apps/bot/src/commands/help.ts
Normal file
17
apps/bot/src/commands/help.ts
Normal 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' },
|
||||
)
|
||||
}
|
||||
152
apps/bot/src/commands/party.ts
Normal file
152
apps/bot/src/commands/party.ts
Normal 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, '\\$&')
|
||||
}
|
||||
14
apps/bot/src/commands/start.ts
Normal file
14
apps/bot/src/commands/start.ts
Normal 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
8
apps/bot/src/config.ts
Normal 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
12
apps/bot/src/index.ts
Normal 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}`)
|
||||
},
|
||||
})
|
||||
32
apps/bot/src/types/index.ts
Normal file
32
apps/bot/src/types/index.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user