diff options
| author | Jan Tuomi <jan@jantuomi.fi> | 2026-05-06 21:46:21 +0300 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2026-05-06 21:46:21 +0300 |
| commit | 713ced9c68cd70a8e56e8ce5ed27cb9bbe3e55c4 (patch) | |
| tree | 29f9997386b9f46db9f263ef63a5e01ca19db393 /src | |
| parent | cba8c3d49b53702a488eaff16109e29e49d94b6d (diff) | |
Add shopping list features, rework
Diffstat (limited to 'src')
| -rw-r--r-- | src/bot.ts | 49 | ||||
| -rw-r--r-- | src/config.ts | 30 | ||||
| -rw-r--r-- | src/db.ts | 74 | ||||
| -rw-r--r-- | src/index.ts | 51 | ||||
| -rw-r--r-- | src/middleware.ts | 10 | ||||
| -rw-r--r-- | src/sheets.ts | 203 | ||||
| -rw-r--r-- | src/tasks.ts | 39 | ||||
| -rw-r--r-- | src/tg.ts | 119 |
8 files changed, 276 insertions, 299 deletions
diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..1de3a22 --- /dev/null +++ b/src/bot.ts @@ -0,0 +1,49 @@ +import { bot, broadcastMessage } from "./tg"; +import { getRecurringTasks, markTaskDone } from "./db"; +import { isThisWeek, today, now } from "./tasks"; +import { isMonday, getHours, format } from "date-fns"; + +const CHECK_INTERVAL_MS = 60 * 1000; +let lastRunKey: string | null = null; + +const runWeeklyTasks = async (): Promise<void> => { + try { + console.log("[weekly] Starting recurring tasks check"); + const tasks = getRecurringTasks(); + const dueTasks = tasks.filter(isThisWeek); + + if (dueTasks.length > 0) { + const taskText = dueTasks.map((t) => `• ${t.name} (${t.interval})`).join("\n"); + await broadcastMessage(`📋 Tällä viikolla tehtävät hommat:\n${taskText}`); + const todayStr = today(); + for (const t of dueTasks) { + markTaskDone(t.id, todayStr); + } + } else { + await broadcastMessage("Tällä viikolla ei toistuvia hommia! 🎉"); + } + console.log("[weekly] Done"); + } catch (err) { + console.error("[weekly] Error:", err); + } +}; + +const checkAndRun = async (): Promise<void> => { + const current = now(); + if (isMonday(current) && getHours(current) === 9) { + const key = format(current, "yyyy-MM-dd"); + if (key !== lastRunKey) { + lastRunKey = key; + await runWeeklyTasks(); + } + } +}; + +setInterval(checkAndRun, CHECK_INTERVAL_MS); +console.log("[bot] Scheduled weekly tasks for Monday 09:00"); + +bot.launch(); +console.log("[bot] Bot is running"); + +process.once("SIGINT", () => bot.stop("SIGINT")); +process.once("SIGTERM", () => bot.stop("SIGTERM")); diff --git a/src/config.ts b/src/config.ts index d8e3ca0..0384656 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,4 @@ import dotenv from "dotenv"; -import fs from "fs"; import path from "path"; if (!process.env.SKIP_DOTENV) { @@ -13,45 +12,20 @@ class ConfigError extends Error { } } -const nodeEnv = process.env.NODE_ENV || "development"; - const tgBotToken = process.env.TELEGRAM_BOT_TOKEN || null; if (!tgBotToken) { throw new ConfigError("Missing env var TELEGRAM_BOT_TOKEN"); } -const sheetsSpreadsheetId = process.env.SHEETS_SPREADSHEET_ID || null; -if (!sheetsSpreadsheetId) { - throw new ConfigError("Missing env var SHEETS_SPREADSHEET_ID"); -} - -const sheetsRange = process.env.SHEETS_RANGE || null; -if (!sheetsRange) { - throw new ConfigError("Missing env var SHEETS_RANGE"); -} +const tz = process.env.TZ || "Europe/Helsinki"; const sqlitePath = process.env.SQLITE_PATH || path.join(process.cwd(), "data", "hommabot.db"); -const googleSaJsonB64 = process.env.G_SA_JSON_B64 || null; -if (!googleSaJsonB64) { - throw new ConfigError("Missing env var G_SA_JSON_B64"); -} - -const googleSaJsonBuff = Buffer.from(googleSaJsonB64, "base64"); -const googleSaJsonUtf8 = googleSaJsonBuff.toString("utf-8"); -const googleSaJson: Record<string, string> = JSON.parse(googleSaJsonUtf8); -const googleSaJsonPath = path.join("sa_key.json"); -fs.writeFileSync(googleSaJsonPath, googleSaJsonUtf8); - const config = { - nodeEnv, tgBotToken, - sheetsSpreadsheetId, - sheetsRange, + tz, sqlitePath, - googleSaJson, - googleSaJsonPath, }; export default config; @@ -51,6 +51,20 @@ const ddl = ` CREATE TABLE IF NOT EXISTS permitted_users ( id INTEGER PRIMARY KEY ) STRICT; + + CREATE TABLE IF NOT EXISTS shopping_list ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + item TEXT NOT NULL, + added_by INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) STRICT; + + CREATE TABLE IF NOT EXISTS recurring_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + interval TEXT NOT NULL, + last_done TEXT + ) STRICT; COMMIT; `; db.exec(ddl); @@ -117,3 +131,63 @@ export const getPermittedUsers = async (): Promise< const stmt = db.prepare("SELECT id FROM permitted_users"); return stmt.all() as PermittedUser[]; }; + +/* Shopping list */ + +interface ShoppingItem { + id: number; + item: string; +} + +export const addShoppingItem = (item: string, addedBy: number): void => { + withWriteTx((db) => { + db.prepare("INSERT INTO shopping_list (item, added_by) VALUES (?, ?)").run(item, addedBy); + }); +}; + +export const getShoppingList = (): ShoppingItem[] => { + return db.prepare("SELECT id, item FROM shopping_list ORDER BY id").all() as ShoppingItem[]; +}; + +export const removeShoppingItem = (id: number): void => { + withWriteTx((db) => { + db.prepare("DELETE FROM shopping_list WHERE id = ?").run(id); + }); +}; + +/* Recurring tasks */ + +export interface RecurringTask { + id: number; + name: string; + interval: string; + last_done: string | null; +} + +export const addRecurringTask = (name: string, interval: string): void => { + withWriteTx((db) => { + db.prepare("INSERT INTO recurring_tasks (name, interval) VALUES (?, ?)").run(name, interval); + }); +}; + +export const getRecurringTasks = (): RecurringTask[] => { + return db.prepare("SELECT id, name, interval, last_done FROM recurring_tasks ORDER BY id").all() as RecurringTask[]; +}; + +export const updateRecurringTask = (id: number, name: string, interval: string): void => { + withWriteTx((db) => { + db.prepare("UPDATE recurring_tasks SET name = ?, interval = ? WHERE id = ?").run(name, interval, id); + }); +}; + +export const markTaskDone = (id: number, date: string): void => { + withWriteTx((db) => { + db.prepare("UPDATE recurring_tasks SET last_done = ? WHERE id = ?").run(date, id); + }); +}; + +export const removeRecurringTask = (id: number): void => { + withWriteTx((db) => { + db.prepare("DELETE FROM recurring_tasks WHERE id = ?").run(id); + }); +}; diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index c6d92eb..0000000 --- a/src/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { buildSheetsClient } from "./sheets"; -import { broadcastMessage } from "./tg"; - -const main = async (): Promise<void> => { - const startTime = Date.now(); - console.log("[run] Starting recurring tasks notification script"); - - const sheets = await buildSheetsClient(); - - // 1. Fetch raw sheet data - const sheetData = await sheets.fetchSheetData(); - console.log(`[run] Retrieved ${sheetData.length} raw rows from Sheets`); - - // 2. Process rows (compute next/last dates) - const entries = sheets.processRows(sheetData); - console.log(`[run] Processed ${entries.length} entries`); - - // 3. Filter only tasks due this week - const thisWeeksEntries = sheets.filterOnlyThisWeek(entries); - console.log(`[run] Found ${thisWeeksEntries.length} entries due this week`); - - // 4. Compose & broadcast Telegram message - if (thisWeeksEntries.length > 0) { - const tasks = thisWeeksEntries.map((e) => `${e.name} (${e.interval})`); - const taskText = tasks.join("\n"); - const msg = `Tällä viikolla tehtävät hommat:\n${taskText}`; - await broadcastMessage(msg); - console.log("[run] Broadcasted weekly task message"); - } else { - const msg = "Tällä viikolla ei toistuvia hommia! 🎉"; - await broadcastMessage(msg); - console.log("[run] Broadcasted empty-week message"); - } - - // 5. Update "last done" column with new date stamps (only tasks done this week are advanced) - const newDateStamps = sheets.updatedLastDoneDateStamps(entries); - await sheets.updateSheetLastDoneColumn(newDateStamps); - console.log("[run] Updated last done column in sheet"); - - const durationMs = Date.now() - startTime; - console.log(`[run] Completed successfully in ${durationMs}ms`); -}; - -main() - .then(() => { - process.exit(0); - }) - .catch((err) => { - console.error("[run] Fatal error:", err); - process.exit(1); - }); diff --git a/src/middleware.ts b/src/middleware.ts index 589022f..6c56352 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,11 +1,5 @@ /** * Telegram-only middleware utilities. - * - * HTTP server functionality has been removed, so any HTTP-specific middleware - * (e.g. header-based auth) has been deleted. - * - * This module currently exposes: - * tgAuth - Ensures that only permitted Telegram user IDs can invoke bot commands. */ import { Context } from "telegraf"; @@ -25,7 +19,7 @@ export const tgAuth = async ( ctx: Context, next: () => Promise<void>, ): Promise<void> => { - const userId = ctx.message?.from.id; + const userId = ctx.from?.id; if (!userId) { await ctx.reply("Käyttäjätunnusta ei voitu lukea (user id puuttuu)."); return; @@ -43,6 +37,6 @@ export const tgAuth = async ( await next(); } catch (err) { console.error("[tgAuth] Authorization check failed:", err); - await ctx.reply("Odottamaton virhe valtuutuksessa."); + await ctx.reply("Odottamaton virhe luvituksessa."); } }; diff --git a/src/sheets.ts b/src/sheets.ts deleted file mode 100644 index 3578198..0000000 --- a/src/sheets.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { google } from "googleapis"; -import { - add, - set, - Duration, - parse, - format, - getDay, - subDays, - addDays, -} from "date-fns"; - -import config from "./config"; - -interface SheetsRawEntry { - intervalStr: string; - name: string; - lastDoneDateStr: string; -} - -interface SheetsProcessedEntry { - name: string; - nextDateStamp: string; - nextDate: Date; - lastDateStamp: string; - lastDate?: Date; - interval: string; -} - -const INTERVAL_ABBREVS = { - pv: 1, - vk: 7, - kk: 30, - v: 365, -} as const; - -type IntervalAbbrevKey = keyof typeof INTERVAL_ABBREVS; - -const strToDate = (s: string) => parse(s, "yyyy-MM-dd", new Date()); -const dateToStr = (d: Date) => format(d, "yyyy-MM-dd"); - -const calculateDuration = (intervalStr: string) => { - const match = /(\d+)(\w+)/.exec(intervalStr); - - if (!match) { - throw new Error("Given interval string does not match regular expression"); - } - - const number = Number.parseInt(match[1]); - const abbrev = match[2]; - - if (number <= 0) { - throw new Error(`"${number}" is an invalid interval number`); - } - - if (!Object.keys(INTERVAL_ABBREVS).includes(abbrev)) { - throw new Error(`"${abbrev}" is an invalid interval string`); - } - - const days = INTERVAL_ABBREVS[abbrev as IntervalAbbrevKey]; - - const totalDuration: Duration = { - days: number * days, - }; - - return totalDuration; -}; - -const processRow = (row: SheetsRawEntry) => { - const duration = calculateDuration(row.intervalStr); - - const calcValues = () => { - if (row.lastDoneDateStr) { - const lastDate = strToDate(row.lastDoneDateStr); - const nextDate = add(lastDate, duration); - const nextDateStamp = dateToStr(nextDate); - - return { lastDate, nextDate, nextDateStamp }; - } else { - const lastDate = undefined; - const nextDate = new Date(); - const nextDateStamp = dateToStr(nextDate); - - return { lastDate, nextDate, nextDateStamp }; - } - }; - - const processedEntry: SheetsProcessedEntry = { - ...calcValues(), - name: row.name, - lastDateStamp: row.lastDoneDateStr, - interval: row.intervalStr, - }; - - return processedEntry; -}; - -const isThisWeek = (entry: SheetsProcessedEntry): boolean => { - const nextDate = entry.nextDate; - const currentDateTime = new Date(); - const today = set(currentDateTime, { - hours: 0, - minutes: 0, - seconds: 0, - milliseconds: 0, - }); - const dayOfWeek = (getDay(today) + 7 - 1) % 7; // getDay returns sunday = 0 - const currentWeekStart = subDays(today, dayOfWeek); - const currentWeekEnd = addDays(currentWeekStart, 7); - - return nextDate >= currentWeekStart && nextDate < currentWeekEnd; -}; - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export const buildSheetsClient = async () => { - const auth = new google.auth.GoogleAuth({ - // Scopes can be specified either as an array or as a single, space-delimited string. - scopes: [ - "https://www.googleapis.com/auth/spreadsheets", - "https://www.googleapis.com/auth/spreadsheets.readonly", - ], - keyFile: config.googleSaJsonPath, - }); - - google.options({ auth }); - const sheets = google.sheets({ version: "v4" }); - - const fetchSheetData = async (): Promise<SheetsRawEntry[]> => { - try { - const res = await sheets.spreadsheets.values.get({ - spreadsheetId: config.sheetsSpreadsheetId, - range: config.sheetsRange, - }); - - const entriesAsLists = res.data.values || []; - const rawEntries: SheetsRawEntry[] = entriesAsLists.map((row) => ({ - intervalStr: row[0], - name: row[1], - lastDoneDateStr: row[2], - })); - - console.log(`Fetched ${rawEntries.length} rows from Sheets.`); - return rawEntries; - } catch (err) { - console.error(err); - throw err; - } - }; - - const getRightmostColumnInRange = (range: string): string => { - const match = /(.+!)?([A-Z]+?)(\d+):([A-Z]+?)(\d+)/.exec(range); - - if (!match) { - throw new Error("Given range does not match regular expression"); - } - - const sheetName = match[1]; - const topLeftRow = Number.parseInt(match[3]); - const bottomRightCol = match[4]; - const bottomRightRow = Number.parseInt(match[5]); - - const rmCol = bottomRightCol; - const rmRowStart = topLeftRow; - const rmRowEnd = bottomRightRow; - - return `${sheetName}${rmCol}${rmRowStart}:${rmCol}${rmRowEnd}`; - }; - - const updateSheetLastDoneColumn = async (newDateStamps: string[]) => { - const lastDoneColumnRange = getRightmostColumnInRange(config.sheetsRange); - - const body = { - values: newDateStamps.map((nds) => [nds]), - }; - - await sheets.spreadsheets.values.update({ - spreadsheetId: config.sheetsSpreadsheetId, - range: lastDoneColumnRange, - valueInputOption: "RAW", - requestBody: body, - }); - }; - - const processRows = (rows: SheetsRawEntry[]) => rows.map(processRow); - - const filterOnlyThisWeek = ( - entries: SheetsProcessedEntry[], - ): SheetsProcessedEntry[] => entries.filter(isThisWeek); - - const updatedLastDoneDateStamps = ( - entries: SheetsProcessedEntry[], - ): string[] => - entries.map((e) => (isThisWeek(e) ? e.nextDateStamp : e.lastDateStamp)); - - return { - fetchSheetData, - getRightmostColumnInRange, - updateSheetLastDoneColumn, - processRows, - filterOnlyThisWeek, - updatedLastDoneDateStamps, - }; -}; diff --git a/src/tasks.ts b/src/tasks.ts new file mode 100644 index 0000000..b370aad --- /dev/null +++ b/src/tasks.ts @@ -0,0 +1,39 @@ +import { add, parse, format, startOfWeek, addWeeks, isWithinInterval, Duration } from "date-fns"; +import { RecurringTask } from "./db"; +import config from "./config"; + +const INTERVAL_ABBREVS: Record<string, number> = { + pv: 1, + vk: 7, + kk: 30, + v: 365, +}; + +export const now = (): Date => { + return new Date(new Date().toLocaleString("en-US", { timeZone: config.tz })); +}; + +export const parseInterval = (intervalStr: string): Duration => { + const match = /^(\d+)(pv|vk|kk|v)$/.exec(intervalStr); + if (!match) throw new Error(`Virheellinen intervalli: "${intervalStr}"`); + const num = parseInt(match[1]); + const days = num * INTERVAL_ABBREVS[match[2]]; + return { days }; +}; + +export const getNextDate = (task: RecurringTask): Date => { + if (!task.last_done) return now(); + const lastDate = parse(task.last_done, "yyyy-MM-dd", now()); + return add(lastDate, parseInterval(task.interval)); +}; + +export const isThisWeek = (task: RecurringTask): boolean => { + const nextDate = getNextDate(task); + const weekStart = startOfWeek(now(), { weekStartsOn: 1 }); + const weekEnd = addWeeks(weekStart, 1); + return isWithinInterval(nextDate, { start: weekStart, end: weekEnd }); +}; + +export const formatDate = (d: Date): string => format(d, "yyyy-MM-dd"); + +export const today = (): string => format(now(), "yyyy-MM-dd"); @@ -1,11 +1,14 @@ -import { Telegraf } from "telegraf"; +import { Markup, Telegraf } from "telegraf"; import config from "./config"; -import { addActiveChat, getActiveChats, removeActiveChat } from "./db"; +import { + addActiveChat, getActiveChats, removeActiveChat, + addShoppingItem, getShoppingList, removeShoppingItem, + addRecurringTask, getRecurringTasks, updateRecurringTask, markTaskDone, removeRecurringTask, +} from "./db"; import { tgAuth } from "./middleware"; +import { getNextDate, formatDate, parseInterval, today } from "./tasks"; -console.log(`Setting up Telegram bot with token ${config.tgBotToken}`); const bot = new Telegraf(config.tgBotToken); - bot.use(tgAuth); bot.command("/start", async (ctx) => { @@ -18,13 +21,111 @@ bot.command("/stop", async (ctx) => { await ctx.reply("HommaBot pysäytetty!"); }); +// --- Shopping list --- + +bot.command("add", async (ctx) => { + const item = ctx.message.text.replace(/^\/add\s*/, "").trim(); + if (!item) { await ctx.reply("Käyttö: /add <tuote>"); return; } + addShoppingItem(item, ctx.message.from.id); + await ctx.reply(`Lisätty: ${item}`); +}); + +bot.command("list", async (ctx) => { + const items = getShoppingList(); + if (items.length === 0) { await ctx.reply("Ostoslista on tyhjä."); return; } + const text = items.map((i, idx) => `${idx + 1}. ${i.item}`).join("\n"); + const buttons = items.map((i) => [Markup.button.callback(`❌ ${i.item}`, `del:${i.id}`)]); + await ctx.reply(`🛒 Ostoslista:\n${text}`, Markup.inlineKeyboard(buttons)); +}); + +bot.action(/^del:(\d+)$/, async (ctx) => { + const id = Number(ctx.match[1]); + removeShoppingItem(id); + await ctx.answerCbQuery("Poistettu!"); + const items = getShoppingList(); + if (items.length === 0) { await ctx.editMessageText("Ostoslista on tyhjä."); return; } + const text = items.map((i, idx) => `${idx + 1}. ${i.item}`).join("\n"); + const buttons = items.map((i) => [Markup.button.callback(`❌ ${i.item}`, `del:${i.id}`)]); + await ctx.editMessageText(`🛒 Ostoslista:\n${text}`, Markup.inlineKeyboard(buttons)); +}); + +// --- Recurring tasks --- + +bot.command("newtask", async (ctx) => { + const args = ctx.message.text.replace(/^\/newtask\s*/, "").trim(); + const match = /^(\d+(?:pv|vk|kk|v))\s+(.+)$/.exec(args); + if (!match) { + await ctx.reply("Käyttö: /newtask <intervalli> <nimi>\nEsim: /newtask 2vk Imurointi\n\nIntervallit: pv, vk, kk, v"); + return; + } + try { + parseInterval(match[1]); + } catch { + await ctx.reply("Virheellinen intervalli. Käytä: pv, vk, kk, v (esim. 2vk, 1kk)"); + return; + } + addRecurringTask(match[2], match[1]); + await ctx.reply(`✅ Uusi tehtävä: ${match[2]} (joka ${match[1]})`); +}); + +bot.command("tasks", async (ctx) => { + const tasks = getRecurringTasks(); + if (tasks.length === 0) { await ctx.reply("Ei tehtäviä. Lisää: /newtask"); return; } + const lines = tasks.map((t) => { + const next = formatDate(getNextDate(t)); + const done = t.last_done || "—"; + return `*${t.id}.* ${t.name}\n ↻ ${t.interval} | edellinen: ${done} | seuraava: ${next}`; + }); + const buttons = tasks.map((t) => [ + Markup.button.callback(`✅ ${t.name}`, `taskdone:${t.id}`), + Markup.button.callback(`🗑️`, `taskdel:${t.id}`), + ]); + await ctx.reply(`📋 Toistuvat tehtävät:\n\n${lines.join("\n\n")}`, { + parse_mode: "Markdown", + ...Markup.inlineKeyboard(buttons), + }); +}); + +bot.command("edittask", async (ctx) => { + const args = ctx.message.text.replace(/^\/edittask\s*/, "").trim(); + const match = /^(\d+)\s+(\d+(?:pv|vk|kk|v))\s+(.+)$/.exec(args); + if (!match) { + await ctx.reply("Käyttö: /edittask <id> <intervalli> <nimi>\nEsim: /edittask 3 1kk Ikkunoiden pesu"); + return; + } + try { + parseInterval(match[2]); + } catch { + await ctx.reply("Virheellinen intervalli."); + return; + } + updateRecurringTask(Number(match[1]), match[3], match[2]); + await ctx.reply(`✏️ Päivitetty: ${match[3]} (${match[2]})`); +}); + +bot.command("done", async (ctx) => { + const idStr = ctx.message.text.replace(/^\/done\s*/, "").trim(); + const id = Number(idStr); + if (!id) { await ctx.reply("Käyttö: /done <id>"); return; } + markTaskDone(id, today()); + await ctx.reply("✅ Merkitty tehdyksi!"); +}); + +bot.action(/^taskdone:(\d+)$/, async (ctx) => { + const id = Number(ctx.match[1]); + markTaskDone(id, today()); + await ctx.answerCbQuery("Merkitty tehdyksi!"); +}); + +bot.action(/^taskdel:(\d+)$/, async (ctx) => { + const id = Number(ctx.match[1]); + removeRecurringTask(id); + await ctx.answerCbQuery("Poistettu!"); +}); + const broadcastMessage = async (msg: string): Promise<void> => { const activeChats = await getActiveChats(); - const chatIds = activeChats.map(c => c.id); - - await Promise.all(chatIds.map(async chatId => - bot.telegram.sendMessage(chatId, msg), - )); + await Promise.all(activeChats.map((c) => bot.telegram.sendMessage(c.id, msg))); }; export { bot, broadcastMessage }; |
