From 7ce86f3036b55fa5d84e00cd58e5ce0e5974a059 Mon Sep 17 00:00:00 2001 From: Stefano Pigozzi Date: Sun, 20 Oct 2024 06:13:09 +0200 Subject: [PATCH] Refactor all existing code --- src/deno/logging.ts | 18 ++++ src/deno/router.ts | 41 +++++++ src/deno/server.ts | 31 ++++++ src/dv/dotinoVeloce.ts | 200 +++++++++++++++++++++++++++++++++++ src/dv/hostMeta.ts | 20 ++++ src/federation.ts | 140 ------------------------ src/fedify/kv.ts | 12 +++ src/fedify/redis.ts | 11 ++ src/graphql/stratz.ts | 99 ----------------- src/handler.ts | 40 ------- src/main.ts | 41 ++++--- src/redis.ts | 11 -- src/stratz/api.ts | 143 +++++++++++++++++++++++++ tests/graphql_stratz_test.ts | 2 +- 14 files changed, 497 insertions(+), 312 deletions(-) create mode 100644 src/deno/logging.ts create mode 100644 src/deno/router.ts create mode 100644 src/deno/server.ts create mode 100644 src/dv/dotinoVeloce.ts create mode 100644 src/dv/hostMeta.ts delete mode 100644 src/federation.ts create mode 100644 src/fedify/kv.ts create mode 100644 src/fedify/redis.ts delete mode 100644 src/graphql/stratz.ts delete mode 100644 src/handler.ts delete mode 100644 src/redis.ts create mode 100644 src/stratz/api.ts diff --git a/src/deno/logging.ts b/src/deno/logging.ts new file mode 100644 index 0000000..866f0b7 --- /dev/null +++ b/src/deno/logging.ts @@ -0,0 +1,18 @@ +import { configure, getConsoleSink, getLogger } from "@logtape/logtape" + + +const l = getLogger(["dotino-veloce", "deno", "logging"]) + + +export async function initLogging() { + await configure({ + sinks: { console: getConsoleSink() }, + filters: {}, + loggers: [ + { category: ["logtape", "meta"], sinks: ["console"], level: "warning" }, + { category: ["fedify"], sinks: ["console"], level: "info" }, + { category: ["dotino-veloce"], sinks: ["console"], level: "debug" }, + ], + }) + l.info`Logging initialized successfully!` +} diff --git a/src/deno/router.ts b/src/deno/router.ts new file mode 100644 index 0000000..9ec7246 --- /dev/null +++ b/src/deno/router.ts @@ -0,0 +1,41 @@ +import { getLogger } from "@logtape/logtape" +import { handleHostMeta } from "../dv/hostMeta.ts" +import { DotinoVeloce } from "../dv/dotinoVeloce.ts" + + +const l = getLogger(["dotino-veloce", "deno", "router"]) + +export function createRouter(ap: DotinoVeloce) { + return async function router(request: Request, _info: Deno.ServeHandlerInfo): Promise { + l.debug`Routing request: ${request}` + + l.debug`Determining request's User-Agent...` + const agent = request.headers.get("User-Agent") + l.debug`Request's User-Agent is: ${agent}` + + l.debug`Determining request's URL...` + const url = new URL(request.url) + l.debug`Request's URL is: ${url}` + + l.debug`Determining request's pathname...` + const pathname = url.pathname + l.debug`Request's pathname is: ${pathname}` + + if (url.pathname === "/.well-known/host-meta") + { + l.debug`Delegating handling to host-meta generator...` + return handleHostMeta(url.origin) + } + else + { + l.debug`Delegating request to Fedify...` + return ap.federation.fetch( + request, + { + contextData: undefined, + }, + ) + } + } +} + diff --git a/src/deno/server.ts b/src/deno/server.ts new file mode 100644 index 0000000..f868cc9 --- /dev/null +++ b/src/deno/server.ts @@ -0,0 +1,31 @@ +import { getLogger } from "https://jsr.io/@logtape/logtape/0.6.3/logtape/logger.ts" + + +const l = getLogger(["dotino-veloce", "deno", "server"]) + + +export function doServe(router: Deno.ServeHandler) { + l.info`Starting server...` + Deno.serve( + { + port: 8080, + + onListen(localAddr) { + l.info`Listening on: ${localAddr}` + }, + + // deno-lint-ignore require-await + async onError(error) { + l.error`Error caught at the serve boundary: ${error}` + return new Response( + "An internal server error has occoured.", + { + status: 500, + } + ) + } + }, + + router, + ) +} \ No newline at end of file diff --git a/src/dv/dotinoVeloce.ts b/src/dv/dotinoVeloce.ts new file mode 100644 index 0000000..a99140d --- /dev/null +++ b/src/dv/dotinoVeloce.ts @@ -0,0 +1,200 @@ +// deno-lint-ignore-file require-await +import { createFederation, Person, Application, Image, PropertyValue, Organization, Federation, KvStore, Context, Actor } from "@fedify/fedify" +import { getLogger } from "https://jsr.io/@logtape/logtape/0.6.3/logtape/logger.ts" +import { escapeHtml } from "@@x/escape" +import { StratzAPI } from "../stratz/api.ts" + + +const l = getLogger(["dotino-veloce", "ap", "federation"]) + + +type ContextData = undefined + + +export class DotinoVeloce { + stratz: StratzAPI + federation: Federation + + constructor(kv: KvStore, stratz: StratzAPI) { + this.stratz = stratz + this.federation = createFederation({ kv }) + this.federation.setInboxListeners("/inbox/{identifier}", /* We don't need shared inboxes here. */) + this.federation.setActorDispatcher("/users/{identifier}", this.#actorDispatcher) + } + + #commonActorProperties(ctx: Context, handle: string): Partial { + l.debug`Generating common actor properties for ${handle}...` + const properties = { + id: ctx.getActorUri(handle), + inbox: ctx.getInboxUri(handle), + preferredUsername: handle, + // Akkoma expects URL to be equal to ID + // Or does it? This makes no sense to me... + // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/object/fetcher.ex#L287 + // url: id, + // Akkoma requires inboxes to be setup to display profiles + // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 + } + l.debug`Generated common actor properties for ${handle}: ${properties}` + return properties + } + + static playerMatcher = /u([0-9]+)/ + + async #playerActor(ctx: Context, handle: string): Promise { + l.debug`Checking if handle ${handle} matches regex for player accounts...` + const match = handle.match(DotinoVeloce.playerMatcher) + if(!match) { + l.debug`No match for player account regex, returning null.` + return null + } + + l.debug`Getting handle ${handle}'s SteamID...` + const steamId = Number.parseInt(match[1] as any) + l.debug`Handle ${handle}'s SteamID seems to be: ${steamId}` + + l.debug`Making sure the SteamID parsing didn't explode...` + if(Number.isFinite(steamId)) { + l.error`SteamID parsing for ${handle} exploded with ${steamId}, returning null.` + return null + } + + l.debug`Getting player data for ${steamId}...` + const player = await this.stratz.doQueryPlayer(steamId) + if(player === null) { + l.debug`No Steam account was found with ID: ${steamId}` + return null + } + + l.debug`Generating ActivityPub actor for player ${steamId}...` + const actor = new Person({ + ...this.#commonActorProperties(ctx, handle), + + name: `[TEST] ${escapeHtml(player.name)}`, + icon: new Image({ + url: new URL(player.avatar), + mediaType: "image/jpeg" + }), + attachments: [ + new PropertyValue({ + name: "Steam", + value: `https://steamcommunity.com/profiles/[U:1:${player.id}]`, + }), + new PropertyValue({ + name: "STRATZ", + value: `https://stratz.com/players/${player.id}`, + }), + new PropertyValue({ + name: "OpenDota", + value: `https://www.opendota.com/players/${player.id}`, + }), + new PropertyValue({ + name: "DOTABUFF", + value: `https://www.dotabuff.com/players/${player.id}`, + }), + ] + }) + l.debug`Generated ActivityPub actor for player ${steamId}: ${actor}` + + return actor + } + + static guildMatcher = /g([0-9]+)/ + + async #guildActor(ctx: Context, handle: string): Promise { + l.debug`Checking if handle ${handle} matches regex for guild accounts...` + const match = handle.match(DotinoVeloce.guildMatcher) + if(!match) { + l.debug`No match for guild account regex, returning null.` + return null + } + + l.debug`Getting handle ${handle}'s Guild ID...` + const guildId = Number.parseInt(match[1] as any) + l.debug`Handle ${handle}'s Guild ID seems to be: ${guildId}` + + l.debug`Making sure the Guild ID parsing didn't explode...` + if(Number.isFinite(guildId)) { + l.error`Guild ID parsing for ${handle} exploded with ${guildId}, returning null.` + return null + } + + + const guild = await this.stratz.doQueryGuild(guildId) + + if(guild === null) { + l.warn`No guild was found with ID: ${guildId}` + return null + } + + l.debug`Generating ActivityPub actor for guild ${guildId}...` + const actor = new Organization({ + ...this.#commonActorProperties(ctx, handle), + + name: `[TEST] ${escapeHtml(guild.name)}`, + summary: escapeHtml(guild.description), + icon: new Image({ + url: new URL(`https://steamusercontent-a.akamaihd.net/ugc/${guild.logo}`), + mediaType: "image/jpeg" + }), + published: Temporal.Instant.fromEpochMilliseconds(guild.createdDateTime * 1000), + attachments: [ + new PropertyValue({ + name: "Tag", + value: `[${escapeHtml(guild.tag)}]`, + }), + new PropertyValue({ + name: "Message of the day", + value: escapeHtml(guild.motd), + }), + new PropertyValue({ + name: "STRATZ", + value: `https://stratz.com/guilds/${guild.id}`, + }), + ] + }) + l.debug`Generated ActivityPub actor for guild ${guildId}: ${actor}` + + return actor + } + + static serviceMatcher = /service/ + + async #serviceActor(ctx: Context, handle: string): Promise { + l.debug`Checking if handle ${handle} matches regex for the service account...` + const match = handle.match(DotinoVeloce.serviceMatcher) + if(!match) { + l.debug`No match for service account regex, returning null.` + return null + } + + l.debug`Generating ActivityPub actor for service...` + const actor = new Application({ + ...this.#commonActorProperties(ctx, handle), + + name: "[TEST] Dotino Veloce", + summary: "Service account of a Dotino Veloce instance.", + attachments: [ + new PropertyValue({ + name: "Source code", + value: `https://forge.steffo.eu/steffo/dotino-veloce`, + }), + ] + }) + + l.debug`Generated ActivityPub actor for service: ${actor}` + return actor + } + + async #actorDispatcher(ctx: any, handle: string) { + l.debug`Handling actor with handle: ${handle}` + + let actor = null + + actor ??= this.#playerActor(ctx, handle) + actor ??= this.#guildActor(ctx, handle) + actor ??= this.#serviceActor(ctx, handle) + + return actor + } +} diff --git a/src/dv/hostMeta.ts b/src/dv/hostMeta.ts new file mode 100644 index 0000000..e5986ea --- /dev/null +++ b/src/dv/hostMeta.ts @@ -0,0 +1,20 @@ +import { getLogger } from "@logtape/logtape" + + +const l = getLogger(["dotino-veloce", "ap", "hostMeta"]) + + +export function handleHostMeta(origin: string): Response { + l.info`Creating host-meta definition for: ${origin}` + + // Akkoma expects host-meta to be correctly setup + // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/web_finger.ex#L177 + return new Response( + ``, + { + headers: { + "Content-Type": "application/xml", + }, + }, + ) +} diff --git a/src/federation.ts b/src/federation.ts deleted file mode 100644 index 563dad2..0000000 --- a/src/federation.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { createFederation, Person, Application, Image, PropertyValue, Organization, Group } from "@fedify/fedify" -import { kv } from "./redis.ts" -import { getLogger } from "https://jsr.io/@logtape/logtape/0.6.3/logtape/logger.ts" -import { doQueryGuild, doQueryPlayer } from "./graphql/stratz.ts" -import { escapeHtml } from "@@x/escape" - -const l = getLogger(["dotino-veloce", "federation"]) - -l.debug`Creating federation object...` -export const federation = createFederation({ kv }) - -const userMatcher = /u([0-9]+)/ -const guildMatcher = /g([0-9]+)/ - -l.debug`Creating actor dispatcher...` -// deno-lint-ignore require-await -async function actorDispatcher(ctx: any, handle: string) { - l.debug`Determining id of requested actor: ${handle}` - const id = ctx.getActorUri(handle) - l.debug`Requested actor is: ${id.href}` - - if (handle === "service") { - l.info`Dispatching service account...` - return new Application({ - id, - preferredUsername: id.href, - // Akkoma expects URL to be equal to ID - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/object/fetcher.ex#L287 - url: id, - // Akkoma requires inboxes to be setup to display profiles - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 - inbox: ctx.getInboxUri(handle), - - name: "[TEST] Dotino Veloce", - summary: "Service account of a Dotino Veloce instance.", - }) - } - - const steamId = Number.parseInt(handle.match(userMatcher)?.[1] as any) - if(Number.isFinite(steamId)) { - l.info`Dispatching user account with Steam ID: ${steamId}` - - const player = await doQueryPlayer(steamId) - - if(player === null) { - l.warn`No Steam account was found with ID: ${steamId}` - return null - } - - return new Person({ - id, - preferredUsername: id.href, - // Akkoma expects URL to be equal to ID - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/object/fetcher.ex#L287 - url: id, - // Akkoma requires inboxes to be setup to display profiles - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 - inbox: ctx.getInboxUri(handle), - - name: `[TEST] ${escapeHtml(player.name)}`, - icon: new Image({ - url: new URL(player.avatar), - mediaType: "image/jpeg" - }), - attachments: [ - new PropertyValue({ - name: "Steam", - value: `https://steamcommunity.com/profiles/[U:1:${player.id}]`, - }), - new PropertyValue({ - name: "STRATZ", - value: `https://stratz.com/players/${player.id}`, - }), - new PropertyValue({ - name: "OpenDota", - value: `https://www.opendota.com/players/${player.id}`, - }), - new PropertyValue({ - name: "DOTABUFF", - value: `https://www.dotabuff.com/players/${player.id}`, - }), - ] - }) - } - - const guildId = Number.parseInt(handle.match(guildMatcher)?.[1] as any) - if(Number.isFinite(guildId)) { - l.info`Dispatching guild with Guild ID: ${guildId}` - - const guild = await doQueryGuild(guildId) - - if(guild === null) { - l.warn`No guild was found with ID: ${guildId}` - return null - } - - return new Group({ - id, - preferredUsername: id.href, - // Akkoma expects URL to be equal to ID - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/object/fetcher.ex#L287 - url: id, - // Akkoma requires inboxes to be setup to display profiles - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/activity_pub/object_validators/user_validator.ex#L72 - inbox: ctx.getInboxUri(handle), - - name: `[TEST] ${escapeHtml(guild.name)}`, - summary: escapeHtml(guild.description), - icon: new Image({ - url: new URL(`https://steamusercontent-a.akamaihd.net/ugc/${guild.logo}`), - mediaType: "image/jpeg" - }), - published: Temporal.Instant.fromEpochMilliseconds(guild.createdDateTime * 1000), - attachments: [ - new PropertyValue({ - name: "Tag", - value: `[${escapeHtml(guild.tag)}]`, - }), - new PropertyValue({ - name: "Message of the day", - value: escapeHtml(guild.motd), - }), - new PropertyValue({ - name: "STRATZ", - value: `https://stratz.com/guilds/${guild.id}`, - }), - ] - }) - } - - l.warn`No dispatcher was found for handle: ${handle}` - return null - -} - -l.debug`Connecting actor dispatcher to federation object...` -federation.setActorDispatcher("/users/{identifier}", actorDispatcher) - -l.debug`Initializing inbox listener...` -federation.setInboxListeners("/inbox/{identifier}") // I don't really care about the shared inbox for this project diff --git a/src/fedify/kv.ts b/src/fedify/kv.ts new file mode 100644 index 0000000..8eb7050 --- /dev/null +++ b/src/fedify/kv.ts @@ -0,0 +1,12 @@ +import { Redis } from "@@npm/ioredis" +import { RedisKvStore } from "@fedify/redis/kv" +import { getLogger } from "@logtape/logtape" + + +const l = getLogger(["dotino-veloce", "fedify", "kv"]) + + +export function createRedisKvStore(redis: Redis) { + l.debug`Creating Redis key-value store...` + return new RedisKvStore(redis, {}) +} diff --git a/src/fedify/redis.ts b/src/fedify/redis.ts new file mode 100644 index 0000000..1838846 --- /dev/null +++ b/src/fedify/redis.ts @@ -0,0 +1,11 @@ +import { getLogger } from "@logtape/logtape" +import { Redis } from "@@npm/ioredis" + + +const l = getLogger(["dotino-veloce", "fedify", "redis"]) + + +export function createRedis() { + l.debug`Creating Redis object...` + return new Redis({}) +} diff --git a/src/graphql/stratz.ts b/src/graphql/stratz.ts deleted file mode 100644 index 6bf4e65..0000000 --- a/src/graphql/stratz.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { getLogger } from "@logtape/logtape" - -const l = getLogger(["dotino-veloce", "graphql"]) - -l.debug`Getting Stratz API URL from environment variable DOTINO_STRATZ_URL...` -const urlString = Deno.env.get("DOTINO_STRATZ_URL") -if(!urlString) { - l.error`DOTINO_STRATZ_URL is unset.` - throw new Error("DOTINO_STRATZ_URL is unset.") -} - -l.debug`Attempting to parse Stratz API URL...` -const url = new URL(urlString) - -l.debug`Getting Stratz API key from environment variable DOTINO_STRATZ_KEY...` -const key = Deno.env.get("DOTINO_STRATZ_KEY") -if(!key) { - l.error`DOTINO_STRATZ_KEY is unset.` - throw new Error("DOTINO_STRATZ_KEY is unset.") -} - -// Little trick to get VSCode to highlight queries! -const graphql = String.raw - -export async function doQuery(query: string, variables: object) { - const request = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - "Authorization": `Bearer ${key}`, - "User-Agent": "STRATZ_API", - }, - body: JSON.stringify({query, variables}) - }) - const data = await request.json() - - if(data["error"]) { - throw new Error("GraphQL query failed.", data["error"]) - } - - return data["data"] -} - -export async function doQueryPlayer(steamId: number): Promise<{ - communityVisibleState: number, - isAnonymous: boolean, - id: number, - name: string, - avatar: string, -} | null> { - l.info`Querying player ${steamId} on the Stratz API...` - const response = await doQuery( - graphql` - query ($steamId: Long!) { - player (steamAccountId: $steamId) { - steamAccount { - communityVisibleState - isAnonymous - id - name - avatar - } - } - } - `, - {steamId} - ) - return response?.player?.steamAccount ?? null -} - -export async function doQueryGuild(guildId: number): Promise<{ - id: number, - motd: string, - name: string, - tag: string, - logo: string, - description: string, - createdDateTime: number, -} | null> { - l.info`Querying guild ${guildId} on the Stratz API...` - const response = await doQuery( - graphql` - query ($guildId: Int!) { - guild(id: $guildId) { - id, - motd, - name, - tag, - logo, - description, - createdDateTime, - } - } - `, - {guildId} - ) - return response?.guild ?? null -} diff --git a/src/handler.ts b/src/handler.ts deleted file mode 100644 index da7fb02..0000000 --- a/src/handler.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { getLogger } from "@logtape/logtape" -import { behindProxy, Fetch } from "@hongminhee/x-forwarded-fetch" -import { federation } from "./federation.ts" - -const l = getLogger(["dotino-veloce", "handler"]) - -l.debug`Creating Deno handler...` -function handler(request: Request, _info: Deno.ServeHandlerInfo) { - l.debug`Received a request, processing...` - - const agent = request.headers.get("User-Agent") - const requestUrl = new URL(request.url) - l.debug`Received request from ${agent} to ${requestUrl.href}` - - // Akkoma expects host-meta to be correctly setup - // https://akkoma.dev/AkkomaGang/akkoma/src/commit/f1018867097e6f293d8b2b5b6935f0a7ebf99bd0/lib/pleroma/web/web_finger.ex#L177 - l.debug`Routing request to: ${requestUrl.pathname}` - if (requestUrl.pathname === "/.well-known/host-meta") { - l.debug`Intercepting request to inject host-meta for ${requestUrl.origin}` - return new Response( - ``, - { - headers: { - "Content-Type": "application/xml", - }, - }, - ) - } - - l.debug`Delegating request to Federation...` - return federation.fetch( - request, - { - contextData: undefined, - }, - ) -} - -l.debug`Creating proxyied Deno handler...` -export const proxyHandler: Deno.ServeHandler = behindProxy(handler as Fetch) // Should be good. diff --git a/src/main.ts b/src/main.ts index 82c2f3a..4afbac7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,24 +1,23 @@ -import { configure, getConsoleSink, getLogger } from "@logtape/logtape" -import { proxyHandler } from "./handler.ts" +import { doServe } from "./deno/server.ts" +import { createRedis } from "./fedify/redis.ts" +import { initLogging } from "./deno/logging.ts" +import { createRedisKvStore } from "./fedify/kv.ts" +import { StratzAPI } from "./stratz/api.ts" +import { behindProxy, Fetch } from "@hongminhee/x-forwarded-fetch" +import { createRouter } from "./deno/router.ts" +import { DotinoVeloce } from "./dv/dotinoVeloce.ts" -await configure({ - sinks: { console: getConsoleSink() }, - filters: {}, - loggers: [ - { category: ["logtape", "meta"], sinks: ["console"], level: "warning" }, - { category: ["fedify"], sinks: ["console"], level: "info" }, - { category: ["dotino-veloce"], sinks: ["console"], level: "debug" }, - ], -}) -const l = getLogger(["dotino-veloce", "main"]) +async function main() { + await initLogging() + const redis = createRedis() + const kv = createRedisKvStore(redis) + const stratz = StratzAPI.fromEnv() + const ap = new DotinoVeloce(kv, stratz) + const router = createRouter(ap) + const routerBehindProxy = behindProxy(router as Fetch) + await doServe(routerBehindProxy) +} -l.info`Starting server...` -Deno.serve( - { - port: 8080, - onListen: (_localAddr) => { - }, - }, - proxyHandler, -) + +main() diff --git a/src/redis.ts b/src/redis.ts deleted file mode 100644 index deae4f9..0000000 --- a/src/redis.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { RedisKvStore } from "@fedify/redis" -import { getLogger } from "@logtape/logtape" -import { Redis } from "@@npm/ioredis" - -const l = getLogger(["dotino-veloce", "redis"]) - -l.debug`Creating redis object...` -export const redis = new Redis({}) - -l.debug`Creating federation object...` -export const kv = new RedisKvStore(redis, {}) diff --git a/src/stratz/api.ts b/src/stratz/api.ts new file mode 100644 index 0000000..fe35a60 --- /dev/null +++ b/src/stratz/api.ts @@ -0,0 +1,143 @@ +import { getLogger } from "@logtape/logtape" + + +const l = getLogger(["dotino-veloce", "stratz", "api"]) +const graphql = String.raw + + +export type QueryPlayer = { + communityVisibleState: number, + isAnonymous: boolean, + id: number, + name: string, + avatar: string, +} + +export type QueryGuild = { + id: number, + motd: string, + name: string, + tag: string, + logo: string, + description: string, + createdDateTime: number, +} + +export class StratzAPI { + url: URL + key: string + + constructor(url: string, key: string) { + l.info`Creating new Stratz API client sending requests at: ${url}` + + l.debug`Using Stratz API key: ${key}` + this.key = key + + l.debug`Attempting to parse Stratz API URL...` + this.url = new URL(url) + l.debug`Stratz API URL is: ${this.url}` + } + + static fromEnv(): StratzAPI { + l.debug`Getting Stratz API URL from environment variable DOTINO_STRATZ_URL...` + const url = Deno.env.get("DOTINO_STRATZ_URL") + if(!url) { + l.error`DOTINO_STRATZ_URL is unset.` + throw new Error("DOTINO_STRATZ_URL is unset.") + } + + l.debug`Getting Stratz API key from environment variable DOTINO_STRATZ_KEY...` + const key = Deno.env.get("DOTINO_STRATZ_KEY") + if(!key) { + l.error`DOTINO_STRATZ_KEY is unset.` + throw new Error("DOTINO_STRATZ_KEY is unset.") + } + + return new StratzAPI(url, key) + } + + async doQuery(query: string, variables: object): Promise { + l.debug`Sending to Stratz API at ${this.url} query ${query} with variables ${variables}` + const response = await fetch(this.url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "Authorization": `Bearer ${this.key}`, + "User-Agent": "STRATZ_API", // Stratz requires this specific User-Agent in API calls + }, + body: JSON.stringify({query, variables}) + }) + l.debug`Received Stratz API response: ${response}` + + l.debug`Parsing Stratz API response as JSON...` + const parsed = await response.json() + l.debug`Parsed Stratz API response as: ${parsed}` + + const error = parsed["error"] + if(error) { + l.error`GraphQL query failed: ${error}` + throw new Error("GraphQL query failed.", error) + } + + l.debug`Attempting to get data from the parsed API response...` + const data = parsed["data"] + l.debug`Got data from the parsed API response: ${data}` + + return data + } + + async doQueryPlayer(steamId: number): Promise { + const query = graphql` + query ($steamId: Long!) { + player (steamAccountId: $steamId) { + steamAccount { + communityVisibleState + isAnonymous + id + name + avatar + } + } + } + ` + const variables = {steamId} + + l.info`Querying player ${steamId} on the Stratz API...` + const response = await this.doQuery(query, variables) + l.debug`Player query successful, received: ${response}` + + l.debug`Attempting to retrieve player's Steam account from the Stratz API response...` + const steamAccount = response?.player?.steamAccount ?? null + l.debug`Player's Steam account is: ${steamAccount}` + + return steamAccount + } + + async doQueryGuild(guildId: number): Promise { + const query = graphql` + query ($guildId: Int!) { + guild(id: $guildId) { + id, + motd, + name, + tag, + logo, + description, + createdDateTime, + } + } + ` + const variables = {guildId} + + l.info`Querying guild ${guildId} on the Stratz API...` + const response = await this.doQuery(query, variables) + l.debug`Player query successful, received: ${response}` + + l.debug`Attempting to retrieve guild data from the Stratz API response...` + const guildData = response?.guild ?? null + l.debug`Guild data is: ${guildData}` + + return guildData + } +} diff --git a/tests/graphql_stratz_test.ts b/tests/graphql_stratz_test.ts index 23ec1cf..252c82f 100644 --- a/tests/graphql_stratz_test.ts +++ b/tests/graphql_stratz_test.ts @@ -1,5 +1,5 @@ import { configure, getConsoleSink } from "@logtape/logtape" -import { doQueryPlayer } from "../src/graphql/stratz.ts" +import { doQueryPlayer } from "../src/stratz/api.ts" import { assert, assertEquals} from "@std/assert"