aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--biome.json6
-rw-r--r--package-lock.json193
-rw-r--r--package.json8
-rw-r--r--src/bot.ts4
-rw-r--r--src/db.ts52
-rw-r--r--src/tasks.ts15
-rw-r--r--src/tg.ts74
-rw-r--r--tsconfig.json1
9 files changed, 314 insertions, 40 deletions
diff --git a/.gitignore b/.gitignore
index 16208e3..cc929f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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"
}
}
diff --git a/src/bot.ts b/src/bot.ts
index 1de3a22..bda8ace 100644
--- a/src/bot.ts
+++ b/src/bot.ts
@@ -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) {
diff --git a/src/db.ts b/src/db.ts
index a2c5cee..c0c7d1a 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -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 });
diff --git a/src/tg.ts b/src/tg.ts
index fd62af4..afa0477 100644
--- a/src/tg.ts
+++ b/src/tg.ts
@@ -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,