inital commit

This commit is contained in:
chikovanreuden
2025-10-20 02:05:08 +02:00
commit 2fb5f48a54
19 changed files with 601 additions and 0 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
node_modules
Dockerfile*
docker-compose*
.dockerignore
.git
.gitignore
README.md
LICENSE
.vscode
Makefile
helm-charts
.env
.editorconfig
.idea
coverage*

38
.gitignore vendored Normal file
View File

@@ -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

43
Dockerfile Normal file
View File

@@ -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"]

15
README.md Normal file
View File

@@ -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.

148
app/app.ts Normal file
View File

@@ -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 )

View File

@@ -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
};

View File

@@ -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<TEventEntity, "event_uid">
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 });
}
}

View File

@@ -0,0 +1,2 @@
export * from "./events"
export * from "./event.types";

35
app/notification.py Normal file
View File

@@ -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
)

14
app/sendNotification.ts Normal file
View File

@@ -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);
}

13
app/sql.ts Normal file
View File

@@ -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);
}

22
app/util.ts Normal file
View File

@@ -0,0 +1,22 @@
export const createPlaceholders = ( arr: any[] ) => {
return arr.map(() => '?').join(', ');
}
export type AddDollarPrefix<T> = {
[K in keyof T as `$${string & K}`]: T[K];
};
export function prefixKeysWithDollar<T extends Record<string, any>>(obj: T): AddDollarPrefix<T> {
const result = {} as AddDollarPrefix<T>;
for (const key in obj) {
const newKey = `$${key}` as keyof AddDollarPrefix<T>;
result[newKey] = obj[key] as any;
}
return result;
}
export function transformArray<T extends Record<string, any>>(arr: T[]): AddDollarPrefix<T>[] {
return arr.map(prefixKeysWithDollar);
}

37
bun.lock Normal file
View File

@@ -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=="],
}
}

6
entrypoint.sh Normal file
View File

@@ -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

22
package.json Normal file
View File

@@ -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"
}
}

1
requirements.txt Normal file
View File

@@ -0,0 +1 @@
apprise

4
run/db_deleteall.ts Normal file
View File

@@ -0,0 +1,4 @@
import * as db from "../app/sql";
const query = db.db.query("DELETE FROM events;");
query.run();

9
run/db_init.ts Normal file
View File

@@ -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);

28
tsconfig.json Normal file
View File

@@ -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
}
}