From 959c8005baf03e64995f34e424ee4fdba0ceaa11 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Wed, 29 Jul 2026 03:17:12 +0700 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20Update=20deps,?= =?UTF-8?q?=20add=20Biome/Husky/tests,=20rework=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/docker-build.yaml | 16 --- .github/workflows/docker-publish.yaml | 52 ++++++--- .husky/pre-commit | 2 + .prettierrc | 13 --- biome.json | 39 +++++++ bun.lockb | Bin 17791 -> 34352 bytes package.json | 59 +++++----- src/__tests__/server.test.ts | 58 ++++++++++ src/index.ts | 102 ++---------------- src/providers/ConfigProvider.ts | 22 +--- src/providers/DiscordProvider.ts | 30 +++--- src/providers/SteamProvider.ts | 17 ++- .../__tests__/ConfigProvider.test.ts | 53 +++++++++ src/server.ts | 34 ++++++ src/services/PresenceAggregator.ts | 67 ++++++++++++ .../__tests__/PresenceAggregator.test.ts | 87 +++++++++++++++ src/types.ts | 59 ++++++++++ tsconfig.json | 41 ++++--- 18 files changed, 524 insertions(+), 227 deletions(-) delete mode 100644 .github/workflows/docker-build.yaml create mode 100755 .husky/pre-commit delete mode 100644 .prettierrc create mode 100644 biome.json mode change 100644 => 100755 bun.lockb create mode 100644 src/__tests__/server.test.ts create mode 100644 src/providers/__tests__/ConfigProvider.test.ts create mode 100644 src/server.ts create mode 100644 src/services/PresenceAggregator.ts create mode 100644 src/services/__tests__/PresenceAggregator.test.ts create mode 100644 src/types.ts diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml deleted file mode 100644 index d06a446..0000000 --- a/.github/workflows/docker-build.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: Docker Build - -on: [push, pull_request] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v3 - - - name: Build Docker image - id: build-image - uses: docker/build-push-action@v2 - with: - push: false diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml index c1bc907..eb29323 100644 --- a/.github/workflows/docker-publish.yaml +++ b/.github/workflows/docker-publish.yaml @@ -5,43 +5,69 @@ on: branches: - '*' tags: - - '*' + - '*.*.*' + pull_request: env: IMAGE_NAME: ghcr.io/yuzuzensai/kiroku jobs: - build: + check: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Lint, format, and typecheck + run: bun run check + + - name: Test + run: bun test + + docker: + needs: check + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 - name: Log in to the Container registry - uses: docker/login-action@v2 + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 with: registry: ghcr.io - username: ${{ secrets.GHCR_IO_USERNAME }} - password: ${{ secrets.GHCR_IO_TOKEN }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - name: Docker meta id: docker-meta - uses: docker/metadata-action@v3 + uses: docker/metadata-action@v5 with: images: | ${{ env.IMAGE_NAME }} tags: | - type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} type=raw,value=latest-dev,enable=${{ github.ref_type == 'branch' }} - type=raw,value=${{ github.ref_name }} + type=raw,value=${{ github.ref_name }},enable=${{ github.ref_type == 'branch' }} type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }} flavor: | - latest=false + latest=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} - name: Docker Build and Push - uses: docker/build-push-action@v2 + uses: docker/build-push-action@v6 with: context: . - push: true + push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.docker-meta.outputs.tags }} - labels: ${{ steps.docker-meta.outputs.labels }} \ No newline at end of file + labels: ${{ steps.docker-meta.outputs.labels }} diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..eee54e7 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +bun run check +bun test diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 7e243d4..0000000 --- a/.prettierrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "singleQuote": true, - "printWidth": 120, - "editor.formatOnSave": true, - "proseWrap": "always", - "tabWidth": 4, - "requireConfig": false, - "useTabs": false, - "trailingComma": "none", - "bracketSpacing": true, - "jsxBracketSameLine": false, - "semi": true -} \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..78dee05 --- /dev/null +++ b/biome.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 4, + "lineWidth": 120 + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "none", + "bracketSpacing": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index dc13ae4a5d1b17a73b5a12bb65832eaf2bda3f08..015f876e09f904196422f47a293d49a3f76f965c GIT binary patch literal 34352 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 diff --git a/package.json b/package.json index 225d5d3..1de96a3 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,36 @@ { - "name": "kiroku", - "module": "src/index.ts", - "type": "module", - "scripts": { - "tsc": "tsc", - "start": "bun --watch run ./src/index.ts", - "dev": "tsc && bun start" - }, - "devDependencies": { - "@types/bun": "^1.1.10", - "@types/steamapi": "^2.2.5", - "bun-types": "^1.1.29", - "typescript": "^5.6.2" - }, - "peerDependencies": { - "typescript": "^5.0.0" - }, - "dependencies": { - "discord.js": "^14.16.3", - "dotenv": "^16.4.5", - "elysia": "^1.1.17", - "steamapi": "^3.0.12" - } -} \ No newline at end of file + "name": "kiroku", + "module": "src/index.ts", + "type": "module", + "scripts": { + "tsc": "tsc", + "start": "bun --watch run ./src/index.ts", + "dev": "tsc && bun start", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "biome format .", + "format:fix": "biome format --write .", + "typecheck": "tsc --noEmit", + "check": "biome check . && tsc --noEmit", + "check:fix": "biome check --write . && tsc --noEmit", + "test": "bun test", + "prepare": "husky" + }, + "devDependencies": { + "@biomejs/biome": "2.5.6", + "@sinclair/typebox": "^0.34.52", + "@types/bun": "^1.3.14", + "bun-types": "^1.3.14", + "husky": "^9.1.7", + "typescript": "^7.0.2" + }, + "peerDependencies": { + "typescript": "^7.0.0" + }, + "dependencies": { + "discord.js": "^14.27.0", + "dotenv": "^17.4.2", + "elysia": "^1.4.29", + "steamapi": "^3.1.5" + } +} diff --git a/src/__tests__/server.test.ts b/src/__tests__/server.test.ts new file mode 100644 index 0000000..d5d5f32 --- /dev/null +++ b/src/__tests__/server.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import { createServer } from '../server'; +import type { Config, ConfigSource, PresenceStore } from '../types'; + +function makeConfig(config: Config): ConfigSource { + return { getConfig: () => config }; +} + +describe('server', () => { + test('returns all data when no user is requested and lookup_all is enabled', async () => { + const config = makeConfig({ global: { lookup_all: true }, users: {} }); + const presenceStore: PresenceStore = { getAll: () => ({ alice: {} }), get: mock() }; + + const response = await createServer(config, presenceStore).handle(new Request('http://localhost/')); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body).toEqual({ success: true, data: { alice: {} } }); + }); + + test('forbids lookup-all when disabled in config', async () => { + const config = makeConfig({ global: { lookup_all: false }, users: {} }); + const presenceStore: PresenceStore = { getAll: mock(), get: mock() }; + + const response = await createServer(config, presenceStore).handle(new Request('http://localhost/')); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ success: false, message: 'Lookup all is disabled' }); + }); + + test('returns data for a specific user, case-insensitively', async () => { + const config = makeConfig({ global: { lookup_all: true }, users: {} }); + const get = mock().mockReturnValue({ discord: { user: { id: '1' } } }); + const presenceStore: PresenceStore = { getAll: mock(), get }; + + const response = await createServer(config, presenceStore).handle(new Request('http://localhost/?user=Alice')); + const body = await response.json(); + + expect(get).toHaveBeenCalledWith('alice'); + expect(response.status).toBe(200); + expect(body).toEqual({ success: true, data: { discord: { user: { id: '1' } } } }); + }); + + test('returns 404 when the requested user is not found', async () => { + const config = makeConfig({ global: { lookup_all: true }, users: {} }); + const presenceStore: PresenceStore = { getAll: mock(), get: mock().mockReturnValue(undefined) }; + + const response = await createServer(config, presenceStore).handle( + new Request('http://localhost/?user=unknown') + ); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body).toEqual({ success: false, message: 'Unable to find user' }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 742ff7a..b0aba4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,108 +1,18 @@ import dotenv from 'dotenv'; + dotenv.config(); import ConfigProvider from './providers/ConfigProvider'; -import DiscordProvider from './providers/DiscordProvider'; -import SteamProvider from './providers/SteamProvider'; -import { Elysia, t } from 'elysia'; +import { createServer } from './server'; +import PresenceAggregator from './services/PresenceAggregator'; -// 1 minute -const REFRESH_TIME = 60 * 1000; - -interface ReturnData { - [key: string]: { - discord?: { - user: any; - presences: any; - updatedAt: Date; - }; - steam?: { - user: any; - updatedAt: Date; - }; - }; -} - -let latestReturnData: ReturnData = {}; - -async function main() { +function main() { if (!ConfigProvider.isReady()) return; const port = process.env.PORT || 3000; - setInterval(async () => { - let newData: ReturnData = {}; + PresenceAggregator.start(); - for (let [key, value] of Object.entries(ConfigProvider.getConfig().users)) { - key = key.toLowerCase(); - newData[key] = {}; - - // Discord - if (value.discord && DiscordProvider.isReady) { - // Discord data is already cached by the library, no need to limit it - let user = await DiscordProvider.getUser(value.discord); - let presences = await DiscordProvider.getPresence(value.discord); - newData[key].discord = { - user, - presences: presences, - updatedAt: new Date() - }; - } - - // Steam - if (value.steam && SteamProvider.isReady) { - if ( - latestReturnData[key] && - latestReturnData[key].steam && - latestReturnData[key].steam!.updatedAt.getTime() > Date.now() - REFRESH_TIME - ) { - newData[key].steam = latestReturnData[key].steam; - } else { - let user = await SteamProvider.getProfile(value.steam); - newData[key].steam = { - user, - updatedAt: new Date() - }; - } - } - } - - // Check for changes - for (let [key, value] of Object.entries(newData)) { - if (JSON.stringify(value) !== JSON.stringify(latestReturnData[key])) { - latestReturnData[key] = value; - } - } - }, 500); - - const server = new Elysia() - .get('/', async ({ query, error }) => { - query: t.Object({ - user: t.Optional(t.String()) - }); - - let user = query.user; - - if (!user) { - if (!ConfigProvider.getConfig().global.lookup_all) - return error('Forbidden', { success: false, message: 'Lookup all is disabled' }); - - return { - success: true, - data: latestReturnData - }; - } - - user = user.toLowerCase(); - - if (!latestReturnData[user]) return error('Not Found', { success: false, message: 'Unable to find user' }); - const userData = latestReturnData[user]; - - return { - success: true, - data: userData - }; - }) - .listen(port); + createServer().listen(port); console.log(`Listening on port ${port}`); } diff --git a/src/providers/ConfigProvider.ts b/src/providers/ConfigProvider.ts index 4b9c855..62278d1 100644 --- a/src/providers/ConfigProvider.ts +++ b/src/providers/ConfigProvider.ts @@ -1,19 +1,8 @@ -import fs from 'fs'; -import { Client, Events, GatewayIntentBits } from 'discord.js'; +import fs from 'node:fs'; -export interface Config { - global: { - lookup_all: boolean; - discord_guild_id?: string; - discord_bot_token?: string; - steam_api_key?: string; - }; - users: { - [key: string]: any; - }; -} +import type { Config } from '../types'; -class ConfigProvider { +export class ConfigProvider { private config: Config = { global: { lookup_all: false @@ -22,14 +11,13 @@ class ConfigProvider { }; private ready = false; - constructor() { + constructor(path = './config.json') { try { - let data = fs.readFileSync('./config.json', 'utf8'); + const data = fs.readFileSync(path, 'utf8'); this.config = JSON.parse(data); this.ready = true; } catch (err) { console.error('[ConfigProvider]', err); - return; } } diff --git a/src/providers/DiscordProvider.ts b/src/providers/DiscordProvider.ts index 612be34..d3ceb5d 100644 --- a/src/providers/DiscordProvider.ts +++ b/src/providers/DiscordProvider.ts @@ -1,7 +1,9 @@ -import { ActivityType, Client, Events, GatewayIntentBits } from 'discord.js'; +import { ActivityType, Client, Events, GatewayIntentBits, type Presence, type User } from 'discord.js'; import ConfigProvider from './ConfigProvider'; +const STATUS_REFRESH_INTERVAL = 5 * 60 * 1000; + class DiscordProvider { private client: Client; private guildId: string = ''; @@ -17,7 +19,7 @@ class DiscordProvider { this.ready = true; const setStatus = () => { - this.client?.user?.setPresence({ + client.user.setPresence({ activities: [ { name: 'custom', @@ -31,34 +33,30 @@ class DiscordProvider { }; setStatus(); - setInterval(() => { - setStatus(); - }, 1000 * 60 * 5); + setInterval(setStatus, STATUS_REFRESH_INTERVAL); }); const token = ConfigProvider.getConfig().global.discord_bot_token; - const guild_id = ConfigProvider.getConfig().global.discord_guild_id; + const guildId = ConfigProvider.getConfig().global.discord_guild_id; - if (!token || !guild_id) { + if (!token || !guildId) { console.error('[DiscordProvider]', 'Missing token or guild_id in config.json'); return; } - this.guildId = guild_id; + this.guildId = guildId; this.client.login(token); } - public async getUser(id: string) { - const user = this.client.users.cache.get(id) || (await this.client.users.fetch(id)); - return user; + public async getUser(id: string): Promise { + return this.client.users.cache.get(id) ?? (await this.client.users.fetch(id)); } - public async getPresence(id: string) { - const guild = this.client.guilds.cache.get(this.guildId) || (await this.client.guilds.fetch(this.guildId)); - const member = guild.members.cache.get(id) || (await guild.members.fetch(id)); - const presence = member.presence; - return presence; + public async getPresence(id: string): Promise { + const guild = this.client.guilds.cache.get(this.guildId) ?? (await this.client.guilds.fetch(this.guildId)); + const member = guild.members.cache.get(id) ?? (await guild.members.fetch(id)); + return member.presence; } public get isReady(): boolean { diff --git a/src/providers/SteamProvider.ts b/src/providers/SteamProvider.ts index df6d226..51622f6 100644 --- a/src/providers/SteamProvider.ts +++ b/src/providers/SteamProvider.ts @@ -1,27 +1,26 @@ -import SteamAPI from 'steamapi'; +import SteamAPI, { type UserSummary } from 'steamapi'; import ConfigProvider from './ConfigProvider'; class SteamProvider { - private client: SteamAPI; + private client: SteamAPI | undefined; private ready: boolean = false; constructor() { - this.client = new SteamAPI('dummy'); - const token = ConfigProvider.getConfig().global.steam_api_key; + const apiKey = ConfigProvider.getConfig().global.steam_api_key; - if (!token) { + if (!apiKey) { console.error('[SteamProvider]', 'Missing steam_api_key in config'); return; } - this.client = new SteamAPI(token); + this.client = new SteamAPI(apiKey); this.ready = true; } - public async getProfile(id: string) { - let profile = await this.client.getUserSummary(id); - return profile; + public async getProfile(id: string): Promise { + if (!this.client) throw new Error('SteamProvider is not ready'); + return this.client.getUserSummary(id); } public get isReady(): boolean { diff --git a/src/providers/__tests__/ConfigProvider.test.ts b/src/providers/__tests__/ConfigProvider.test.ts new file mode 100644 index 0000000..21b24bd --- /dev/null +++ b/src/providers/__tests__/ConfigProvider.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { ConfigProvider } from '../ConfigProvider'; + +let tmpFile: string | undefined; + +function writeTempConfig(contents: string): string { + tmpFile = path.join(os.tmpdir(), `kiroku-config-test-${Date.now()}-${Math.random()}.json`); + fs.writeFileSync(tmpFile, contents); + return tmpFile; +} + +describe('ConfigProvider', () => { + afterEach(() => { + if (tmpFile) { + fs.rmSync(tmpFile, { force: true }); + tmpFile = undefined; + } + }); + + test('loads and parses a valid config file', () => { + const configPath = writeTempConfig( + JSON.stringify({ + global: { lookup_all: true, steam_api_key: 'abc' }, + users: { alice: { discord: '123' } } + }) + ); + + const provider = new ConfigProvider(configPath); + + expect(provider.isReady()).toBe(true); + expect(provider.getConfig().global.lookup_all).toBe(true); + expect(provider.getConfig().users.alice?.discord).toBe('123'); + }); + + test('is not ready and falls back to defaults when the file is missing', () => { + const provider = new ConfigProvider('./does-not-exist.json'); + + expect(provider.isReady()).toBe(false); + expect(provider.getConfig()).toEqual({ global: { lookup_all: false }, users: {} }); + }); + + test('is not ready when the file contains invalid JSON', () => { + const configPath = writeTempConfig('{ not valid json'); + + const provider = new ConfigProvider(configPath); + + expect(provider.isReady()).toBe(false); + }); +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..8ff5e0d --- /dev/null +++ b/src/server.ts @@ -0,0 +1,34 @@ +import { Elysia, t } from 'elysia'; + +import ConfigProviderSingleton from './providers/ConfigProvider'; +import PresenceAggregatorSingleton from './services/PresenceAggregator'; +import type { ConfigSource, PresenceStore } from './types'; + +export function createServer( + config: ConfigSource = ConfigProviderSingleton, + presenceStore: PresenceStore = PresenceAggregatorSingleton +) { + return new Elysia().get( + '/', + ({ query, status }) => { + const requestedUser = query.user?.toLowerCase(); + + if (!requestedUser) { + if (!config.getConfig().global.lookup_all) + return status('Forbidden', { success: false, message: 'Lookup all is disabled' }); + + return { success: true, data: presenceStore.getAll() }; + } + + const userData = presenceStore.get(requestedUser); + if (!userData) return status('Not Found', { success: false, message: 'Unable to find user' }); + + return { success: true, data: userData }; + }, + { + query: t.Object({ + user: t.Optional(t.String()) + }) + } + ); +} diff --git a/src/services/PresenceAggregator.ts b/src/services/PresenceAggregator.ts new file mode 100644 index 0000000..1fb8037 --- /dev/null +++ b/src/services/PresenceAggregator.ts @@ -0,0 +1,67 @@ +import ConfigProviderSingleton from '../providers/ConfigProvider'; +import DiscordProviderSingleton from '../providers/DiscordProvider'; +import SteamProviderSingleton from '../providers/SteamProvider'; +import type { ConfigSource, DiscordSource, PresenceData, SteamSource } from '../types'; + +const POLL_INTERVAL = 500; +const STEAM_CACHE_TTL = 60 * 1000; + +export class PresenceAggregator { + private data: PresenceData = {}; + private timer: ReturnType | undefined; + + constructor( + private readonly config: ConfigSource = ConfigProviderSingleton, + private readonly discord: DiscordSource = DiscordProviderSingleton, + private readonly steam: SteamSource = SteamProviderSingleton + ) {} + + public start(): void { + if (this.timer) return; + this.timer = setInterval(() => this.refresh(), POLL_INTERVAL); + } + + public stop(): void { + clearInterval(this.timer); + this.timer = undefined; + } + + public getAll(): PresenceData { + return this.data; + } + + public get(user: string): PresenceData[string] | undefined { + return this.data[user]; + } + + public async refresh(): Promise { + const newData: PresenceData = {}; + + for (const [rawKey, userConfig] of Object.entries(this.config.getConfig().users)) { + const key = rawKey.toLowerCase(); + newData[key] = {}; + + if (userConfig.discord && this.discord.isReady) { + const [user, presence] = await Promise.all([ + this.discord.getUser(userConfig.discord), + this.discord.getPresence(userConfig.discord) + ]); + newData[key].discord = { user, presence, updatedAt: new Date() }; + } + + if (userConfig.steam && this.steam.isReady) { + const cached = this.data[key]?.steam; + if (cached && cached.updatedAt.getTime() > Date.now() - STEAM_CACHE_TTL) { + newData[key].steam = cached; + } else { + const user = await this.steam.getProfile(userConfig.steam); + newData[key].steam = { user, updatedAt: new Date() }; + } + } + } + + this.data = newData; + } +} + +export default new PresenceAggregator(); diff --git a/src/services/__tests__/PresenceAggregator.test.ts b/src/services/__tests__/PresenceAggregator.test.ts new file mode 100644 index 0000000..bfc67d8 --- /dev/null +++ b/src/services/__tests__/PresenceAggregator.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, mock, test } from 'bun:test'; +import type { Presence, User } from 'discord.js'; +import type { UserSummary } from 'steamapi'; + +import type { Config, ConfigSource, DiscordSource, SteamSource } from '../../types'; +import { PresenceAggregator } from '../PresenceAggregator'; + +const fakeUser = { id: 'd1', tag: 'alice#0' } as unknown as User; +const fakePresence = { status: 'online' } as unknown as Presence; +const fakeSteamUser = { nickname: 'alice' } as unknown as UserSummary; + +function makeConfig(config: Config): ConfigSource { + return { getConfig: () => config }; +} + +function makeDiscord(overrides: Partial = {}): DiscordSource { + return { + isReady: true, + getUser: mock(), + getPresence: mock(), + ...overrides + }; +} + +function makeSteam(overrides: Partial = {}): SteamSource { + return { + isReady: true, + getProfile: mock(), + ...overrides + }; +} + +describe('PresenceAggregator', () => { + test('aggregates discord and steam data per user, lowercasing keys', async () => { + const discord = makeDiscord({ + getUser: mock().mockResolvedValue(fakeUser), + getPresence: mock().mockResolvedValue(fakePresence) + }); + const steam = makeSteam({ + getProfile: mock().mockResolvedValue(fakeSteamUser) + }); + const config = makeConfig({ + global: { lookup_all: true }, + users: { Alice: { discord: 'd1', steam: 's1' } } + }); + const aggregator = new PresenceAggregator(config, discord, steam); + + await aggregator.refresh(); + + const entry = aggregator.get('alice'); + expect(entry?.discord?.user).toEqual(fakeUser); + expect(entry?.discord?.presence).toEqual(fakePresence); + expect(entry?.steam?.user).toEqual(fakeSteamUser); + expect(aggregator.get('Alice')).toBeUndefined(); + }); + + test('skips discord/steam sections when providers are not ready', async () => { + const discord = makeDiscord({ isReady: false }); + const steam = makeSteam({ isReady: false }); + const config = makeConfig({ + global: { lookup_all: true }, + users: { bob: { discord: 'd2', steam: 's2' } } + }); + const aggregator = new PresenceAggregator(config, discord, steam); + + await aggregator.refresh(); + + expect(aggregator.get('bob')).toEqual({}); + expect(discord.getUser).not.toHaveBeenCalled(); + expect(steam.getProfile).not.toHaveBeenCalled(); + }); + + test('reuses cached steam data within the TTL window instead of refetching', async () => { + const getProfile = mock().mockResolvedValue({ nickname: 'carol' } as unknown as UserSummary); + const steam = makeSteam({ getProfile }); + const config = makeConfig({ + global: { lookup_all: true }, + users: { carol: { steam: 's3' } } + }); + const aggregator = new PresenceAggregator(config, makeDiscord(), steam); + + await aggregator.refresh(); + await aggregator.refresh(); + + expect(getProfile).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..13b884f --- /dev/null +++ b/src/types.ts @@ -0,0 +1,59 @@ +import type { Presence, User } from 'discord.js'; +import type { UserSummary } from 'steamapi'; + +export interface UserConfig { + discord?: string; + steam?: string; +} + +export interface Config { + global: { + lookup_all: boolean; + discord_guild_id?: string; + discord_bot_token?: string; + steam_api_key?: string; + }; + users: { + [key: string]: UserConfig; + }; +} + +export interface DiscordEntry { + user: User; + presence: Presence | null; + updatedAt: Date; +} + +export interface SteamEntry { + user: UserSummary; + updatedAt: Date; +} + +export interface UserData { + discord?: DiscordEntry; + steam?: SteamEntry; +} + +export type PresenceData = { + [key: string]: UserData; +}; + +export interface ConfigSource { + getConfig(): Config; +} + +export interface DiscordSource { + readonly isReady: boolean; + getUser(id: string): Promise; + getPresence(id: string): Promise; +} + +export interface SteamSource { + readonly isReady: boolean; + getProfile(id: string): Promise; +} + +export interface PresenceStore { + getAll(): PresenceData; + get(user: string): PresenceData[string] | undefined; +} diff --git a/tsconfig.json b/tsconfig.json index 5450f44..921517c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,24 +1,19 @@ { - "compilerOptions": { - "lib": [ - "ESNext" - ], - "module": "esnext", - "target": "esnext", - "moduleResolution": "bundler", - "moduleDetection": "force", - "allowImportingTsExtensions": true, - "noEmit": true, - "composite": true, - "strict": true, - "downlevelIteration": true, - "skipLibCheck": true, - "jsx": "react-jsx", - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true, - "allowJs": false, - "types": [ - "bun-types" // add Bun global - ] - } -} \ No newline at end of file + "compilerOptions": { + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": false, + "types": ["bun-types"] + } +}