diff --git a/bun.lockb b/bun.lockb index 1bde39c..502ec7e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/drizzle/0005_shiny_scarecrow.sql b/drizzle/0005_shiny_scarecrow.sql new file mode 100644 index 0000000..375ecf0 --- /dev/null +++ b/drizzle/0005_shiny_scarecrow.sql @@ -0,0 +1,4 @@ +CREATE TABLE `key_value` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..dc0dcb2 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,122 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bca1f597-6db1-4bf8-ab6b-a95c10d3f6a7", + "prevId": "223cc621-0232-4499-973a-9013d134b1f9", + "tables": { + "device_tokens": { + "name": "device_tokens", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "device_tokens_token_unique": { + "name": "device_tokens_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "key_value": { + "name": "key_value", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "watch_status": { + "name": "watch_status", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_id": { + "name": "title_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "watch_status_device_id_device_tokens_device_id_fk": { + "name": "watch_status_device_id_device_tokens_device_id_fk", + "tableFrom": "watch_status", + "tableTo": "device_tokens", + "columnsFrom": ["device_id"], + "columnsTo": ["device_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watch_status_device_id_title_id_pk": { + "columns": ["device_id", "title_id"], + "name": "watch_status_device_id_title_id_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2960175..bf9643f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1718402777422, "tag": "0004_jittery_black_knight", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1725293569918, + "tag": "0005_shiny_scarecrow", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 7e56dd3..e9683e5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@hono/swagger-ui": "^0.2.2", "@hono/zod-openapi": "^0.12.0", "@libsql/client": "^0.6.2", + "@upstash/qstash": "^2.7.0", "drizzle-orm": "^0.31.2", "gql.tada": "^1.7.5", "graphql-request": "^7.0.1", diff --git a/src/controllers/title/anilist.ts b/src/controllers/title/anilist.ts index 172357c..5b5947d 100644 --- a/src/controllers/title/anilist.ts +++ b/src/controllers/title/anilist.ts @@ -2,8 +2,7 @@ import { graphql } from "gql.tada"; import { GraphQLClient } from "graphql-request"; import type { Title } from "~/types/title"; - -import { MediaFragment } from "./mediaFragment"; +import { MediaFragment } from "~/types/title/mediaFragment"; const GetTitleQuery = graphql( ` diff --git a/src/controllers/upcoming/index.ts b/src/controllers/upcoming/index.ts new file mode 100644 index 0000000..5d6f4cd --- /dev/null +++ b/src/controllers/upcoming/index.ts @@ -0,0 +1,10 @@ +import { Hono } from "hono"; + +const app = new Hono(); + +app.route( + "/", + await import("./titles").then((controller) => controller.default), +); + +export default app; diff --git a/src/controllers/upcoming/titles/anilist.ts b/src/controllers/upcoming/titles/anilist.ts new file mode 100644 index 0000000..662f753 --- /dev/null +++ b/src/controllers/upcoming/titles/anilist.ts @@ -0,0 +1,89 @@ +import { graphql } from "gql.tada"; +import { GraphQLClient } from "graphql-request"; +import { DateTime } from "luxon"; + +import { getValue, setValue } from "~/models/kv"; +import type { Env } from "~/types/env"; +import type { Title } from "~/types/title"; +import { MediaFragment } from "~/types/title/mediaFragment"; + +const GetUpcomingTitlesQuery = graphql( + ` + query GetUpcomingTitles( + $page: Int! + $airingAtLowerBound: Int! + $airingAtUpperBound: Int! + ) { + Page(page: $page) { + airingSchedules( + notYetAired: true + sort: TIME + airingAt_lesser: $airingAtUpperBound + airingAt_greater: $airingAtLowerBound + ) { + id + airingAt + timeUntilAiring + episode + media { + ...Media + } + } + pageInfo { + hasNextPage + } + } + } + `, + [MediaFragment], +); + +type AiringSchedule = { + media: Title; + episode: number; + timeUntilAiring: number; + airingAt: number; + id: number; +}; + +export async function getUpcomingTitlesFromAnilist(env: Env) { + const client = new GraphQLClient("https://graphql.anilist.co/"); + const lastCheckedScheduleAt = await getValue( + env, + "schedule_last_checked_at", + ).then((value) => (value ? Number(value) : DateTime.now().toUnixInteger())); + console.log(lastCheckedScheduleAt); + const twoDaysFromNow = DateTime.now().plus({ days: 2 }).toUnixInteger(); + + let currentPage = 1; + let scheduleList: AiringSchedule[] = []; + let shouldContinue = true; + + do { + const { Page } = await client.request(GetUpcomingTitlesQuery, { + page: currentPage++, + airingAtLowerBound: lastCheckedScheduleAt, + airingAtUpperBound: twoDaysFromNow, + }); + + const { airingSchedules, pageInfo } = Page!; + scheduleList = scheduleList.concat( + airingSchedules!.filter( + (schedule): schedule is AiringSchedule => !!schedule, + ), + ); + shouldContinue = pageInfo?.hasNextPage ?? false; + } while (shouldContinue); + + if (scheduleList.length === 0) { + return []; + } + + await setValue( + env, + "schedule_last_checked_at", + scheduleList[scheduleList.length - 1].airingAt.toString(), + ); + + return scheduleList; +} diff --git a/src/controllers/upcoming/titles/index.ts b/src/controllers/upcoming/titles/index.ts new file mode 100644 index 0000000..c4ee5d9 --- /dev/null +++ b/src/controllers/upcoming/titles/index.ts @@ -0,0 +1,75 @@ +import { Hono } from "hono"; +import { env } from "hono/adapter"; +import mapKeys from "lodash.mapkeys"; +import { DateTime } from "luxon"; + +import { Case, changeStringCase } from "~/libs/changeStringCase"; +import type { AdminSdkCredentials } from "~/libs/fcm/getGoogleAuthToken"; +import { sendFcmMessage } from "~/libs/fcm/sendFcmMessage"; +import { verifyQstashHeader } from "~/libs/qstash/verifyQstashHeader"; +import { readEnvVariable } from "~/libs/readEnvVariable"; +import type { Env } from "~/types/env"; +import { ErrorResponse, SuccessResponse } from "~/types/schema"; + +import { getUpcomingTitlesFromAnilist } from "./anilist"; + +const app = new Hono(); + +app.post("/titles", async (c) => { + if ( + !(await verifyQstashHeader( + env(c, "workerd"), + c.req.header("Upstash-Signature"), + await c.req.text(), + )) + ) { + return c.json(ErrorResponse, { status: 401 }); + } + + const titles = await getUpcomingTitlesFromAnilist( + env(c, "workerd"), + ); + + await Promise.all( + titles.map(async (title) => { + const titleName = + title.media.title?.userPreferred ?? + title.media.title?.english ?? + "Unknown Title"; + + return sendFcmMessage( + mapKeys( + readEnvVariable(c.env, "ADMIN_SDK_JSON"), + (_, key) => changeStringCase(key, Case.snake_case, Case.camelCase), + ) as unknown as AdminSdkCredentials, + { + topic: "newTitles", + data: { + type: "new_title", + aniListId: title.media.id.toString(), + title: titleName, + airingAt: title.airingAt.toString(), + }, + notification: { + title: "New Series Alert", + body: `${titleName} will be released on ${DateTime.fromSeconds(title.airingAt).toRelative({ unit: "days" })}`, + image: + title.media.coverImage?.medium ?? + title.media.coverImage?.large ?? + title.media.coverImage?.extraLarge ?? + undefined, + }, + android: { + notification: { + click_action: "HANDLE_FCM_NOTIFICATION", + }, + }, + }, + ); + }), + ); + + return c.json(SuccessResponse, 200); +}); + +export default app; diff --git a/src/index.ts b/src/index.ts index 243deaf..18ccf8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,12 @@ app.route( "/token", await import("~/controllers/token").then((controller) => controller.default), ); +app.route( + "/upcoming", + await import("~/controllers/upcoming").then( + (controller) => controller.default, + ), +); // The OpenAPI documentation will be available at /doc app.doc("/openapi.json", { diff --git a/src/libs/fcm/sendFcmMessage.ts b/src/libs/fcm/sendFcmMessage.ts index 24fbb2b..ee2d54f 100644 --- a/src/libs/fcm/sendFcmMessage.ts +++ b/src/libs/fcm/sendFcmMessage.ts @@ -56,7 +56,7 @@ export type FcmMessagePayload = { interface Notification { title: string; body: string; - image: string; + image?: string; } interface AndroidConfig { diff --git a/src/libs/qstash/verifyQstashHeader.ts b/src/libs/qstash/verifyQstashHeader.ts new file mode 100644 index 0000000..d61aa63 --- /dev/null +++ b/src/libs/qstash/verifyQstashHeader.ts @@ -0,0 +1,24 @@ +import { Receiver } from "@upstash/qstash"; + +import type { Env } from "~/types/env"; + +export function verifyQstashHeader( + env: Env, + signature: string | undefined, + body: string, +): Promise { + if (!signature) { + return Promise.resolve(false); + } + + const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, + }); + + return receiver.verify({ + body, + signature, + url: "https://aniplay-v2.rururu.workers.dev", + }); +} diff --git a/src/models/kv.ts b/src/models/kv.ts new file mode 100644 index 0000000..8ecc923 --- /dev/null +++ b/src/models/kv.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; + +import type { Env } from "~/types/env"; + +import { getDb } from "./db"; +import { keyValueTable } from "./schema"; + +export type Key = (typeof keyValueTable.key.enumValues)[number]; + +export function getValue(env: Env, key: Key): Promise { + return getDb(env) + .select() + .from(keyValueTable) + .where(eq(keyValueTable.key, key)) + .then((results) => results[0]?.value); +} + +export function setValue(env: Env, key: Key, value: string) { + return getDb(env) + .insert(keyValueTable) + .values({ key, value }) + .onConflictDoUpdate({ set: { value }, target: [keyValueTable.key] }) + .run(); +} + +export function deleteValue(env: Env, key: Key) { + return getDb(env) + .delete(keyValueTable) + .where(eq(keyValueTable.key, key)) + .run(); +} diff --git a/src/models/schema.ts b/src/models/schema.ts index dd3cc09..b5f141f 100644 --- a/src/models/schema.ts +++ b/src/models/schema.ts @@ -29,4 +29,9 @@ export const watchStatusTable = sqliteTable( }), ); -export const tables = [watchStatusTable, deviceTokensTable]; +export const keyValueTable = sqliteTable("key_value", { + key: text("key", { enum: ["schedule_last_checked_at"] }).primaryKey(), + value: text("value").notNull(), +}); + +export const tables = [watchStatusTable, deviceTokensTable, keyValueTable]; diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 39fdecc..202474c 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -1,4 +1,4 @@ -// Generated by Wrangler on Sat Jun 15 2024 05:15:32 GMT-0400 (Eastern Daylight Time) +// Generated by Wrangler on Mon Sep 02 2024 12:55:01 GMT-0400 (Eastern Daylight Time) // by running `wrangler types src/types/env.d.ts` import type { Env as HonoEnv } from "hono"; @@ -8,5 +8,8 @@ interface Env extends HonoEnv, Record { QSTASH_URL: string; ENABLE_ANIFY: string; ADMIN_SDK_JSON: string; + QSTASH_CURRENT_SIGNING_KEY: string; + QSTASH_NEXT_SIGNING_KEY: string; + QSTASH_TOKEN: string; TURSO_AUTH_TOKEN: string; } diff --git a/src/controllers/title/mediaFragment.ts b/src/types/title/mediaFragment.ts similarity index 100% rename from src/controllers/title/mediaFragment.ts rename to src/types/title/mediaFragment.ts