mirror of
https://github.com/YuzuZensai/Kiroku.git
synced 2026-09-13 10:58:55 +00:00
♻️ refactor: Update deps, add Biome/Husky/tests, rework CI
This commit is contained in:
@@ -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
|
|
||||||
@@ -5,43 +5,69 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- '*'
|
- '*'
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- '*.*.*'
|
||||||
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
IMAGE_NAME: ghcr.io/yuzuzensai/kiroku
|
IMAGE_NAME: ghcr.io/yuzuzensai/kiroku
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- 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
|
- name: Log in to the Container registry
|
||||||
uses: docker/login-action@v2
|
if: github.event_name != 'pull_request'
|
||||||
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ secrets.GHCR_IO_USERNAME }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GHCR_IO_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@v3
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.IMAGE_NAME }}
|
${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
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=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' }}
|
type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }}
|
||||||
flavor: |
|
flavor: |
|
||||||
latest=false
|
latest=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }}
|
||||||
|
|
||||||
- name: Docker Build and Push
|
- name: Docker Build and Push
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.docker-meta.outputs.tags }}
|
tags: ${{ steps.docker-meta.outputs.tags }}
|
||||||
labels: ${{ steps.docker-meta.outputs.labels }}
|
labels: ${{ steps.docker-meta.outputs.labels }}
|
||||||
|
|||||||
Executable
+2
@@ -0,0 +1,2 @@
|
|||||||
|
bun run check
|
||||||
|
bun test
|
||||||
-13
@@ -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
|
|
||||||
}
|
|
||||||
+39
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
-24
@@ -1,25 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "kiroku",
|
"name": "kiroku",
|
||||||
"module": "src/index.ts",
|
"module": "src/index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"tsc": "tsc",
|
"tsc": "tsc",
|
||||||
"start": "bun --watch run ./src/index.ts",
|
"start": "bun --watch run ./src/index.ts",
|
||||||
"dev": "tsc && bun start"
|
"dev": "tsc && bun start",
|
||||||
},
|
"lint": "biome lint .",
|
||||||
"devDependencies": {
|
"lint:fix": "biome lint --write .",
|
||||||
"@types/bun": "^1.1.10",
|
"format": "biome format .",
|
||||||
"@types/steamapi": "^2.2.5",
|
"format:fix": "biome format --write .",
|
||||||
"bun-types": "^1.1.29",
|
"typecheck": "tsc --noEmit",
|
||||||
"typescript": "^5.6.2"
|
"check": "biome check . && tsc --noEmit",
|
||||||
},
|
"check:fix": "biome check --write . && tsc --noEmit",
|
||||||
"peerDependencies": {
|
"test": "bun test",
|
||||||
"typescript": "^5.0.0"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"devDependencies": {
|
||||||
"discord.js": "^14.16.3",
|
"@biomejs/biome": "2.5.6",
|
||||||
"dotenv": "^16.4.5",
|
"@sinclair/typebox": "^0.34.52",
|
||||||
"elysia": "^1.1.17",
|
"@types/bun": "^1.3.14",
|
||||||
"steamapi": "^3.0.12"
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-96
@@ -1,108 +1,18 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
import ConfigProvider from './providers/ConfigProvider';
|
import ConfigProvider from './providers/ConfigProvider';
|
||||||
import DiscordProvider from './providers/DiscordProvider';
|
import { createServer } from './server';
|
||||||
import SteamProvider from './providers/SteamProvider';
|
import PresenceAggregator from './services/PresenceAggregator';
|
||||||
import { Elysia, t } from 'elysia';
|
|
||||||
|
|
||||||
// 1 minute
|
function main() {
|
||||||
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() {
|
|
||||||
if (!ConfigProvider.isReady()) return;
|
if (!ConfigProvider.isReady()) return;
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
setInterval(async () => {
|
PresenceAggregator.start();
|
||||||
let newData: ReturnData = {};
|
|
||||||
|
|
||||||
for (let [key, value] of Object.entries(ConfigProvider.getConfig().users)) {
|
createServer().listen(port);
|
||||||
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);
|
|
||||||
|
|
||||||
console.log(`Listening on port ${port}`);
|
console.log(`Listening on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'node:fs';
|
||||||
import { Client, Events, GatewayIntentBits } from 'discord.js';
|
|
||||||
|
|
||||||
export interface Config {
|
import type { Config } from '../types';
|
||||||
global: {
|
|
||||||
lookup_all: boolean;
|
|
||||||
discord_guild_id?: string;
|
|
||||||
discord_bot_token?: string;
|
|
||||||
steam_api_key?: string;
|
|
||||||
};
|
|
||||||
users: {
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class ConfigProvider {
|
export class ConfigProvider {
|
||||||
private config: Config = {
|
private config: Config = {
|
||||||
global: {
|
global: {
|
||||||
lookup_all: false
|
lookup_all: false
|
||||||
@@ -22,14 +11,13 @@ class ConfigProvider {
|
|||||||
};
|
};
|
||||||
private ready = false;
|
private ready = false;
|
||||||
|
|
||||||
constructor() {
|
constructor(path = './config.json') {
|
||||||
try {
|
try {
|
||||||
let data = fs.readFileSync('./config.json', 'utf8');
|
const data = fs.readFileSync(path, 'utf8');
|
||||||
this.config = JSON.parse(data);
|
this.config = JSON.parse(data);
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ConfigProvider]', err);
|
console.error('[ConfigProvider]', err);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
import ConfigProvider from './ConfigProvider';
|
||||||
|
|
||||||
|
const STATUS_REFRESH_INTERVAL = 5 * 60 * 1000;
|
||||||
|
|
||||||
class DiscordProvider {
|
class DiscordProvider {
|
||||||
private client: Client;
|
private client: Client;
|
||||||
private guildId: string = '';
|
private guildId: string = '';
|
||||||
@@ -17,7 +19,7 @@ class DiscordProvider {
|
|||||||
this.ready = true;
|
this.ready = true;
|
||||||
|
|
||||||
const setStatus = () => {
|
const setStatus = () => {
|
||||||
this.client?.user?.setPresence({
|
client.user.setPresence({
|
||||||
activities: [
|
activities: [
|
||||||
{
|
{
|
||||||
name: 'custom',
|
name: 'custom',
|
||||||
@@ -31,34 +33,30 @@ class DiscordProvider {
|
|||||||
};
|
};
|
||||||
setStatus();
|
setStatus();
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(setStatus, STATUS_REFRESH_INTERVAL);
|
||||||
setStatus();
|
|
||||||
}, 1000 * 60 * 5);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const token = ConfigProvider.getConfig().global.discord_bot_token;
|
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');
|
console.error('[DiscordProvider]', 'Missing token or guild_id in config.json');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.guildId = guild_id;
|
this.guildId = guildId;
|
||||||
|
|
||||||
this.client.login(token);
|
this.client.login(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getUser(id: string) {
|
public async getUser(id: string): Promise<User> {
|
||||||
const user = this.client.users.cache.get(id) || (await this.client.users.fetch(id));
|
return this.client.users.cache.get(id) ?? (await this.client.users.fetch(id));
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPresence(id: string) {
|
public async getPresence(id: string): Promise<Presence | null> {
|
||||||
const guild = this.client.guilds.cache.get(this.guildId) || (await this.client.guilds.fetch(this.guildId));
|
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 member = guild.members.cache.get(id) ?? (await guild.members.fetch(id));
|
||||||
const presence = member.presence;
|
return member.presence;
|
||||||
return presence;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isReady(): boolean {
|
public get isReady(): boolean {
|
||||||
|
|||||||
@@ -1,27 +1,26 @@
|
|||||||
import SteamAPI from 'steamapi';
|
import SteamAPI, { type UserSummary } from 'steamapi';
|
||||||
|
|
||||||
import ConfigProvider from './ConfigProvider';
|
import ConfigProvider from './ConfigProvider';
|
||||||
|
|
||||||
class SteamProvider {
|
class SteamProvider {
|
||||||
private client: SteamAPI;
|
private client: SteamAPI | undefined;
|
||||||
private ready: boolean = false;
|
private ready: boolean = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = new SteamAPI('dummy');
|
const apiKey = ConfigProvider.getConfig().global.steam_api_key;
|
||||||
const token = ConfigProvider.getConfig().global.steam_api_key;
|
|
||||||
|
|
||||||
if (!token) {
|
if (!apiKey) {
|
||||||
console.error('[SteamProvider]', 'Missing steam_api_key in config');
|
console.error('[SteamProvider]', 'Missing steam_api_key in config');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.client = new SteamAPI(token);
|
this.client = new SteamAPI(apiKey);
|
||||||
this.ready = true;
|
this.ready = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getProfile(id: string) {
|
public async getProfile(id: string): Promise<UserSummary> {
|
||||||
let profile = await this.client.getUserSummary(id);
|
if (!this.client) throw new Error('SteamProvider is not ready');
|
||||||
return profile;
|
return this.client.getUserSummary(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isReady(): boolean {
|
public get isReady(): boolean {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<typeof setInterval> | 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<void> {
|
||||||
|
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();
|
||||||
@@ -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> = {}): DiscordSource {
|
||||||
|
return {
|
||||||
|
isReady: true,
|
||||||
|
getUser: mock(),
|
||||||
|
getPresence: mock(),
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSteam(overrides: Partial<SteamSource> = {}): 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<User>;
|
||||||
|
getPresence(id: string): Promise<Presence | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteamSource {
|
||||||
|
readonly isReady: boolean;
|
||||||
|
getProfile(id: string): Promise<UserSummary>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceStore {
|
||||||
|
getAll(): PresenceData;
|
||||||
|
get(user: string): PresenceData[string] | undefined;
|
||||||
|
}
|
||||||
+18
-23
@@ -1,24 +1,19 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": [
|
"lib": ["ESNext"],
|
||||||
"ESNext"
|
"module": "esnext",
|
||||||
],
|
"target": "esnext",
|
||||||
"module": "esnext",
|
"moduleResolution": "bundler",
|
||||||
"target": "esnext",
|
"moduleDetection": "force",
|
||||||
"moduleResolution": "bundler",
|
"allowImportingTsExtensions": true,
|
||||||
"moduleDetection": "force",
|
"noEmit": true,
|
||||||
"allowImportingTsExtensions": true,
|
"composite": true,
|
||||||
"noEmit": true,
|
"strict": true,
|
||||||
"composite": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"jsx": "react-jsx",
|
||||||
"downlevelIteration": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"skipLibCheck": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"jsx": "react-jsx",
|
"allowJs": false,
|
||||||
"allowSyntheticDefaultImports": true,
|
"types": ["bun-types"]
|
||||||
"forceConsistentCasingInFileNames": true,
|
}
|
||||||
"allowJs": false,
|
}
|
||||||
"types": [
|
|
||||||
"bun-types" // add Bun global
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user