Compare commits
10 Commits
93aad601cf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 04293f1c08 | |||
| ef75e79274 | |||
| e64010591a | |||
| f9c20b2a17 | |||
| 39944f01a8 | |||
| da52da09e0 | |||
| c5079c12dc | |||
| 0efe63792e | |||
| c49b10e11d | |||
| fc57fe2415 |
@@ -4,7 +4,26 @@
|
||||
"Skill(superpowers:writing-plans)",
|
||||
"Skill(superpowers:subagent-driven-development)",
|
||||
"Bash(npm init *)",
|
||||
"Bash(npm install *)"
|
||||
"Bash(npm install *)",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npx jest *)",
|
||||
"Bash(git -C /home/sjoerd/Work/assistant add package.json package-lock.json)",
|
||||
"Bash(git -C /home/sjoerd/Work/assistant commit -m \"chore: add ts-node for jest.config.ts parsing\")",
|
||||
"Bash(git -C /home/sjoerd/Work/assistant log --oneline)",
|
||||
"Bash(node *)",
|
||||
"Bash(npm list *)",
|
||||
"Bash(npm ls *)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(npm test *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(npm view *)",
|
||||
"Bash(grep -E '\\\\.\\(json|ts\\)$|env')",
|
||||
"Bash(npm uninstall *)",
|
||||
"Bash(docker build *)",
|
||||
"Bash(git init *)",
|
||||
"Skill(superpowers:using-superpowers)",
|
||||
"Skill(superpowers:using-superpowers:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
.env
|
||||
*.md
|
||||
.git/
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
RUN apk del python3 make g++
|
||||
COPY --from=builder /app/dist ./dist
|
||||
RUN mkdir -p /app/data && chown appuser:appgroup /app/data
|
||||
USER appuser
|
||||
CMD ["node", "dist/index.js"]
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
assistant:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- TZ=${TZ:-Europe/Amsterdam}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Updating the Bot
|
||||
|
||||
Pull latest code and rebuild:
|
||||
|
||||
docker compose down
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
|
||||
The SQLite database in `./data/` is preserved across updates.
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import { createAiClient } from './client';
|
||||
import { buildContext } from './context';
|
||||
import { progressPrompt, nextStepPrompt, reschedulePrompt, setupPrompt } from './prompts';
|
||||
import { parseScheduleJson, type ScheduleProposal } from './parser';
|
||||
import { getSetting } from '../db/settings';
|
||||
import { config } from '../config';
|
||||
|
||||
function getModel(db: Database): string {
|
||||
return getSetting(db, 'model') ?? config.defaultModel;
|
||||
}
|
||||
|
||||
async function chat(
|
||||
db: Database,
|
||||
system: string,
|
||||
user: string,
|
||||
jsonMode = false
|
||||
): Promise<string> {
|
||||
const client = createAiClient();
|
||||
const model = getModel(db);
|
||||
const response = await client.chat.completions.create({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
...(jsonMode ? { response_format: { type: 'json_object' } } : {}),
|
||||
});
|
||||
return response.choices[0]?.message.content ?? '';
|
||||
}
|
||||
|
||||
export async function getProgressAnalysis(db: Database, today: string): Promise<string> {
|
||||
const ctx = buildContext(db, today);
|
||||
const { system, user } = progressPrompt(ctx);
|
||||
return chat(db, system, user);
|
||||
}
|
||||
|
||||
export async function getNextStep(db: Database, today: string): Promise<string> {
|
||||
const ctx = buildContext(db, today);
|
||||
const { system, user } = nextStepPrompt(ctx);
|
||||
return chat(db, system, user);
|
||||
}
|
||||
|
||||
export async function generateSchedule(
|
||||
db: Database, today: string, note: string | null
|
||||
): Promise<ScheduleProposal[]> {
|
||||
const ctx = buildContext(db, today);
|
||||
const { system, user } = reschedulePrompt(ctx, note);
|
||||
const raw = await chat(db, system, user);
|
||||
return parseScheduleJson(raw);
|
||||
}
|
||||
|
||||
export async function generateSetupSchedule(
|
||||
db: Database,
|
||||
today: string,
|
||||
currentLevel: string,
|
||||
availability: string,
|
||||
goal: string
|
||||
): Promise<ScheduleProposal[]> {
|
||||
const { system, user } = setupPrompt(currentLevel, availability, goal);
|
||||
const raw = await chat(db, system, user);
|
||||
return parseScheduleJson(raw);
|
||||
}
|
||||
|
||||
export async function freeChat(db: Database, today: string, message: string): Promise<string> {
|
||||
const ctx = buildContext(db, today);
|
||||
const { system } = progressPrompt(ctx);
|
||||
return chat(db, system, message);
|
||||
}
|
||||
+2
-31
@@ -2,6 +2,7 @@ import type { Database } from 'better-sqlite3';
|
||||
import { getRecentSessions, type Session } from '../db/sessions';
|
||||
import { getUpcomingSchedule, type ScheduleEntry } from '../db/schedule';
|
||||
import { getSetting } from '../db/settings';
|
||||
import { getStreaks } from '../utils/streak';
|
||||
|
||||
export interface AIContext {
|
||||
today: string;
|
||||
@@ -12,41 +13,11 @@ export interface AIContext {
|
||||
recentSkips: Array<{ date: string; reason: string | null }>;
|
||||
}
|
||||
|
||||
function calculateStreak(db: Database): number {
|
||||
const rows = db.prepare(
|
||||
"SELECT date FROM schedule WHERE status = 'completed' ORDER BY date DESC"
|
||||
).all() as Array<{ date: string }>;
|
||||
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
let streak = 0;
|
||||
let prev: string | null = null;
|
||||
|
||||
for (const { date } of rows) {
|
||||
if (prev === null) {
|
||||
streak = 1;
|
||||
prev = date;
|
||||
continue;
|
||||
}
|
||||
const prevDate = new Date(prev + 'T12:00:00');
|
||||
const currDate = new Date(date + 'T12:00:00');
|
||||
const diffDays = Math.round((prevDate.getTime() - currDate.getTime()) / 86400000);
|
||||
if (diffDays <= 2) {
|
||||
streak++;
|
||||
prev = date;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return streak;
|
||||
}
|
||||
|
||||
export function buildContext(db: Database, today: string): AIContext {
|
||||
const recentSessions = getRecentSessions(db, 30);
|
||||
const upcomingSchedule = getUpcomingSchedule(db, today);
|
||||
const goal = getSetting(db, 'goal') ?? 'Not set';
|
||||
const currentStreak = calculateStreak(db);
|
||||
const currentStreak = getStreaks(db).current;
|
||||
|
||||
const recentSkips = (db.prepare(
|
||||
"SELECT date, skip_reason as reason FROM schedule WHERE status = 'skipped' ORDER BY date DESC LIMIT 10"
|
||||
|
||||
@@ -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,38 @@
|
||||
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);
|
||||
const restartNote = (key === 'morning_reminder' || key === 'evening_reminder')
|
||||
? '\n\n⚠️ Restart the bot for the new reminder time to take effect.'
|
||||
: '';
|
||||
await ctx.reply(`Updated ${key} = ${value}${restartNote}`);
|
||||
};
|
||||
}
|
||||
@@ -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,10 @@
|
||||
import type { Context } from 'telegraf';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import { getStreaks } from '../../utils/streak';
|
||||
|
||||
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`);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Telegraf } from 'telegraf';
|
||||
import { message } from 'telegraf/filters';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import { handleLog } from './commands/log';
|
||||
import { handleSkip } from './commands/skip';
|
||||
import { handleSnooze } from './commands/snooze';
|
||||
import { handleStatus } from './commands/status';
|
||||
import { handleHistory } from './commands/history';
|
||||
import { handleStreak } from './commands/streak';
|
||||
import { handleSettings } from './commands/settings-cmd';
|
||||
import { handleProgress } from './commands/progress';
|
||||
import { handleNext } from './commands/next';
|
||||
import { handleReschedule } from './commands/reschedule';
|
||||
import { handleChat } from './commands/chat';
|
||||
import { handleSetup, handleSetupReply } from './commands/setup';
|
||||
|
||||
export function createBot(token: string, chatId: string, db: Database): Telegraf {
|
||||
const bot = new Telegraf(token);
|
||||
|
||||
bot.command('log', handleLog(db));
|
||||
bot.command('skip', handleSkip(db));
|
||||
bot.command('snooze', handleSnooze(db, chatId));
|
||||
bot.command('status', handleStatus(db));
|
||||
bot.command('history', handleHistory(db));
|
||||
bot.command('streak', handleStreak(db));
|
||||
bot.command('settings', handleSettings(db));
|
||||
bot.command('progress', handleProgress(db));
|
||||
bot.command('next', handleNext(db));
|
||||
bot.command('reschedule', handleReschedule(db));
|
||||
bot.command('chat', handleChat(db));
|
||||
bot.command('setup', handleSetup(db));
|
||||
|
||||
const setupReply = handleSetupReply(db);
|
||||
bot.on(message('text'), async (ctx) => {
|
||||
if (ctx.message.text.startsWith('/')) return;
|
||||
await setupReply(ctx);
|
||||
});
|
||||
|
||||
bot.catch((err) => {
|
||||
console.error('Bot error:', err);
|
||||
});
|
||||
|
||||
bot.telegram.setMyCommands([
|
||||
{ command: 'log', description: 'Log a session — e.g. /log 2h or /log 45m' },
|
||||
{ command: 'skip', description: 'Skip today\'s session — e.g. /skip work trip' },
|
||||
{ command: 'snooze', description: 'Remind me again in 2 hours' },
|
||||
{ command: 'status', description: 'Today\'s plan and completion state' },
|
||||
{ command: 'history', description: 'Last 7 sessions' },
|
||||
{ command: 'streak', description: 'Current and longest streak' },
|
||||
{ command: 'progress', description: 'AI analysis of recent progress' },
|
||||
{ command: 'next', description: 'AI recommendation for next training step' },
|
||||
{ command: 'reschedule', description: 'AI-generated schedule — e.g. /reschedule busy next week' },
|
||||
{ command: 'chat', description: 'Free-form coaching — e.g. /chat how am I doing?' },
|
||||
{ command: 'settings', description: 'View or change settings — e.g. /settings model gpt-4o' },
|
||||
{ command: 'setup', description: 'First-time onboarding' },
|
||||
]).catch((err) => console.error('Failed to set bot commands:', err));
|
||||
|
||||
return bot;
|
||||
}
|
||||
+1
-1
@@ -16,7 +16,7 @@ export function insertScheduleEntry(
|
||||
db: Database, date: string, planned_min: number, ai_rationale: string | null
|
||||
): ScheduleEntry {
|
||||
db.prepare(
|
||||
'INSERT OR IGNORE INTO schedule (date, planned_min, ai_rationale) VALUES (?, ?, ?)'
|
||||
'INSERT OR REPLACE INTO schedule (date, planned_min, ai_rationale, status) VALUES (?, ?, ?, \'pending\')'
|
||||
).run(date, planned_min, ai_rationale);
|
||||
return getScheduleByDate(db, date)!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { config } from './config';
|
||||
import { getDb } from './db';
|
||||
import { createBot } from './bot';
|
||||
import { startScheduler } from './scheduler';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const db = getDb(config.dbPath);
|
||||
const bot = createBot(config.telegramToken, config.telegramChatId, db);
|
||||
|
||||
startScheduler(bot, db, config.telegramChatId);
|
||||
|
||||
await bot.launch();
|
||||
console.log('Training assistant bot started.');
|
||||
|
||||
process.once('SIGINT', () => bot.stop('SIGINT'));
|
||||
process.once('SIGTERM', () => bot.stop('SIGTERM'));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import cron from 'node-cron';
|
||||
import type { Telegraf } from 'telegraf';
|
||||
import type { Database } from 'better-sqlite3';
|
||||
import { getSetting } from '../db/settings';
|
||||
import { config } from '../config';
|
||||
import { sendMorningReminder, sendEveningNudge } from './reminders';
|
||||
|
||||
function parseCronTime(hhmm: string): string {
|
||||
const [hh, mm] = hhmm.split(':');
|
||||
return `${mm} ${hh} * * *`;
|
||||
}
|
||||
|
||||
export function startScheduler(bot: Telegraf, db: Database, chatId: string): void {
|
||||
const morningTime = getSetting(db, 'morning_reminder') ?? config.morningReminder;
|
||||
const eveningTime = getSetting(db, 'evening_reminder') ?? config.eveningReminder;
|
||||
|
||||
cron.schedule(parseCronTime(morningTime), async () => {
|
||||
try {
|
||||
await sendMorningReminder(bot, db, chatId);
|
||||
} catch (err) {
|
||||
console.error('Morning reminder failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
cron.schedule(parseCronTime(eveningTime), async () => {
|
||||
try {
|
||||
await sendEveningNudge(bot, db, chatId);
|
||||
} catch (err) {
|
||||
console.error('Evening nudge failed:', err);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Reminders scheduled: morning=${morningTime}, evening=${eveningTime}`);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Telegraf } 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 async function sendMorningReminder(bot: Telegraf, db: Database, chatId: string): Promise<void> {
|
||||
const today = todayString();
|
||||
const scheduled = getScheduleByDate(db, today);
|
||||
if (!scheduled || scheduled.status !== 'pending') return;
|
||||
|
||||
const session = getSessionByDate(db, today);
|
||||
if (session) return;
|
||||
|
||||
await bot.telegram.sendMessage(
|
||||
chatId,
|
||||
`Good morning! You have a ${formatDuration(scheduled.planned_min)} session planned today.\n\nUse /log when done, /skip if you can't make it, or /snooze to remind you later.`
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendEveningNudge(bot: Telegraf, db: Database, chatId: string): Promise<void> {
|
||||
const today = todayString();
|
||||
const scheduled = getScheduleByDate(db, today);
|
||||
if (!scheduled || scheduled.status !== 'pending') return;
|
||||
|
||||
const session = getSessionByDate(db, today);
|
||||
if (session) return;
|
||||
|
||||
await bot.telegram.sendMessage(
|
||||
chatId,
|
||||
`Still time for today's session (${formatDuration(scheduled.planned_min)})!\n\nUse /log to record it, or /skip to mark it as skipped.`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Database } from 'better-sqlite3';
|
||||
|
||||
export 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 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;
|
||||
}
|
||||
}
|
||||
|
||||
return { current: streak, longest };
|
||||
}
|
||||
Reference in New Issue
Block a user