diff options
| author | Jan Tuomi <jan@jantuomi.fi> | 2026-05-11 13:40:31 +0300 |
|---|---|---|
| committer | Jan Tuomi <jan@jantuomi.fi> | 2026-05-11 13:40:31 +0300 |
| commit | 7f92c60ed89e3495f9dd660755a68af647fd8ce2 (patch) | |
| tree | 106d801001c7b60e99658b50d3f135a932cf89ef | |
| parent | f7e272ab8726ed6a205b46d6723cc91aa9d63be1 (diff) | |
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | biome.json | 6 | ||||
| -rw-r--r-- | package-lock.json | 193 | ||||
| -rw-r--r-- | package.json | 8 | ||||
| -rw-r--r-- | src/bot.ts | 4 | ||||
| -rw-r--r-- | src/db.ts | 52 | ||||
| -rw-r--r-- | src/tasks.ts | 15 | ||||
| -rw-r--r-- | src/tg.ts | 74 | ||||
| -rw-r--r-- | tsconfig.json | 1 |
9 files changed, 314 insertions, 40 deletions
@@ -1,3 +1,4 @@ node_modules/ .vscode/ .env +TODO.md diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..4e68cbc --- /dev/null +++ b/biome.json @@ -0,0 +1,6 @@ +{ + "formatter": { + "indentStyle": "space", + "indentWidth": 2 + } +} diff --git a/package-lock.json b/package-lock.json index b1f48c6..d9949f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,186 @@ "telegraf": "4.16.3" }, "devDependencies": { + "@biomejs/biome": "^2.4.15", "@types/better-sqlite3": "^7.6.13", "@types/node": "25.6.0", - "tsx": "^4.19.0" + "tsx": "^4.19.0", + "typescript": "^6.0.3" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", + "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.15", + "@biomejs/cli-darwin-x64": "2.4.15", + "@biomejs/cli-linux-arm64": "2.4.15", + "@biomejs/cli-linux-arm64-musl": "2.4.15", + "@biomejs/cli-linux-x64": "2.4.15", + "@biomejs/cli-linux-x64-musl": "2.4.15", + "@biomejs/cli-win32-arm64": "2.4.15", + "@biomejs/cli-win32-x64": "2.4.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", + "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", + "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", + "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", + "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", + "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", + "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", + "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", + "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" } }, "node_modules/@esbuild/aix-ppc64": { @@ -1196,6 +1373,20 @@ "node": "*" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", diff --git a/package.json b/package.json index dcc31f2..7b0ddde 100644 --- a/package.json +++ b/package.json @@ -5,20 +5,24 @@ "main": "src/bot.ts", "scripts": { "start": "tsx src/bot.ts", + "check": "tsc --noEmit", + "fmt": "biome format --write", "test": "echo \"Error: no test specified\" && exit 1" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { + "better-sqlite3": "12.9.0", "date-fns": "4.1.0", "dotenv": "17.4.2", - "better-sqlite3": "12.9.0", "telegraf": "4.16.3" }, "devDependencies": { + "@biomejs/biome": "^2.4.15", "@types/better-sqlite3": "^7.6.13", "@types/node": "25.6.0", - "tsx": "^4.19.0" + "tsx": "^4.19.0", + "typescript": "^6.0.3" } } @@ -13,7 +13,9 @@ const runWeeklyTasks = async (): Promise<void> => { const dueTasks = tasks.filter(isThisWeek); if (dueTasks.length > 0) { - const taskText = dueTasks.map((t) => `• ${t.name} (${t.interval})`).join("\n"); + 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) { @@ -38,7 +38,7 @@ const db = new Database(DB_FILE, { db.pragma("journal_mode = WAL"); db.pragma("busy_timeout = 5000"); db.pragma("synchronous = NORMAL"); -db.pragma("cache_size = 1000000000"); +db.pragma("cache_size = 10000"); db.pragma("foreign_keys = ON"); db.pragma("temp_store = MEMORY"); @@ -46,25 +46,25 @@ const ddl = ` BEGIN IMMEDIATE; CREATE TABLE IF NOT EXISTS active_chats ( id INTEGER PRIMARY KEY - ) STRICT; + ) STRICT; CREATE TABLE IF NOT EXISTS permitted_users ( id INTEGER PRIMARY KEY - ) STRICT; + ) 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; + ) 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; + ) STRICT; COMMIT; `; db.exec(ddl); @@ -79,14 +79,14 @@ function ensurePath() { } } +const begin = db.prepare("BEGIN IMMEDIATE"); +const commit = db.prepare("COMMIT"); +const rollback = db.prepare("ROLLBACK"); + /** * Wrap a write operation in a BEGIN IMMEDIATE transaction. */ function withWriteTx<T>(fn: (db: BetterSqliteDb) => T): T { - const begin = db.prepare("BEGIN IMMEDIATE"); - const commit = db.prepare("COMMIT"); - const rollback = db.prepare("ROLLBACK"); - begin.run(); try { const result = fn(db); @@ -141,12 +141,17 @@ interface ShoppingItem { export const addShoppingItem = (item: string, addedBy: number): void => { withWriteTx((db) => { - db.prepare("INSERT INTO shopping_list (item, added_by) VALUES (?, ?)").run(item, addedBy); + 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[]; + return db + .prepare("SELECT id, item FROM shopping_list ORDER BY id") + .all() as ShoppingItem[]; }; export const removeShoppingItem = (id: number): void => { @@ -166,23 +171,38 @@ export interface RecurringTask { export const addRecurringTask = (name: string, interval: string): void => { withWriteTx((db) => { - db.prepare("INSERT INTO recurring_tasks (name, interval) VALUES (?, ?)").run(name, interval); + 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[]; + 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 => { +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); + 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); + db.prepare("UPDATE recurring_tasks SET last_done = ? WHERE id = ?").run( + date, + id, + ); }); }; diff --git a/src/tasks.ts b/src/tasks.ts index b370aad..6d89ada 100644 --- a/src/tasks.ts +++ b/src/tasks.ts @@ -1,4 +1,11 @@ -import { add, parse, format, startOfWeek, addWeeks, isWithinInterval, Duration } from "date-fns"; +import { + add, + parse, + startOfWeek, + addWeeks, + isWithinInterval, + Duration, +} from "date-fns"; import { RecurringTask } from "./db"; import config from "./config"; @@ -34,6 +41,8 @@ export const isThisWeek = (task: RecurringTask): boolean => { return isWithinInterval(nextDate, { start: weekStart, end: weekEnd }); }; -export const formatDate = (d: Date): string => format(d, "yyyy-MM-dd"); +export const formatDate = (d: Date): string => + d.toLocaleDateString("en-CA", { timeZone: config.tz }); -export const today = (): string => format(now(), "yyyy-MM-dd"); +export const today = (): string => + new Date().toLocaleDateString("en-CA", { timeZone: config.tz }); @@ -1,9 +1,17 @@ import { Markup, Telegraf } from "telegraf"; import config from "./config"; import { - addActiveChat, getActiveChats, removeActiveChat, - addShoppingItem, getShoppingList, removeShoppingItem, - addRecurringTask, getRecurringTasks, updateRecurringTask, markTaskDone, removeRecurringTask, + addActiveChat, + getActiveChats, + removeActiveChat, + addShoppingItem, + getShoppingList, + removeShoppingItem, + addRecurringTask, + getRecurringTasks, + updateRecurringTask, + markTaskDone, + removeRecurringTask, } from "./db"; import { tgAuth } from "./middleware"; import { getNextDate, formatDate, parseInterval, today } from "./tasks"; @@ -25,16 +33,24 @@ bot.command("/stop", async (ctx) => { bot.command("add", async (ctx) => { const item = ctx.message.text.replace(/^\/add(@\w+)?\s*/, "").trim(); - if (!item) { await ctx.reply("Käyttö: /add <tuote>"); return; } + 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; } + 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}`)]); + const buttons = items.map((i) => [ + Markup.button.callback(`❌ ${i.item}`, `del:${i.id}`), + ]); await ctx.reply(`🛒 Ostoslista:\n${text}`, Markup.inlineKeyboard(buttons)); }); @@ -43,10 +59,18 @@ bot.action(/^del:(\d+)$/, async (ctx) => { removeShoppingItem(id); await ctx.answerCbQuery("Poistettu!"); const items = getShoppingList(); - if (items.length === 0) { await ctx.editMessageText("Ostoslista on tyhjä."); return; } + 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)); + const buttons = items.map((i) => [ + Markup.button.callback(`❌ ${i.item}`, `del:${i.id}`), + ]); + await ctx.editMessageText( + `🛒 Ostoslista:\n${text}`, + Markup.inlineKeyboard(buttons), + ); }); // --- Recurring tasks --- @@ -55,13 +79,17 @@ bot.command("newtask", async (ctx) => { const args = ctx.message.text.replace(/^\/newtask(@\w+)?\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"); + 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)"); + await ctx.reply( + "Virheellinen intervalli. Käytä: pv, vk, kk, v (esim. 2vk, 1kk)", + ); return; } addRecurringTask(match[2], match[1]); @@ -70,11 +98,14 @@ bot.command("newtask", async (ctx) => { bot.command("tasks", async (ctx) => { const tasks = getRecurringTasks(); - if (tasks.length === 0) { await ctx.reply("Ei tehtäviä. Lisää: /newtask"); return; } + 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}`; + return `*${t.id}.* ${t.name}\n ↻ ${t.interval} | edellinen: ${done} | seuraava: ${next}`; }); const buttons = tasks.map((t) => [ Markup.button.callback(`✅`, `taskdone:${t.id}`), @@ -90,7 +121,9 @@ bot.command("edittask", async (ctx) => { const args = ctx.message.text.replace(/^\/edittask(@\w+)?\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"); + await ctx.reply( + "Käyttö: /edittask <id> <intervalli> <nimi>\nEsim: /edittask 3 1kk Ikkunoiden pesu", + ); return; } try { @@ -106,7 +139,10 @@ bot.command("edittask", async (ctx) => { bot.command("done", async (ctx) => { const idStr = ctx.message.text.replace(/^\/done(@\w+)?\s*/, "").trim(); const id = Number(idStr); - if (!id) { await ctx.reply("Käyttö: /done <id>"); return; } + if (!id) { + await ctx.reply("Käyttö: /done <id>"); + return; + } markTaskDone(id, today()); await ctx.reply("✅ Merkitty tehdyksi!"); }); @@ -125,7 +161,13 @@ bot.action(/^taskdel:(\d+)$/, async (ctx) => { const broadcastMessage = async (msg: string): Promise<void> => { const activeChats = await getActiveChats(); - await Promise.all(activeChats.map((c) => bot.telegram.sendMessage(c.id, msg))); + for (const chat of activeChats) { + try { + await bot.telegram.sendMessage(chat.id, msg); + } catch (err) { + console.error("[broadcastMessage] Failed to send message:", err); + } + } }; export { bot, broadcastMessage }; diff --git a/tsconfig.json b/tsconfig.json index 30a75ac..6313c21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,6 @@ "compilerOptions": { "target": "ES2020", "module": "CommonJS", - "moduleResolution": "Node", "esModuleInterop": true, "strict": true, "skipLibCheck": true, |
