diff --git a/src/bot/commands/chat.ts b/src/bot/commands/chat.ts new file mode 100644 index 0000000..bf68a81 --- /dev/null +++ b/src/bot/commands/chat.ts @@ -0,0 +1,22 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { todayString } from '../../utils/date'; +import { freeChat } from '../../ai/coach'; + +export function handleChat(db: Database) { + return async (ctx: Context) => { + const text = (ctx.message as { text?: string })?.text ?? ''; + const message = text.split(/\s+/).slice(1).join(' '); + if (!message) { + await ctx.reply('Usage: /chat '); + return; + } + await ctx.reply('Thinking…'); + try { + const reply = await freeChat(db, todayString(), message); + await ctx.reply(reply); + } catch (err) { + await ctx.reply(`AI unavailable: ${(err as Error).message}`); + } + }; +} diff --git a/src/bot/commands/history.ts b/src/bot/commands/history.ts new file mode 100644 index 0000000..41793af --- /dev/null +++ b/src/bot/commands/history.ts @@ -0,0 +1,18 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { getRecentSessions } from '../../db/sessions'; +import { formatDuration } from '../../utils/duration'; + +export function handleHistory(db: Database) { + return async (ctx: Context) => { + const sessions = getRecentSessions(db, 7); + if (sessions.length === 0) { + await ctx.reply('No sessions recorded yet. Use /log to record your first session.'); + return; + } + const lines = sessions.map(s => + `${s.date}: ${formatDuration(s.duration_min)}${s.notes ? ` — ${s.notes}` : ''}` + ); + await ctx.reply(`Last ${sessions.length} sessions:\n\n${lines.join('\n')}`); + }; +} diff --git a/src/bot/commands/log.ts b/src/bot/commands/log.ts new file mode 100644 index 0000000..2ff6ec6 --- /dev/null +++ b/src/bot/commands/log.ts @@ -0,0 +1,40 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { parseDuration, formatDuration } from '../../utils/duration'; +import { todayString } from '../../utils/date'; +import { insertSession, getSessionByDate } from '../../db/sessions'; +import { getScheduleByDate, updateScheduleStatus } from '../../db/schedule'; + +export function handleLog(db: Database) { + return async (ctx: Context) => { + const text = (ctx.message as { text?: string })?.text ?? ''; + const args = text.split(/\s+/).slice(1).join(' '); + + if (!args) { + await ctx.reply('Usage: /log e.g. /log 2h or /log 45m'); + return; + } + + const minutes = parseDuration(args); + if (minutes === null) { + await ctx.reply(`Could not parse "${args}". Try: /log 2h, /log 90m, /log 45min`); + return; + } + + const today = todayString(); + const existing = getSessionByDate(db, today); + if (existing) { + await ctx.reply(`Already logged ${formatDuration(existing.duration_min)} for today. Each day supports one session.`); + return; + } + + insertSession(db, today, minutes, null); + + const scheduled = getScheduleByDate(db, today); + if (scheduled && scheduled.status === 'pending') { + updateScheduleStatus(db, today, 'completed', null); + } + + await ctx.reply(`Logged ${formatDuration(minutes)} for today. Great work!`); + }; +} diff --git a/src/bot/commands/next.ts b/src/bot/commands/next.ts new file mode 100644 index 0000000..c6c0ef6 --- /dev/null +++ b/src/bot/commands/next.ts @@ -0,0 +1,16 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { todayString } from '../../utils/date'; +import { getNextStep } from '../../ai/coach'; + +export function handleNext(db: Database) { + return async (ctx: Context) => { + await ctx.reply('Thinking about your next step…'); + try { + const result = await getNextStep(db, todayString()); + await ctx.reply(result); + } catch (err) { + await ctx.reply(`AI unavailable: ${(err as Error).message}`); + } + }; +} diff --git a/src/bot/commands/progress.ts b/src/bot/commands/progress.ts new file mode 100644 index 0000000..d52ec63 --- /dev/null +++ b/src/bot/commands/progress.ts @@ -0,0 +1,16 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { todayString } from '../../utils/date'; +import { getProgressAnalysis } from '../../ai/coach'; + +export function handleProgress(db: Database) { + return async (ctx: Context) => { + await ctx.reply('Analysing your recent history…'); + try { + const result = await getProgressAnalysis(db, todayString()); + await ctx.reply(result); + } catch (err) { + await ctx.reply(`AI unavailable: ${(err as Error).message}`); + } + }; +} diff --git a/src/bot/commands/reschedule.ts b/src/bot/commands/reschedule.ts new file mode 100644 index 0000000..5ebb479 --- /dev/null +++ b/src/bot/commands/reschedule.ts @@ -0,0 +1,25 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { todayString } from '../../utils/date'; +import { generateSchedule } from '../../ai/coach'; +import { insertScheduleEntry } from '../../db/schedule'; +import { formatDuration } from '../../utils/duration'; + +export function handleReschedule(db: Database) { + return async (ctx: Context) => { + const text = (ctx.message as { text?: string })?.text ?? ''; + const note = text.split(/\s+/).slice(1).join(' ') || null; + + await ctx.reply('Generating new schedule…'); + try { + const proposals = await generateSchedule(db, todayString(), note); + for (const p of proposals) { + insertScheduleEntry(db, p.date, p.planned_min, p.rationale); + } + const lines = proposals.map(p => `${p.date}: ${formatDuration(p.planned_min)}`); + await ctx.reply(`New schedule (${proposals.length} sessions):\n\n${lines.join('\n')}`); + } catch (err) { + await ctx.reply(`Failed to generate schedule: ${(err as Error).message}`); + } + }; +} diff --git a/src/bot/commands/settings-cmd.ts b/src/bot/commands/settings-cmd.ts new file mode 100644 index 0000000..59bfec4 --- /dev/null +++ b/src/bot/commands/settings-cmd.ts @@ -0,0 +1,35 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { getSetting, setSetting, getAllSettings } from '../../db/settings'; + +const ALLOWED_KEYS = ['model', 'goal', 'morning_reminder', 'evening_reminder']; + +export function handleSettings(db: Database) { + return async (ctx: Context) => { + const text = (ctx.message as { text?: string })?.text ?? ''; + const parts = text.split(/\s+/).slice(1); + + if (parts.length === 0) { + const all = getAllSettings(db); + const lines = ALLOWED_KEYS.map(k => `${k}: ${all[k] ?? '(not set)'}`); + await ctx.reply(`Current settings:\n\n${lines.join('\n')}\n\nUsage: /settings `); + return; + } + + const key = parts[0]!; + const value = parts.slice(1).join(' '); + + if (!ALLOWED_KEYS.includes(key)) { + await ctx.reply(`Unknown setting "${key}". Allowed: ${ALLOWED_KEYS.join(', ')}`); + return; + } + + if (!value) { + await ctx.reply(`Current value for "${key}": ${getSetting(db, key) ?? '(not set)'}`); + return; + } + + setSetting(db, key, value); + await ctx.reply(`Updated ${key} = ${value}`); + }; +} diff --git a/src/bot/commands/setup.ts b/src/bot/commands/setup.ts new file mode 100644 index 0000000..6729911 --- /dev/null +++ b/src/bot/commands/setup.ts @@ -0,0 +1,79 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { setSetting, getSetting } from '../../db/settings'; +import { generateSetupSchedule } from '../../ai/coach'; +import { insertScheduleEntry } from '../../db/schedule'; +import { todayString } from '../../utils/date'; +import { formatDuration } from '../../utils/duration'; + +type SetupState = { + step: 'level' | 'availability' | 'goal'; + level?: string; + availability?: string; +}; + +const sessions = new Map(); + +export function handleSetup(db: Database) { + return async (ctx: Context) => { + const chatId = ctx.from?.id; + if (!chatId) return; + + const alreadySetup = getSetting(db, 'goal'); + if (alreadySetup) { + await ctx.reply('Already set up. Use /settings to change individual values, or /reschedule to regenerate your schedule.'); + return; + } + + sessions.set(chatId, { step: 'level' }); + await ctx.reply( + 'Welcome! Let\'s set up your training plan.\n\nWhat is your current level?\n\nExamples: "beginner", "can wear small plug for 30 minutes", "comfortable with medium plug for 1 hour"' + ); + }; +} + +export function handleSetupReply(db: Database) { + return async (ctx: Context) => { + const chatId = ctx.from?.id; + if (!chatId) return; + + const state = sessions.get(chatId); + if (!state) return; + + const text = (ctx.message as { text?: string })?.text?.trim() ?? ''; + if (!text) return; + + if (state.step === 'level') { + state.level = text; + state.step = 'availability'; + await ctx.reply('How many days per week are you typically available for training? (e.g. "3 days", "every other day", "Mon/Wed/Fri")'); + return; + } + + if (state.step === 'availability') { + state.availability = text; + state.step = 'goal'; + await ctx.reply('What is your training goal? (e.g. "wear large plug comfortably for 2 hours", "build up to daily wear")'); + return; + } + + if (state.step === 'goal') { + sessions.delete(chatId); + setSetting(db, 'goal', text); + + await ctx.reply('Got it! Generating your starter schedule…'); + try { + const proposals = await generateSetupSchedule(db, todayString(), state.level!, state.availability!, text); + for (const p of proposals) { + insertScheduleEntry(db, p.date, p.planned_min, p.rationale); + } + const lines = proposals.map(p => `${p.date}: ${formatDuration(p.planned_min)}`); + await ctx.reply( + `Setup complete! Here's your starter schedule (${proposals.length} sessions):\n\n${lines.join('\n')}\n\nUse /status to see today's plan, /log to record a session.` + ); + } catch (err) { + await ctx.reply(`Setup saved but schedule generation failed: ${(err as Error).message}\n\nRun /reschedule to try again.`); + } + } + }; +} diff --git a/src/bot/commands/skip.ts b/src/bot/commands/skip.ts new file mode 100644 index 0000000..f773b79 --- /dev/null +++ b/src/bot/commands/skip.ts @@ -0,0 +1,35 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { todayString } from '../../utils/date'; +import { getScheduleByDate, updateScheduleStatus } from '../../db/schedule'; +import { getSessionByDate } from '../../db/sessions'; + +export function handleSkip(db: Database) { + return async (ctx: Context) => { + const text = (ctx.message as { text?: string })?.text ?? ''; + const reason = text.split(/\s+/).slice(1).join(' ') || null; + + const today = todayString(); + + const session = getSessionByDate(db, today); + if (session) { + await ctx.reply(`You already logged a session today (${session.duration_min}m). Nothing to skip.`); + return; + } + + const scheduled = getScheduleByDate(db, today); + if (!scheduled) { + await ctx.reply('No session scheduled for today anyway.'); + return; + } + + if (scheduled.status !== 'pending') { + await ctx.reply(`Today's session is already marked as ${scheduled.status}.`); + return; + } + + updateScheduleStatus(db, today, 'skipped', reason); + const msg = reason ? `Skipped today (${reason}). The AI will account for this next time you reschedule.` : 'Skipped today. The AI will account for this next time you reschedule.'; + await ctx.reply(msg); + }; +} diff --git a/src/bot/commands/snooze.ts b/src/bot/commands/snooze.ts new file mode 100644 index 0000000..2696640 --- /dev/null +++ b/src/bot/commands/snooze.ts @@ -0,0 +1,35 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { todayString } from '../../utils/date'; +import { getScheduleByDate } from '../../db/schedule'; +import { getSessionByDate } from '../../db/sessions'; +import { formatDuration } from '../../utils/duration'; + +export function handleSnooze(db: Database, telegramChatId: string) { + return async (ctx: Context) => { + const today = todayString(); + const session = getSessionByDate(db, today); + if (session) { + await ctx.reply('Session already logged today — nothing to snooze.'); + return; + } + const scheduled = getScheduleByDate(db, today); + if (!scheduled || scheduled.status !== 'pending') { + await ctx.reply('No pending session today to snooze.'); + return; + } + + await ctx.reply('Got it — I\'ll remind you again in 2 hours.'); + + setTimeout(async () => { + try { + await ctx.telegram.sendMessage( + telegramChatId, + `⏰ Snooze reminder: don't forget your session today (${formatDuration(scheduled.planned_min)}). Use /log when done or /skip to skip.` + ); + } catch { + // best-effort; bot may have been restarted + } + }, 2 * 60 * 60 * 1000); + }; +} diff --git a/src/bot/commands/status.ts b/src/bot/commands/status.ts new file mode 100644 index 0000000..c70533c --- /dev/null +++ b/src/bot/commands/status.ts @@ -0,0 +1,30 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; +import { todayString } from '../../utils/date'; +import { getSessionByDate } from '../../db/sessions'; +import { getScheduleByDate } from '../../db/schedule'; +import { getSetting } from '../../db/settings'; +import { formatDuration } from '../../utils/duration'; + +export function handleStatus(db: Database) { + return async (ctx: Context) => { + const today = todayString(); + const session = getSessionByDate(db, today); + const scheduled = getScheduleByDate(db, today); + const morning = getSetting(db, 'morning_reminder') ?? '05:30'; + const evening = getSetting(db, 'evening_reminder') ?? '16:30'; + + const lines: string[] = [`📅 Today: ${today}`]; + + if (session) { + lines.push(`✅ Session logged: ${formatDuration(session.duration_min)}`); + } else if (scheduled) { + lines.push(`⏳ Planned: ${formatDuration(scheduled.planned_min)} (${scheduled.status})`); + lines.push(`⏰ Reminders: ${morning} and ${evening}`); + } else { + lines.push('No session scheduled for today.'); + } + + await ctx.reply(lines.join('\n')); + }; +} diff --git a/src/bot/commands/streak.ts b/src/bot/commands/streak.ts new file mode 100644 index 0000000..5d4a2b4 --- /dev/null +++ b/src/bot/commands/streak.ts @@ -0,0 +1,34 @@ +import type { Context } from 'telegraf'; +import type { Database } from 'better-sqlite3'; + +function getStreaks(db: Database): { current: number; longest: number } { + const rows = db.prepare( + "SELECT date FROM schedule WHERE status = 'completed' ORDER BY date ASC" + ).all() as Array<{ date: string }>; + + if (rows.length === 0) return { current: 0, longest: 0 }; + + let current = 1, longest = 1, streak = 1; + + for (let i = 1; i < rows.length; i++) { + const prev = new Date(rows[i - 1]!.date + 'T12:00:00'); + const curr = new Date(rows[i]!.date + 'T12:00:00'); + const diff = Math.round((curr.getTime() - prev.getTime()) / 86400000); + if (diff <= 2) { + streak++; + if (streak > longest) longest = streak; + } else { + streak = 1; + } + } + + current = streak; + return { current, longest }; +} + +export function handleStreak(db: Database) { + return async (ctx: Context) => { + const { current, longest } = getStreaks(db); + await ctx.reply(`🔥 Current streak: ${current} sessions\n🏆 Longest streak: ${longest} sessions`); + }; +}