-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
* move to JSR dependencies * move to r2d2 from deno_redis and use redis json (fix #57) * redo a lot of the core to move stuff (fix #38) * clean up and document public api * change stuff to lightning from bolt * redo plugin config * publish to JSR --------- Signed-off-by: Jersey <wgyt735yt@gmail.com>
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,7 @@ | ||
version: '2' | ||
|
||
services: | ||
bolt: | ||
lightning: | ||
build: . | ||
volumes: | ||
- ./config/data:/app/data | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,12 +1,13 @@ | ||
ARG DENO_VERSION=v1.40.4 | ||
ARG DENO_VERSION=v1.41.3 | ||
|
||
FROM docker.io/lukechannings/deno:${DENO_VERSION} | ||
FROM docker.io/denoland/deno:${DENO_VERSION} | ||
|
||
# add bolt to the image | ||
# add lightning to the image | ||
WORKDIR /app | ||
ADD ./packages/bolt /app | ||
RUN deno install -A --unstable-temporal -n bolt /app/cli.ts | ||
# TODO: change when repos split | ||
ADD ./packages/lightning /app | ||
RUN deno install -A --unstable-temporal -n lightning /app/cli.ts | ||
|
||
# set bolt as the entrypoint and use the run command by default | ||
ENTRYPOINT [ "bolt" ] | ||
# set lightning as the entrypoint and use the run command by default | ||
ENTRYPOINT [ "lightning" ] | ||
CMD [ "--run", "--config", "./data/config.ts"] |
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
import { bridges } from './mod.ts'; | ||
import { message, deleted_message, log_error, plugin } from '../utils/mod.ts'; | ||
import { bridge_platform } from './types.ts'; | ||
import { lightning } from '../lightning.ts'; | ||
|
||
export class bridge_internals_dont_use_or_look_at { | ||
private bridges: bridges; | ||
private l: lightning; | ||
// TODO: find a better way to do this, maps work BUT don't't scale well | ||
private bridged_message_id_map = new Map<string, boolean>(); | ||
|
||
constructor(bridge: bridges, l: lightning) { | ||
this.l = l; | ||
this.bridges = bridge; | ||
} | ||
|
||
is_bridged_internal(msg: deleted_message<unknown>): boolean { | ||
return Boolean(this.bridged_message_id_map.get(msg.id)); | ||
} | ||
|
||
async handle_message( | ||
msg: message<unknown> | deleted_message<unknown>, | ||
action: 'create_message' | 'edit_message' | 'delete_message' | ||
): Promise<void> { | ||
const bridge_info = await this.get_platforms(msg, action); | ||
if (!bridge_info) return; | ||
|
||
if (bridge_info.bridge.settings?.realnames === true) { | ||
if ('author' in msg && msg.author) { | ||
msg.author.username = msg.author.rawname; | ||
} | ||
} | ||
|
||
const data: (bridge_platform & { id: string })[] = []; | ||
|
||
for (const plat of bridge_info.platforms) { | ||
const { plugin, platform } = await this.get_sane_plugin(plat, action); | ||
if (!plugin || !platform) continue; | ||
|
||
let dat; | ||
|
||
try { | ||
dat = await plugin[action]( | ||
{ | ||
...msg, | ||
replytoid: await this.get_replytoid(msg, platform) | ||
} as message<unknown>, | ||
platform | ||
); | ||
} catch (e) { | ||
if (action === 'delete_message') continue; | ||
const err = await log_error(e, { platform, action }); | ||
try { | ||
dat = await plugin[action](err.message, platform); | ||
} catch (e) { | ||
await log_error( | ||
new Error(`logging failed for ${err.uuid}`, { cause: e }) | ||
); | ||
continue; | ||
} | ||
} | ||
this.bridged_message_id_map.set(dat.id!, true); | ||
data.push(dat as bridge_platform & { id: string }); | ||
} | ||
|
||
for (const i of data) { | ||
await this.l.redis.sendCommand([ | ||
'JSON.SET', | ||
`lightning-bridge-${i.id}`, | ||
'$', | ||
JSON.stringify(data) | ||
]); | ||
} | ||
|
||
await this.l.redis.sendCommand([ | ||
'JSON.SET', | ||
`lightning-bridge-${msg.id}`, | ||
'$', | ||
JSON.stringify(data) | ||
]); | ||
} | ||
|
||
private async get_platforms( | ||
msg: message<unknown> | deleted_message<unknown>, | ||
action: 'create_message' | 'edit_message' | 'delete_message' | ||
) { | ||
const bridge = await this.bridges.get_bridge(msg); | ||
if (!bridge) return; | ||
if ( | ||
action !== 'create_message' && | ||
bridge.settings?.editing_allowed !== true | ||
) | ||
return; | ||
|
||
const platforms = | ||
action === 'create_message' | ||
? bridge.platforms.filter(i => i.channel !== msg.channel) | ||
: await this.bridges.get_bridge_message(msg.id); | ||
if (!platforms || platforms.length < 1) return; | ||
return { platforms, bridge }; | ||
} | ||
|
||
private async get_replytoid( | ||
msg: message<unknown> | deleted_message<unknown>, | ||
platform: bridge_platform | ||
) { | ||
let replytoid; | ||
if ('replytoid' in msg && msg.replytoid) { | ||
try { | ||
replytoid = ( | ||
await this.bridges.get_bridge_message(msg.replytoid) | ||
)?.find( | ||
i => i.channel === platform.channel && i.plugin === platform.plugin | ||
)?.id; | ||
} catch { | ||
replytoid = undefined; | ||
} | ||
} | ||
return replytoid; | ||
} | ||
|
||
private async get_sane_plugin( | ||
platform: bridge_platform, | ||
action: 'create_message' | 'edit_message' | 'delete_message' | ||
): Promise<{ | ||
plugin?: plugin<unknown>; | ||
platform?: bridge_platform & { id: string }; | ||
}> { | ||
const plugin = this.l.plugins.get(platform.plugin); | ||
|
||
if (!plugin || !plugin[action]) { | ||
await log_error(new Error(`plugin ${platform.plugin} has no ${action}`)); | ||
return {}; | ||
} | ||
|
||
if (!platform.senddata || (action !== 'create_message' && !platform.id)) | ||
return {}; | ||
|
||
return { plugin, platform: platform } as { | ||
plugin: plugin<unknown>; | ||
platform: bridge_platform & { id: string }; | ||
}; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
import { bridge_commands } from './_commands.ts'; | ||
import { lightning } from '../lightning.ts'; | ||
import { Collection } from 'mongo'; | ||
import { deleted_message } from '../utils/mod.ts'; | ||
import { bridge_document, bridge_platform } from './types.ts'; | ||
import { bridge_internals_dont_use_or_look_at } from './_internal.ts'; | ||
|
||
/** a thing that bridges messages between platforms defined by plugins */ | ||
export class bridges { | ||
/** the parent instance of lightning */ | ||
private l: lightning; | ||
/** the database collection containing all the bridges */ | ||
private bridge_collection: Collection<bridge_document>; | ||
/** the scary internals that you never want to look at */ | ||
private internals: bridge_internals_dont_use_or_look_at; | ||
|
||
/** create a bridge instance and attach to lightning */ | ||
constructor(l: lightning) { | ||
this.l = l; | ||
this.internals = new bridge_internals_dont_use_or_look_at(this, l); | ||
this.bridge_collection = l.mongo | ||
.database(l.config.mongo_database) | ||
.collection('bridges'); | ||
l.on('create_message', async msg => { | ||
await new Promise(res => setTimeout(res, 250)); | ||
if (this.is_bridged(msg)) return; | ||
l.emit('create_nonbridged_message', msg); | ||
await this.internals.handle_message(msg, 'create_message'); | ||
}); | ||
l.on('edit_message', async msg => { | ||
await new Promise(res => setTimeout(res, 250)); | ||
if (this.is_bridged(msg)) return; | ||
await this.internals.handle_message(msg, 'edit_message'); | ||
}); | ||
l.on('delete_message', async msg => { | ||
await new Promise(res => setTimeout(res, 400)); | ||
await this.internals.handle_message(msg, 'delete_message'); | ||
}); | ||
l.cmds.set('bridge', bridge_commands(l)); | ||
} | ||
|
||
/** get all the platforms a message was bridged to */ | ||
async get_bridge_message(id: string): Promise<bridge_platform[] | null> { | ||
const rdata = await this.l.redis.sendCommand([ | ||
'JSON.GET', | ||
`lightning-bridge-${id}` | ||
]); | ||
if (!rdata) return [] as bridge_platform[]; | ||
return JSON.parse(rdata as string) as bridge_platform[]; | ||
} | ||
|
||
/** check if a message was bridged */ | ||
is_bridged(msg: deleted_message<unknown>): boolean { | ||
const platform = this.l.plugins.get(msg.platform.name); | ||
if (!platform) return false; | ||
const platsays = platform.is_bridged(msg); | ||
if (platsays !== 'query') return platsays; | ||
return this.internals.is_bridged_internal(msg); | ||
} | ||
|
||
/** get a bridge using the bridges name or a channel in it */ | ||
async get_bridge({ | ||
_id, | ||
channel | ||
}: { | ||
_id?: string; | ||
channel?: string; | ||
}): Promise<bridge_document | undefined> { | ||
const query = {} as Record<string, string>; | ||
|
||
if (_id) { | ||
query._id = _id; | ||
} | ||
if (channel) { | ||
query['platforms.channel'] = channel; | ||
} | ||
return (await this.bridge_collection.findOne(query)) || undefined; | ||
} | ||
|
||
/** update a bridge in a database */ | ||
async update_bridge(bridge: bridge_document): Promise<void> { | ||
await this.bridge_collection.replaceOne({ _id: bridge._id }, bridge, { | ||
upsert: true | ||
}); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
/** the database's representation of a bridge */ | ||
export interface bridge_document { | ||
/** the bridge's id */ | ||
_id: string; | ||
/** each platform within the bridge */ | ||
platforms: bridge_platform[]; | ||
/** the settings for the bridge */ | ||
settings?: bridge_settings; | ||
} | ||
|
||
/** platform within a bridge */ | ||
export interface bridge_platform { | ||
/** the channel to be bridged */ | ||
channel: string; | ||
/** the plugin used for this platform */ | ||
plugin: string; | ||
/** the data needed for a message to be sent */ | ||
senddata: unknown; | ||
/** the id of a sent message */ | ||
id?: string; | ||
} | ||
|
||
/** bridge settings */ | ||
export interface bridge_settings { | ||
/** use an authors rawname instead of username */ | ||
realnames?: boolean; | ||
/** whether or not to allow editing to be bridged */ | ||
editing_allowed?: boolean; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
{ | ||
"name": "@jersey/lightning", | ||
"version": "0.6.0", | ||
"imports": { | ||
"assert_eq": "jsr:@std/assert@^0.219.1/assert_equals", | ||
"event": "jsr:@denosaurs/event@^2.0.2", | ||
"mongo": "jsr:@db/mongo@^0.33.0", | ||
"r2d2": "jsr:@iuioiua/r2d2@2.1.1", | ||
"std_args": "jsr:@std/cli@^0.219.1/parse_args" | ||
}, | ||
"exports": { | ||
".": "./mod.ts", | ||
"./utils": "./utils/mod.ts" | ||
}, | ||
"publish": { | ||
"exclude": ["./_testdata.ts", "./_tests.ts"] | ||
}, | ||
"test": { | ||
"include": ["./_tests.ts"] | ||
}, | ||
"lint": { | ||
"exclude": ["./_testdata.ts", "./_tests.ts"] | ||
}, | ||
"lock": false, | ||
"unstable": ["temporal"] | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import { EventEmitter } from 'event'; | ||
import { MongoClient } from 'mongo'; | ||
import { RedisClient } from 'r2d2'; | ||
import { bridges } from './bridges/mod.ts'; | ||
import { | ||
commands, | ||
plugin, | ||
config, | ||
create_plugin, | ||
log_error, | ||
plugin_events | ||
} from './utils/mod.ts'; | ||
|
||
/** an instance of lightning */ | ||
export class lightning extends EventEmitter<plugin_events> { | ||
bridge: bridges; | ||
/** a command handler */ | ||
cmds: commands = new commands(); | ||
/** the config used */ | ||
config: config; | ||
/** a mongo client */ | ||
mongo: MongoClient; | ||
/** a redis client */ | ||
redis: RedisClient; | ||
/** the plugins loaded */ | ||
plugins: Map<string, plugin<unknown>> = new Map<string, plugin<unknown>>(); | ||
|
||
/** setup an instance with the given config, mongo instance, and redis connection */ | ||
constructor(config: config, mongo: MongoClient, redis_conn: Deno.TcpConn) { | ||
super(); | ||
this.config = config; | ||
this.mongo = mongo; | ||
this.redis = new RedisClient(redis_conn); | ||
this.bridge = new bridges(this); | ||
this.cmds.listen(this); | ||
this.load(this.config.plugins); | ||
} | ||
|
||
/** load plugins */ | ||
async load(plugins: create_plugin<plugin<unknown>>[]) { | ||
for (const { type, config } of plugins) { | ||
const plugin = new type(this, config); | ||
if (!plugin.support.includes('0.5.5')) { | ||
throw ( | ||
await log_error( | ||
new Error( | ||
`plugin '${plugin.name}' doesn't support this version of lightning` | ||
) | ||
) | ||
).e; | ||
} else { | ||
this.plugins.set(plugin.name, plugin); | ||
(async () => { | ||
for await (const event of plugin) { | ||
this.emit(event.name, ...event.value); | ||
} | ||
})(); | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
/** | ||
* @module | ||
*/ | ||
|
||
export { bridges } from './bridges/mod.ts'; | ||
export { | ||
type bridge_document, | ||
type bridge_platform, | ||
type bridge_settings | ||
} from './bridges/types.ts'; | ||
export * from './utils/mod.ts'; | ||
export { | ||
lightning, | ||
/** | ||
* TODO: remove in 0.7.0 | ||
* @deprecated will be removed in 0.7.0 | ||
*/ | ||
lightning as Bolt | ||
} from './lightning.ts'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import { Document } from 'mongo'; | ||
import { versions } from './migrations.ts'; | ||
|
||
type doc = { | ||
_id: string; | ||
value: { | ||
bridges: { platform: string; channel: string; senddata: unknown }[]; | ||
}; | ||
}; | ||
|
||
export default { | ||
from: '0.4-beta' as versions, | ||
to: '0.5' as versions, | ||
from_db: 'bridgev1', | ||
to_db: 'bridges', | ||
translate: (itemslist: (doc | Document)[]) => | ||
(itemslist as doc[]).flatMap(({ _id, value }) => { | ||
if (_id.startsWith('message-')) return []; | ||
return [ | ||
{ | ||
_id, | ||
platforms: value.bridges.map(({ platform, channel, senddata }) => ({ | ||
plugin: map_plugins(platform), | ||
channel, | ||
senddata | ||
})) | ||
} | ||
]; | ||
}) as Document[] | ||
}; | ||
|
||
function map_plugins(pluginname: string): string { | ||
// the use of bolt is intentional | ||
if (pluginname === 'discord') return 'bolt-discord'; | ||
if (pluginname === 'guilded') return 'bolt-guilded'; | ||
if (pluginname === 'revolt') return 'bolt-revolt'; | ||
return 'unknown'; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import { Document } from 'mongo'; | ||
import { versions } from './migrations.ts'; | ||
|
||
export default { | ||
from: '0.4' as versions, | ||
to: '0.4-beta' as versions, | ||
from_db: 'bridge', | ||
to_db: 'bridgev1', | ||
translate: ( | ||
items: (Document | { _id: string; value: string | unknown })[] | ||
): Document[] => { | ||
const obj = {} as { | ||
[key: string]: { | ||
platform: string; | ||
channel: string; | ||
senddata: unknown; | ||
}[]; | ||
}; | ||
|
||
for (const item of items) { | ||
const [platform, ...join] = item._id.split('-'); | ||
const name = join.join('-'); | ||
if (is_channel(name)) continue; | ||
const _id = items.find( | ||
i => i._id.startsWith(platform) && i.value === name | ||
)?._id; | ||
if (!_id) continue; | ||
if (!obj[name]) obj[name] = []; | ||
obj[name].push({ | ||
platform, | ||
channel: _id.split('-').slice(1).join('-'), | ||
senddata: item.value | ||
}); | ||
} | ||
|
||
return Object.entries(obj) | ||
.filter(([key, value]) => !is_channel(key) && value.length >= 2) | ||
.map(([key, value]) => ({ | ||
_id: key, | ||
value: { bridges: value } | ||
})); | ||
} | ||
}; | ||
|
||
function is_channel(channel: string): boolean { | ||
if ( | ||
channel.match( | ||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i | ||
) | ||
) { | ||
return true; | ||
} | ||
if (channel.match(/[0-7][0-9A-HJKMNP-TV-Z]{25}/gm)) return true; | ||
if (!isNaN(Number(channel))) return true; | ||
if ( | ||
channel.startsWith('discord-') || | ||
channel.startsWith('guilded-') || | ||
channel.startsWith('revolt-') | ||
) { | ||
return true; | ||
} | ||
return false; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,135 @@ | ||
import { lightning } from '../lightning.ts'; | ||
import { log_error } from './errors.ts'; | ||
import { parseArgs } from 'std_args'; | ||
import { create_message, message } from './messages.ts'; | ||
|
||
/** | ||
* commands implements simple command handling for lightning that others may find useful | ||
*/ | ||
export class commands extends Map<string, command> { | ||
// TODO: make this configurable | ||
prefix = '!bolt'; | ||
|
||
/** | ||
* creates a command handler instance with the given commands | ||
* @param default_cmds - the commands to use by default, should include help as a fallback command | ||
*/ | ||
constructor(default_cmds: [string, command][] = default_commands) { | ||
super(default_cmds); | ||
} | ||
|
||
/** | ||
* listen for commands on the given lightning instance | ||
*/ | ||
listen(l: lightning) { | ||
l.on('create_nonbridged_message', msg => { | ||
if (msg.content?.startsWith(this.prefix)) { | ||
const args = parseArgs(msg.content.split(' ')); | ||
args._.shift(); | ||
this.run({ | ||
channel: msg.channel, | ||
cmd: args._.shift() as string, | ||
subcmd: args._.shift() as string, | ||
opts: args as Record<string, string>, | ||
platform: msg.platform.name, | ||
timestamp: msg.timestamp, | ||
replyfn: msg.reply | ||
}); | ||
} | ||
}); | ||
|
||
l.on('create_command', async cmd => { | ||
await this.run(cmd); | ||
}); | ||
} | ||
|
||
/** | ||
* run a command given the options that would be passed to it | ||
*/ | ||
async run(opts: Omit<command_arguments, 'commands'>) { | ||
let reply; | ||
try { | ||
const cmd = this.get(opts.cmd) || this.get('help')!; | ||
const execute = | ||
cmd.options?.subcommands && opts.subcmd | ||
? cmd.options.subcommands.find(i => i.name === opts.subcmd) | ||
?.execute || cmd.execute | ||
: cmd.execute; | ||
reply = await execute({ ...opts, commands: this }); | ||
} catch (e) { | ||
reply = (await log_error(e, { ...opts, reply: undefined })).message; | ||
} | ||
try { | ||
await opts.replyfn(reply, false); | ||
} catch (e) { | ||
await log_error(e, { ...opts, reply: undefined }); | ||
} | ||
} | ||
} | ||
|
||
// TODO: remove in 0.7.0 and make its own package | ||
|
||
const default_commands: [string, command][] = [ | ||
[ | ||
'help', | ||
{ | ||
name: 'help', | ||
description: 'get help', | ||
execute: () => | ||
create_message( | ||
'check out [the docs](https://williamhorning.dev/bolt/) for help.' | ||
) | ||
} | ||
], | ||
[ | ||
'version', | ||
{ | ||
name: 'version', | ||
description: "get lightning's version", | ||
execute: () => create_message('hello from lightning (bolt) v0.6.0!') | ||
} | ||
], | ||
[ | ||
'ping', | ||
{ | ||
name: 'ping', | ||
description: 'pong', | ||
execute: ({ timestamp }) => | ||
create_message( | ||
`Pong! 🏓 ${Temporal.Now.instant() | ||
.since(timestamp) | ||
.total('milliseconds')}ms` | ||
) | ||
} | ||
] | ||
]; | ||
|
||
export interface command_arguments { | ||
channel: string; | ||
cmd: string; | ||
opts: Record<string, string>; | ||
platform: string; | ||
replyfn: message<unknown>['reply']; | ||
subcmd?: string; | ||
timestamp: Temporal.Instant; | ||
commands: commands; | ||
} | ||
|
||
export interface command { | ||
/** the name of the command */ | ||
name: string; | ||
/** an optional description */ | ||
description?: string; | ||
options?: { | ||
/** this will be the key passed to options.opts in the execute function */ | ||
argument_name?: string; | ||
/** whether or not the argument provided is required */ | ||
argument_required?: boolean; | ||
/** an array of commands that show as subcommands */ | ||
subcommands?: command[]; | ||
}; | ||
/** a function that returns a message */ | ||
execute: ( | ||
options: command_arguments | ||
) => Promise<message<unknown>> | message<unknown>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { create_plugin } from './plugins.ts'; | ||
|
||
/** a function that returns a config object when given a partial config object */ | ||
export function define_config(config?: Partial<config>): config { | ||
return { | ||
plugins: [], | ||
mongo_uri: 'mongodb://localhost:27017', | ||
mongo_database: 'lightning', | ||
redis_host: 'localhost', | ||
redis_port: 6379, | ||
...(config || {}) | ||
}; | ||
} | ||
|
||
export interface config { | ||
/** a list of plugins */ | ||
// deno-lint-ignore no-explicit-any | ||
plugins: create_plugin<any>[]; | ||
/** the URI that points to your instance of mongodb */ | ||
mongo_uri: string; | ||
/** the database to use */ | ||
mongo_database: string; | ||
/** the hostname of your redis instance */ | ||
redis_host: string; | ||
/** the port of your redis instance */ | ||
redis_port?: number; | ||
/** the webhook used to send errors to */ | ||
errorURL?: string; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { create_message, message } from './messages.ts'; | ||
|
||
/** | ||
* logs an error and returns a unique id and a message for users | ||
* @param e the error to log | ||
* @param extra any extra data to log | ||
* @param _id a function that returns a unique id (used for testing) | ||
*/ | ||
export async function log_error( | ||
e: Error, | ||
extra: Record<string, unknown> = {}, | ||
_id: () => string = crypto.randomUUID | ||
): Promise<{ | ||
e: Error; | ||
extra: Record<string, unknown>; | ||
uuid: string; | ||
message: message<unknown>; | ||
}> { | ||
const uuid = _id(); | ||
const error_hook = Deno.env.get('LIGHTNING_ERROR_HOOK') || ''; | ||
|
||
if (error_hook !== '') { | ||
delete extra.msg; | ||
|
||
await ( | ||
await fetch(error_hook, { | ||
method: 'POST', | ||
headers: { 'Content-Type': 'application/json' }, | ||
body: JSON.stringify({ | ||
embeds: [ | ||
{ | ||
title: e.message, | ||
description: `\`\`\`js\n${e.stack}\n${JSON.stringify( | ||
{ ...extra, uuid }, | ||
r(), | ||
2 | ||
)}\`\`\`` | ||
} | ||
] | ||
}) | ||
}) | ||
).text(); | ||
} | ||
|
||
console.error(`%cLightning Error ${uuid}`, 'color: red', e, extra); | ||
|
||
return { | ||
e, | ||
uuid, | ||
extra, | ||
message: create_message( | ||
`Something went wrong! [Look here](https://williamhorning.dev/bolt) for help.\n\`\`\`\n${e.message}\n${uuid}\n\`\`\`` | ||
) | ||
}; | ||
} | ||
|
||
function r() { | ||
const seen = new WeakSet(); | ||
return (_: string, value: unknown) => { | ||
if (typeof value === 'object' && value !== null) { | ||
if (seen.has(value)) { | ||
return '[Circular]'; | ||
} | ||
seen.add(value); | ||
} | ||
if (typeof value === 'bigint') { | ||
return value.toString(); | ||
} | ||
return value; | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,112 @@ | ||
/** | ||
* creates a message that can be sent using lightning | ||
* @param text the text of the message (can be markdown) | ||
*/ | ||
export function create_message(text: string): message<undefined> { | ||
const data = { | ||
author: { | ||
// TODO: make this configurable | ||
username: 'Bolt', | ||
profile: | ||
'https://cdn.discordapp.com/icons/1011741670510968862/2d4ce9ff3f384c027d8781fa16a38b07.png?size=1024', | ||
rawname: 'lightning', | ||
id: 'lightning' | ||
}, | ||
content: text, | ||
channel: '', | ||
id: '', | ||
reply: async () => {}, | ||
timestamp: Temporal.Now.instant(), | ||
platform: { | ||
name: 'lightning', | ||
message: undefined | ||
} | ||
}; | ||
return data; | ||
} | ||
|
||
export interface attachment { | ||
/** alt text for images */ | ||
alt?: string; | ||
/** a URL pointing to the file */ | ||
file: string; | ||
/** the file's name */ | ||
name?: string; | ||
/** whether or not the file has a spoiler */ | ||
spoiler?: boolean; | ||
/** | ||
* file size | ||
* @deprecated will be removed in 0.7.0 | ||
*/ | ||
size: number; | ||
} | ||
|
||
export interface platform<t> { | ||
/** the name of a plugin */ | ||
name: string; | ||
/** the platforms representation of a message */ | ||
message: t; | ||
/** the webhook the message was sent with */ | ||
webhookid?: string; | ||
} | ||
|
||
export interface embed_media { | ||
height?: number; | ||
url: string; | ||
width?: number; | ||
} | ||
|
||
/** a discord-style embed */ | ||
export interface embed { | ||
author?: { name: string; url?: string; icon_url?: string }; | ||
color?: number; | ||
description?: string; | ||
fields?: { name: string; value: string; inline?: boolean }[]; | ||
footer?: { text: string; icon_url?: string }; | ||
image?: embed_media; | ||
thumbnail?: embed_media; | ||
timestamp?: number; | ||
title?: string; | ||
url?: string; | ||
video?: Omit<embed_media, 'url'> & { url?: string }; | ||
} | ||
|
||
export interface message<t> extends deleted_message<t> { | ||
attachments?: attachment[]; | ||
author: { | ||
/** the nickname of the author */ | ||
username: string; | ||
/** the author's username */ | ||
rawname: string; | ||
/** a url pointing to the authors profile picture */ | ||
profile?: string; | ||
/** a url pointing to the authors banner */ | ||
banner?: string; | ||
/** the author's id on their platform */ | ||
id: string; | ||
/** the color of an author */ | ||
color?: string; | ||
}; | ||
/** message content (can be markdown) */ | ||
content?: string; | ||
/** discord-style embeds */ | ||
embeds?: embed[]; | ||
/** a function to reply to a message */ | ||
reply: (message: message<unknown>, optional?: unknown) => Promise<void>; | ||
/** the id of the message replied to */ | ||
replytoid?: string; | ||
} | ||
|
||
export interface deleted_message<t> { | ||
/** the message's id */ | ||
id: string; | ||
/** the channel the message was sent in */ | ||
channel: string; | ||
/** the platform the message was sent on */ | ||
platform: platform<t>; | ||
/** | ||
* the time the message was sent/edited as a temporal instant | ||
* @see https://tc39.es/proposal-temporal/docs/instant.html | ||
*/ | ||
timestamp: Temporal.Instant; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import { Document } from 'mongo'; | ||
import fourfourbeta from './_fourfourbeta.ts'; | ||
import fourbetafive from './_fourfourbeta.ts'; | ||
|
||
/** the type of a migration */ | ||
export interface migration { | ||
/** the version to translate from */ | ||
from: versions; | ||
/** the version to translate to */ | ||
to: versions; | ||
/** the database to translate from */ | ||
from_db: string; | ||
/** the database to translate to */ | ||
to_db: string; | ||
/** translate a document from one version to another */ | ||
translate: (data: Document[]) => Document[]; | ||
} | ||
|
||
/** all of the versions with migrations to/from them */ | ||
export enum versions { | ||
/** all versions below 0.5 */ | ||
Four = '0.4', | ||
/** versions after commit 7de1cf2 but below 0.5 */ | ||
FourBeta = '0.4-beta', | ||
/** versions 0.5 and above */ | ||
Five = '0.5' | ||
} | ||
|
||
const migrations: migration[] = [fourbetafive, fourfourbeta]; | ||
|
||
/** get migrations that can then be applied using apply_migrations */ | ||
export function get_migrations(from: versions, to: versions): migration[] { | ||
const indexoffrom = migrations.findIndex(i => i.from === from); | ||
const indexofto = migrations.findLastIndex(i => i.to === to); | ||
return migrations.slice(indexoffrom, indexofto); | ||
} | ||
|
||
/** apply many migrations given mongodb documents */ | ||
export function apply_migrations( | ||
migrations: migration[], | ||
data: Document[] | ||
): Document[] { | ||
return migrations.reduce((acc, migration) => migration.translate(acc), data); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
/** | ||
* Various utilities for lightning | ||
* @module | ||
*/ | ||
|
||
export { commands, type command, type command_arguments } from './commands.ts'; | ||
export { type config, define_config } from './config.ts'; | ||
export { log_error } from './errors.ts'; | ||
export { | ||
create_message, | ||
type deleted_message, | ||
type embed, | ||
type embed_media, | ||
type message, | ||
type platform, | ||
type attachment | ||
} from './messages.ts'; | ||
export { | ||
apply_migrations, | ||
get_migrations, | ||
type migration, | ||
versions | ||
} from './migrations.ts'; | ||
export { plugin, type create_plugin, type plugin_events } from './plugins.ts'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
import { EventEmitter } from 'event'; | ||
import { lightning } from '../lightning.ts'; | ||
import { bridge_platform } from '../bridges/types.ts'; | ||
import { message, deleted_message } from './messages.ts'; | ||
import { command_arguments } from './commands.ts'; | ||
|
||
/** | ||
* a plugin for lightning | ||
*/ | ||
export abstract class plugin<cfg> extends EventEmitter<plugin_events> { | ||
/** | ||
* access the instance of lightning you're connected to | ||
* @deprecated use `l` instead, will be removed in 0.7.0 | ||
*/ | ||
bolt: lightning; | ||
/** access the instance of lightning you're connected to */ | ||
lightning: lightning; | ||
/** access the config passed to you by lightning */ | ||
config: cfg; | ||
|
||
/** the name of your plugin */ | ||
abstract name: string; | ||
/** the version of your plugin */ | ||
abstract version: string; | ||
/** a list of major versions supported by your plugin, should include 0.5.5 */ | ||
abstract support: string[]; | ||
|
||
/** create a new plugin instance */ | ||
static new<T extends plugin<unknown>>( | ||
this: new (l: lightning, config: T['config']) => T, | ||
config: T['config'] | ||
): create_plugin<T> { | ||
return { type: this, config }; | ||
} | ||
constructor(l: lightning, config: cfg) { | ||
super(); | ||
this.bolt = l; | ||
this.lightning = l; | ||
this.config = config; | ||
} | ||
|
||
/** this should return the data you need to send to the channel given */ | ||
abstract create_bridge(channel: string): Promise<unknown>; | ||
|
||
/** this is used to check whether or not a message is bridged, return query if you don't know for sure */ | ||
abstract is_bridged(message: deleted_message<unknown>): boolean | 'query'; | ||
|
||
/** this is used to bridge a NEW message */ | ||
abstract create_message( | ||
message: message<unknown>, | ||
bridge: bridge_platform | ||
): Promise<bridge_platform>; | ||
|
||
/** this is used to bridge an EDITED message */ | ||
abstract edit_message( | ||
new_message: message<unknown>, | ||
bridge: bridge_platform & { id: string } | ||
): Promise<bridge_platform>; | ||
|
||
/** this is used to bridge a DELETED message */ | ||
abstract delete_message( | ||
message: deleted_message<unknown>, | ||
bridge: bridge_platform & { id: string } | ||
): Promise<bridge_platform>; | ||
} | ||
|
||
export type plugin_events = { | ||
/** when a message is created */ | ||
create_message: [message<unknown>]; | ||
/** when a command is run (not a text command) */ | ||
create_command: [Omit<command_arguments, 'commands'>]; | ||
/** when a message isn't already bridged (don't emit outside of core) */ | ||
create_nonbridged_message: [message<unknown>]; | ||
/** when a message is edited */ | ||
edit_message: [message<unknown>]; | ||
/** when a message is deleted */ | ||
delete_message: [deleted_message<unknown>]; | ||
/** when your plugin is ready */ | ||
ready: []; | ||
}; | ||
|
||
/** the constructor for a plugin */ | ||
export interface create_plugin<T extends plugin<T['config']>> { | ||
type: new (l: lightning, config: T['config']) => T; | ||
config: T['config']; | ||
} |