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:
|
||||
- '*'
|
||||
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 }}
|
||||
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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<User> {
|
||||
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<Presence | null> {
|
||||
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 {
|
||||
|
||||
@@ -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<UserSummary> {
|
||||
if (!this.client) throw new Error('SteamProvider is not ready');
|
||||
return this.client.getUserSummary(id);
|
||||
}
|
||||
|
||||
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": {
|
||||
"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
|
||||
]
|
||||
}
|
||||
}
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user