feat: support sending "new title" alerts to devices
This commit is contained in:
4
drizzle/0005_shiny_scarecrow.sql
Normal file
4
drizzle/0005_shiny_scarecrow.sql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
CREATE TABLE `key_value` (
|
||||||
|
`key` text PRIMARY KEY NOT NULL,
|
||||||
|
`value` text NOT NULL
|
||||||
|
);
|
||||||
122
drizzle/meta/0005_snapshot.json
Normal file
122
drizzle/meta/0005_snapshot.json
Normal file
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@
|
|||||||
"when": 1718402777422,
|
"when": 1718402777422,
|
||||||
"tag": "0004_jittery_black_knight",
|
"tag": "0004_jittery_black_knight",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1725293569918,
|
||||||
|
"tag": "0005_shiny_scarecrow",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@hono/swagger-ui": "^0.2.2",
|
"@hono/swagger-ui": "^0.2.2",
|
||||||
"@hono/zod-openapi": "^0.12.0",
|
"@hono/zod-openapi": "^0.12.0",
|
||||||
"@libsql/client": "^0.6.2",
|
"@libsql/client": "^0.6.2",
|
||||||
|
"@upstash/qstash": "^2.7.0",
|
||||||
"drizzle-orm": "^0.31.2",
|
"drizzle-orm": "^0.31.2",
|
||||||
"gql.tada": "^1.7.5",
|
"gql.tada": "^1.7.5",
|
||||||
"graphql-request": "^7.0.1",
|
"graphql-request": "^7.0.1",
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { graphql } from "gql.tada";
|
|||||||
import { GraphQLClient } from "graphql-request";
|
import { GraphQLClient } from "graphql-request";
|
||||||
|
|
||||||
import type { Title } from "~/types/title";
|
import type { Title } from "~/types/title";
|
||||||
|
import { MediaFragment } from "~/types/title/mediaFragment";
|
||||||
import { MediaFragment } from "./mediaFragment";
|
|
||||||
|
|
||||||
const GetTitleQuery = graphql(
|
const GetTitleQuery = graphql(
|
||||||
`
|
`
|
||||||
|
|||||||
10
src/controllers/upcoming/index.ts
Normal file
10
src/controllers/upcoming/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
app.route(
|
||||||
|
"/",
|
||||||
|
await import("./titles").then((controller) => controller.default),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default app;
|
||||||
89
src/controllers/upcoming/titles/anilist.ts
Normal file
89
src/controllers/upcoming/titles/anilist.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
75
src/controllers/upcoming/titles/index.ts
Normal file
75
src/controllers/upcoming/titles/index.ts
Normal file
@@ -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<Env, typeof c>(c, "workerd"),
|
||||||
|
c.req.header("Upstash-Signature"),
|
||||||
|
await c.req.text(),
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
return c.json(ErrorResponse, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const titles = await getUpcomingTitlesFromAnilist(
|
||||||
|
env<Env, typeof c>(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<AdminSdkCredentials>(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;
|
||||||
@@ -33,6 +33,12 @@ app.route(
|
|||||||
"/token",
|
"/token",
|
||||||
await import("~/controllers/token").then((controller) => controller.default),
|
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
|
// The OpenAPI documentation will be available at /doc
|
||||||
app.doc("/openapi.json", {
|
app.doc("/openapi.json", {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export type FcmMessagePayload = {
|
|||||||
interface Notification {
|
interface Notification {
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
image: string;
|
image?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AndroidConfig {
|
interface AndroidConfig {
|
||||||
|
|||||||
24
src/libs/qstash/verifyQstashHeader.ts
Normal file
24
src/libs/qstash/verifyQstashHeader.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
31
src/models/kv.ts
Normal file
31
src/models/kv.ts
Normal file
@@ -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<string | undefined> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
|||||||
5
src/types/env.d.ts
vendored
5
src/types/env.d.ts
vendored
@@ -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`
|
// by running `wrangler types src/types/env.d.ts`
|
||||||
import type { Env as HonoEnv } from "hono";
|
import type { Env as HonoEnv } from "hono";
|
||||||
|
|
||||||
@@ -8,5 +8,8 @@ interface Env extends HonoEnv, Record<string, unknown> {
|
|||||||
QSTASH_URL: string;
|
QSTASH_URL: string;
|
||||||
ENABLE_ANIFY: string;
|
ENABLE_ANIFY: string;
|
||||||
ADMIN_SDK_JSON: string;
|
ADMIN_SDK_JSON: string;
|
||||||
|
QSTASH_CURRENT_SIGNING_KEY: string;
|
||||||
|
QSTASH_NEXT_SIGNING_KEY: string;
|
||||||
|
QSTASH_TOKEN: string;
|
||||||
TURSO_AUTH_TOKEN: string;
|
TURSO_AUTH_TOKEN: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user