feat: command handlers
Add all 11 Telegraf command handler files (tasks 12–16): log, skip, status, history, streak, snooze, settings-cmd, progress, next, reschedule, chat, and setup (including multi-step onboarding flow). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <your message>');
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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')}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 <duration> 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!`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 <key> <value>`);
|
||||||
|
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}`);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<number, SetupState>();
|
||||||
|
|
||||||
|
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.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user