aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.ts15
-rw-r--r--src/db.ts1
-rw-r--r--src/index.ts30
-rw-r--r--src/middleware.ts53
-rw-r--r--src/sheets.ts194
-rw-r--r--src/tg.ts15
6 files changed, 246 insertions, 62 deletions
diff --git a/src/config.ts b/src/config.ts
index 962fd49..8766db9 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -1,4 +1,6 @@
import dotenv from "dotenv";
+import fs from "fs";
+import path from "path";
if (!process.env.SKIP_DOTENV) {
dotenv.config();
@@ -36,6 +38,17 @@ if (!pgConnectionUri) {
throw new ConfigError("Missing env var PG_CONNECTION_URI");
}
+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 = {
port: process.env.PORT ? Number(process.env.PORT) : 3000,
tgWebhookUrl,
@@ -43,6 +56,8 @@ const config = {
sheetsSpreadsheetId,
sheetsRange,
pgConnectionUri,
+ googleSaJson,
+ googleSaJsonPath,
};
export default config;
diff --git a/src/db.ts b/src/db.ts
index b2c9c1e..f649d2d 100644
--- a/src/db.ts
+++ b/src/db.ts
@@ -26,6 +26,7 @@ export const addActiveChat = async (id: number): Promise<QueryResultType<void>>
INSERT INTO active_chats
(id) VALUES
(${id})
+ ON CONFLICT DO NOTHING
`);
export const removeActiveChat = async (id: number): Promise<QueryResultType<void>> =>
diff --git a/src/index.ts b/src/index.ts
index 7553b56..8bb5c69 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,8 +1,10 @@
import { App } from "@tinyhttp/app";
import { logger } from "@tinyhttp/logger";
+import bodyParser from "body-parser";
import config from "./config";
+import { httpMiddleware } from "./middleware";
import { buildSheetsClient } from "./sheets";
-import { bot } from "./tg";
+import { bot, broadcastMessage } from "./tg";
const main = async () => {
const sheets = await buildSheetsClient();
@@ -21,12 +23,32 @@ const main = async () => {
},
}))
.use(bot.webhookCallback("/webhook/telegram"))
+ .use(bodyParser.json())
.get("/", async (_, res) => {
res.send({ service: "hommabot2 API" });
})
- .post("/scheduler/trigger", async (_req, res) => {
- await sheets.fetchSheetData();
- res.send({ scheduler: "trigger" });
+ .post("/scheduler/trigger", httpMiddleware.verifyGoogleJWT, async (_req, res) => {
+ try {
+ const sheetData = await sheets.fetchSheetData();
+ const entries = sheets.processRows(sheetData);
+ const thisWeeksEntries = sheets.filterOnlyThisWeek(entries);
+
+ 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);
+ } else {
+ await broadcastMessage("Tällä viikolla ei toistuvia hommia! 🎉");
+ }
+
+ const newDateStamps = sheets.updatedLastDoneDateStamps(entries);
+ await sheets.updateSheetLastDoneColumn(newDateStamps);
+ res.sendStatus(200);
+ } catch (err) {
+ console.error(err);
+ res.sendStatus(500);
+ }
});
console.log(`Setting up webhook on ${config.tgWebhookUrl}`);
diff --git a/src/middleware.ts b/src/middleware.ts
index 9aa0775..8239121 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -1,7 +1,11 @@
+import { Request, Response } from "@tinyhttp/app";
import { Context } from "telegraf";
+import got from "got";
+import jwt from "jsonwebtoken";
+
import { getPermittedUsers } from "./db";
-const auth = async (ctx: Context, next: () => Promise<void>): Promise<void> => {
+const tgAuth = async (ctx: Context, next: () => Promise<void>): Promise<void> => {
const userId = ctx.message?.from.id;
const tgUsers = await getPermittedUsers();
@@ -14,8 +18,51 @@ const auth = async (ctx: Context, next: () => Promise<void>): Promise<void> => {
}
};
+const verifyGoogleJWT = async (req: Request, res: Response, next: () => void): Promise<void> => {
+ const authHeader = req.headers.authorization || null;
+ if (!authHeader) {
+ res.sendStatus(401);
+ return;
+ }
+
+ const token = authHeader.split(" ")[1];
+ if (!token) {
+ res.sendStatus(401);
+ return;
+ }
+
+ const decoded = jwt.decode(token, { complete: true });
+ if (!decoded) {
+ res.sendStatus(401);
+ return;
+ }
+
+ const kid: string = decoded.header.kid;
+
+ const response = await got("https://www.googleapis.com/oauth2/v1/certs", { json: true });
+ const googleCerts: Record<string, string> = response.body;
+
+ const cert = googleCerts[kid];
+ if (!cert) {
+ console.error("KID not found in google certificates");
+ res.sendStatus(500);
+ return;
+ }
+
+ try {
+ jwt.verify(token, cert);
+ } catch (err) {
+ res.sendStatus(403);
+ return;
+ }
+
+ next();
+};
+
export const tgMiddleware = {
- auth,
+ tgAuth,
};
-export const httpMiddleware = {};
+export const httpMiddleware = {
+ verifyGoogleJWT,
+};
diff --git a/src/sheets.ts b/src/sheets.ts
index 8f6c81c..e99f089 100644
--- a/src/sheets.ts
+++ b/src/sheets.ts
@@ -1,30 +1,101 @@
-import got from "got";
import { google } from "googleapis";
+import { add, Duration, parse, format, getDay, subDays, addDays } from "date-fns";
+
import config from "./config";
-export const fetchIdToken = async function (aud: string): Promise<string> {
- const metadataServerTokenURL = `http://metadata/computeMetadata/v1/instance/service-accounts/default/identity?audience=${aud}`;
+interface SheetsRawEntry {
+ intervalStr: string;
+ name: string;
+ lastDoneDateStr: string;
+}
- let resp;
- try {
- resp = await got(metadataServerTokenURL, {
- headers: {
- "Metadata-Flavor": "Google",
- },
- });
- } catch (err) {
- console.error(err);
- throw new Error("Failed to fetch ID token from Google metadata endpoint");
+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`);
}
- const token = resp.body;
- if (!token) {
- throw new Error("ID token from Google metadata endpoint is empty");
+ if (!Object.keys(INTERVAL_ABBREVS).includes(abbrev)) {
+ throw new Error(`"${abbrev}" is an invalid interval string`);
}
- return token;
+ 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 today = new Date();
+ 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.
@@ -32,64 +103,83 @@ export const buildSheetsClient = async () => {
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/spreadsheets.readonly",
],
+ keyFile: config.googleSaJsonPath,
});
// Acquire an auth client, and bind it to all future calls
const authClient = await auth.getClient();
google.options({ auth: authClient });
+ const sheets = google.sheets({ version: "v4" });
- const fetchSheetData = async (): Promise<void> => {
- // TODO
+ const fetchSheetData = async (): Promise<SheetsRawEntry[]> => {
try {
- const sheets = google.sheets({ version: "v4" });
const res = await sheets.spreadsheets.values.get({
spreadsheetId: config.sheetsSpreadsheetId,
range: config.sheetsRange,
});
- console.log("res", res);
+ 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;
}
};
- return {
- fetchSheetData,
+ 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);
-/*
-def fetch_sheet_data(service: Resource) -> list[SheetsRow]:
- logging.info("Calling Sheets API to fetch data...")
- sheet = service.spreadsheets()
- result = (
- sheet.values()
- .get(
- spreadsheetId=getenv("SHEETS_SPREADSHEET_ID"),
- range=getenv("SHEETS_RANGE"),
- )
- .execute()
- )
+ const body = {
+ values: newDateStamps.map(nds => [nds]),
+ };
- rows = result.get("values", [])
- logging.info(f"Fetched {len(rows)} rows of data")
- return [_row_to_dataclass(row) for row in rows]
+ await sheets.spreadsheets.values.update({
+ spreadsheetId: config.sheetsSpreadsheetId,
+ range: lastDoneColumnRange,
+ valueInputOption: "RAW",
+ requestBody: body,
+ });
+ };
+ const processRows = (rows: SheetsRawEntry[]) => rows.map(processRow);
-def update_sheet_last_done_column(service: Resource, new_datestamps: list[str]):
- logging.info("Calling Sheets API to update last done column...")
- range = getenv("SHEETS_RANGE")
- sheet = service.spreadsheets()
- last_done_column_range = _get_rightmost_column_in_range(range)
+ const filterOnlyThisWeek = (entries: SheetsProcessedEntry[]): SheetsProcessedEntry[] => entries.filter(isThisWeek);
- body = {"values": [[datestamp] for datestamp in new_datestamps]}
+ const updatedLastDoneDateStamps = (entries: SheetsProcessedEntry[]): string[] =>
+ entries.map(e => isThisWeek(e) ? e.nextDateStamp : e.lastDateStamp);
- sheet.values().update(
- spreadsheetId=getenv("SHEETS_SPREADSHEET_ID"),
- range=last_done_column_range,
- valueInputOption="RAW",
- body=body,
- ).execute()
-*/ \ No newline at end of file
+ return {
+ fetchSheetData,
+ getRightmostColumnInRange,
+ updateSheetLastDoneColumn,
+ processRows,
+ filterOnlyThisWeek,
+ updatedLastDoneDateStamps,
+ };
+};
diff --git a/src/tg.ts b/src/tg.ts
index da8d02d..45c60c7 100644
--- a/src/tg.ts
+++ b/src/tg.ts
@@ -1,12 +1,12 @@
import { Telegraf } from "telegraf";
import config from "./config";
-import { addActiveChat, removeActiveChat } from "./db";
+import { addActiveChat, getActiveChats, removeActiveChat } from "./db";
import { tgMiddleware } from "./middleware";
console.log(`Setting up Telegram bot with token ${config.tgBotToken}`);
const bot = new Telegraf(config.tgBotToken);
-bot.use(tgMiddleware.auth);
+bot.use(tgMiddleware.tgAuth);
bot.command("/start", async (ctx) => {
await addActiveChat(ctx.chat.id);
@@ -18,4 +18,13 @@ bot.command("/stop", async (ctx) => {
await ctx.reply("HommaBot pysäytetty!");
});
-export { bot };
+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),
+ ));
+};
+
+export { bot, broadcastMessage };