Skip to content

Commit

Permalink
Less-dynamic module system
Browse files Browse the repository at this point in the history
  • Loading branch information
lynn committed Jan 3, 2025
1 parent ea518a0 commit 8feb026
Show file tree
Hide file tree
Showing 8 changed files with 354 additions and 325 deletions.
12 changes: 12 additions & 0 deletions core/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,18 @@ actions.vote = guard(
e.votes[uname] = i.vote;
e.score += i.vote - old_vote;
ret(good({ entry: present(e, uname) }));

const cleanup = config.modules['modules/cleanup.js'];
if (cleanup.enabled) {
if (cleanup.users && !cleanup.users.includes(e.user)) return;
if (e.score > cleanup.vote_threshold) return;
call(
{ action: 'remove', id: e.id },
() => console.log(`-- ${e.head} weeded out`),
e.user,
);
}

emitter.emit('vote', e, uname);
},
);
Expand Down
16 changes: 15 additions & 1 deletion core/commons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,21 @@ export interface ToaduaConfig {
*/
password_rounds: number;

modules: Record<string, object>;
modules: {
'modules/disk.js'?: {
enabled: boolean;
save_interval: number;
backup_interval: number;
};
'modules/housekeep.js'?: Record<string, never>;
'modules/update.js'?: { enabled: boolean; save_interval: number };
'modules/cleanup.js'?: {
enabled: boolean;
vote_threshold: number;
users?: string[];
};
'modules/announce.js'?: { enabled: boolean; hook: string };
};
}

const toaduaPath = getToaduaPath();
Expand Down
91 changes: 57 additions & 34 deletions core/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import * as fs from 'node:fs';
import * as argparse from 'argparse';
import * as commons from './commons.js';

import { HousekeepModule } from '../modules/housekeep.js';
import { DiskModule } from '../modules/disk.js';
import { UpdateModule } from '../modules/update.js';
import { AnnounceModule } from '../modules/announce.js';

const argparser = new argparse.ArgumentParser({
description: 'Toaq dictionary',
add_help: true,
Expand Down Expand Up @@ -36,6 +41,7 @@ console.log(`starting up v${VERSION}...`);
import * as http from 'node:http';
import * as api from './api.js';
import type { Socket } from 'node:net';
import type { EventEmitter } from 'node:stream';

const fourohfour = static_handler('frontend/404.html', 'text/html', 404);
const routes = {
Expand Down Expand Up @@ -169,37 +175,62 @@ function handler(r, s_) {
}
}

const modules: Record<string, any> = {};
class ToaduaModules {
private housekeep?: HousekeepModule;
private disk?: DiskModule;
private announce?: AnnounceModule;
private update?: UpdateModule;

async function load_modules(data: commons.ToaduaConfig): Promise<void> {
for (const path of Object.keys(data.modules)) {
if (!modules[path]) {
try {
modules[path] = { ...(await import(`./../${path}`)), path };
} catch (e) {
if (config.exit_on_module_load_error) throw e;
console.log(`error when loading module '${path}': ${e.stack}`);
delete modules[path];
}
constructor(
private store: commons.Store,
private config: commons.ToaduaConfig,
private emitter: EventEmitter,
) {
const diskConfig = config.modules['modules/disk.js'];
if (diskConfig) {
this.disk = new DiskModule(
diskConfig.save_interval,
diskConfig.backup_interval,
);
}
}
for (const path in modules) {
const new_options = data.modules[path];
// note that when an entry in the module table is removed,
// `new_options === undefined`. this is all right
if (JSON.stringify(new_options) !== JSON.stringify(modules[path].options)) {
modules[path].options = new_options;
console.log(`changing state for module '${path}'`);
try {
modules[path].state_change.call(new_options);
} catch (e) {
console.log(`error for module '${path}': ${e.stack}`);
}

const housekeepConfig = config.modules['modules/housekeep.js'];
if (housekeepConfig) {
this.housekeep = new HousekeepModule();
}

const announceConfig = config.modules['modules/announce.js'];
if (announceConfig) {
this.announce = new AnnounceModule(
announceConfig.enabled,
announceConfig.hook,
);
}

const updateConfig = config.modules['modules/update.js'];
if (updateConfig) {
this.update = new UpdateModule(
updateConfig.enabled,
updateConfig.save_interval,
this.announce,
);
}
}

public up(): void {
this.housekeep?.up(this.store, this.config);
this.disk?.up(this.store);
this.update?.up(this.store);
this.announce?.up(this.emitter);
}

public down(): void {
this.disk?.down(this.store);
}
}

await load_modules(config);
const modules = new ToaduaModules(commons.store, config, commons.emitter);
modules.up();

const server = http.createServer(handler);
const connections: Socket[] = [];
Expand All @@ -223,15 +254,7 @@ function bye(error) {
for (const connection of connections) {
connection.destroy();
}
for (const [path, _] of Object.entries(modules).reverse()) {
try {
_.state_change.call(null);
} catch (e) {
console.log(
`ignoring state change error for module '${path}': ${e.stack}`,
);
}
}
modules.down();
process.exitCode = 0;
}

Expand Down
183 changes: 94 additions & 89 deletions modules/announce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as commons from '../core/commons.js';
import request from 'request-promise-native';
import type { Entry, Note } from '../core/commons.js';
import * as shared from '../frontend/shared/index.js';
import type { EventEmitter } from 'node:stream';

const event_types = [
'create',
Expand All @@ -30,101 +31,105 @@ function trim(max: number, str: string): string {
return `${str.substring(0, max - 1)}…`;
}

export function onAnnounceEvent(ev: AnnounceEvent, entry: Entry, note?: Note) {
const action = {
create: 'created',
note: 'noted on',
remove: 'removed',
removenote: 'removed a note on',
edit: 'edited',
move: 'moved',
}[ev];
if (!action) {
console.log(`!! unexpected action ${action} in announce.entry`);
return;
}
export class AnnounceModule {
private queue: request.Options[] = [];

const scope =
ev === 'move'
? ` to scope __${entry.scope}__`
: entry.scope !== 'en'
? ` in scope __${entry.scope}__`
: '';
const title = note
? `*${note.user}* ${action} **${entry.head}**`
: `*${entry.user}* ${action} **${entry.head}**${scope}`;
constructor(
private enabled: boolean,
private hook: string,
) {}

const noteField = () => ({
name: trim(256, `(definition by *${entry.user}*${scope})`),
value: trim(1024, entry.body),
});
const backlink = `${commons.config.entry_point}#%23${entry.id}`;
private onAnnounceEvent(ev: AnnounceEvent, entry: Entry, note?: Note) {
const action = {
create: 'created',
note: 'noted on',
remove: 'removed',
removenote: 'removed a note on',
edit: 'edited',
move: 'moved',
}[ev];
if (!action) {
console.log(`!! unexpected action ${action} in announce.entry`);
return;
}

const payload: WebhookEmbed = {
color: shared.color_for(note?.user ?? entry.user).hex,
title: trim(256, title),
fields: note ? [noteField()] : undefined,
description: trim(4096, note ? note.content : entry.body),
url: ev !== 'remove' ? backlink : undefined,
};
message(payload);
}
const scope =
ev === 'move'
? ` to scope __${entry.scope}__`
: entry.scope !== 'en'
? ` in scope __${entry.scope}__`
: '';
const title = note
? `*${note.user}* ${action} **${entry.head}**`
: `*${entry.user}* ${action} **${entry.head}**${scope}`;

export function message(what: WebhookEmbed) {
if (!enabled) return;
const url: string = options.hook;
if (!url) return;
const color = what.color ?? 0;
const entrypoint = what.url ?? commons.config.entry_point;
const req: request.Options = {
url,
method: 'POST',
json: true,
body: { embeds: [{ color, url: entrypoint, ...what }] },
};
if (queue.push(req) === 1) setTimeout(send_off, 0);
}
const noteField = () => ({
name: trim(256, `(definition by *${entry.user}*${scope})`),
value: trim(1024, entry.body),
});
const backlink = `${commons.config.entry_point}#%23${entry.id}`;

function send_off() {
if (!queue.length) return;
if (queue.length > 10) {
const top = queue[0];
if (top?.body?.embeds?.[0]?.title) {
const title = trim(200, top.body.embeds[0].title);
const n = queue.length - 1;
top.body.embeds[0].title = `${title} (+ ${n} other events)`;
request(top);
} else {
message({ title: `${queue.length} events omitted` });
}
queue.splice(0, queue.length);
return;
const payload: WebhookEmbed = {
color: shared.color_for(note?.user ?? entry.user).hex,
title: trim(256, title),
fields: note ? [noteField()] : undefined,
description: trim(4096, note ? note.content : entry.body),
url: ev !== 'remove' ? backlink : undefined,
};
this.message(payload);
}

public message(what: WebhookEmbed) {
if (!this.enabled) return;
const url: string = this.hook;
if (!url) return;
const color = what.color ?? 0;
const entrypoint = what.url ?? commons.config.entry_point;
const req: request.Options = {
url,
method: 'POST',
json: true,
body: { embeds: [{ color, url: entrypoint, ...what }] },
};
if (this.queue.push(req) === 1) setTimeout(() => this.send_off(), 0);
}
const m = queue.shift();
request(m).then(
() => {
console.log(`-> '${m.body.embeds[0].title}' announced`);
setTimeout(send_off, 0);
},
err => {
queue.push(m);
if (err.statusCode === 429) setTimeout(send_off, err.error.retryAfter);
else {
console.log(`-> error when posting message: ${err.stack}`);
if (err.statusCode !== 400) setTimeout(send_off, 0);

private send_off() {
if (!this.queue.length) return;
if (this.queue.length > 10) {
const top = this.queue[0];
if (top?.body?.embeds?.[0]?.title) {
const title = trim(200, top.body.embeds[0].title);
const n = this.queue.length - 1;
top.body.embeds[0].title = `${title} (+ ${n} other events)`;
request(top);
} else {
this.message({ title: `${this.queue.length} events omitted` });
}
},
);
}
this.queue.splice(0, this.queue.length);
return;
}
const m = this.queue.shift();
request(m).then(
() => {
console.log(`-> '${m.body.embeds[0].title}' announced`);
setTimeout(() => this.send_off(), 0);
},
err => {
this.queue.push(m);
if (err.statusCode === 429)
setTimeout(() => this.send_off(), err.error.retryAfter);
else {
console.log(`-> error when posting message: ${err.stack}`);
if (err.statusCode !== 400) setTimeout(() => this.send_off(), 0);
}
},
);
}

let enabled: boolean;
let options: { enabled: boolean; hook: string };
const queue: request.Options[] = [];
export function state_change() {
options = this ?? {};
if (enabled !== options.enabled)
for (const ev of event_types)
commons.emitter[options.enabled ? 'on' : 'off'](ev, onAnnounceEvent);
enabled = options.enabled;
if (!enabled) queue.splice(0, queue.length);
public up(emitter: EventEmitter) {
for (const et of event_types) {
emitter.on(et, (e, entry, note) => this.onAnnounceEvent(e, entry, note));
}
}
}
27 changes: 0 additions & 27 deletions modules/cleanup.ts

This file was deleted.

Loading

0 comments on commit 8feb026

Please sign in to comment.