commit 2fb5f48a542d145ed01985bf549d08a1dea5d824 Author: chikovanreuden Date: Mon Oct 20 02:05:08 2025 +0200 inital commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9b49524 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +Dockerfile* +docker-compose* +.dockerignore +.git +.gitignore +README.md +LICENSE +.vscode +Makefile +helm-charts +.env +.editorconfig +.idea +coverage* \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d2a127 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# dependencies (bun install) +node_modules + +# output +output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +*.db +*.sqlite diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..89fe97c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,43 @@ +FROM debian:12 AS base +WORKDIR /usr/src/app +RUN apt-get update && \ + apt-get install -y curl unzip ca-certificates python3 python3-pip && \ + rm -rf /var/lib/apt/lists/* +# install BunJs +RUN curl -fsSL https://bun.com/install | bash +ENV PATH="/root/.bun/bin:$PATH" +# symlink python +RUN ln -s /usr/bin/python3 /usr/bin/python + +# install dependencies into temp directory +# this will cache them and speed up future builds +FROM base AS install +RUN mkdir -p /temp/dev +COPY package.json bun.lock /temp/dev/ +RUN cd /temp/dev && bun install --frozen-lockfile + +# install with --production (exclude devDependencies) +RUN mkdir -p /temp/prod +COPY package.json bun.lock /temp/prod/ +RUN cd /temp/prod && bun install --frozen-lockfile --production +# prepare python packages +RUN pip3 install -r requirements.txt + +# copy node_modules from temp directory +# then copy all (non-ignored) project files into the image +FROM base AS prerelease +COPY --from=install /temp/dev/node_modules node_modules +COPY . . + +# [optional] tests & build +ENV NODE_ENV=production + +# copy production dependencies and source code into final image +FROM base AS release +COPY --from=install /temp/prod/node_modules node_modules +COPY --from=prerelease /usr/src/app/index.ts . +COPY --from=prerelease /usr/src/app/package.json . + +# run the app +USER bun +ENTRYPOINT ["./entrypoint.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8373cfa --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +# 77th Event Calender Notifcations + +To install dependencies: + +```bashe +bun install +``` + +To run: + +```bash +bun run index.ts +``` + +This project was created using `bun init` in bun v1.3.0. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. diff --git a/app/app.ts b/app/app.ts new file mode 100644 index 0000000..6b9ccb7 --- /dev/null +++ b/app/app.ts @@ -0,0 +1,148 @@ +import { TEventType } from "./component/event/event.types"; +import { db } from "./sql"; +import { Event, type TEventEntityNew } from "./component/event/events"; +import { sendNotification } from "./sendNotification"; +import { createPlaceholders } from "./util"; + +const argv = require('minimist')(process.argv.slice(2)); +console.dir(argv) + +const TS_TODAY = new Date(); + +function pad_l2 ( _thing: string | number ): string { + if ( typeof _thing == "number" ) { + _thing = JSON.stringify(_thing); + }; + return _thing.padStart(2, "0"); +} + +function getTsNow() { + const now = new Date(); + const rtn = { + year: now.getFullYear(), + month: now.getMonth() + 1, + day: now.getDate(), + minute: now.getMinutes(), + seconds: now.getSeconds() + } + return rtn; +} + +async function main( ) { + const events = await Event.fetch_events( TS_TODAY.getFullYear(), TS_TODAY.getMonth() + 1 , -120 ); + + // Write to JSON File Section START + // const data = JSON.stringify(events, null, 2); + // const TS = `${TS_TODAY.getFullYear()}-${TS_TODAY.getMonth() + 1}-${TS_TODAY.getDate()}_${TS_TODAY.getHours()}-${TS_TODAY.getMinutes()}-${TS_TODAY.getSeconds()}`; + // await Bun.write(path.join(import.meta.dir, "output", `output_${TS}.json`), data ); + // Write to JSON File Section END + + const allEventUids = events.map( event => { return event.uid; }); + const placeholders = createPlaceholders( allEventUids ); + const getAllRelevantEventsQuery = db.query( + `SELECT * FROM events WHERE uid IN (${placeholders}); ` + ).as(Event ); + const AllRelevantEvents = getAllRelevantEventsQuery.all(...allEventUids); + + const eventsToInsert: TEventEntityNew[] = []; + for ( const ev of events ) { + const found = AllRelevantEvents.find(event => event.uid === ev.uid); + if ( found ) { + if ( + found.title != ev.title || + found.description != ev.description || + found.date_at != ev.date_at || + found.time_start != ev.time_start || + found.time_end != ev.time_end || + found.posted_by != ev.posted_by || + found.location != ev.location || + found.event_type != ev.event_type || + found.timezone != ev.timezone || + found.link != ev.link + ) { + const newEventToInsert: TEventEntityNew = {... ev, notification: "changed"}; + eventsToInsert.push( newEventToInsert ); + } + } else { + const newEventToInsert: TEventEntityNew = {... ev, notification: "new"}; + eventsToInsert.push( newEventToInsert ); + } + } + + Event.insert( eventsToInsert, db); + const list_of_events = Event.get_events(["new", "changed"], db); + for ( const ev of list_of_events ) { + const body = [ + `Title: ${ev.title}`, + `Location: ${ev.location}`, + `Type: ${ TEventType[ ev.event_type ] }`, + `Date: ${ev.date_at}`, + `Time: ${ev.time_start}`, + `By: ${ev.posted_by}`, + `Link: ${ev.link}`, + ].join("\n"); + const notification_prefix = ( (event: Event) => { + switch( event.notification) { + case "new": + return "New"; + case "changed": + return "Changed"; + case "deleted": + return "Deleted"; + default: + return null; + } + } ) ( ev ); + + const today_prefix = ( (ev: Event) => { + const now = getTsNow(); + const [year, month, day] = ev.date_at.split("-") + if ( + year == String(now.year) && + month == pad_l2( String(now.month) ) && + day == pad_l2( String( now.day ) ) + ) { + return true; + } + return false; + })( ev ); + sendNotification( + `${today_prefix ? "TODAY " : ""}${notification_prefix ? notification_prefix + ": " : ""} ${ev.title} (${ TEventType[ ev.event_type ] })`, + `${body}`, + `${ev.link || "https://77th-jsoc.com/#/events"}` + ); + ev.set_notification("done", db); + } + // events.forEach( event => { + // const now = getTsNow(); + // const [year, month, day] = event.date_at.split("-") + // if ( + // year == String(now.year) && + // month == pad_l2( String(now.month) ) && + // day == pad_l2( String( now.day ) ) + // ) { + // // console.dir( event ); + // const body = [ + // `Title: ${event.title}`, + // `Location: ${event.location}`, + // `Type: ${ TEventType[ event.event_type ] }`, + // `Date: ${event.date_at}`, + // `Time: ${event.time_start}`, + // `By: ${event.posted_by}`, + // `Link: ${event.link}`, + // ].join("\n"); + + // sendNotification( + // `TODAY ${ TEventType[ event.event_type ] } - ${event.title}`, + // `${body}`, + // `${event.link || "https://77th-jsoc.com/#/events"}` + // ); + // } + // }); +}; +main(); +// do { +// await getEvents(TS_TODAY.getFullYear(), TS_TODAY.getMonth() + 1 , -120); +// await Bun.sleep(1000 * 60 * 60 * 24); +// } +// while( true ) diff --git a/app/component/event/event.types.ts b/app/component/event/event.types.ts new file mode 100644 index 0000000..0e64dff --- /dev/null +++ b/app/component/event/event.types.ts @@ -0,0 +1,19 @@ +export const TEventType = { + "1": "Public Event", + "2": "Private Mission", + "3": "Private Meeting" +} as const + +export type TEvent = { + uid: string, + title: string, + description: string, + date_at: string, + time_start: string, + time_end: string, + posted_by: string, + location: string, + event_type: keyof typeof TEventType, + timezone: string, + link: string +}; \ No newline at end of file diff --git a/app/component/event/events.ts b/app/component/event/events.ts new file mode 100644 index 0000000..65e6b59 --- /dev/null +++ b/app/component/event/events.ts @@ -0,0 +1,130 @@ +import { Database } from "bun:sqlite"; +import type { TEvent } from "./event.types"; +import { transformArray } from "../../util"; + +const BASE_URL = "https://77th-jsoc.com/service.php?action=get_events"; + +export type TEventEntity = TEvent & { + event_uid: number + notification: "new" | "changed" | "deleted" | "done" +} + +export type TEventEntityNew = Omit + +export class Event implements TEventEntity { + static table_name: "events" + static createTable (db: Database): void { + const query = db.query(`CREATE TABLE IF NOT EXISTS events ( + event_uid INTEGER PRIMARY KEY, + uid TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + date_at DATETIME NOT NULL, + time_start TEXT NOT NULL, + time_end TEXT NOT NULL, + posted_by TEXT NOT NULL, + location TEXT NOT NULL, + event_type TEXT NOT NULL, + link TEXT NOT NULL, + description TEXT NOT NULL, + timezone TEXT NOT NULL, + notification TEXT NOT NULL DEFAULT "new" + );`); + query.run(); + } + + static insert ( events: TEventEntityNew[], db: Database ) { + const insert = db.prepare("INSERT OR REPLACE INTO events (uid, title, date_at, time_start, time_end, posted_by, location, event_type, link, description, timezone, notification) VALUES ($uid, $title, $date_at, $time_start, $time_end, $posted_by, $location, $event_type, $link, $description, $timezone, $notification)"); + const insertEvents = db.transaction(events => { + for (const event of events) insert.run(event); + return events.length; + }); + + const transforedEventArray = transformArray( events ); + const count = insertEvents(transforedEventArray); + + console.log(`Inserted ${count} events`); + } + + static async fetch_events( _year_: number, _month_: number, timezone: number) { + const url = `${BASE_URL}&year=${_year_}&month=${_month_}&timezone=${timezone}` + const response = await fetch(url, { + method: "GET", + }); + const body = await response.json() as {events: TEvent[] }; + const events = body.events.sort( ( a, b ) => ( new Date(a.date_at) < new Date(b.date_at ) ) ? -1 : 1 ); + return events; + } + + static get_events (notification: TEventEntity["notification"][] | null, db: Database ) { + const whereConditions: string[] = []; + if ( notification ) { + whereConditions.push( `notification IN ('${ notification.join("', '") }')` ) + } + const where = ( () => { + let str = "WHERE "; + if ( whereConditions.length >= 1 ) { + str += whereConditions.join(" AND "); + } + return str; + })() + const query = db.query(`SELECT * FROM events${ where ? ( " " + where ) : ""};`).as(Event); + return query.all(); + } + + event_uid: number; + uid: string; + title: string; + description: string; + date_at: string; + time_start: string; + time_end: string; + posted_by: string; + location: string; + event_type: TEventEntity["event_type"]; + timezone: string; + link: string; + notification: TEventEntity["notification"] + + constructor(event_uid: number, uid: string, title: string, description: string, date_at: string, time_start: string, time_end: string, posted_by: string, location: string, event_type: TEventEntity["event_type"], timezone: string, link: string, notification: TEventEntity["notification"]) { + this.event_uid = event_uid; + this.uid = uid; + this.title = title; + this.description = description; + this.date_at = date_at; + this.time_start = time_start; + this.time_end = time_end; + this.posted_by = posted_by; + this.location = location; + this.event_type = event_type; + this.timezone = timezone; + this.link = link; + this.notification = notification; + } + syncWithDb ( db: Database ) { + const query = db.prepare( `SELECT * FROM ${Event.table_name} WHERE event_uid = $event_uid;`).as(Event); + const entity = query.get({$event_uid: this.event_uid }); + if ( ! entity ) { throw new Error(`Could not find Event with event_uid ${this.event_uid} in DB!`); } + this.uid = entity.uid; + this.title = entity.title; + this.description = entity.description; + this.date_at = entity.date_at; + this.time_start = entity.time_start; + this.time_end = entity.time_end; + this.posted_by = entity.posted_by; + this.location = entity.location; + this.event_type = entity.event_type; + this.timezone = entity.timezone; + this.link = entity.link; + this.notification = entity.notification; + return this; + } + + set_notification ( newValue: TEventEntity["notification"], db: Database ) { + const query = db.prepare( + `UPDATE events + SET notification = $notification + WHERE event_uid = $event_uid;` + ); + query.get({$notification: newValue, $event_uid: this.event_uid }); + } +} \ No newline at end of file diff --git a/app/component/event/index.ts b/app/component/event/index.ts new file mode 100644 index 0000000..6e3cc19 --- /dev/null +++ b/app/component/event/index.ts @@ -0,0 +1,2 @@ +export * from "./events" +export * from "./event.types"; \ No newline at end of file diff --git a/app/notification.py b/app/notification.py new file mode 100644 index 0000000..e545367 --- /dev/null +++ b/app/notification.py @@ -0,0 +1,35 @@ +from dotenv import load_dotenv +import os + +load_dotenv() # Load environment variables from .env file +ntfy_username = os.getenv('ntfy_username') +ntfy_password = os.getenv('ntfy_password') +ntfy_host = os.getenv('ntfy_host') +ntfy_topic = os.getenv('ntfy_topic') +dc_webhook = os.getenv('dc_webhook') + +from argparse import ArgumentParser +import apprise + +parser = ArgumentParser() +parser.add_argument("--title") +parser.add_argument("--body") +parser.add_argument("--click") +args = parser.parse_args() +print(args) + +apobj = apprise.Apprise() +# config = apprise.AppriseConfig() +# config.add('https://myserver:8080/path/to/config') +if ntfy_host and ntfy_topic: + ntfy_link = f"ntfys://{ntfy_username}:{ntfy_password}@{ntfy_host}/{ntfy_topic}" + if args.click: + ntfy_link = ntfy_link + "?click=" + args.click + apobj.add(ntfy_link) +if dc_webhook: + apobj.add(f"https://discord.com/api/webhooks/{dc_webhook}"); + +apobj.notify( + body=args.body, + title=args.title +) \ No newline at end of file diff --git a/app/sendNotification.ts b/app/sendNotification.ts new file mode 100644 index 0000000..86d3777 --- /dev/null +++ b/app/sendNotification.ts @@ -0,0 +1,14 @@ +import * as Bun from "bun"; + +export function sendNotification(title: string, body: string, click?: string) { + const command = [ + "python", + "./app/notification.py", + `--title=${title}`, + `--body=${body}`, + ]; + if (click) { + command.push(`--click=${click}`); + } + Bun.spawn(command); +} diff --git a/app/sql.ts b/app/sql.ts new file mode 100644 index 0000000..04be71c --- /dev/null +++ b/app/sql.ts @@ -0,0 +1,13 @@ +import { Database } from "bun:sqlite"; +import * as path from "node:path"; +import { Event } from "./component/event"; +export const db_filename = "77th_eventntfy.db"; +export const db_filepath = path.join("data", "db", db_filename); +console.log(db_filepath); +// const db_file = Bun.file(db_filepath); + +export const db = new Database(db_filepath); + +export function init () { + Event.createTable(db); +} \ No newline at end of file diff --git a/app/util.ts b/app/util.ts new file mode 100644 index 0000000..b2f0b12 --- /dev/null +++ b/app/util.ts @@ -0,0 +1,22 @@ +export const createPlaceholders = ( arr: any[] ) => { + return arr.map(() => '?').join(', '); +} + +export type AddDollarPrefix = { + [K in keyof T as `$${string & K}`]: T[K]; +}; + +export function prefixKeysWithDollar>(obj: T): AddDollarPrefix { + const result = {} as AddDollarPrefix; + + for (const key in obj) { + const newKey = `$${key}` as keyof AddDollarPrefix; + result[newKey] = obj[key] as any; + } + + return result; +} + +export function transformArray>(arr: T[]): AddDollarPrefix[] { + return arr.map(prefixKeysWithDollar); +} \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d77fc9f --- /dev/null +++ b/bun.lock @@ -0,0 +1,37 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "eventcalender", + "dependencies": { + "dotenv": "^17.2.3", + "minimist": "^1.2.8", + }, + "devDependencies": { + "@types/bun": "^1.3.0", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], + + "@types/node": ["@types/node@24.8.1", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q=="], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + + "bun-types": ["bun-types@1.3.0", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-u8X0thhx+yJ0KmkxuEo9HAtdfgCBaM/aI9K90VQcQioAmkVp3SG3FkwWGibUFz3WdXAdcsqOcbU40lK7tbHdkQ=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.14.0", "", {}, "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA=="], + } +} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..d3d8520 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/bash +crontab -l > mycron +echo "0 8 * * * bun run ./app/app.ts --today > /dev/null 2>&1" >> mycron +echo "0 * * * * bun run ./app/app.ts --all > /dev/null 2>&1" >> mycron +crontab mycron +rm mycron \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e48c0b5 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "eventcalender", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "^1.3.0" + }, + "scripts": { + "dev": "bun run ./app/app.ts", + "dev:init": "bun run ./app/app.ts --init", + "db:init": "bun run ./run/db_init.ts", + "db:deleteall": "bun run ./run/db_deleteall.ts" + }, + "peerDependencies": { + "typescript": "^5" + }, + "dependencies": { + "dotenv": "^17.2.3", + "minimist": "^1.2.8" + } +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..356ca44 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +apprise \ No newline at end of file diff --git a/run/db_deleteall.ts b/run/db_deleteall.ts new file mode 100644 index 0000000..8a73b8d --- /dev/null +++ b/run/db_deleteall.ts @@ -0,0 +1,4 @@ +import * as db from "../app/sql"; + +const query = db.db.query("DELETE FROM events;"); +query.run(); \ No newline at end of file diff --git a/run/db_init.ts b/run/db_init.ts new file mode 100644 index 0000000..7c00847 --- /dev/null +++ b/run/db_init.ts @@ -0,0 +1,9 @@ +import { Event } from "../app/component/event/events"; +import * as db from "../app/sql"; +import { Database } from "bun:sqlite"; + +export function init ( db: Database ) { + Event.createTable( db ); +}; + +init(db.db); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d4467c8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}