diff --git a/package.json b/package.json index 65ca0b5d..39275fa2 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "author": "SernDevs", "license": "MIT", "dependencies": { + "callsites": "^3.1.0", "iti": "^0.6.0", "rxjs": "^7.8.0", "ts-results-es": "^4.0.0" diff --git a/src/core/module-loading.ts b/src/core/module-loading.ts index 4b9e08d9..f45400b6 100644 --- a/src/core/module-loading.ts +++ b/src/core/module-loading.ts @@ -6,9 +6,19 @@ import assert from 'assert'; import { createRequire } from 'node:module'; import type { ImportPayload, Wrapper } from '../types/core'; import type { Module } from '../types/core-modules'; +import { existsSync } from 'fs'; +import { fileURLToPath } from 'node:url'; + +export const shouldHandle = (path: string, fpath: string) => { + const newPath = new URL(fpath+extname(path), path).href; + return { + exists: existsSync(fileURLToPath(newPath)), + path: newPath + } +} -export type ModuleResult = Promise>; +export type ModuleResult = Promise>; /** * Import any module based on the absolute path. * This can accept four types of exported modules @@ -66,6 +76,7 @@ const isSkippable = (filename: string) => { const validExtensions = ['.js', '.cjs', '.mts', '.mjs', '.cts', '.ts', '']; return filename[0] === '!' || !validExtensions.includes(extname(filename)); }; + async function deriveFileInfo(dir: string, file: string) { const fullPath = join(dir, file); return { @@ -74,6 +85,7 @@ async function deriveFileInfo(dir: string, file: string) { base: basename(file), }; } + async function* readPaths(dir: string): AsyncGenerator { try { const files = await readdir(dir); @@ -118,6 +130,8 @@ export function loadConfig(wrapper: Wrapper | 'file'): Wrapper { eventsPath = makePath('events'); console.log('Events path is set to', eventsPath); } + + return { defaultPrefix: config.defaultPrefix, commands: commandsPath, diff --git a/src/core/modules.ts b/src/core/modules.ts index d944f84c..6ed89752 100644 --- a/src/core/modules.ts +++ b/src/core/modules.ts @@ -61,18 +61,19 @@ export function discordEvent(mod: { }); } + +/** + * @deprecated + */ function prepareClassPlugins(c: Module) { const [onEvent, initPlugins] = partitionPlugins(c.plugins); c.plugins = initPlugins as InitPlugin[]; c.onEvent = onEvent as ControlPlugin[]; } -// -// Class modules: -// Can be refactored. -// Both implement singleton, could I make them inherit a singleton parent class? + /** - * @Experimental - * Will be refactored / changed in future + * @deprecated + * Will be removed in future */ export abstract class CommandExecutable { abstract type: Type; @@ -92,8 +93,8 @@ export abstract class CommandExecutable { abstract type: Type; diff --git a/src/core/presences.ts b/src/core/presences.ts new file mode 100644 index 00000000..1fdf5e9f --- /dev/null +++ b/src/core/presences.ts @@ -0,0 +1,70 @@ +import type { ActivitiesOptions } from "discord.js"; +import type { IntoDependencies } from "../types/ioc"; +import type { Emitter } from "./contracts/emitter"; + +type Status = 'online' | 'idle' | 'invisible' | 'dnd' +type PresenceReduce = (previous: Result) => Result; + +export interface Result { + status?: Status; + afk?: boolean; + activities?: ActivitiesOptions[]; + shardId?: number[]; + repeat?: number | [Emitter, string]; + onRepeat?: (previous: Result) => Result; +} + +export type Config = +{ + inject?: [...T] + execute: (...v: IntoDependencies) => Result; +}; + +/** + * A small wrapper to provide type inference. + * Create a Presence module which **MUST** be put in a file called presence. + * adjacent to the file where **Sern.init** is CALLED. + */ +export function module +(conf: Config) { + return conf; +} + + +/** + * Create a Presence body which can be either: + * - once, the presence is activated only once. + * - repeated, per cycle or event, the presence can be changed. + */ +export function of(root: Omit) { + return { + /** + * @example + * Presence + * .of({ + * activities: [{ name: "deez nuts" }] + * }) //starts the presence with "deez nuts". + * .repeated(prev => { + * return { + * afk: true, + * activities: prev.activities?.map(s => ({ ...s, name: s.name+"s" })) + * }; + * }, 10000)) //every 10 s, the callback sets the presence to the returned one. + */ + repeated: (onRepeat: PresenceReduce, repeat: number | [Emitter, string]) => { + return { repeat, onRepeat, ...root } + }, + /** + * @example + * Presence + * .of({ + * activities: [ + * { name: "Chilling out" } + * ] + * }) + * .once() // Sets the presence once, with what's provided in '.of()' + */ + once: () => root + }; +} + diff --git a/src/handlers/presence.ts b/src/handlers/presence.ts new file mode 100644 index 00000000..4719e72d --- /dev/null +++ b/src/handlers/presence.ts @@ -0,0 +1,46 @@ +import { concatMap, from, interval, of, map, scan, startWith, fromEvent, take } from "rxjs" +import { Files } from "../core/_internal"; +import * as Presence from "../core/presences"; +import { Services } from "../core/ioc"; +import assert from "node:assert"; + +type SetPresence = (conf: Presence.Result) => Promise + +const parseConfig = async (conf: Promise) => { + return conf.then(s => { + if('repeat' in s) { + const { onRepeat, repeat } = s; + assert(repeat !== undefined, "repeat option is undefined"); + assert(onRepeat !== undefined, "onRepeat callback is undefined, but repeat exists"); + const src$ = typeof repeat === 'number' + ? interval(repeat) + : fromEvent(...repeat); + return src$ + .pipe(scan(onRepeat, s), + startWith(s)); + } + //take 1? + return of(s).pipe(take(1)); + }) +}; + +export const presenceHandler = (path: string, setPresence: SetPresence) => { + interface PresenceModule { + module: Presence.Config<(keyof Dependencies)[]> + } + const presence = Files + .importModule(path) + .then(({ module }) => { + //fetch services with the order preserved, passing it to the execute fn + const fetchedServices = Services(...module.inject ?? []); + return async () => module.execute(...fetchedServices); + }) + const module$ = from(presence); + return module$.pipe( + //compose:. + //call the execute function, passing that result into parseConfig. + //concatMap resolves the promise, and passes it to the next concatMap. + concatMap(fn => parseConfig(fn())), + // subscribe to the observable parseConfig yields, and set the presence. + concatMap(conf => conf.pipe(map(setPresence)))) +} diff --git a/src/handlers/ready-event.ts b/src/handlers/ready-event.ts index 017a9673..9e6753df 100644 --- a/src/handlers/ready-event.ts +++ b/src/handlers/ready-event.ts @@ -25,8 +25,7 @@ export function startReadyEvent( const once = () => pipe( first(), - ignoreElements() -) + ignoreElements()) function register>( diff --git a/src/index.ts b/src/index.ts index 62bcbad8..e72c2e26 100644 --- a/src/index.ts +++ b/src/index.ts @@ -50,6 +50,8 @@ export { CommandExecutable, } from './core/modules'; +export * as Presence from './core/presences' + export { useContainerRaw } from './core/_internal' diff --git a/src/sern.ts b/src/sern.ts index 0dfd5b68..9e29a5bb 100644 --- a/src/sern.ts +++ b/src/sern.ts @@ -1,4 +1,5 @@ import { handleCrash } from './handlers/_internal'; +import callsites from 'callsites'; import { err, ok, Files } from './core/_internal'; import { merge } from 'rxjs'; import { Services } from './core/ioc'; @@ -7,6 +8,8 @@ import { eventsHandler } from './handlers/user-defined-events'; import { startReadyEvent } from './handlers/ready-event'; import { messageHandler } from './handlers/message-event'; import { interactionHandler } from './handlers/interaction-event'; +import { presenceHandler } from './handlers/presence'; +import { Client } from 'discord.js'; /** * @since 1.0.0 @@ -31,14 +34,21 @@ export function init(maybeWrapper: Wrapper | 'file') { if (wrapper.events !== undefined) { eventsHandler(dependencies, Files.getFullPathTree(wrapper.events)); } + const initCallsite = callsites()[1].getFileName(); + const presencePath = Files.shouldHandle(initCallsite!, "presence"); //Ready event: load all modules and when finished, time should be taken and logged - startReadyEvent(dependencies, Files.getFullPathTree(wrapper.commands)).add(() => { - const time = ((performance.now() - startTime) / 1000).toFixed(2); - dependencies[0].emit('modulesLoaded'); - logger?.info({ - message: `sern: registered all modules in ${time} s`, + startReadyEvent(dependencies, Files.getFullPathTree(wrapper.commands)) + .add(() => { + const time = ((performance.now() - startTime) / 1000).toFixed(2); + dependencies[0].emit('modulesLoaded'); + logger?.info({ message: `sern: registered all modules in ${time} s`, }); + if(presencePath.exists) { + const setPresence = async (p: any) => { + return (dependencies[4] as Client).user?.setPresence(p); + } + presenceHandler(presencePath.path, setPresence).subscribe(); + } }); - }); const messages$ = messageHandler(dependencies, wrapper.defaultPrefix); const interactions$ = interactionHandler(dependencies); diff --git a/test/core/presence.test.ts b/test/core/presence.test.ts new file mode 100644 index 00000000..904b8637 --- /dev/null +++ b/test/core/presence.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Presence } from '../../src'; + + +// Example test suite for the module function +describe('module function', () => { + it('should return a valid configuration', () => { + const config: Presence.Config<['dependency1', 'dependency2']> = Presence.module({ + inject: ['dependency1', 'dependency2'], + execute: vi.fn(), + }); + + expect(config).toBeDefined(); + expect(config.inject).toEqual(['dependency1', 'dependency2']); + expect(typeof config.execute).toBe('function'); + }); +}); + + +describe('of function', () => { + it('should return a valid presence configuration without repeat and onRepeat', () => { + const presenceConfig = Presence.of({ + status: 'online', + afk: false, + activities: [{ name: 'Test Activity' }], + shardId: [1, 2, 3], + }).once(); + + expect(presenceConfig).toBeDefined(); + //@ts-ignore Maybe fix? + expect(presenceConfig.repeat).toBeUndefined(); + //@ts-ignore Maybe fix? + expect(presenceConfig.onRepeat).toBeUndefined(); + expect(presenceConfig).toMatchObject({ + status: 'online', + afk: false, + activities: [{ name: 'Test Activity' }], + shardId: [1, 2, 3], + }); + }); + + it('should return a valid presence configuration with repeat and onRepeat', () => { + const onRepeatCallback = vi.fn(); + const presenceConfig = Presence.of({ + status: 'idle', + activities: [{ name: 'Another Test Activity' }], + }).repeated(onRepeatCallback, 5000); + + expect(presenceConfig).toBeDefined(); + expect(presenceConfig.repeat).toBe(5000); + expect(presenceConfig.onRepeat).toBe(onRepeatCallback); + expect(presenceConfig).toMatchObject({ + status: 'idle', + activities: [{ name: 'Another Test Activity' }], + }); + }); +}) diff --git a/yarn.lock b/yarn.lock index 7b09c48a..701090b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -627,6 +627,7 @@ __metadata: "@types/node": ^18.15.11 "@typescript-eslint/eslint-plugin": 5.58.0 "@typescript-eslint/parser": 5.59.1 + callsites: ^3.1.0 discord.js: ^14.11.0 esbuild: ^0.17.0 eslint: 8.39.0 @@ -1186,7 +1187,7 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0": +"callsites@npm:^3.0.0, callsites@npm:^3.1.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" checksum: 072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3