From 5000ce98253df124654926eb99d87a006eb0ba40 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 3 Oct 2024 12:19:23 -0400 Subject: [PATCH 01/97] formatting changes --- .github/workflows/publish.yml | 4 ++-- packages/lightning-plugin-revolt/src/messages.ts | 10 +++------- packages/lightning-plugin-revolt/src/permissions.ts | 7 +------ packages/lightning/src/bridges/cmd_internals.ts | 4 ++-- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1b044890..2324c4cf 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -34,8 +34,8 @@ jobs: run: cd packages/lightning - name: publish to jsr run: | - deno publish - cd packages/lightning + deno publish + cd packages/lightning - name: setup docker metadata id: metadata uses: docker/metadata-action@v5 diff --git a/packages/lightning-plugin-revolt/src/messages.ts b/packages/lightning-plugin-revolt/src/messages.ts index 57971ae4..c91b3d0c 100644 --- a/packages/lightning-plugin-revolt/src/messages.ts +++ b/packages/lightning-plugin-revolt/src/messages.ts @@ -47,8 +47,7 @@ export async function torvapi( embeds: message.embeds?.map((embed) => { if (embed.fields) { for (const field of embed.fields) { - embed.description += - `\n\n**${field.name}**\n${field.value}`; + embed.description += `\n\n**${field.name}**\n${field.value}`; } } return { @@ -128,17 +127,14 @@ export async function fromrvapi( : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), embeds: (message.embeds as Embed[] | undefined)?.map((i) => { return { - color: i.colour - ? parseInt(i.colour.replace('#', ''), 16) - : undefined, + color: i.colour ? parseInt(i.colour.replace('#', ''), 16) : undefined, ...i, } as embed; }), plugin: 'bolt-revolt', attachments: message.attachments?.map((i) => { return { - file: - `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, + file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, name: i.filename, size: i.size, }; diff --git a/packages/lightning-plugin-revolt/src/permissions.ts b/packages/lightning-plugin-revolt/src/permissions.ts index e5269ea1..667e0e7c 100644 --- a/packages/lightning-plugin-revolt/src/permissions.ts +++ b/packages/lightning-plugin-revolt/src/permissions.ts @@ -1,10 +1,5 @@ import type { Client } from '@jersey/rvapi'; -import type { - Channel, - Member, - Role, - Server, -} from '@jersey/revolt-api-types'; +import type { Channel, Member, Role, Server } from '@jersey/revolt-api-types'; export async function revolt_perms( client: Client, diff --git a/packages/lightning/src/bridges/cmd_internals.ts b/packages/lightning/src/bridges/cmd_internals.ts index 951e5e42..6cf5529a 100644 --- a/packages/lightning/src/bridges/cmd_internals.ts +++ b/packages/lightning/src/bridges/cmd_internals.ts @@ -82,8 +82,8 @@ export async function leave( export async function reset(opts: command_arguments) { if (typeof opts.opts.name !== 'string') { - opts.opts.name = - (await get_channel_bridge(opts.lightning, opts.channel))?.id!; + opts.opts.name = (await get_channel_bridge(opts.lightning, opts.channel)) + ?.id!; } let [ok, text] = await leave(opts); From b5ffa7a9611c43fb4147a49d8e28fdf969e01824 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 3 Oct 2024 13:17:35 -0400 Subject: [PATCH 02/97] changes to temporal interface --- packages/lightning-plugin-telegram/src/messages.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lightning-plugin-telegram/src/messages.ts b/packages/lightning-plugin-telegram/src/messages.ts index da728f3a..f3b09fd0 100644 --- a/packages/lightning-plugin-telegram/src/messages.ts +++ b/packages/lightning-plugin-telegram/src/messages.ts @@ -115,7 +115,7 @@ async function get_base_msg( }, channel: msg.chat.id.toString(), id: msg.message_id.toString(), - timestamp: Temporal.Instant.fromEpochSeconds(msg.edit_date || msg.date), + timestamp: Temporal.Instant.fromEpochMilliseconds((msg.edit_date || msg.date) * 1000), plugin: 'bolt-telegram', reply: async (lmsg) => { for (const m of from_lightning(lmsg)) { From 686e387b78c01132b83dc01df22625b34b2c31d4 Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 11 Nov 2024 19:14:18 -0500 Subject: [PATCH 03/97] steps towards postgres - redo event handling logic to be flatter - cleanup command types - move data handling for bridges to `bridge_data` - remove the `create_nonbridged_message` event - core no longer extends EventEmitter - make the types for log_error work - temporarily remove migrations code - temporarily remove CLI --- .gitignore | 1 + deno.jsonc | 3 + packages/lightning/deno.jsonc | 12 +- packages/lightning/dockerfile | 8 +- packages/lightning/logo.svg | 28 +++ packages/lightning/readme.md | 19 +- packages/lightning/src/bridge/cmd.ts | 191 ++++++++++++++++ packages/lightning/src/bridge/data.ts | 147 ++++++++++++ packages/lightning/src/bridge/msg.ts | 178 +++++++++++++++ .../lightning/src/bridges/cmd_internals.ts | 132 ----------- .../lightning/src/bridges/db_internals.ts | 44 ---- .../lightning/src/bridges/handle_message.ts | 212 ----------------- .../lightning/src/bridges/setup_bridges.ts | 55 ----- packages/lightning/src/cli/migrations.ts | 93 -------- packages/lightning/src/cli/mod.ts | 64 ------ packages/lightning/src/cmds.ts | 34 --- .../src/{commands.ts => commands/mod.ts} | 89 +++----- packages/lightning/src/commands/run.ts | 46 ++++ packages/lightning/src/errors.ts | 81 ++++--- packages/lightning/src/lightning.ts | 103 ++++++--- packages/lightning/src/messages.ts | 181 ++++++++++++++- packages/lightning/src/migrations.ts | 77 ------- packages/lightning/src/mod.ts | 13 -- packages/lightning/src/plugins.ts | 6 +- packages/lightning/src/types.ts | 214 ------------------ 25 files changed, 931 insertions(+), 1100 deletions(-) create mode 100644 packages/lightning/logo.svg create mode 100644 packages/lightning/src/bridge/cmd.ts create mode 100644 packages/lightning/src/bridge/data.ts create mode 100644 packages/lightning/src/bridge/msg.ts delete mode 100644 packages/lightning/src/bridges/cmd_internals.ts delete mode 100644 packages/lightning/src/bridges/db_internals.ts delete mode 100644 packages/lightning/src/bridges/handle_message.ts delete mode 100644 packages/lightning/src/bridges/setup_bridges.ts delete mode 100644 packages/lightning/src/cli/migrations.ts delete mode 100644 packages/lightning/src/cli/mod.ts delete mode 100644 packages/lightning/src/cmds.ts rename packages/lightning/src/{commands.ts => commands/mod.ts} (50%) create mode 100644 packages/lightning/src/commands/run.ts delete mode 100644 packages/lightning/src/migrations.ts delete mode 100644 packages/lightning/src/mod.ts delete mode 100644 packages/lightning/src/types.ts diff --git a/.gitignore b/.gitignore index 20944bbe..690e9262 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.env /config /config.ts +packages/lightning-old \ No newline at end of file diff --git a/deno.jsonc b/deno.jsonc index 504a3227..b375ef02 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -21,6 +21,9 @@ }, "workspace": [ "./packages/lightning", + // TODO(jersey): remove these two + "./packages/lightning-old", + "./packages/postgres", "./packages/lightning-plugin-telegram", "./packages/lightning-plugin-revolt", "./packages/lightning-plugin-guilded", diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index 64669705..cbdcc1dd 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -1,19 +1,15 @@ { "name": "@jersey/lightning", - "version": "0.7.4", + "version": "0.8.0-alpha.0", "exports": { ".": "./src/mod.ts", + // TODO(jersey): add the cli back in along with migrations, except make migrations not suck as much? "./cli": "./src/cli/mod.ts" }, - "publish": { - "exclude": ["./src/tests/*"] - }, - "test": { - "include": ["./src/tests/*"] - }, "imports": { "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", - "@iuioiua/r2d2": "jsr:@iuioiua/r2d2@^2.1.1", + "@db/postgres": "jsr:@db/postgres@^0.19.4", + "@std/ulid": "jsr:@std/ulid@^1.0.0", "@std/cli/parse-args": "jsr:@std/cli@^1.0.3/parse-args" } } diff --git a/packages/lightning/dockerfile b/packages/lightning/dockerfile index ff0f899c..b1fec7be 100644 --- a/packages/lightning/dockerfile +++ b/packages/lightning/dockerfile @@ -1,11 +1,11 @@ -ARG DENO_VERSION=1.45.5 - -FROM docker.io/denoland/deno:${DENO_VERSION} +FROM docker.io/denoland/deno:2.0.3 # add lightning to the image -RUN deno install -A --unstable-temporal jsr:@jersey/lightning@0.7.4/cli +RUN deno install -g -A --unstable-temporal jsr:@jersey/lightning@0.8.0-alpha.0/cli RUN mkdir -p /app/data +WORKDIR /app/data # set lightning as the entrypoint and use the run command by default ENTRYPOINT [ "lightning" ] +# TODO(jersey): do i need to do this? CMD [ "run", "--config", "file:///app/data/config.ts"] diff --git a/packages/lightning/logo.svg b/packages/lightning/logo.svg new file mode 100644 index 00000000..c9db9d12 --- /dev/null +++ b/packages/lightning/logo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/lightning/readme.md b/packages/lightning/readme.md index 60e5b738..cc12d72b 100644 --- a/packages/lightning/readme.md +++ b/packages/lightning/readme.md @@ -1,3 +1,5 @@ +![lightning](logo.svg) + # @jersey/lightning lightning is a typescript-based chatbot that supports bridging multiple chat @@ -5,19 +7,4 @@ apps via plugins ## [docs](https://williamhorning.eu.org/bolt) -## example config - -```ts -import type { config } from 'jsr:@jersey/lightning@0.7.4'; -import { discord_plugin } from 'jsr:@jersey/lightning-plugin-discord@0.7.4'; - -export default { - redis_host: 'localhost', - redis_port: 6379, - plugins: [ - discord_plugin.new({ - // ... - }), - ], -} as config; -``` + diff --git a/packages/lightning/src/bridge/cmd.ts b/packages/lightning/src/bridge/cmd.ts new file mode 100644 index 00000000..5ce6ed30 --- /dev/null +++ b/packages/lightning/src/bridge/cmd.ts @@ -0,0 +1,191 @@ +import type { command } from '../commands/mod.ts'; +import { log_error } from '../errors.ts'; + +export const bridge_command = { + name: 'bridge', + description: 'bridge commands', + execute: () => 'take a look at the docs for help with bridges', + options: { + subcommands: [ + // TODO(jersey): eventually reimplement reset command? + { + name: 'join', + description: 'join a bridge', + // TODO(jersey): update this to support multiple options + // TODO(jersey): make command options more flexible + options: { argument_name: 'name', argument_required: true }, + execute: async ({ lightning, channel, opts, plugin }) => { + const current_bridge = await lightning.data + .get_bridge_by_channel( + channel, + ); + + // live laugh love validation + + if (current_bridge) { + return `You are already in a bridge called ${current_bridge.name}`; + } + if (opts.id && opts.name) { + return `You can only provide an id or a name, not both`; + } + if (!opts.id && !opts.name) { + return `You must provide either an id or a name`; + } + + const bridge_channel = { + id: channel, + data: undefined as unknown, + disabled: false, + plugin, + }; + + try { + bridge_channel.data = lightning.plugins.get(plugin) + ?.create_bridge(channel); + } catch (e) { + return (await log_error( + new Error('error creating bridge', { cause: e }), + { + channel, + plugin_name: plugin, + }, + )).message.content as string; + } + + if (opts.id) { + const bridge = await lightning.data.get_bridge_by_id( + opts.id, + ); + + if (!bridge) return `No bridge found with that id`; + + bridge.channels.push(bridge_channel); + + try { + await lightning.data.update_bridge(bridge); + return `Bridge joined successfully`; + } catch (e) { + return (await log_error( + new Error('error updating bridge', { cause: e }), + { + bridge, + }, + )).message.content as string; + } + } else { + try { + await lightning.data.new_bridge({ + name: opts.name, + channels: [bridge_channel], + settings: { + allow_editing: true, + allow_everyone: false, + use_rawname: false, + }, + }); + return `Bridge joined successfully`; + } catch (e) { + return (await log_error( + new Error('error inserting bridge', { cause: e }), + { + bridge: { + name: opts.name, + channels: [bridge_channel], + settings: { + allow_editing: true, + allow_everyone: false, + use_rawname: false, + }, + }, + }, + )).message.content as string; + } + } + }, + }, + { + name: 'leave', + description: 'leave a bridge', + execute: async ({ lightning, channel }) => { + const bridge = await lightning.data.get_bridge_by_channel( + channel, + ); + + if (!bridge) return `You are not in a bridge`; + + bridge.channels = bridge.channels.filter(( + ch, + ) => ch.id !== channel); + + try { + await lightning.data.update_bridge( + bridge, + ); + return `Bridge left successfully`; + } catch (e) { + return await log_error( + new Error('error updating bridge', { cause: e }), + { + bridge, + }, + ); + } + }, + }, + { + name: 'toggle', + description: 'toggle a setting on a bridge', + options: { argument_name: 'setting', argument_required: true }, + execute: async ({ opts, lightning, channel }) => { + const bridge = await lightning.data.get_bridge_by_channel( + channel, + ); + + if (!bridge) return `You are not in a bridge`; + + if ( + !['allow_editing', 'allow_everyone', 'use_rawname'] + .includes(opts.setting) + ) { + return `that setting does not exist`; + } + + const key = opts.setting as keyof typeof bridge.settings; + + bridge.settings[key] = !bridge + .settings[key]; + + try { + await lightning.data.update_bridge( + bridge, + ); + return `Setting toggled successfully`; + } catch (e) { + return await log_error( + new Error('error updating bridge', { cause: e }), + { + bridge, + }, + ); + } + }, + }, + { + name: 'status', + description: 'see what bridges you are in', + execute: async ({ lightning, channel }) => { + const existing_bridge = await lightning.data + .get_bridge_by_channel( + channel, + ); + + if (!existing_bridge) return `You are not in a bridge`; + + return `You are in a bridge called ${existing_bridge.name} that's connected to ${ + existing_bridge.channels.length - 1 + } other channels`; + }, + }, + ], + }, +} as command; diff --git a/packages/lightning/src/bridge/data.ts b/packages/lightning/src/bridge/data.ts new file mode 100644 index 00000000..f9dd4ba1 --- /dev/null +++ b/packages/lightning/src/bridge/data.ts @@ -0,0 +1,147 @@ +import { Client, type ClientOptions } from '@db/postgres'; +import { ulid } from '@std/ulid'; + +export interface bridge { + id: string; /* ulid */ + name: string; /* name of the bridge */ + channels: bridge_channel[]; /* channels bridged */ + settings: bridge_settings; /* settings for the bridge */ +} + +export interface bridge_channel { + id: string; /* from the platform */ + data: unknown; /* data needed to bridge this channel */ + disabled: boolean; /* whether the channel is disabled */ + plugin: string; /* the plugin used to bridge this channel */ +} + +export interface bridge_settings { + allow_editing: boolean; /* allow editing/deletion */ + allow_everyone: boolean; /* @everyone/@here/@room */ + use_rawname: boolean; /* rawname = username */ +} + +export interface bridge_message extends bridge { + original_id: string; /* original message id */ + messages: bridged_message[]; /* bridged messages */ +} + +export interface bridged_message { + id: string[]; /* message id */ + channel: string; /* channel id */ + plugin: string; /* plugin id */ +} + +export class bridge_data { + private pg: Client; + + static async create(pg_options: ClientOptions) { + const pg = new Client(pg_options); + await pg.connect(); + + await this.create_table(pg); + + return new bridge_data(pg); + } + + private static async create_table(pg: Client) { + const exists = (await pg.queryArray`SELECT relname FROM pg_class + WHERE relname = 'bridges'`).rows.length > 0; + + if (exists) return; + + await pg.queryArray`CREATE TABLE bridges ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + channels JSONB NOT NULL, + settings JSONB NOT NULL + )`; + await pg.queryArray`CREATE TABLE bridge_messages ( + original_id TEXT PRIMARY KEY, + id TEXT NOT NULL, + name TEXT NOT NULL, + channels JSONB NOT NULL REFERENCES bridges(channels), + messages JSONB NOT NULL, + settings JSONB NOT NULL REFERENCES bridges(settings), + CONSTRAINT fk_id FOREIGN KEY(id) REFERENCES bridges(id) + )`; + } + + private constructor(pg_client: Client) { + this.pg = pg_client; + } + + async new_bridge(bridge: Omit): Promise { + const id = ulid(); + + await this.pg.queryArray`INSERT INTO bridges + (id, name, channels, settings) VALUES + (${id}, ${bridge.name}, ${bridge.channels}, ${bridge.settings})`; + + return { id, ...bridge }; + } + + async update_bridge(bridge: bridge): Promise { + await this.pg.queryArray`UPDATE bridges SET + name = ${bridge.name}, + channels = ${bridge.channels}, + settings = ${bridge.settings} + WHERE id = ${bridge.id}`; + + return bridge; + } + + async get_bridge_by_id(id: string): Promise { + const resp = await this.pg.queryObject` + SELECT * FROM bridges WHERE id = ${id}`; + + return resp.rows[0]; + } + + async get_bridge_by_channel(channel: string): Promise { + const resp = await this.pg.queryObject` + SELECT * FROM bridges WHERE JSON_QUERY(channels, '$[*].id') = ${channel}`; + + return resp.rows[0]; + } + + async new_bridge_message(message: bridge_message): Promise { + await this.pg.queryArray`INSERT INTO bridge_messages + (original_id, id, name, channels, messages, settings) VALUES + (${message.original_id}, ${message.id}, ${message.name}, ${message.channels}, ${message.messages}, ${message.settings})`; + + return message; + } + + async update_bridge_message( + message: bridge_message, + ): Promise { + await this.pg.queryArray`UPDATE bridge_messages SET + id = ${message.id}, + channels = ${message.channels}, + messages = ${message.messages}, + settings = ${message.settings} + WHERE original_id = ${message.original_id}`; + + return message; + } + + async delete_bridge_message(id: string): Promise { + await this.pg + .queryArray`DELETE FROM bridge_messages WHERE original_id = ${id}`; + } + + async get_bridge_message(id: string): Promise { + const resp = await this.pg.queryObject` + SELECT * FROM bridge_messages WHERE original_id = ${id}`; + + return resp.rows[0]; + } + + async is_bridged_message(id: string): Promise { + const resp = await this.pg.queryObject` + SELECT * FROM bridge_messages WHERE JSON_QUERY(messages, '$[*].id') = ${id}`; + + return resp.rows.length > 0; + } +} diff --git a/packages/lightning/src/bridge/msg.ts b/packages/lightning/src/bridge/msg.ts new file mode 100644 index 00000000..b62a3738 --- /dev/null +++ b/packages/lightning/src/bridge/msg.ts @@ -0,0 +1,178 @@ +import type { lightning } from '../lightning.ts'; +import { log_error } from '../errors.ts'; +import type { + deleted_message, + message, + unprocessed_message, +} from '../messages.ts'; +import type { + bridge, + bridge_channel, + bridge_message, + bridged_message, +} from './data.ts'; + +export async function handle_message( + core: lightning, + msg: message | deleted_message, + type: 'create' | 'edit' | 'delete', +): Promise { + const br = type === 'create' + ? await core.data.get_bridge_by_channel(msg.channel) + : await core.data.get_bridge_message(msg.id); + + if (!br) return; + + if (type !== 'create' && br.settings.allow_editing !== true) return; + + if ( + br.channels.find((i) => + i.id === msg.channel && i.plugin === msg.plugin && i.disabled + ) + ) return; + + const channels = br.channels.filter( + (i) => i.id !== msg.channel || i.plugin !== msg.plugin, + ); + + if (channels.length < 1) return; + + const messages = [] as bridged_message[]; + + for (const ch of channels) { + if (!ch.data || ch.disabled) continue; + + const bridged_id = (br as Partial).messages?.find((i) => + i.channel === ch.id && i.plugin === ch.plugin + ); + + if ((type !== 'create' && !bridged_id)) { + continue; + } + + const plugin = core.plugins.get(ch.plugin); + + if (!plugin) { + await disable_channel( + ch, + br, + core, + (await log_error( + new Error(`plugin ${ch.plugin} doesn't exist`), + { channel: ch, bridged_id }, + )).cause, + ); + + continue; + } + + const reply_id = await get_reply_id(core, msg as message, ch); + + let res; + + try { + res = await plugin.process_message({ + action: type as 'edit', + channel: ch, + message: msg as message, + edit_id: bridged_id?.id as string[], + reply_id, + }); + + if (res.error) throw res.error; + } catch (e) { + if (type === 'delete') continue; + + if ((res as unprocessed_message).disable) { + await disable_channel(ch, br, core, e); + + continue; + } + + const err = await log_error(e, { + channel: ch, + bridged_id, + message: msg, + }); + + try { + res = await plugin.process_message({ + action: type as 'edit', + channel: ch, + message: err.message as message, + edit_id: bridged_id?.id as string[], + reply_id, + }); + + if (res.error) throw res.error; + } catch (e) { + await log_error( + new Error(`failed to log error`, { cause: e }), + { channel: ch, bridged_id, original_id: err.id }, + ); + + continue; + } + } + + for (const id of res.id) { + sessionStorage.setItem(`${ch.plugin}-${id}`, '1'); + } + + messages.push({ + id: res.id, + channel: ch.id, + plugin: ch.plugin, + }); + } + + const method = type === 'create' ? 'new' : 'update'; + + await core.data[`${method}_bridge_message`]({ + ...br, + original_id: msg.id, + messages, + }); +} + +async function disable_channel( + channel: bridge_channel, + bridge: bridge, + core: lightning, + error: unknown, +): Promise { + await log_error(error, { channel, bridge }); + + await core.data.update_bridge({ + ...bridge, + channels: bridge.channels.map((i) => + i.id === channel.id && i.plugin === channel.plugin + ? { ...i, disabled: true, data: error } + : i + ), + }); +} + +async function get_reply_id( + core: lightning, + msg: message, + channel: bridge_channel, +): Promise { + if (msg.reply_id) { + try { + const bridged = await core.data.get_bridge_message(msg.reply_id); + + if (!bridged) return; + + const br_ch = bridged.channels.find((i) => + i.id === channel.id && i.plugin === channel.plugin + ); + + if (!br_ch) return; + + return br_ch.id; + } catch { + return; + } + } +} diff --git a/packages/lightning/src/bridges/cmd_internals.ts b/packages/lightning/src/bridges/cmd_internals.ts deleted file mode 100644 index 6cf5529a..00000000 --- a/packages/lightning/src/bridges/cmd_internals.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { command_arguments } from '../commands.ts'; -import { log_error } from '../errors.ts'; -import { - del_key, - get_bridge, - get_channel_bridge, - set_bridge, -} from './db_internals.ts'; - -export async function join( - opts: command_arguments, -): Promise<[boolean, string]> { - if ( - await get_channel_bridge( - opts.lightning, - `lightning-bchannel-${opts.channel}`, - ) - ) { - return [ - false, - "To do this, you can't be in a bridge. Try leaving your bridge first.", - ]; - } - - const id = opts.opts.name?.split(' ')[0]; - - if (!id) { - return [ - false, - 'You need to provide a name your bridge. Try `join --name=` instead.', - ]; - } - - const plugin = opts.lightning.plugins.get(opts.plugin); - - const bridge = (await get_bridge(opts.lightning, id)) || { - allow_editing: false, - channels: [], - id, - use_rawname: false, - }; - - try { - const data = await plugin!.create_bridge(opts.channel); - - bridge.channels.push({ - id: opts.channel, - disabled: false, - plugin: opts.plugin, - data, - }); - - await set_bridge(opts.lightning, bridge); - - return [true, 'Joined a bridge!']; - } catch (e) { - const err = await log_error(e, { opts }); - return [false, err.message.content!]; - } -} - -export async function leave( - opts: command_arguments, -): Promise<[boolean, string]> { - const bridge = await get_channel_bridge(opts.lightning, opts.channel); - - if (!bridge) { - return [true, "You're not in a bridge, so try joining a bridge first."]; - } - - await set_bridge(opts.lightning, { - ...bridge, - channels: bridge.channels.filter( - (i) => i.id !== opts.channel && i.plugin !== opts.plugin, - ), - }); - - await del_key(opts.lightning, `lightning-bchannel-${opts.channel}`); - - return [true, 'Left a bridge!']; -} - -export async function reset(opts: command_arguments) { - if (typeof opts.opts.name !== 'string') { - opts.opts.name = (await get_channel_bridge(opts.lightning, opts.channel)) - ?.id!; - } - - let [ok, text] = await leave(opts); - if (!ok) return text; - [ok, text] = await join(opts); - if (!ok) return text; - return 'Reset this bridge!'; -} - -export async function toggle(opts: command_arguments) { - const bridge = await get_channel_bridge(opts.lightning, opts.channel); - - if (!bridge) { - return "You're not in a bridge right now. Try joining one first."; - } - - if (!opts.opts.setting) { - return 'You need to specify a setting to toggle. Try `toggle --setting=` instead.'; - } - - if (!['allow_editing', 'use_rawname'].includes(opts.opts.setting)) { - return "That setting doesn't exist! Try `allow_editing` or `use_rawname` instead."; - } - - const setting = opts.opts.setting as 'allow_editing' | 'use_rawname'; - - bridge[setting] = !bridge[setting]; - - await set_bridge(opts.lightning, bridge); - - return 'Toggled that setting!'; -} - -export async function status(args: command_arguments) { - const current = await get_channel_bridge(args.lightning, args.channel); - - if (!current) { - return "You're not in any bridges right now."; - } - - return `This channel is connected to \`${current.id}\`, a bridge with ${ - current.channels.length - 1 - } other channels connected to it, with editing ${ - current.allow_editing ? 'enabled' : 'disabled' - } and nicknames ${current.use_rawname ? 'disabled' : 'enabled'}`; -} diff --git a/packages/lightning/src/bridges/db_internals.ts b/packages/lightning/src/bridges/db_internals.ts deleted file mode 100644 index f08befcb..00000000 --- a/packages/lightning/src/bridges/db_internals.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { lightning } from '../lightning.ts'; -import type { bridge_document } from '../types.ts'; - -export async function get_json( - l: lightning, - key: string, -): Promise { - const reply = await l.redis.sendCommand(['GET', key]); - if (!reply || reply === 'OK') return; - return JSON.parse(reply as string) as T; -} - -export async function del_key(l: lightning, key: string) { - await l.redis.sendCommand(['DEL', key]); -} - -export async function set_json(l: lightning, key: string, value: unknown) { - await l.redis.sendCommand(['SET', key, JSON.stringify(value)]); -} - -export async function get_bridge(l: lightning, id: string) { - return await get_json(l, `lightning-bridge-${id}`); -} - -export async function get_channel_bridge(l: lightning, id: string) { - const ch = await l.redis.sendCommand(['GET', `lightning-bchannel-${id}`]); - return await get_bridge(l, ch as string); -} - -export async function get_message_bridge(l: lightning, id: string) { - return await get_json(l, `lightning-bridged-${id}`); -} - -export async function set_bridge(l: lightning, bridge: bridge_document) { - set_json(l, `lightning-bridge-${bridge.id}`, bridge); - - for (const channel of bridge.channels) { - await l.redis.sendCommand([ - 'SET', - `lightning-bchannel-${channel.id}`, - bridge.id, - ]); - } -} diff --git a/packages/lightning/src/bridges/handle_message.ts b/packages/lightning/src/bridges/handle_message.ts deleted file mode 100644 index 874b8b17..00000000 --- a/packages/lightning/src/bridges/handle_message.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { log_error } from '../errors.ts'; -import type { lightning } from '../lightning.ts'; -import type { - bridge_channel, - bridge_document, - bridge_message, - deleted_message, - message, - process_result, -} from '../types.ts'; -import { - get_channel_bridge, - get_message_bridge, - set_json, -} from './db_internals.ts'; - -export async function handle_message( - lightning: lightning, - msg: message | deleted_message, - type: 'create_message' | 'edit_message' | 'delete_message', -): Promise { - await new Promise((res) => setTimeout(res, 150)); - - if (type !== 'delete_message') { - if (sessionStorage.getItem(`${msg.plugin}-${msg.id}`)) { - return sessionStorage.removeItem(`${msg.plugin}-${msg.id}`); - } else if (type === 'create_message') { - lightning.emit(`create_nonbridged_message`, msg as message); - } - } - - const bridge = type === 'create_message' - ? await get_channel_bridge(lightning, msg.channel) - : await get_message_bridge(lightning, msg.id); - - if (!bridge) return; - - if ( - bridge.channels.find((i) => - i.id === msg.channel && i.plugin === msg.plugin && i.disabled - ) - ) return; - - if (type !== 'create_message' && bridge.allow_editing !== true) return; - - const channels = bridge.channels.filter( - (i) => i.id !== msg.channel || i.plugin !== msg.plugin, - ); - - if (channels.length < 1) return; - - const messages = [] as bridge_message[]; - - for (const channel of channels) { - if (!channel.data || channel.disabled) continue; - - const bridged_id = bridge.messages?.find((i) => - i.channel === channel.id && i.plugin === channel.plugin - ); - - if ((type !== 'create_message' && !bridged_id)) { - continue; - } - - const plugin = lightning.plugins.get(channel.plugin); - - if (!plugin) { - const err = await log_error( - new Error(`plugin ${channel.plugin} doesn't exist`), - { channel, bridged_id }, - ); - - await disable_channel(channel, bridge, lightning, err.e); - - continue; - } - - let dat: process_result; - - const reply_id = await get_reply_id(lightning, msg as message, channel); - - try { - dat = await plugin.process_message({ - // maybe find a better way to deal w/types - action: type.replace('_message', '') as 'edit', - channel, - message: msg as message, - edit_id: bridged_id?.id as string[], - reply_id, - }); - } catch (e) { - dat = { - channel, - disable: false, - error: e, - }; - - if (type === 'delete_message') continue; - } - - if (dat.error) { - if (type === 'delete_message') continue; - - if (dat.disable) { - await disable_channel(channel, bridge, lightning, dat.error); - - continue; - } - - const logged = await log_error(dat.error, { - channel, - dat, - bridged_id, - }); - - try { - dat = await plugin.process_message({ - action: type.replace('_message', '') as 'edit', - channel, - message: logged.message, - edit_id: bridged_id?.id as string[], - reply_id, - }); - - if (dat.error) throw dat.error; - } catch (e) { - await log_error( - new Error('failed to send error', { cause: e }), - { channel, dat, bridged_id, logged: logged.uuid }, - ); - - continue; - } - } - - for (const i of dat.id) { - sessionStorage.setItem(`${channel.plugin}-${i}`, '1'); - } - - messages.push({ - id: dat.id, - channel: channel.id, - plugin: channel.plugin, - }); - } - - for (const i of messages) { - await set_json(lightning, `lightning-bridged-${i.id}`, { - ...bridge, - messages, - }); - } - - await set_json(lightning, `lightning-bridged-${msg.id}`, { - ...bridge, - messages, - }); -} - -async function disable_channel( - channel: bridge_channel, - bridge: bridge_document, - lightning: lightning, - e: Error, -) { - channel.disabled = true; - - bridge.channels = bridge.channels.map((i) => { - if (i.id === channel.id && i.plugin === channel.plugin) { - i.disabled = true; - } - return i; - }); - - await set_json( - lightning, - `lightning-bridge-${bridge.id}`, - bridge, - ); - - const err = new Error( - `disabled channel ${channel.id} on ${channel.plugin}`, - { cause: e }, - ); - - await log_error(err, { channel }); -} - -async function get_reply_id( - lightning: lightning, - msg: message, - channel: bridge_channel, -) { - if (msg.reply_id) { - try { - const bridged = await get_message_bridge(lightning, msg.reply_id); - - if (!bridged) return; - - const bridge_channel = bridged.messages?.find( - (i) => i.channel === channel.id && i.plugin === channel.plugin, - ); - - if (!bridge_channel) return; - - return bridge_channel.id[0]; - } catch { - return; - } - } - return; -} diff --git a/packages/lightning/src/bridges/setup_bridges.ts b/packages/lightning/src/bridges/setup_bridges.ts deleted file mode 100644 index 82d55ae6..00000000 --- a/packages/lightning/src/bridges/setup_bridges.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { lightning } from '../lightning.ts'; -import { join, leave, reset, status, toggle } from './cmd_internals.ts'; -import { handle_message } from './handle_message.ts'; - -export function setup_bridges(l: lightning) { - l.on('create_message', (msg) => { - handle_message(l, msg, 'create_message'); - }); - - l.on('edit_message', (msg) => { - handle_message(l, msg, 'edit_message'); - }); - - l.on('delete_message', (msg) => { - handle_message(l, msg, 'delete_message'); - }); - - l.commands.set('bridge', { - name: 'bridge', - description: 'bridge this channel to somewhere else', - execute: () => `Try running the help command for help with bridges`, - options: { - subcommands: [ - { - name: 'join', - description: 'join a bridge', - execute: async (opts) => (await join(opts))[1], - options: { argument_name: 'name', argument_required: true }, - }, - { - name: 'leave', - description: 'leave a bridge', - execute: async (opts) => (await leave(opts))[1], - }, - { - name: 'reset', - description: 'reset a bridge', - execute: async (opts) => await reset(opts), - options: { argument_name: 'name' }, - }, - { - name: 'toggle', - description: 'toggle a setting on a bridge', - execute: async (opts) => await toggle(opts), - options: { argument_name: 'setting', argument_required: true }, - }, - { - name: 'status', - description: 'see what bridges you are in', - execute: async (opts) => await status(opts), - }, - ], - }, - }); -} diff --git a/packages/lightning/src/cli/migrations.ts b/packages/lightning/src/cli/migrations.ts deleted file mode 100644 index 8c7aec6c..00000000 --- a/packages/lightning/src/cli/migrations.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { RedisClient } from '@iuioiua/r2d2'; -import { get_migrations, versions } from '../migrations.ts'; - -const redis_hostname = prompt( - `what hostname is used by your redis instance?`, - 'localhost', -); -const redis_port = prompt(`what port is used by your redis instance?`, '6379'); - -if (!redis_hostname || !redis_port) Deno.exit(); - -const redis = new RedisClient( - await Deno.connect({ - hostname: redis_hostname, - port: Number(redis_port), - }), -); - -console.log('connected to redis!'); - -console.log(`available versions: ${Object.values(versions).join(', ')}`); - -const from_version = prompt('what version are you migrating from?') as - | versions - | undefined; -const to_version = prompt('what version are you migrating to?') as - | versions - | undefined; - -if (!from_version || !to_version) Deno.exit(); - -const migrations = get_migrations(from_version, to_version); - -if (migrations.length < 1) Deno.exit(); - -console.log(`downloading data from redis...`); - -const keys = (await redis.sendCommand(['KEYS', 'lightning-*'])) as string[]; -const redis_data = [] as [string, unknown][]; - -// sorry database :( - -for (const key of keys) { - const type = await redis.sendCommand(['TYPE', key]); - const val = await redis.sendCommand([ - type === 'string' ? 'GET' : 'JSON.GET', - key, - ]); - - try { - redis_data.push([ - key, - JSON.parse(val as string), - ]); - } catch { - redis_data.push([ - key, - val as string, - ]); - continue; - } -} - -console.log(`downloaded data from redis!`); -console.log(`applying migrations...`); - -const data = migrations.reduce((r, m) => m.translate(r), redis_data); - -const final_data = data.map(([key, value]) => { - return [key, typeof value !== 'string' ? JSON.stringify(value) : value]; -}); - -console.log(`migrated your data!`); - -const file = await Deno.makeTempFile(); - -await Deno.writeTextFile(file, JSON.stringify(final_data, null, 2)); - -const write = confirm( - `do you want the data in ${file} to be written to the database?`, -); - -if (!write) Deno.exit(); - -await redis.sendCommand(['DEL', ...keys]); - -const reply = await redis.sendCommand(['MSET', ...final_data.flat()]); - -if (reply === 'OK') { - console.log('data written to database'); -} else { - console.log('error writing data to database', reply); -} diff --git a/packages/lightning/src/cli/mod.ts b/packages/lightning/src/cli/mod.ts deleted file mode 100644 index ea35474a..00000000 --- a/packages/lightning/src/cli/mod.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { parseArgs } from '@std/cli/parse-args'; -import { log_error } from '../errors.ts'; -import { type config, lightning } from '../lightning.ts'; - -const _ = parseArgs(Deno.args, { - string: ['config'], -}); - -const cmd = _._[0]; - -if (cmd === 'version') { - console.log('0.7.4'); -} else if (cmd === 'run') { - const cfg = (await import(_.config || `${Deno.cwd()}/config.ts`)) - ?.default as config; - - Deno.env.set('LIGHTNING_ERROR_HOOK', cfg.errorURL || ''); - - addEventListener('unhandledrejection', async (e) => { - if (e.reason instanceof Error) { - await log_error(e.reason); - } else { - await log_error(new Error('global rejection'), { - extra: e.reason, - }); - } - - Deno.exit(1); - }); - - addEventListener('error', async (e) => { - if (e.error instanceof Error) { - await log_error(e.error); - } else { - await log_error(new Error('global error'), { extra: e.error }); - } - - Deno.exit(1); - }); - - try { - new lightning( - cfg, - await Deno.connect({ - hostname: cfg.redis_host || 'localhost', - port: cfg.redis_port || 6379, - }), - ); - } catch (e) { - await log_error(e); - Deno.exit(1); - } -} else if (cmd === 'migrations') { - import('./migrations.ts'); -} else { - console.log('lightning v0.7.4 - extensible chatbot connecting communities'); - console.log(' Usage: lightning [subcommand] '); - console.log(' Subcommands:'); - console.log(' run: run an of lightning using the settings in config.ts'); - console.log(' migrations: run migration script'); - console.log(' version: shows version'); - console.log(' Options:'); - console.log(' --config : path to config file'); -} diff --git a/packages/lightning/src/cmds.ts b/packages/lightning/src/cmds.ts deleted file mode 100644 index 96c7f336..00000000 --- a/packages/lightning/src/cmds.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { command } from './commands.ts'; - -export const default_cmds = [ - [ - 'help', - { - name: 'help', - description: 'get help', - execute: () => - 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', - }, - ], - [ - 'version', - { - name: 'version', - description: 'get the bots version', - execute: () => 'hello from v0.7.4!', - }, - ], - [ - 'ping', - { - name: 'ping', - description: 'pong', - execute: ({ timestamp }) => - `Pong! ๐Ÿ“ ${ - Temporal.Now.instant() - .since(timestamp) - .total('milliseconds') - }ms`, - }, - ], -] as [string, command][]; diff --git a/packages/lightning/src/commands.ts b/packages/lightning/src/commands/mod.ts similarity index 50% rename from packages/lightning/src/commands.ts rename to packages/lightning/src/commands/mod.ts index 52fe30f9..0eadcff8 100644 --- a/packages/lightning/src/commands.ts +++ b/packages/lightning/src/commands/mod.ts @@ -1,32 +1,5 @@ -import { parseArgs } from '@std/cli/parse-args'; -import { log_error } from './errors.ts'; -import type { lightning } from './lightning.ts'; -import type { message } from './types.ts'; -import { create_message } from './messages.ts'; - -/** setup commands on an instance of lightning */ -export function setup_commands(l: lightning) { - const prefix = l.config.cmd_prefix || 'l!'; - - l.on('create_nonbridged_message', (m) => { - if (!m.content?.startsWith(prefix)) return; - - const { - _: [cmd, subcmd], - ...opts - } = parseArgs(m.content.replace(prefix, '').split(' ')); - - run_command({ - lightning: l, - cmd: cmd as string, - subcmd: subcmd as string, - opts, - ...m, - }); - }); - - l.on('run_command', (i) => run_command({ lightning: l, ...i })); -} +import type { lightning } from '../lightning.ts'; +import type { message } from '../messages.ts'; /** arguments passed to a command */ export interface command_arguments { @@ -38,6 +11,8 @@ export interface command_arguments { channel: string; /** the plugin its being run on */ plugin: string; + /** the id of the associated event */ + id: string; /** timestamp given */ timestamp: Temporal.Instant; /** options passed by the user */ @@ -49,6 +24,7 @@ export interface command_arguments { } /** options when parsing a command */ +// TODO(jersey): make the options more flexible export interface command_options { /** this will be the key passed to options.opts in the execute function */ argument_name?: string; @@ -70,26 +46,35 @@ export interface command { execute: (options: command_arguments) => Promise | string; } -async function run_command(args: command_arguments) { - let reply; - - try { - const cmd = args.lightning.commands.get(args.cmd) || - args.lightning.commands.get('help')!; - - const exec = cmd.options?.subcommands?.find((i) => - i.name === args.subcmd - )?.execute || - cmd.execute; - - reply = create_message(await exec(args)); - } catch (e) { - reply = (await log_error(e, { ...args, reply: undefined })).message; - } - - try { - await args.reply(reply, false); - } catch (e) { - await log_error(e, { ...args, reply: undefined }); - } -} +export const default_commands = [ + [ + 'help', + { + name: 'help', + description: 'get help', + execute: () => + 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', + }, + ], + [ + 'version', + { + name: 'version', + description: 'get the bots version', + execute: () => 'hello from v0.7.4!', + }, + ], + [ + 'ping', + { + name: 'ping', + description: 'pong', + execute: ({ timestamp }) => + `Pong! ๐Ÿ“ ${ + Temporal.Now.instant() + .since(timestamp) + .total('milliseconds') + }ms`, + }, + ], +] as [string, command][]; diff --git a/packages/lightning/src/commands/run.ts b/packages/lightning/src/commands/run.ts new file mode 100644 index 00000000..69d855fb --- /dev/null +++ b/packages/lightning/src/commands/run.ts @@ -0,0 +1,46 @@ +import type { command_arguments } from './mod.ts'; +import { create_message, type message } from '../messages.ts'; +import { log_error } from '../errors.ts'; +import type { lightning } from '../lightning.ts'; +import { parseArgs } from '@std/cli/parse-args'; + +export function handle_command_message(m: message, l: lightning) { + if (!m.content?.startsWith(l.config.cmd_prefix)) return; + + const { + _: [cmd, subcmd], + ...opts + } = parseArgs(m.content.replace(l.config.cmd_prefix, '').split(' ')); + + run_command({ + lightning: l, + cmd: cmd as string, + subcmd: subcmd as string, + opts, + ...m, + }); +} + +export async function run_command(args: command_arguments) { + let reply; + + try { + const cmd = args.lightning.commands.get(args.cmd) || + args.lightning.commands.get('help')!; + + const exec = cmd.options?.subcommands?.find((i) => + i.name === args.subcmd + )?.execute || + cmd.execute; + + reply = create_message(await exec(args)); + } catch (e) { + reply = (await log_error(e, { ...args, reply: undefined })).message; + } + + try { + await args.reply(reply, false); + } catch (e) { + await log_error(e, { ...args, reply: undefined }); + } +} diff --git a/packages/lightning/src/errors.ts b/packages/lightning/src/errors.ts index 6cb12d2f..b4a44fa5 100644 --- a/packages/lightning/src/errors.ts +++ b/packages/lightning/src/errors.ts @@ -1,14 +1,13 @@ -import { create_message } from './messages.ts'; -import type { message } from './types.ts'; +import { create_message, type message } from './messages.ts'; /** the error returned from log_error */ export interface err { + /** id of the error */ + id: string; /** the original error */ - e: Error; + cause: Error; /** extra information about the error */ extra: Record; - /** the uuid associated with the error */ - uuid: string; /** the message associated with the error */ message: message; } @@ -19,59 +18,55 @@ export interface err { * @param extra any extra data to log */ export async function log_error( - e: Error, + e: unknown, extra: Record = {}, ): Promise { - const uuid = crypto.randomUUID(); - const error_hook = Deno.env.get('LIGHTNING_ERROR_HOOK'); + const id = crypto.randomUUID(); + const webhook = Deno.env.get('LIGHTNING_ERROR_HOOK'); + const cause = e instanceof Error + ? e + : e instanceof Object + ? new Error(JSON.stringify(e)) + : new Error(String(e)); + const user_facing_text = + `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${cause.message}\n${id}\n\`\`\``; - if ('lightning' in extra) delete extra.lightning; + for (const key in extra) { + if (key === 'lightning') { + delete extra[key]; + } + + if (typeof extra[key] === 'object' && extra[key] !== null) { + if ('lightning' in extra[key]) { + delete extra[key].lightning; + } + } + } + + // TODO(jersey): this is a really bad way of error handling-especially given it doesn't do a lot of stuff that would help debug errors-but it'll be replaced + + console.error(`%clightning error ${id}`, 'color: red'); + console.error(cause, extra); - if ( - 'opts' in extra && - 'lightning' in (extra.opts as Record) - ) delete (extra.opts as Record).lightning; + if (webhook && webhook.length > 0) { + let json_str = `\`\`\`json\n${JSON.stringify(extra, null, 2)}\n\`\`\``; - if (error_hook && error_hook.length > 0) { - const resp = await fetch(error_hook, { + if (json_str.length > 2000) json_str = '*see console*'; + + await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - content: `# ${e.message}\n*${uuid}*`, + content: `# ${cause.message}\n*${id}*`, embeds: [ { title: 'extra', - description: `\`\`\`json\n${ - JSON.stringify(extra, null, 2) - }\n\`\`\``, + description: json_str, }, ], }), }); - - if (!resp.ok) { - await fetch(error_hook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: `# ${e.message}\n*${uuid}*`, - embeds: [ - { - title: 'extra', - description: '*see console*', - }, - ], - }), - }); - } } - console.error(`%clightning error ${uuid}`, 'color: red'); - console.error(e, extra); - - const message = create_message( - `Something went wrong! [Look here](https://williamhorning.eu.org/bolt) for help.\n\`\`\`\n${e.message}\n${uuid}\n\`\`\``, - ); - - return { e, uuid, extra, message }; + return { id, cause, extra, message: create_message(user_facing_text) }; } diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index d3fb132f..e30e2cfd 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -1,60 +1,95 @@ -import { EventEmitter } from '@denosaurs/event'; -import { RedisClient } from '@iuioiua/r2d2'; -import { setup_bridges } from './bridges/setup_bridges.ts'; -import { default_cmds } from './cmds.ts'; -import { type command, setup_commands } from './commands.ts'; -import type { create_plugin, plugin, plugin_events } from './plugins.ts'; +import type { ClientOptions } from '@db/postgres'; +import { + type command, + type command_arguments, + default_commands, +} from './commands/mod.ts'; +import type { create_plugin, plugin } from './plugins.ts'; +import { bridge_data } from './bridge/data.ts'; +import { handle_message } from './bridge/msg.ts'; +import { run_command } from './commands/run.ts'; +import { handle_command_message } from './commands/run.ts'; +import type { message } from './messages.ts'; /** configuration options for lightning */ export interface config { + /** database options */ + postgres_options: ClientOptions; /** a list of plugins */ // deno-lint-ignore no-explicit-any - plugins?: create_plugin[]; + plugins?: create_plugin>[]; /** the prefix used for commands */ - cmd_prefix?: string; - /** the set of commands to use */ - commands?: [string, command][]; - /** 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; + cmd_prefix: string; } /** an instance of lightning */ -export class lightning extends EventEmitter { +export class lightning { + /** bridge data handling */ + data: bridge_data; /** the commands registered */ - commands: Map; + commands: Map = new Map(default_commands); /** the config used */ config: config; - /** a redis client */ - redis: RedisClient; + /** set of processed messages */ + private processed: Set<`${string}-${string}`> = new Set(); /** the plugins loaded */ plugins: Map>; - /** setup an instance with the given config and redis connection */ - constructor(config: config, redis_conn: Deno.TcpConn) { - super(); - + /** setup an instance with the given config and bridge data */ + constructor(bridge_data: bridge_data, config: config) { + this.data = bridge_data; this.config = config; - this.commands = new Map(config.commands || default_cmds); - this.redis = new RedisClient(redis_conn); this.plugins = new Map>(); - setup_commands(this); - setup_bridges(this); - for (const p of this.config.plugins || []) { if (p.support.some((v) => ['0.7.3'].includes(v))) { const plugin = new p.type(this, p.config); this.plugins.set(plugin.name, plugin); - (async () => { - for await (const event of plugin) { - this.emit(event.name, ...event.value); - } - })(); + this._handle_events(plugin); } } } + + private async _handle_events(plugin: plugin) { + for await (const event of plugin) { + await new Promise((res) => setTimeout(res, 150)); + + const id = `${event.value[0].plugin}-${event.value[0].id}` as const; + + if (!this.processed.has(id)) { + this.processed.add(id); + + if (event.name === 'run_command') { + run_command({ + ...(event.value[0] as Omit< + command_arguments, + 'lightning' + >), + lightning: this, + }); + + continue; + } + + if (event.name === 'create_message') { + handle_command_message(event.value[0] as message, this); + } + + handle_message( + this, + event.value[0] as message, + event.name.split('_')[0] as 'create', + ); + } else { + this.processed.delete(id); + } + } + } + + /** create a new instance of lightning */ + static async create(config: config) { + const data = await bridge_data.create(config.postgres_options); + + return new lightning(data, config); + } } diff --git a/packages/lightning/src/messages.ts b/packages/lightning/src/messages.ts index f959efc8..4dec59bf 100644 --- a/packages/lightning/src/messages.ts +++ b/packages/lightning/src/messages.ts @@ -1,4 +1,4 @@ -import type { message } from './types.ts'; +import type { bridge_channel } from './bridge/data.ts'; /** * creates a message that can be sent using lightning @@ -21,3 +21,182 @@ export function create_message(text: string): message { }; return data; } + +/** attachments within a message */ +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 */ + size: number; +} + +/** a representation of a message that has been deleted */ +export interface deleted_message { + /** the message's id */ + id: string; + /** the channel the message was sent in */ + channel: string; + /** the plugin that recieved the message */ + plugin: string; + /** the time the message was sent/edited as a temporal instant */ + timestamp: Temporal.Instant; +} + +/** a discord-style embed */ +export interface embed { + /** the author of the embed */ + author?: { + /** the name of the author */ + name: string; + /** the url of the author */ + url?: string; + /** the icon of the author */ + icon_url?: string; + }; + /** the color of the embed */ + color?: number; + /** the text in an embed */ + description?: string; + /** fields within the embed */ + fields?: { + /** the name of the field */ + name: string; + /** the value of the field */ + value: string; + /** whether or not the field is inline */ + inline?: boolean; + }[]; + /** a footer shown in the embed */ + footer?: { + /** the footer text */ + text: string; + /** the icon of the footer */ + icon_url?: string; + }; + /** an image shown in the embed */ + image?: media; + /** a thumbnail shown in the embed */ + thumbnail?: media; + /** the time (in epoch ms) shown in the embed */ + timestamp?: number; + /** the title of the embed */ + title?: string; + /** a site linked to by the embed */ + url?: string; + /** a video inside of the embed */ + video?: media; +} + +/** media inside of an embed */ +export interface media { + /** the height of the media */ + height?: number; + /** the url of the media */ + url: string; + /** the width of the media */ + width?: number; +} + +/** a message recieved by a plugin */ +export interface message extends deleted_message { + /** the attachments sent with the message */ + attachments?: attachment[]; + /** the author of the message */ + 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 */ + 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, optional?: unknown) => Promise; + /** the id of the message replied to */ + reply_id?: string; +} + +/** the options given to plugins when a message needs to be sent */ +export interface create_message_opts { + /** the action to take */ + action: 'create'; + /** the channel to send the message to */ + channel: bridge_channel; + /** the message to send */ + message: message; + /** the id of the message to reply to */ + reply_id?: string; +} + +/** the options given to plugins when a message needs to be edited */ +export interface edit_message_opts { + /** the action to take */ + action: 'edit'; + /** the channel to send the message to */ + channel: bridge_channel; + /** the message to send */ + message: message; + /** the id of the message to edit */ + edit_id: string[]; + /** the id of the message to reply to */ + reply_id?: string; +} + +/** the options given to plugins when a message needs to be deleted */ +export interface delete_message_opts { + /** the action to take */ + action: 'delete'; + /** the channel to send the message to */ + channel: bridge_channel; + /** the message to send */ + message: deleted_message; + /** the id of the message to delete */ + edit_id: string[]; + /** the id of the message to reply to */ + reply_id?: string; +} + +/** the options given to plugins when a message needs to be processed */ +export type message_options = + | create_message_opts + | edit_message_opts + | delete_message_opts; + +/** successfully processed message */ +export interface processed_message { + /** whether there was an error */ + error?: undefined; + /** the message that was processed */ + id: string[]; + /** the channel the message was sent to */ + channel: bridge_channel; +} + +/** messages not processed */ +export interface unprocessed_message { + /** the channel the message was to be sent to */ + channel: bridge_channel; + /** whether the channel should be disabled */ + disable: boolean; + /** the error causing this */ + error: Error; +} + +/** process result */ +export type process_result = processed_message | unprocessed_message; diff --git a/packages/lightning/src/migrations.ts b/packages/lightning/src/migrations.ts deleted file mode 100644 index a4a64099..00000000 --- a/packages/lightning/src/migrations.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * get migrations that can then be applied using apply_migrations - * @param from the version that the data is currently in - * @param to the version that the data will be migrated to - */ -export function get_migrations(from: versions, to: versions): migration[] { - return migrations.slice( - migrations.findIndex((i) => i.from === from), - migrations.findLastIndex((i) => i.to === to) + 1, - ); -} - -/** the type of a migration */ -export interface migration { - /** the version to translate from */ - from: versions; - /** the version to translate to */ - to: versions; - /** a function to translate a document */ - translate: (data: [string, unknown][]) => [string, unknown][]; -} - -/** all of the versions with migrations to/from them */ -export enum versions { - /** versions 0.7 through 0.7.2 */ - Seven = '0.7', - /** versions 0.7.3 and higher */ - SevenDotThree = '0.7.3', -} - -/** the internal list of migrations */ -const migrations = [ - { - from: versions.Seven, - to: versions.SevenDotThree, - translate: (items) => - items.map(([key, val]) => { - if (!key.startsWith('lightning-bridge')) return [key, val]; - - return [ - key, - { - ...(val as Record), - channels: (val as { - channels: { - id: string; - data: unknown; - plugin: string; - }[]; - }).channels?.map((i) => { - return { - data: i.data, - disabled: false, - id: i.id, - plugin: i.plugin, - }; - }), - messages: (val as { - messages: { - id: string | string[]; - channel: string; - plugin: string; - }[]; - }).messages?.map((i) => { - if (typeof i.id === 'string') { - return { - ...i, - id: [i.id], - }; - } - return i; - }), - }, - ]; - }), - }, -] as migration[]; diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts deleted file mode 100644 index 07325aac..00000000 --- a/packages/lightning/src/mod.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * lightning is a typescript-based chatbot that supports - * bridging multiple chat apps via plugins. - * @module - */ - -export type * from './commands.ts'; -export * from './errors.ts'; -export * from './lightning.ts'; -export * from './messages.ts'; -export * from './migrations.ts'; -export * from './plugins.ts'; -export * from './types.ts'; diff --git a/packages/lightning/src/plugins.ts b/packages/lightning/src/plugins.ts index e37a178a..775aa12f 100644 --- a/packages/lightning/src/plugins.ts +++ b/packages/lightning/src/plugins.ts @@ -1,12 +1,12 @@ import { EventEmitter } from '@denosaurs/event'; -import type { command_arguments } from './commands.ts'; +import type { command_arguments } from './commands/mod.ts'; import type { lightning } from './lightning.ts'; import type { deleted_message, message, message_options, process_result, -} from './types.ts'; +} from './messages.ts'; /** the way to make a plugin */ export interface create_plugin< @@ -24,8 +24,6 @@ export interface create_plugin< export type plugin_events = { /** when a message is created */ create_message: [message]; - /** when a message isn't already bridged (don't emit outside of core) */ - create_nonbridged_message: [message]; /** when a message is edited */ edit_message: [message]; /** when a message is deleted */ diff --git a/packages/lightning/src/types.ts b/packages/lightning/src/types.ts deleted file mode 100644 index 9ed989dc..00000000 --- a/packages/lightning/src/types.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** attachments within a message */ -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 */ - size: number; -} - -/** channel within a bridge */ -export interface bridge_channel { - /** the id of this channel */ - id: string; - /** the data needed to bridge this channel */ - data: unknown; - /** whether the channel is disabled */ - disabled: boolean; - /** the plugin used to bridge this channel */ - plugin: string; -} - -/** the representation of a bridge */ -export interface bridge_document { - /** whether or not to allow editing */ - allow_editing: boolean; - /** the channels to be bridged */ - channels: bridge_channel[]; - /** the id of the bridge */ - id: string; - /** messages bridged using these channels */ - messages?: bridge_message[]; - /** whether or not to use nicknames */ - use_rawname: boolean; -} - -/** bridged messages */ -export interface bridge_message { - /** the id of the message */ - id: string[]; - /** the id of the channel the message was sent in */ - channel: string; - /** the plugin the message was sent using */ - plugin: string; -} - -/** a representation of a message that has been deleted */ -export interface deleted_message { - /** the message's id */ - id: string; - /** the channel the message was sent in */ - channel: string; - /** the plugin that recieved the message */ - plugin: string; - /** the time the message was sent/edited as a temporal instant */ - timestamp: Temporal.Instant; -} - -/** a discord-style embed */ -export interface embed { - /** the author of the embed */ - author?: { - /** the name of the author */ - name: string; - /** the url of the author */ - url?: string; - /** the icon of the author */ - icon_url?: string; - }; - /** the color of the embed */ - color?: number; - /** the text in an embed */ - description?: string; - /** fields within the embed */ - fields?: { - /** the name of the field */ - name: string; - /** the value of the field */ - value: string; - /** whether or not the field is inline */ - inline?: boolean; - }[]; - /** a footer shown in the embed */ - footer?: { - /** the footer text */ - text: string; - /** the icon of the footer */ - icon_url?: string; - }; - /** an image shown in the embed */ - image?: media; - /** a thumbnail shown in the embed */ - thumbnail?: media; - /** the time (in epoch ms) shown in the embed */ - timestamp?: number; - /** the title of the embed */ - title?: string; - /** a site linked to by the embed */ - url?: string; - /** a video inside of the embed */ - video?: media; -} - -/** media inside of an embed */ -export interface media { - /** the height of the media */ - height?: number; - /** the url of the media */ - url: string; - /** the width of the media */ - width?: number; -} - -/** a message recieved by a plugin */ -export interface message extends deleted_message { - /** the attachments sent with the message */ - attachments?: attachment[]; - /** the author of the message */ - 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 */ - 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, optional?: unknown) => Promise; - /** the id of the message replied to */ - reply_id?: string; -} - -/** the options given to plugins when a message needs to be sent */ -export interface create_message_opts { - /** the action to take */ - action: 'create'; - /** the channel to send the message to */ - channel: bridge_channel; - /** the message to send */ - message: message; - /** the id of the message to reply to */ - reply_id?: string; -} - -/** the options given to plugins when a message needs to be edited */ -export interface edit_message_opts { - /** the action to take */ - action: 'edit'; - /** the channel to send the message to */ - channel: bridge_channel; - /** the message to send */ - message: message; - /** the id of the message to edit */ - edit_id: string[]; - /** the id of the message to reply to */ - reply_id?: string; -} - -/** the options given to plugins when a message needs to be deleted */ -export interface delete_message_opts { - /** the action to take */ - action: 'delete'; - /** the channel to send the message to */ - channel: bridge_channel; - /** the message to send */ - message: deleted_message; - /** the id of the message to delete */ - edit_id: string[]; - /** the id of the message to reply to */ - reply_id?: string; -} - -/** the options given to plugins when a message needs to be processed */ -export type message_options = - | create_message_opts - | edit_message_opts - | delete_message_opts; - -/** successfully processed message */ -export interface processed_message { - /** whether there was an error */ - error?: undefined; - /** the message that was processed */ - id: string[]; - /** the channel the message was sent to */ - channel: bridge_channel; -} - -/** messages not processed */ -export interface unprocessed_message { - /** the channel the message was to be sent to */ - channel: bridge_channel; - /** whether the channel should be disabled */ - disable: boolean; - /** the error causing this */ - error: Error; -} - -/** process result */ -export type process_result = processed_message | unprocessed_message; From da596ca1382dd85693c96e29872d6bd0d525766a Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 16 Nov 2024 17:11:08 -0500 Subject: [PATCH 04/97] cleanup and add back cli --- .gitignore | 3 +- packages/lightning/deno.jsonc | 8 ++--- packages/lightning/src/bridge/cmd.ts | 2 -- packages/lightning/src/cli/mod.ts | 53 ++++++++++++++++++++++++++++ packages/lightning/src/errors.ts | 2 +- 5 files changed, 60 insertions(+), 8 deletions(-) create mode 100644 packages/lightning/src/cli/mod.ts diff --git a/.gitignore b/.gitignore index 690e9262..c3da77e8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.env /config /config.ts -packages/lightning-old \ No newline at end of file +packages/lightning-old +packages/postgres \ No newline at end of file diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index cbdcc1dd..d589a245 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -3,13 +3,13 @@ "version": "0.8.0-alpha.0", "exports": { ".": "./src/mod.ts", - // TODO(jersey): add the cli back in along with migrations, except make migrations not suck as much? "./cli": "./src/cli/mod.ts" }, "imports": { - "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", "@db/postgres": "jsr:@db/postgres@^0.19.4", - "@std/ulid": "jsr:@std/ulid@^1.0.0", - "@std/cli/parse-args": "jsr:@std/cli@^1.0.3/parse-args" + "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", + "@std/cli/parse-args": "jsr:@std/cli@^1.0.3/parse-args", + "@std/path": "jsr:@std/path@^1.0.0", + "@std/ulid": "jsr:@std/ulid@^1.0.0" } } diff --git a/packages/lightning/src/bridge/cmd.ts b/packages/lightning/src/bridge/cmd.ts index 5ce6ed30..2663b718 100644 --- a/packages/lightning/src/bridge/cmd.ts +++ b/packages/lightning/src/bridge/cmd.ts @@ -11,8 +11,6 @@ export const bridge_command = { { name: 'join', description: 'join a bridge', - // TODO(jersey): update this to support multiple options - // TODO(jersey): make command options more flexible options: { argument_name: 'name', argument_required: true }, execute: async ({ lightning, channel, opts, plugin }) => { const current_bridge = await lightning.data diff --git a/packages/lightning/src/cli/mod.ts b/packages/lightning/src/cli/mod.ts new file mode 100644 index 00000000..dc432da7 --- /dev/null +++ b/packages/lightning/src/cli/mod.ts @@ -0,0 +1,53 @@ +import { parseArgs } from '@std/cli/parse-args'; +import { join, toFileUrl } from '@std/path'; +import { log_error } from '../errors.ts'; +import { type config, lightning } from '../lightning.ts'; + +const version = '0.8.0-alpha.0'; +const _ = parseArgs(Deno.args); + +if (_.v || _.version) { + console.log(version); +} else if (_.h || _.help) { + run_help(); +} else if (_._[0] === 'run') { + if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); + + const config: config = await import(toFileUrl(_.config).toString()); + + addEventListener('error', async (ev) => { + await log_error(ev.error, { type: 'global error' }); + Deno.exit(1); + }); + + addEventListener('unhandledrejection', async (ev) => { + await log_error(ev.reason, { type: 'global rejection' }); + Deno.exit(1); + }); + + try { + await lightning.create(config); + } catch (e) { + await log_error(e, { type: 'global class error' }); + Deno.exit(1); + } +} else if (_._[0] === 'migrations') { + // TODO(jersey): implement migrations +} else { + console.log('[lightning] command not found, showing help'); + run_help(); +} + +function run_help() { + console.log(`lightning v${version} - extensible chatbot connecting communities`); + console.log(' Usage: lightning [subcommand] '); + console.log(' Subcommands:'); + console.log(' run: run a lightning instance'); + console.log(' migrations: run migration script'); + console.log(' Options:'); + console.log(' -h, --help: display this help message'); + console.log(' -v, --version: display the version number'); + console.log(' -c, --config: the config file to use'); + console.log(' Environment Variables:'); + console.log(' LIGHTNING_ERROR_WEBHOOK: the webhook to send errors to'); +} diff --git a/packages/lightning/src/errors.ts b/packages/lightning/src/errors.ts index b4a44fa5..2a764236 100644 --- a/packages/lightning/src/errors.ts +++ b/packages/lightning/src/errors.ts @@ -22,7 +22,7 @@ export async function log_error( extra: Record = {}, ): Promise { const id = crypto.randomUUID(); - const webhook = Deno.env.get('LIGHTNING_ERROR_HOOK'); + const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); const cause = e instanceof Error ? e : e instanceof Object From 5b35253d91e89c171dba303634c020ba3c80113a Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 16 Nov 2024 17:38:22 -0500 Subject: [PATCH 05/97] cleanup some stuff --- packages/lightning-plugin-discord/deno.json | 4 +- .../src/process_message.ts | 9 ++- packages/lightning-plugin-guilded/deno.json | 4 +- packages/lightning-plugin-guilded/src/mod.ts | 12 +++- packages/lightning-plugin-revolt/deno.json | 4 +- packages/lightning-plugin-revolt/src/mod.ts | 11 ++- packages/lightning-plugin-telegram/deno.json | 4 +- .../lightning-plugin-telegram/src/messages.ts | 4 +- packages/lightning-plugin-telegram/src/mod.ts | 4 +- packages/lightning/deno.jsonc | 2 +- packages/lightning/dockerfile | 2 +- packages/lightning/src/bridge/data.ts | 2 +- packages/lightning/src/cli/mod.ts | 48 +++++++------ packages/lightning/src/commands/run.ts | 70 +++++++++---------- packages/lightning/src/lightning.ts | 2 +- packages/lightning/src/messages.ts | 1 + packages/lightning/src/mod.ts | 9 +++ 17 files changed, 110 insertions(+), 82 deletions(-) create mode 100644 packages/lightning/src/mod.ts diff --git a/packages/lightning-plugin-discord/deno.json b/packages/lightning-plugin-discord/deno.json index c2e5f6b2..b22054a0 100644 --- a/packages/lightning-plugin-discord/deno.json +++ b/packages/lightning-plugin-discord/deno.json @@ -1,9 +1,9 @@ { "name": "@jersey/lightning-plugin-discord", - "version": "0.7.4", + "version": "0.8.0", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.7.3", + "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "@discordjs/core": "npm:@discordjs/core@^1.2.0", "@discordjs/rest": "npm:@discordjs/rest@^2.3.0", "@discordjs/ws": "npm:@discordjs/ws@^1.1.1", diff --git a/packages/lightning-plugin-discord/src/process_message.ts b/packages/lightning-plugin-discord/src/process_message.ts index c8068b73..ef5f8816 100644 --- a/packages/lightning-plugin-discord/src/process_message.ts +++ b/packages/lightning-plugin-discord/src/process_message.ts @@ -46,10 +46,13 @@ export async function process_message(api: API, opts: message_options) { channel: opts.channel, }; } catch (e) { - if (e.status === 404 && opts.action !== 'edit') { + if ( + (e as { status: number }).status === 404 && + opts.action !== 'edit' + ) { return { channel: opts.channel, - error: e, + error: e as Error, disable: true, }; } else { @@ -71,7 +74,7 @@ export async function process_message(api: API, opts: message_options) { } catch (e) { return { channel: opts.channel, - error: e, + error: e as Error, disable: false, }; } diff --git a/packages/lightning-plugin-guilded/deno.json b/packages/lightning-plugin-guilded/deno.json index aa518307..622bc6ba 100644 --- a/packages/lightning-plugin-guilded/deno.json +++ b/packages/lightning-plugin-guilded/deno.json @@ -1,9 +1,9 @@ { "name": "@jersey/lightning-plugin-guilded", - "version": "0.7.4", + "version": "0.8.0", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.7.3", + "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "guilded.js": "npm:guilded.js@^0.25.0", "@guildedjs/api": "npm:@guildedjs/api@^0.4.0" } diff --git a/packages/lightning-plugin-guilded/src/mod.ts b/packages/lightning-plugin-guilded/src/mod.ts index 47716f57..948a2f82 100644 --- a/packages/lightning-plugin-guilded/src/mod.ts +++ b/packages/lightning-plugin-guilded/src/mod.ts @@ -73,13 +73,19 @@ export class guilded_plugin extends plugin { channel: opts.channel, }; } catch (e) { - if (e.response.status === 404) { + if ( + (e as { response: { status: number } }).response + .status === 404 + ) { return { channel: opts.channel, disable: true, error: new Error('webhook not found!'), }; - } else if (e.response.status === 403) { + } else if ( + (e as { response: { status: number } }).response + .status === 403 + ) { return { channel: opts.channel, disable: true, @@ -111,7 +117,7 @@ export class guilded_plugin extends plugin { // TODO(@williamhorning): improve error handling return { channel: opts.channel, - error: e, + error: e as Error, disable: false, }; } diff --git a/packages/lightning-plugin-revolt/deno.json b/packages/lightning-plugin-revolt/deno.json index ebdf6b81..36faa946 100644 --- a/packages/lightning-plugin-revolt/deno.json +++ b/packages/lightning-plugin-revolt/deno.json @@ -1,9 +1,9 @@ { "name": "@jersey/lightning-plugin-revolt", - "version": "0.7.4", + "version": "0.8.0", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.7.3", + "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.3", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.7.14", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/lightning-plugin-revolt/src/mod.ts b/packages/lightning-plugin-revolt/src/mod.ts index 0b283d82..675bea14 100644 --- a/packages/lightning-plugin-revolt/src/mod.ts +++ b/packages/lightning-plugin-revolt/src/mod.ts @@ -88,11 +88,16 @@ export class revolt_plugin extends plugin { id: [resp._id], }; } catch (e) { - if (e.cause.status === 403 || e.cause.status === 404) { + if ( + (e as { cause: { status: number } }).cause.status === + 403 || + (e as { cause: { status: number } }).cause.status === + 404 + ) { return { channel: opts.channel, disable: true, - error: e, + error: e as Error, }; } else { throw e; @@ -128,7 +133,7 @@ export class revolt_plugin extends plugin { return { channel: opts.channel, disable: false, - error: e, + error: e as Error, }; } } diff --git a/packages/lightning-plugin-telegram/deno.json b/packages/lightning-plugin-telegram/deno.json index eabd50da..82137b38 100644 --- a/packages/lightning-plugin-telegram/deno.json +++ b/packages/lightning-plugin-telegram/deno.json @@ -1,9 +1,9 @@ { "name": "@jersey/lightning-plugin-telegram", - "version": "0.7.4", + "version": "0.8.0", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.7.3", + "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "telegramify-markdown": "npm:telegramify-markdown@^1.2.2", "grammy": "npm:grammy@^1.28.0", "grammy/types": "npm:grammy@^1.28.0/types" diff --git a/packages/lightning-plugin-telegram/src/messages.ts b/packages/lightning-plugin-telegram/src/messages.ts index f3b09fd0..6e6cd568 100644 --- a/packages/lightning-plugin-telegram/src/messages.ts +++ b/packages/lightning-plugin-telegram/src/messages.ts @@ -115,7 +115,9 @@ async function get_base_msg( }, channel: msg.chat.id.toString(), id: msg.message_id.toString(), - timestamp: Temporal.Instant.fromEpochMilliseconds((msg.edit_date || msg.date) * 1000), + timestamp: Temporal.Instant.fromEpochMilliseconds( + (msg.edit_date || msg.date) * 1000, + ), plugin: 'bolt-telegram', reply: async (lmsg) => { for (const m of from_lightning(lmsg)) { diff --git a/packages/lightning-plugin-telegram/src/mod.ts b/packages/lightning-plugin-telegram/src/mod.ts index 02bb3c9f..1dc58c7a 100644 --- a/packages/lightning-plugin-telegram/src/mod.ts +++ b/packages/lightning-plugin-telegram/src/mod.ts @@ -118,9 +118,9 @@ export class telegram_plugin extends plugin { } catch (e) { // TODO(@williamhorning): improve error handling logic return { - error: e, - id: [opts.message.id], + error: e as Error, channel: opts.channel, + disable: false, }; } } diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index d589a245..3c2c21fb 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -1,6 +1,6 @@ { "name": "@jersey/lightning", - "version": "0.8.0-alpha.0", + "version": "0.8.0", "exports": { ".": "./src/mod.ts", "./cli": "./src/cli/mod.ts" diff --git a/packages/lightning/dockerfile b/packages/lightning/dockerfile index b1fec7be..4bb88313 100644 --- a/packages/lightning/dockerfile +++ b/packages/lightning/dockerfile @@ -1,7 +1,7 @@ FROM docker.io/denoland/deno:2.0.3 # add lightning to the image -RUN deno install -g -A --unstable-temporal jsr:@jersey/lightning@0.8.0-alpha.0/cli +RUN deno install -g -A --unstable-temporal jsr:@jersey/lightning@0.8.0/cli RUN mkdir -p /app/data WORKDIR /app/data diff --git a/packages/lightning/src/bridge/data.ts b/packages/lightning/src/bridge/data.ts index f9dd4ba1..56407e46 100644 --- a/packages/lightning/src/bridge/data.ts +++ b/packages/lightning/src/bridge/data.ts @@ -35,7 +35,7 @@ export interface bridged_message { export class bridge_data { private pg: Client; - static async create(pg_options: ClientOptions) { + static async create(pg_options: ClientOptions): Promise { const pg = new Client(pg_options); await pg.connect(); diff --git a/packages/lightning/src/cli/mod.ts b/packages/lightning/src/cli/mod.ts index dc432da7..fa945bbc 100644 --- a/packages/lightning/src/cli/mod.ts +++ b/packages/lightning/src/cli/mod.ts @@ -3,43 +3,45 @@ import { join, toFileUrl } from '@std/path'; import { log_error } from '../errors.ts'; import { type config, lightning } from '../lightning.ts'; -const version = '0.8.0-alpha.0'; +const version = '0.8.0'; const _ = parseArgs(Deno.args); if (_.v || _.version) { - console.log(version); + console.log(version); } else if (_.h || _.help) { - run_help(); + run_help(); } else if (_._[0] === 'run') { - if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); + if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); - const config: config = await import(toFileUrl(_.config).toString()); + const config: config = await import(toFileUrl(_.config).toString()); - addEventListener('error', async (ev) => { - await log_error(ev.error, { type: 'global error' }); - Deno.exit(1); - }); + addEventListener('error', async (ev) => { + await log_error(ev.error, { type: 'global error' }); + Deno.exit(1); + }); - addEventListener('unhandledrejection', async (ev) => { - await log_error(ev.reason, { type: 'global rejection' }); - Deno.exit(1); - }); + addEventListener('unhandledrejection', async (ev) => { + await log_error(ev.reason, { type: 'global rejection' }); + Deno.exit(1); + }); - try { - await lightning.create(config); - } catch (e) { - await log_error(e, { type: 'global class error' }); - Deno.exit(1); - } + try { + await lightning.create(config); + } catch (e) { + await log_error(e, { type: 'global class error' }); + Deno.exit(1); + } } else if (_._[0] === 'migrations') { - // TODO(jersey): implement migrations + // TODO(jersey): implement migrations } else { - console.log('[lightning] command not found, showing help'); - run_help(); + console.log('[lightning] command not found, showing help'); + run_help(); } function run_help() { - console.log(`lightning v${version} - extensible chatbot connecting communities`); + console.log( + `lightning v${version} - extensible chatbot connecting communities`, + ); console.log(' Usage: lightning [subcommand] '); console.log(' Subcommands:'); console.log(' run: run a lightning instance'); diff --git a/packages/lightning/src/commands/run.ts b/packages/lightning/src/commands/run.ts index 69d855fb..04b48f20 100644 --- a/packages/lightning/src/commands/run.ts +++ b/packages/lightning/src/commands/run.ts @@ -5,42 +5,42 @@ import type { lightning } from '../lightning.ts'; import { parseArgs } from '@std/cli/parse-args'; export function handle_command_message(m: message, l: lightning) { - if (!m.content?.startsWith(l.config.cmd_prefix)) return; - - const { - _: [cmd, subcmd], - ...opts - } = parseArgs(m.content.replace(l.config.cmd_prefix, '').split(' ')); - - run_command({ - lightning: l, - cmd: cmd as string, - subcmd: subcmd as string, - opts, - ...m, - }); + if (!m.content?.startsWith(l.config.cmd_prefix)) return; + + const { + _: [cmd, subcmd], + ...opts + } = parseArgs(m.content.replace(l.config.cmd_prefix, '').split(' ')); + + run_command({ + lightning: l, + cmd: cmd as string, + subcmd: subcmd as string, + opts, + ...m, + }); } export async function run_command(args: command_arguments) { - let reply; - - try { - const cmd = args.lightning.commands.get(args.cmd) || - args.lightning.commands.get('help')!; - - const exec = cmd.options?.subcommands?.find((i) => - i.name === args.subcmd - )?.execute || - cmd.execute; - - reply = create_message(await exec(args)); - } catch (e) { - reply = (await log_error(e, { ...args, reply: undefined })).message; - } - - try { - await args.reply(reply, false); - } catch (e) { - await log_error(e, { ...args, reply: undefined }); - } + let reply; + + try { + const cmd = args.lightning.commands.get(args.cmd) || + args.lightning.commands.get('help')!; + + const exec = cmd.options?.subcommands?.find((i) => + i.name === args.subcmd + )?.execute || + cmd.execute; + + reply = create_message(await exec(args)); + } catch (e) { + reply = (await log_error(e, { ...args, reply: undefined })).message; + } + + try { + await args.reply(reply, false); + } catch (e) { + await log_error(e, { ...args, reply: undefined }); + } } diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index e30e2cfd..d7c2d7d6 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -87,7 +87,7 @@ export class lightning { } /** create a new instance of lightning */ - static async create(config: config) { + static async create(config: config): Promise { const data = await bridge_data.create(config.postgres_options); return new lightning(data, config); diff --git a/packages/lightning/src/messages.ts b/packages/lightning/src/messages.ts index 4dec59bf..1720f751 100644 --- a/packages/lightning/src/messages.ts +++ b/packages/lightning/src/messages.ts @@ -195,6 +195,7 @@ export interface unprocessed_message { /** whether the channel should be disabled */ disable: boolean; /** the error causing this */ + // TODO(jersey): make this unknown ideally error: Error; } diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts new file mode 100644 index 00000000..57180e60 --- /dev/null +++ b/packages/lightning/src/mod.ts @@ -0,0 +1,9 @@ +export type { + command, + command_arguments, + command_options, +} from './commands/mod.ts'; +export { log_error } from './errors.ts'; +export { type config, lightning } from './lightning.ts'; +export * from './messages.ts'; +export * from './plugins.ts'; From 9fef90133e414d00a65919fc60feedcd33cf86d7 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 16 Nov 2024 17:48:09 -0500 Subject: [PATCH 06/97] clean up error handling a little --- .../src/process_message.ts | 1 - packages/lightning-plugin-guilded/src/mod.ts | 1 - packages/lightning-plugin-revolt/src/mod.ts | 1 - packages/lightning-plugin-telegram/src/mod.ts | 1 - packages/lightning/dockerfile | 5 ++--- packages/lightning/src/bridge/cmd.ts | 17 +++++++---------- packages/lightning/src/messages.ts | 2 +- 7 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/lightning-plugin-discord/src/process_message.ts b/packages/lightning-plugin-discord/src/process_message.ts index ef5f8816..6edbe54d 100644 --- a/packages/lightning-plugin-discord/src/process_message.ts +++ b/packages/lightning-plugin-discord/src/process_message.ts @@ -75,7 +75,6 @@ export async function process_message(api: API, opts: message_options) { return { channel: opts.channel, error: e as Error, - disable: false, }; } } diff --git a/packages/lightning-plugin-guilded/src/mod.ts b/packages/lightning-plugin-guilded/src/mod.ts index 948a2f82..d034e04b 100644 --- a/packages/lightning-plugin-guilded/src/mod.ts +++ b/packages/lightning-plugin-guilded/src/mod.ts @@ -118,7 +118,6 @@ export class guilded_plugin extends plugin { return { channel: opts.channel, error: e as Error, - disable: false, }; } } diff --git a/packages/lightning-plugin-revolt/src/mod.ts b/packages/lightning-plugin-revolt/src/mod.ts index 675bea14..03b87f7a 100644 --- a/packages/lightning-plugin-revolt/src/mod.ts +++ b/packages/lightning-plugin-revolt/src/mod.ts @@ -132,7 +132,6 @@ export class revolt_plugin extends plugin { } catch (e) { return { channel: opts.channel, - disable: false, error: e as Error, }; } diff --git a/packages/lightning-plugin-telegram/src/mod.ts b/packages/lightning-plugin-telegram/src/mod.ts index 1dc58c7a..4e211d9f 100644 --- a/packages/lightning-plugin-telegram/src/mod.ts +++ b/packages/lightning-plugin-telegram/src/mod.ts @@ -120,7 +120,6 @@ export class telegram_plugin extends plugin { return { error: e as Error, channel: opts.channel, - disable: false, }; } } diff --git a/packages/lightning/dockerfile b/packages/lightning/dockerfile index 4bb88313..817f3e77 100644 --- a/packages/lightning/dockerfile +++ b/packages/lightning/dockerfile @@ -1,11 +1,10 @@ FROM docker.io/denoland/deno:2.0.3 # add lightning to the image -RUN deno install -g -A --unstable-temporal jsr:@jersey/lightning@0.8.0/cli +RUN deno install -g -A --unstable-temporal ./cli/mod.ts RUN mkdir -p /app/data WORKDIR /app/data # set lightning as the entrypoint and use the run command by default ENTRYPOINT [ "lightning" ] -# TODO(jersey): do i need to do this? -CMD [ "run", "--config", "file:///app/data/config.ts"] +CMD [ "run"] diff --git a/packages/lightning/src/bridge/cmd.ts b/packages/lightning/src/bridge/cmd.ts index 2663b718..4ff4d934 100644 --- a/packages/lightning/src/bridge/cmd.ts +++ b/packages/lightning/src/bridge/cmd.ts @@ -3,14 +3,13 @@ import { log_error } from '../errors.ts'; export const bridge_command = { name: 'bridge', - description: 'bridge commands', - execute: () => 'take a look at the docs for help with bridges', + description: 'Bridge commands', + execute: () => 'Take a look at the docs for help with bridges', options: { subcommands: [ - // TODO(jersey): eventually reimplement reset command? { name: 'join', - description: 'join a bridge', + description: 'Join a bridge', options: { argument_name: 'name', argument_required: true }, execute: async ({ lightning, channel, opts, plugin }) => { const current_bridge = await lightning.data @@ -18,8 +17,6 @@ export const bridge_command = { channel, ); - // live laugh love validation - if (current_bridge) { return `You are already in a bridge called ${current_bridge.name}`; } @@ -103,7 +100,7 @@ export const bridge_command = { }, { name: 'leave', - description: 'leave a bridge', + description: 'Leave a bridge', execute: async ({ lightning, channel }) => { const bridge = await lightning.data.get_bridge_by_channel( channel, @@ -132,7 +129,7 @@ export const bridge_command = { }, { name: 'toggle', - description: 'toggle a setting on a bridge', + description: 'Toggle a setting on a bridge', options: { argument_name: 'setting', argument_required: true }, execute: async ({ opts, lightning, channel }) => { const bridge = await lightning.data.get_bridge_by_channel( @@ -145,7 +142,7 @@ export const bridge_command = { !['allow_editing', 'allow_everyone', 'use_rawname'] .includes(opts.setting) ) { - return `that setting does not exist`; + return `That setting does not exist`; } const key = opts.setting as keyof typeof bridge.settings; @@ -170,7 +167,7 @@ export const bridge_command = { }, { name: 'status', - description: 'see what bridges you are in', + description: 'See what bridges you are in', execute: async ({ lightning, channel }) => { const existing_bridge = await lightning.data .get_bridge_by_channel( diff --git a/packages/lightning/src/messages.ts b/packages/lightning/src/messages.ts index 1720f751..c8a6ba96 100644 --- a/packages/lightning/src/messages.ts +++ b/packages/lightning/src/messages.ts @@ -193,7 +193,7 @@ export interface unprocessed_message { /** the channel the message was to be sent to */ channel: bridge_channel; /** whether the channel should be disabled */ - disable: boolean; + disable?: boolean; /** the error causing this */ // TODO(jersey): make this unknown ideally error: Error; From 93833f792016dbee66ac5d9881d5e8b0ad0a652e Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 27 Nov 2024 16:56:32 -0500 Subject: [PATCH 07/97] clean up the data structures a little bit? --- packages/lightning/src/bridge/data.ts | 29 ++++++++++++--------------- packages/lightning/src/bridge/msg.ts | 8 +++++--- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/packages/lightning/src/bridge/data.ts b/packages/lightning/src/bridge/data.ts index 56407e46..d16fa87e 100644 --- a/packages/lightning/src/bridge/data.ts +++ b/packages/lightning/src/bridge/data.ts @@ -21,9 +21,12 @@ export interface bridge_settings { use_rawname: boolean; /* rawname = username */ } -export interface bridge_message extends bridge { - original_id: string; /* original message id */ +export interface bridge_message { + id: string; /* original message id */ + bridge_id: string; /* bridge id */ + channels: bridge_channel[]; /* channels bridged */ messages: bridged_message[]; /* bridged messages */ + settings: bridge_settings; /* settings for the bridge */ } export interface bridged_message { @@ -57,13 +60,11 @@ export class bridge_data { settings JSONB NOT NULL )`; await pg.queryArray`CREATE TABLE bridge_messages ( - original_id TEXT PRIMARY KEY, - id TEXT NOT NULL, - name TEXT NOT NULL, - channels JSONB NOT NULL REFERENCES bridges(channels), + id TEXT PRIMARY KEY, + bridge_id TEXT NOT NULL, + channels JSONB NOT NULL, messages JSONB NOT NULL, - settings JSONB NOT NULL REFERENCES bridges(settings), - CONSTRAINT fk_id FOREIGN KEY(id) REFERENCES bridges(id) + settings JSONB NOT NULL )`; } @@ -81,14 +82,11 @@ export class bridge_data { return { id, ...bridge }; } - async update_bridge(bridge: bridge): Promise { + async update_bridge(bridge: {channels: bridge_channel[], settings: bridge_settings, id: string}): Promise { await this.pg.queryArray`UPDATE bridges SET - name = ${bridge.name}, channels = ${bridge.channels}, settings = ${bridge.settings} WHERE id = ${bridge.id}`; - - return bridge; } async get_bridge_by_id(id: string): Promise { @@ -107,8 +105,8 @@ export class bridge_data { async new_bridge_message(message: bridge_message): Promise { await this.pg.queryArray`INSERT INTO bridge_messages - (original_id, id, name, channels, messages, settings) VALUES - (${message.original_id}, ${message.id}, ${message.name}, ${message.channels}, ${message.messages}, ${message.settings})`; + (id, bridge_id, channels, messages, settings) VALUES + (${message.id}, ${message.bridge_id}, ${message.channels}, ${message.messages}, ${message.settings})`; return message; } @@ -117,11 +115,10 @@ export class bridge_data { message: bridge_message, ): Promise { await this.pg.queryArray`UPDATE bridge_messages SET - id = ${message.id}, channels = ${message.channels}, messages = ${message.messages}, settings = ${message.settings} - WHERE original_id = ${message.original_id}`; + WHERE id = ${message.id}`; return message; } diff --git a/packages/lightning/src/bridge/msg.ts b/packages/lightning/src/bridge/msg.ts index b62a3738..1e5f186f 100644 --- a/packages/lightning/src/bridge/msg.ts +++ b/packages/lightning/src/bridge/msg.ts @@ -130,26 +130,28 @@ export async function handle_message( await core.data[`${method}_bridge_message`]({ ...br, - original_id: msg.id, + id: msg.id, messages, + bridge_id: br.id, }); } async function disable_channel( channel: bridge_channel, - bridge: bridge, + bridge: bridge | bridge_message, core: lightning, error: unknown, ): Promise { await log_error(error, { channel, bridge }); await core.data.update_bridge({ - ...bridge, + id: "bridge_id" in bridge ? bridge.bridge_id : bridge.id, channels: bridge.channels.map((i) => i.id === channel.id && i.plugin === channel.plugin ? { ...i, disabled: true, data: error } : i ), + settings: bridge.settings }); } From cc0d5cdde0c08672c0d627a5c6ba0e8e70a60e44 Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 27 Nov 2024 17:00:51 -0500 Subject: [PATCH 08/97] clean up unnecessary catches --- .../src/process_message.ts | 109 ++++++++---------- packages/lightning-plugin-guilded/src/mod.ts | 92 +++++++-------- packages/lightning-plugin-revolt/src/mod.ts | 101 ++++++++-------- packages/lightning-plugin-telegram/src/mod.ts | 94 +++++++-------- 4 files changed, 183 insertions(+), 213 deletions(-) diff --git a/packages/lightning-plugin-discord/src/process_message.ts b/packages/lightning-plugin-discord/src/process_message.ts index 6edbe54d..04327ca1 100644 --- a/packages/lightning-plugin-discord/src/process_message.ts +++ b/packages/lightning-plugin-discord/src/process_message.ts @@ -3,78 +3,71 @@ import type { message_options } from '@jersey/lightning'; import { to_discord } from './discord.ts'; export async function process_message(api: API, opts: message_options) { - try { - const data = opts.channel.data as { token: string; id: string }; + const data = opts.channel.data as { token: string; id: string }; - if (opts.action !== 'delete') { - let replied_message; + if (opts.action !== 'delete') { + let replied_message; - if (opts.reply_id) { - try { - replied_message = await api.channels - .getMessage(opts.channel.id, opts.reply_id); - } catch { - // safe to ignore - } + if (opts.reply_id) { + try { + replied_message = await api.channels + .getMessage(opts.channel.id, opts.reply_id); + } catch { + // safe to ignore } + } - const msg = await to_discord( - opts.message, - replied_message, - ); - - try { - let wh; + const msg = await to_discord( + opts.message, + replied_message, + ); - if (opts.action === 'edit') { - wh = await api.webhooks.editMessage( - data.id, - data.token, - opts.edit_id[0], - msg, - ); - } else { - wh = await api.webhooks.execute( - data.id, - data.token, - msg, - ); - } + try { + let wh; - return { - id: [wh.id], - channel: opts.channel, - }; - } catch (e) { - if ( - (e as { status: number }).status === 404 && - opts.action !== 'edit' - ) { - return { - channel: opts.channel, - error: e as Error, - disable: true, - }; - } else { - throw e; - } + if (opts.action === 'edit') { + wh = await api.webhooks.editMessage( + data.id, + data.token, + opts.edit_id[0], + msg, + ); + } else { + wh = await api.webhooks.execute( + data.id, + data.token, + msg, + ); } - } else { - await api.webhooks.deleteMessage( - data.id, - data.token, - opts.edit_id[0], - ); return { - id: opts.edit_id, + id: [wh.id], channel: opts.channel, }; + } catch (e) { + if ( + (e as { status: number }).status === 404 && + opts.action !== 'edit' + ) { + return { + channel: opts.channel, + error: e as Error, + disable: true, + }; + } else { + throw e; + } } - } catch (e) { + } else { + await api.webhooks.deleteMessage( + data.id, + data.token, + opts.edit_id[0], + ); + return { + id: opts.edit_id, channel: opts.channel, - error: e as Error, }; } } diff --git a/packages/lightning-plugin-guilded/src/mod.ts b/packages/lightning-plugin-guilded/src/mod.ts index d034e04b..90b34c58 100644 --- a/packages/lightning-plugin-guilded/src/mod.ts +++ b/packages/lightning-plugin-guilded/src/mod.ts @@ -59,65 +59,57 @@ export class guilded_plugin extends plugin { } async process_message(opts: message_options): Promise { - try { - if (opts.action === 'create') { - try { - const { id } = await new WebhookClient( - opts.channel.data as { token: string; id: string }, - ).send( - await convert_msg(opts.message, opts.channel.id, this), - ); + if (opts.action === 'create') { + try { + const { id } = await new WebhookClient( + opts.channel.data as { token: string; id: string }, + ).send( + await convert_msg(opts.message, opts.channel.id, this), + ); + return { + id: [id], + channel: opts.channel, + }; + } catch (e) { + if ( + (e as { response: { status: number } }).response + .status === 404 + ) { + return { + channel: opts.channel, + disable: true, + error: new Error('webhook not found!'), + }; + } else if ( + (e as { response: { status: number } }).response + .status === 403 + ) { return { - id: [id], channel: opts.channel, + disable: true, + error: new Error('no permission to send messages!'), }; - } catch (e) { - if ( - (e as { response: { status: number } }).response - .status === 404 - ) { - return { - channel: opts.channel, - disable: true, - error: new Error('webhook not found!'), - }; - } else if ( - (e as { response: { status: number } }).response - .status === 403 - ) { - return { - channel: opts.channel, - disable: true, - error: new Error('no permission to send messages!'), - }; - } else { - throw e; - } + } else { + throw e; } - } else if (opts.action === 'delete') { - const msg = await this.bot.messages.fetch( - opts.channel.id, - opts.edit_id[0], - ); + } + } else if (opts.action === 'delete') { + const msg = await this.bot.messages.fetch( + opts.channel.id, + opts.edit_id[0], + ); - await msg.delete(); + await msg.delete(); - return { - channel: opts.channel, - id: opts.edit_id, - }; - } else { - return { - channel: opts.channel, - id: opts.edit_id, - }; - } - } catch (e) { - // TODO(@williamhorning): improve error handling return { channel: opts.channel, - error: e as Error, + id: opts.edit_id, + }; + } else { + return { + channel: opts.channel, + id: opts.edit_id, }; } } diff --git a/packages/lightning-plugin-revolt/src/mod.ts b/packages/lightning-plugin-revolt/src/mod.ts index 03b87f7a..8d035be7 100644 --- a/packages/lightning-plugin-revolt/src/mod.ts +++ b/packages/lightning-plugin-revolt/src/mod.ts @@ -69,70 +69,63 @@ export class revolt_plugin extends plugin { /** process a message */ async process_message(opts: message_options): Promise { - try { - if (opts.action === 'create') { - try { - const msg = await torvapi(this.bot, { - ...opts.message, - reply_id: opts.reply_id, - }); + if (opts.action === 'create') { + try { + const msg = await torvapi(this.bot, { + ...opts.message, + reply_id: opts.reply_id, + }); - const resp = (await this.bot.request( - 'post', - `/channels/${opts.channel.id}/messages`, - msg, - )) as Message; + const resp = (await this.bot.request( + 'post', + `/channels/${opts.channel.id}/messages`, + msg, + )) as Message; + return { + channel: opts.channel, + id: [resp._id], + }; + } catch (e) { + if ( + (e as { cause: { status: number } }).cause.status === + 403 || + (e as { cause: { status: number } }).cause.status === + 404 + ) { return { channel: opts.channel, - id: [resp._id], + disable: true, + error: e as Error, }; - } catch (e) { - if ( - (e as { cause: { status: number } }).cause.status === - 403 || - (e as { cause: { status: number } }).cause.status === - 404 - ) { - return { - channel: opts.channel, - disable: true, - error: e as Error, - }; - } else { - throw e; - } + } else { + throw e; } - } else if (opts.action === 'edit') { - await this.bot.request( - 'patch', - `/channels/${opts.channel.id}/messages/${opts.edit_id[0]}`, - await torvapi(this.bot, { - ...opts.message, - reply_id: opts.reply_id, - }), - ); + } + } else if (opts.action === 'edit') { + await this.bot.request( + 'patch', + `/channels/${opts.channel.id}/messages/${opts.edit_id[0]}`, + await torvapi(this.bot, { + ...opts.message, + reply_id: opts.reply_id, + }), + ); - return { - channel: opts.channel, - id: opts.edit_id, - }; - } else { - await this.bot.request( - 'delete', - `/channels/${opts.channel.id}/messages/${opts.edit_id[0]}`, - undefined, - ); + return { + channel: opts.channel, + id: opts.edit_id, + }; + } else { + await this.bot.request( + 'delete', + `/channels/${opts.channel.id}/messages/${opts.edit_id[0]}`, + undefined, + ); - return { - channel: opts.channel, - id: opts.edit_id, - }; - } - } catch (e) { return { channel: opts.channel, - error: e as Error, + id: opts.edit_id, }; } } diff --git a/packages/lightning-plugin-telegram/src/mod.ts b/packages/lightning-plugin-telegram/src/mod.ts index 4e211d9f..b4610715 100644 --- a/packages/lightning-plugin-telegram/src/mod.ts +++ b/packages/lightning-plugin-telegram/src/mod.ts @@ -58,69 +58,61 @@ export class telegram_plugin extends plugin { /** process a message event */ async process_message(opts: message_options): Promise { - try { - if (opts.action === 'delete') { - for (const id of opts.edit_id) { - await this.bot.api.deleteMessage( - opts.channel.id, - Number(id), - ); - } + if (opts.action === 'delete') { + for (const id of opts.edit_id) { + await this.bot.api.deleteMessage( + opts.channel.id, + Number(id), + ); + } + + return { + id: opts.edit_id, + channel: opts.channel, + }; + } else if (opts.action === 'edit') { + const content = from_lightning(opts.message)[0]; + + await this.bot.api.editMessageText( + opts.channel.id, + Number(opts.edit_id[0]), + content.value, + { + parse_mode: 'MarkdownV2', + }, + ); - return { - id: opts.edit_id, - channel: opts.channel, - }; - } else if (opts.action === 'edit') { - const content = from_lightning(opts.message)[0]; + return { + id: opts.edit_id, + channel: opts.channel, + }; + } else if (opts.action === 'create') { + const content = from_lightning(opts.message); + const messages = []; - await this.bot.api.editMessageText( + for (const msg of content) { + const result = await this.bot.api[msg.function]( opts.channel.id, - Number(opts.edit_id[0]), - content.value, + msg.value, { + reply_parameters: opts.reply_id + ? { + message_id: Number(opts.reply_id), + } + : undefined, parse_mode: 'MarkdownV2', }, ); - return { - id: opts.edit_id, - channel: opts.channel, - }; - } else if (opts.action === 'create') { - const content = from_lightning(opts.message); - const messages = []; - - for (const msg of content) { - const result = await this.bot.api[msg.function]( - opts.channel.id, - msg.value, - { - reply_parameters: opts.reply_id - ? { - message_id: Number(opts.reply_id), - } - : undefined, - parse_mode: 'MarkdownV2', - }, - ); - - messages.push(String(result.message_id)); - } - - return { - id: messages, - channel: opts.channel, - }; - } else { - throw new Error('unknown action'); + messages.push(String(result.message_id)); } - } catch (e) { - // TODO(@williamhorning): improve error handling logic + return { - error: e as Error, + id: messages, channel: opts.channel, }; + } else { + throw new Error('unknown action'); } } } From a349663b2c957d0dad41204b8067d038c62f17ad Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 27 Nov 2024 22:44:36 -0500 Subject: [PATCH 09/97] working postgres --- packages/lightning/src/bridge/cmd.ts | 36 +++++--- packages/lightning/src/bridge/data.ts | 126 ++++++++++++++------------ packages/lightning/src/bridge/msg.ts | 6 +- packages/lightning/src/cli/mod.ts | 3 +- packages/lightning/src/lightning.ts | 52 +++++------ 5 files changed, 117 insertions(+), 106 deletions(-) diff --git a/packages/lightning/src/bridge/cmd.ts b/packages/lightning/src/bridge/cmd.ts index 4ff4d934..59a74e1f 100644 --- a/packages/lightning/src/bridge/cmd.ts +++ b/packages/lightning/src/bridge/cmd.ts @@ -26,17 +26,20 @@ export const bridge_command = { if (!opts.id && !opts.name) { return `You must provide either an id or a name`; } + + const p = lightning.plugins.get(plugin); - const bridge_channel = { - id: channel, - data: undefined as unknown, - disabled: false, - plugin, - }; + if (!p) return (await log_error( + new Error('plugin not found'), + { + plugin, + }, + )).message.content as string; + + let data; try { - bridge_channel.data = lightning.plugins.get(plugin) - ?.create_bridge(channel); + data = await p.create_bridge(channel); } catch (e) { return (await log_error( new Error('error creating bridge', { cause: e }), @@ -47,6 +50,13 @@ export const bridge_command = { )).message.content as string; } + const bridge_channel = { + id: channel, + data, + disabled: false, + plugin, + }; + if (opts.id) { const bridge = await lightning.data.get_bridge_by_id( opts.id, @@ -57,7 +67,7 @@ export const bridge_command = { bridge.channels.push(bridge_channel); try { - await lightning.data.update_bridge(bridge); + await lightning.data.edit_bridge(bridge); return `Bridge joined successfully`; } catch (e) { return (await log_error( @@ -69,7 +79,7 @@ export const bridge_command = { } } else { try { - await lightning.data.new_bridge({ + await lightning.data.create_bridge({ name: opts.name, channels: [bridge_channel], settings: { @@ -113,7 +123,7 @@ export const bridge_command = { ) => ch.id !== channel); try { - await lightning.data.update_bridge( + await lightning.data.edit_bridge( bridge, ); return `Bridge left successfully`; @@ -151,7 +161,7 @@ export const bridge_command = { .settings[key]; try { - await lightning.data.update_bridge( + await lightning.data.edit_bridge( bridge, ); return `Setting toggled successfully`; @@ -176,7 +186,7 @@ export const bridge_command = { if (!existing_bridge) return `You are not in a bridge`; - return `You are in a bridge called ${existing_bridge.name} that's connected to ${ + return `You are in a bridge called "${existing_bridge.name}" that's connected to ${ existing_bridge.channels.length - 1 } other channels`; }, diff --git a/packages/lightning/src/bridge/data.ts b/packages/lightning/src/bridge/data.ts index d16fa87e..fdd9af82 100644 --- a/packages/lightning/src/bridge/data.ts +++ b/packages/lightning/src/bridge/data.ts @@ -42,7 +42,7 @@ export class bridge_data { const pg = new Client(pg_options); await pg.connect(); - await this.create_table(pg); + await bridge_data.create_table(pg); return new bridge_data(pg); } @@ -53,92 +53,100 @@ export class bridge_data { if (exists) return; - await pg.queryArray`CREATE TABLE bridges ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - channels JSONB NOT NULL, - settings JSONB NOT NULL - )`; - await pg.queryArray`CREATE TABLE bridge_messages ( - id TEXT PRIMARY KEY, - bridge_id TEXT NOT NULL, - channels JSONB NOT NULL, - messages JSONB NOT NULL, - settings JSONB NOT NULL - )`; + await pg.queryArray` + CREATE TABLE bridges ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + channels JSONB NOT NULL, + settings JSONB NOT NULL + ); + + CREATE TABLE bridge_messages ( + id TEXT PRIMARY KEY, + bridge_id TEXT NOT NULL, + channels JSONB NOT NULL, + messages JSONB NOT NULL, + settings JSONB NOT NULL + ); + `; } private constructor(pg_client: Client) { this.pg = pg_client; } - async new_bridge(bridge: Omit): Promise { + async create_bridge(br: Omit): Promise { const id = ulid(); - await this.pg.queryArray`INSERT INTO bridges - (id, name, channels, settings) VALUES - (${id}, ${bridge.name}, ${bridge.channels}, ${bridge.settings})`; + await this.pg.queryArray` + INSERT INTO bridges (id, name, channels, settings) + VALUES (${id}, ${br.name}, ${JSON.stringify(br.channels)}, ${JSON.stringify(br.settings)}) + `; - return { id, ...bridge }; + return { id, ...br }; } - async update_bridge(bridge: {channels: bridge_channel[], settings: bridge_settings, id: string}): Promise { - await this.pg.queryArray`UPDATE bridges SET - channels = ${bridge.channels}, - settings = ${bridge.settings} - WHERE id = ${bridge.id}`; + async edit_bridge(br: Omit): Promise { + await this.pg.queryArray` + UPDATE bridges + SET channels = ${JSON.stringify(br.channels)}, settings = ${JSON.stringify(br.settings)} + WHERE id = ${br.id} + `; } async get_bridge_by_id(id: string): Promise { - const resp = await this.pg.queryObject` - SELECT * FROM bridges WHERE id = ${id}`; + const res = await this.pg.queryObject` + SELECT * FROM bridges + WHERE id = ${id} + `; - return resp.rows[0]; + return res.rows[0]; } - async get_bridge_by_channel(channel: string): Promise { - const resp = await this.pg.queryObject` - SELECT * FROM bridges WHERE JSON_QUERY(channels, '$[*].id') = ${channel}`; + async get_bridge_by_channel(ch: string): Promise { + const res = await this.pg.queryObject(` + SELECT * FROM bridges + WHERE EXISTS ( + SELECT 1 FROM jsonb_array_elements(channels) AS ch + WHERE ch->>'id' = '${ch}' + ) + `); - return resp.rows[0]; + return res.rows[0]; } - async new_bridge_message(message: bridge_message): Promise { + async create_bridge_message(msg: bridge_message): Promise { await this.pg.queryArray`INSERT INTO bridge_messages (id, bridge_id, channels, messages, settings) VALUES - (${message.id}, ${message.bridge_id}, ${message.channels}, ${message.messages}, ${message.settings})`; - - return message; + (${msg.id}, ${msg.bridge_id}, ${JSON.stringify(msg.channels)}, ${JSON.stringify(msg.messages)}, ${JSON.stringify(msg.settings)})`; } - async update_bridge_message( - message: bridge_message, - ): Promise { - await this.pg.queryArray`UPDATE bridge_messages SET - channels = ${message.channels}, - messages = ${message.messages}, - settings = ${message.settings} - WHERE id = ${message.id}`; - - return message; + async edit_bridge_message(msg: bridge_message): Promise { + await this.pg.queryArray` + UPDATE bridge_messages + SET messages = ${JSON.stringify(msg.messages)}, channels = ${JSON.stringify(msg.channels)}, settings = ${JSON.stringify(msg.settings)} + WHERE id = ${msg.id} + `; } - async delete_bridge_message(id: string): Promise { - await this.pg - .queryArray`DELETE FROM bridge_messages WHERE original_id = ${id}`; + async delete_bridge_message({ id }: bridge_message): Promise { + await this.pg.queryArray` + DELETE FROM bridge_messages WHERE id = ${id} + `; } async get_bridge_message(id: string): Promise { - const resp = await this.pg.queryObject` - SELECT * FROM bridge_messages WHERE original_id = ${id}`; - - return resp.rows[0]; - } - - async is_bridged_message(id: string): Promise { - const resp = await this.pg.queryObject` - SELECT * FROM bridge_messages WHERE JSON_QUERY(messages, '$[*].id') = ${id}`; - - return resp.rows.length > 0; + const res = await this.pg.queryObject(` + SELECT * FROM bridge_messages + WHERE id = '${id}' OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(messages) AS msg + WHERE EXISTS ( + SELECT 1 FROM jsonb_array_elements(msg->'id') AS id + WHERE id = '${id}' + ) + ) + `); + + return res.rows[0]; } } diff --git a/packages/lightning/src/bridge/msg.ts b/packages/lightning/src/bridge/msg.ts index 1e5f186f..47525d30 100644 --- a/packages/lightning/src/bridge/msg.ts +++ b/packages/lightning/src/bridge/msg.ts @@ -126,9 +126,7 @@ export async function handle_message( }); } - const method = type === 'create' ? 'new' : 'update'; - - await core.data[`${method}_bridge_message`]({ + await core.data[`${type}_bridge_message`]({ ...br, id: msg.id, messages, @@ -144,7 +142,7 @@ async function disable_channel( ): Promise { await log_error(error, { channel, bridge }); - await core.data.update_bridge({ + await core.data.edit_bridge({ id: "bridge_id" in bridge ? bridge.bridge_id : bridge.id, channels: bridge.channels.map((i) => i.id === channel.id && i.plugin === channel.plugin diff --git a/packages/lightning/src/cli/mod.ts b/packages/lightning/src/cli/mod.ts index fa945bbc..02ba47e6 100644 --- a/packages/lightning/src/cli/mod.ts +++ b/packages/lightning/src/cli/mod.ts @@ -11,9 +11,10 @@ if (_.v || _.version) { } else if (_.h || _.help) { run_help(); } else if (_._[0] === 'run') { + // TODO(jersey): this is somewhat broken when acting as a JSR package if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); - const config: config = await import(toFileUrl(_.config).toString()); + const config: config = (await import(toFileUrl(_.config).toString())).default; addEventListener('error', async (ev) => { await log_error(ev.error, { type: 'global error' }); diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index d7c2d7d6..d7623fb5 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -10,6 +10,7 @@ import { handle_message } from './bridge/msg.ts'; import { run_command } from './commands/run.ts'; import { handle_command_message } from './commands/run.ts'; import type { message } from './messages.ts'; +import { bridge_command } from './bridge/cmd.ts'; /** configuration options for lightning */ export interface config { @@ -17,7 +18,7 @@ export interface config { postgres_options: ClientOptions; /** a list of plugins */ // deno-lint-ignore no-explicit-any - plugins?: create_plugin>[]; + plugins?: create_plugin[]; /** the prefix used for commands */ cmd_prefix: string; } @@ -30,8 +31,6 @@ export class lightning { commands: Map = new Map(default_commands); /** the config used */ config: config; - /** set of processed messages */ - private processed: Set<`${string}-${string}`> = new Set(); /** the plugins loaded */ plugins: Map>; @@ -39,6 +38,7 @@ export class lightning { constructor(bridge_data: bridge_data, config: config) { this.data = bridge_data; this.config = config; + this.commands.set('bridge', bridge_command); this.plugins = new Map>(); for (const p of this.config.plugins || []) { @@ -51,38 +51,32 @@ export class lightning { } private async _handle_events(plugin: plugin) { - for await (const event of plugin) { + for await (const { name, value } of plugin) { await new Promise((res) => setTimeout(res, 150)); - const id = `${event.value[0].plugin}-${event.value[0].id}` as const; + if (sessionStorage.getItem(`${value[0].plugin}-${value[0].id}`)) continue; - if (!this.processed.has(id)) { - this.processed.add(id); + if (name === 'run_command') { + run_command({ + ...(value[0] as Omit< + command_arguments, + 'lightning' + >), + lightning: this, + }); - if (event.name === 'run_command') { - run_command({ - ...(event.value[0] as Omit< - command_arguments, - 'lightning' - >), - lightning: this, - }); - - continue; - } - - if (event.name === 'create_message') { - handle_command_message(event.value[0] as message, this); - } + continue; + } - handle_message( - this, - event.value[0] as message, - event.name.split('_')[0] as 'create', - ); - } else { - this.processed.delete(id); + if (name === 'create_message') { + handle_command_message(value[0] as message, this); } + + handle_message( + this, + value[0] as message, + name.split('_')[0] as 'create', + ); } } From d9a8aa55473c800d8f7839c00245fdadb96f0551 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 28 Nov 2024 00:28:54 -0500 Subject: [PATCH 10/97] commands v2 starter --- .../lightning-plugin-discord/src/commands.ts | 2 + packages/lightning/src/bridge/cmd.ts | 196 ------------------ packages/lightning/src/commands/mod.ts | 80 ------- packages/lightning/src/commands/run.ts | 2 + .../lightning/src/commands_v2/bridge/add.ts | 120 +++++++++++ .../lightning/src/commands_v2/bridge/mod.ts | 54 +++++ .../src/commands_v2/bridge/modify.ts | 61 ++++++ .../src/commands_v2/bridge/status.ts | 26 +++ packages/lightning/src/commands_v2/mod.ts | 54 +++++ packages/lightning/src/lightning.ts | 17 +- packages/lightning/src/mod.ts | 6 +- packages/lightning/src/plugins.ts | 4 +- 12 files changed, 325 insertions(+), 297 deletions(-) delete mode 100644 packages/lightning/src/bridge/cmd.ts delete mode 100644 packages/lightning/src/commands/mod.ts create mode 100644 packages/lightning/src/commands_v2/bridge/add.ts create mode 100644 packages/lightning/src/commands_v2/bridge/mod.ts create mode 100644 packages/lightning/src/commands_v2/bridge/modify.ts create mode 100644 packages/lightning/src/commands_v2/bridge/status.ts create mode 100644 packages/lightning/src/commands_v2/mod.ts diff --git a/packages/lightning-plugin-discord/src/commands.ts b/packages/lightning-plugin-discord/src/commands.ts index 1915396b..8b6e24ff 100644 --- a/packages/lightning-plugin-discord/src/commands.ts +++ b/packages/lightning-plugin-discord/src/commands.ts @@ -4,6 +4,8 @@ import type { APIInteraction } from 'discord-api-types'; import { to_discord } from './discord.ts'; import { instant } from './lightning.ts'; +// TODO(jersey): migrate over to commands_v2 + export function to_command(interaction: { api: API; data: APIInteraction }) { if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; const opts = {} as Record; diff --git a/packages/lightning/src/bridge/cmd.ts b/packages/lightning/src/bridge/cmd.ts deleted file mode 100644 index 59a74e1f..00000000 --- a/packages/lightning/src/bridge/cmd.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { command } from '../commands/mod.ts'; -import { log_error } from '../errors.ts'; - -export const bridge_command = { - name: 'bridge', - description: 'Bridge commands', - execute: () => 'Take a look at the docs for help with bridges', - options: { - subcommands: [ - { - name: 'join', - description: 'Join a bridge', - options: { argument_name: 'name', argument_required: true }, - execute: async ({ lightning, channel, opts, plugin }) => { - const current_bridge = await lightning.data - .get_bridge_by_channel( - channel, - ); - - if (current_bridge) { - return `You are already in a bridge called ${current_bridge.name}`; - } - if (opts.id && opts.name) { - return `You can only provide an id or a name, not both`; - } - if (!opts.id && !opts.name) { - return `You must provide either an id or a name`; - } - - const p = lightning.plugins.get(plugin); - - if (!p) return (await log_error( - new Error('plugin not found'), - { - plugin, - }, - )).message.content as string; - - let data; - - try { - data = await p.create_bridge(channel); - } catch (e) { - return (await log_error( - new Error('error creating bridge', { cause: e }), - { - channel, - plugin_name: plugin, - }, - )).message.content as string; - } - - const bridge_channel = { - id: channel, - data, - disabled: false, - plugin, - }; - - if (opts.id) { - const bridge = await lightning.data.get_bridge_by_id( - opts.id, - ); - - if (!bridge) return `No bridge found with that id`; - - bridge.channels.push(bridge_channel); - - try { - await lightning.data.edit_bridge(bridge); - return `Bridge joined successfully`; - } catch (e) { - return (await log_error( - new Error('error updating bridge', { cause: e }), - { - bridge, - }, - )).message.content as string; - } - } else { - try { - await lightning.data.create_bridge({ - name: opts.name, - channels: [bridge_channel], - settings: { - allow_editing: true, - allow_everyone: false, - use_rawname: false, - }, - }); - return `Bridge joined successfully`; - } catch (e) { - return (await log_error( - new Error('error inserting bridge', { cause: e }), - { - bridge: { - name: opts.name, - channels: [bridge_channel], - settings: { - allow_editing: true, - allow_everyone: false, - use_rawname: false, - }, - }, - }, - )).message.content as string; - } - } - }, - }, - { - name: 'leave', - description: 'Leave a bridge', - execute: async ({ lightning, channel }) => { - const bridge = await lightning.data.get_bridge_by_channel( - channel, - ); - - if (!bridge) return `You are not in a bridge`; - - bridge.channels = bridge.channels.filter(( - ch, - ) => ch.id !== channel); - - try { - await lightning.data.edit_bridge( - bridge, - ); - return `Bridge left successfully`; - } catch (e) { - return await log_error( - new Error('error updating bridge', { cause: e }), - { - bridge, - }, - ); - } - }, - }, - { - name: 'toggle', - description: 'Toggle a setting on a bridge', - options: { argument_name: 'setting', argument_required: true }, - execute: async ({ opts, lightning, channel }) => { - const bridge = await lightning.data.get_bridge_by_channel( - channel, - ); - - if (!bridge) return `You are not in a bridge`; - - if ( - !['allow_editing', 'allow_everyone', 'use_rawname'] - .includes(opts.setting) - ) { - return `That setting does not exist`; - } - - const key = opts.setting as keyof typeof bridge.settings; - - bridge.settings[key] = !bridge - .settings[key]; - - try { - await lightning.data.edit_bridge( - bridge, - ); - return `Setting toggled successfully`; - } catch (e) { - return await log_error( - new Error('error updating bridge', { cause: e }), - { - bridge, - }, - ); - } - }, - }, - { - name: 'status', - description: 'See what bridges you are in', - execute: async ({ lightning, channel }) => { - const existing_bridge = await lightning.data - .get_bridge_by_channel( - channel, - ); - - if (!existing_bridge) return `You are not in a bridge`; - - return `You are in a bridge called "${existing_bridge.name}" that's connected to ${ - existing_bridge.channels.length - 1 - } other channels`; - }, - }, - ], - }, -} as command; diff --git a/packages/lightning/src/commands/mod.ts b/packages/lightning/src/commands/mod.ts deleted file mode 100644 index 0eadcff8..00000000 --- a/packages/lightning/src/commands/mod.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { lightning } from '../lightning.ts'; -import type { message } from '../messages.ts'; - -/** arguments passed to a command */ -export interface command_arguments { - /** the name of the command */ - cmd: string; - /** the subcommand being run, if any */ - subcmd?: string; - /** the channel its being run in */ - channel: string; - /** the plugin its being run on */ - plugin: string; - /** the id of the associated event */ - id: string; - /** timestamp given */ - timestamp: Temporal.Instant; - /** options passed by the user */ - opts: Record; - /** the function to reply to the command */ - reply: (message: message, optional?: unknown) => Promise; - /** the instance of lightning the command is ran against */ - lightning: lightning; -} - -/** options when parsing a command */ -// TODO(jersey): make the options more flexible -export interface command_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[]; -} - -/** commands are a way for users to interact with the bot */ -export interface command { - /** the name of the command */ - name: string; - /** an optional description */ - description?: string; - /** options when parsing the command */ - options?: command_options; - /** a function that returns a message */ - execute: (options: command_arguments) => Promise | string; -} - -export const default_commands = [ - [ - 'help', - { - name: 'help', - description: 'get help', - execute: () => - 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', - }, - ], - [ - 'version', - { - name: 'version', - description: 'get the bots version', - execute: () => 'hello from v0.7.4!', - }, - ], - [ - 'ping', - { - name: 'ping', - description: 'pong', - execute: ({ timestamp }) => - `Pong! ๐Ÿ“ ${ - Temporal.Now.instant() - .since(timestamp) - .total('milliseconds') - }ms`, - }, - ], -] as [string, command][]; diff --git a/packages/lightning/src/commands/run.ts b/packages/lightning/src/commands/run.ts index 04b48f20..f803b073 100644 --- a/packages/lightning/src/commands/run.ts +++ b/packages/lightning/src/commands/run.ts @@ -4,6 +4,8 @@ import { log_error } from '../errors.ts'; import type { lightning } from '../lightning.ts'; import { parseArgs } from '@std/cli/parse-args'; +// TODO(jersey): migrate over to commands_v2 + export function handle_command_message(m: message, l: lightning) { if (!m.content?.startsWith(l.config.cmd_prefix)) return; diff --git a/packages/lightning/src/commands_v2/bridge/add.ts b/packages/lightning/src/commands_v2/bridge/add.ts new file mode 100644 index 00000000..6fde26a0 --- /dev/null +++ b/packages/lightning/src/commands_v2/bridge/add.ts @@ -0,0 +1,120 @@ +import type { bridge_channel } from '../../bridge/data.ts'; +import { log_error } from '../../errors.ts'; +import { create_message, type message } from '../../messages.ts'; +import type { command_execute_options } from '../mod.ts'; + +export async function create(opts: command_execute_options): Promise { + const result = await _lightning_bridge_add_common(opts, 'name'); + + if (!('data' in result)) return result; + + const bridge_data = { + name: opts.arguments.name, + channels: [result], + settings: { + allow_editing: true, + allow_everyone: false, + use_rawname: false, + }, + }; + + try { + await opts.lightning.data.create_bridge(bridge_data); + return create_message( + `Bridge created successfully! You can now join it using \`${opts.lightning.config.cmd_prefix}join ${result.id}\`. Keep this id safe, don't share it with anyone, and delete this message.`, + ); + } catch (e) { + return (await log_error( + new Error('Failed to insert bridge into database', { cause: e }), + bridge_data, + )).message; + } +} + +export async function join(opts: command_execute_options): Promise { + const target_bridge = await opts.lightning.data.get_bridge_by_id( + opts.arguments.id, + ); + + if (!target_bridge) { + return create_message( + `Bridge with id \`${opts.arguments.id}\` not found. Make sure you have the correct id.`, + ); + } + + const result = await _lightning_bridge_add_common(opts, 'id'); + + if (!('data' in result)) return result; + + target_bridge.channels.push(result); + + try { + await opts.lightning.data.edit_bridge(target_bridge); + + return create_message( + `Bridge joined successfully!`, + ); + } catch (e) { + return (await log_error( + new Error('Failed to update bridge in database', { + cause: e, + }), + { + bridge: target_bridge, + }, + )).message; + } +} + +async function _lightning_bridge_add_common( + opts: command_execute_options, + option_name: 'name' | 'id', +): Promise { + const existing_bridge = await opts.lightning.data.get_bridge_by_channel( + opts.channel, + ); + + if (existing_bridge) { + return create_message( + `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.lightning.config.cmd_prefix}leave\` or \`${opts.lightning.config.cmd_prefix}help\` commands.`, + ); + } + + if (!opts.arguments[option_name]) { + return create_message( + `Please provide the \`${option_name}\` argument. Try using \`${opts.lightning.config.cmd_prefix}help\` command.`, + ); + } + + const plugin = opts.lightning.plugins.get(opts.plugin); + + if (!plugin) { + return (await log_error( + new Error('Internal error: platform support not found'), + { + plugin: opts.plugin, + }, + )).message; + } + + let bridge_data; + + try { + bridge_data = await plugin.create_bridge(opts.channel); + } catch (e) { + return (await log_error( + new Error('Failed to create bridge using plugin', { cause: e }), + { + channel: opts.channel, + plugin_name: opts.plugin, + }, + )).message; + } + + return { + id: opts.channel, + data: bridge_data, + disabled: false, + plugin: opts.plugin, + }; +} diff --git a/packages/lightning/src/commands_v2/bridge/mod.ts b/packages/lightning/src/commands_v2/bridge/mod.ts new file mode 100644 index 00000000..b3fec425 --- /dev/null +++ b/packages/lightning/src/commands_v2/bridge/mod.ts @@ -0,0 +1,54 @@ +import { create, join } from './add.ts'; +import { leave, toggle } from './modify.ts'; +import { status } from './status.ts'; +import type { command } from '../mod.ts'; +import { create_message } from '../../messages.ts'; + +export const bridge_command = { + name: 'bridge', + description: 'bridge commands', + execute: () => + create_message('take a look at the subcommands of this command'), + subcommands: [ + { + name: 'create', + description: 'create a new bridge', + arguments: [{ + name: 'name', + description: 'name of the bridge', + required: true, + }], + execute: create, + }, + { + name: 'join', + description: 'join an existing bridge', + arguments: [{ + name: 'id', + description: 'id of the bridge', + required: true, + }], + execute: join, + }, + { + name: 'leave', + description: 'leave the current bridge', + execute: leave, + }, + { + name: 'toggle', + description: 'toggle a setting on the current bridge', + arguments: [{ + name: 'setting', + description: 'setting to toggle', + required: true, + }], + execute: toggle, + }, + { + name: 'status', + description: 'get the status of the current bridge', + execute: status, + }, + ], +} as command; diff --git a/packages/lightning/src/commands_v2/bridge/modify.ts b/packages/lightning/src/commands_v2/bridge/modify.ts new file mode 100644 index 00000000..e51844e0 --- /dev/null +++ b/packages/lightning/src/commands_v2/bridge/modify.ts @@ -0,0 +1,61 @@ +import { log_error } from '../../errors.ts'; +import { create_message, type message } from '../../messages.ts'; +import type { command_execute_options } from '../mod.ts'; + +export async function leave(opts: command_execute_options): Promise { + const bridge = await opts.lightning.data.get_bridge_by_channel( + opts.channel, + ); + + if (!bridge) return create_message(`You are not in a bridge`); + + bridge.channels = bridge.channels.filter(( + ch, + ) => ch.id !== opts.channel); + + try { + await opts.lightning.data.edit_bridge( + bridge, + ); + return create_message(`Bridge left successfully`); + } catch (e) { + return (await log_error( + new Error('Error updating bridge', { cause: e }), + { + bridge, + }, + )).message; + } +} + +const settings = ['allow_editing', 'allow_everyone', 'use_rawname']; + +export async function toggle(opts: command_execute_options): Promise { + const bridge = await opts.lightning.data.get_bridge_by_channel( + opts.channel, + ); + + if (!bridge) return create_message(`You are not in a bridge`); + + if (!settings.includes(opts.arguments.setting)) { + return create_message(`That setting does not exist`); + } + + const key = opts.arguments.setting as keyof typeof bridge.settings; + + bridge.settings[key] = !bridge.settings[key]; + + try { + await opts.lightning.data.edit_bridge( + bridge, + ); + return create_message(`Bridge settings updated successfully`); + } catch (e) { + return (await log_error( + new Error('Error updating bridge', { cause: e }), + { + bridge, + }, + )).message; + } +} diff --git a/packages/lightning/src/commands_v2/bridge/status.ts b/packages/lightning/src/commands_v2/bridge/status.ts new file mode 100644 index 00000000..c700b55f --- /dev/null +++ b/packages/lightning/src/commands_v2/bridge/status.ts @@ -0,0 +1,26 @@ +import { create_message, type message } from '../../messages.ts'; +import type { command_execute_options } from '../mod.ts'; + +export async function status(opts: command_execute_options): Promise { + const bridge = await opts.lightning.data.get_bridge_by_channel( + opts.channel, + ); + + if (!bridge) return create_message(`You are not in a bridge`); + + let str = `*Bridge status:*\n\n`; + str += `**Name:** ${bridge.name}\n`; + str += `**Channels:**\n`; + + for (const [i, value] of bridge.channels.entries()) { + str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`\n`; + } + + str += `\n**Settings:**\n`; + + for (const [key, value] of Object.entries(bridge.settings)) { + str += `\`${key}: ${value}\`\n`; + } + + return create_message(str); +} \ No newline at end of file diff --git a/packages/lightning/src/commands_v2/mod.ts b/packages/lightning/src/commands_v2/mod.ts new file mode 100644 index 00000000..4bdd45a8 --- /dev/null +++ b/packages/lightning/src/commands_v2/mod.ts @@ -0,0 +1,54 @@ +import { bridge_command } from './bridge/mod.ts'; +import type { lightning } from '../lightning.ts'; +import { create_message, type message } from '../messages.ts'; + +export interface command_execute_options { + channel: string; + plugin: string; + timestamp: Temporal.Instant; + arguments: Record; + lightning: lightning; + id: string; +} + +export interface command { + name: string; + description: string; + arguments?: { + name: string; + description: string; + required: boolean; + }[]; + subcommands?: command[]; + // TODO(jersey): message | string | Promise | Promise + execute: (opts: command_execute_options) => Promise | message; +} + +export const default_commands = [ + ['help', { + name: 'help', + description: 'get help with the bot', + execute: () => + create_message( + 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', + ), + }], + ['ping', { + name: 'ping', + description: 'check if the bot is alive', + execute: ({ timestamp }) => { + const diff = Temporal.Now.instant().since(timestamp).total( + 'milliseconds', + ); + return create_message(`Pong! ๐Ÿ“ ${diff}ms`); + }, + }], + ['version', { + name: 'version', + description: 'get the bots version', + execute: () => create_message('hello from v0.8.0!'), + }], + ['bridge', bridge_command], +] as [string, command][]; + +// TODO(jersey): make command runners diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index d7623fb5..8bc50a41 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -1,16 +1,12 @@ import type { ClientOptions } from '@db/postgres'; import { type command, - type command_arguments, default_commands, -} from './commands/mod.ts'; +} from './commands_v2/mod.ts'; import type { create_plugin, plugin } from './plugins.ts'; import { bridge_data } from './bridge/data.ts'; import { handle_message } from './bridge/msg.ts'; -import { run_command } from './commands/run.ts'; -import { handle_command_message } from './commands/run.ts'; import type { message } from './messages.ts'; -import { bridge_command } from './bridge/cmd.ts'; /** configuration options for lightning */ export interface config { @@ -38,7 +34,6 @@ export class lightning { constructor(bridge_data: bridge_data, config: config) { this.data = bridge_data; this.config = config; - this.commands.set('bridge', bridge_command); this.plugins = new Map>(); for (const p of this.config.plugins || []) { @@ -57,19 +52,13 @@ export class lightning { if (sessionStorage.getItem(`${value[0].plugin}-${value[0].id}`)) continue; if (name === 'run_command') { - run_command({ - ...(value[0] as Omit< - command_arguments, - 'lightning' - >), - lightning: this, - }); + // TODO(jersey): migrate over to commands_v2 continue; } if (name === 'create_message') { - handle_command_message(value[0] as message, this); + // TODO(jersey): migrate over to commands_v2 } handle_message( diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index 57180e60..61629083 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -1,8 +1,4 @@ -export type { - command, - command_arguments, - command_options, -} from './commands/mod.ts'; +// TODO(jersey): add exports from commands_v2 export { log_error } from './errors.ts'; export { type config, lightning } from './lightning.ts'; export * from './messages.ts'; diff --git a/packages/lightning/src/plugins.ts b/packages/lightning/src/plugins.ts index 775aa12f..8b421e31 100644 --- a/packages/lightning/src/plugins.ts +++ b/packages/lightning/src/plugins.ts @@ -1,5 +1,4 @@ import { EventEmitter } from '@denosaurs/event'; -import type { command_arguments } from './commands/mod.ts'; import type { lightning } from './lightning.ts'; import type { deleted_message, @@ -7,6 +6,7 @@ import type { message_options, process_result, } from './messages.ts'; +import type { command_execute_options } from './commands_v2/mod.ts'; /** the way to make a plugin */ export interface create_plugin< @@ -29,7 +29,7 @@ export type plugin_events = { /** when a message is deleted */ delete_message: [deleted_message]; /** when a command is run */ - run_command: [Omit]; + run_command: [Omit]; }; /** a plugin for lightning */ From 06257719b4f6b329ec56901d94d0761f48810e98 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 28 Nov 2024 17:18:28 -0500 Subject: [PATCH 11/97] commands v2 --- .../{commands_v2 => commands}/bridge/add.ts | 62 ++++------ .../{commands_v2 => commands}/bridge/mod.ts | 4 +- .../bridge/modify.ts | 23 ++-- .../bridge/status.ts | 15 +-- packages/lightning/src/commands/default.ts | 26 ++++ packages/lightning/src/commands/mod.ts | 113 ++++++++++++++++++ packages/lightning/src/commands/run.ts | 48 -------- packages/lightning/src/commands_v2/mod.ts | 54 --------- packages/lightning/src/lightning.ts | 20 +++- packages/lightning/src/plugins.ts | 4 +- 10 files changed, 198 insertions(+), 171 deletions(-) rename packages/lightning/src/{commands_v2 => commands}/bridge/add.ts (54%) rename packages/lightning/src/{commands_v2 => commands}/bridge/mod.ts (90%) rename packages/lightning/src/{commands_v2 => commands}/bridge/modify.ts (70%) rename packages/lightning/src/{commands_v2 => commands}/bridge/status.ts (54%) create mode 100644 packages/lightning/src/commands/default.ts create mode 100644 packages/lightning/src/commands/mod.ts delete mode 100644 packages/lightning/src/commands/run.ts delete mode 100644 packages/lightning/src/commands_v2/mod.ts diff --git a/packages/lightning/src/commands_v2/bridge/add.ts b/packages/lightning/src/commands/bridge/add.ts similarity index 54% rename from packages/lightning/src/commands_v2/bridge/add.ts rename to packages/lightning/src/commands/bridge/add.ts index 6fde26a0..3f2faaaa 100644 --- a/packages/lightning/src/commands_v2/bridge/add.ts +++ b/packages/lightning/src/commands/bridge/add.ts @@ -1,12 +1,13 @@ import type { bridge_channel } from '../../bridge/data.ts'; import { log_error } from '../../errors.ts'; -import { create_message, type message } from '../../messages.ts'; import type { command_execute_options } from '../mod.ts'; -export async function create(opts: command_execute_options): Promise { - const result = await _lightning_bridge_add_common(opts, 'name'); +export async function create( + opts: command_execute_options, +): Promise { + const result = await _lightning_bridge_add_common(opts); - if (!('data' in result)) return result; + if (typeof result === 'string') return result; const bridge_data = { name: opts.arguments.name, @@ -20,81 +21,68 @@ export async function create(opts: command_execute_options): Promise { try { await opts.lightning.data.create_bridge(bridge_data); - return create_message( - `Bridge created successfully! You can now join it using \`${opts.lightning.config.cmd_prefix}join ${result.id}\`. Keep this id safe, don't share it with anyone, and delete this message.`, - ); + return `Bridge created successfully! You can now join it using \`${opts.lightning.config.cmd_prefix}join ${result.id}\`. Keep this id safe, don't share it with anyone, and delete this message.`; } catch (e) { - return (await log_error( + throw await log_error( new Error('Failed to insert bridge into database', { cause: e }), bridge_data, - )).message; + ); } } -export async function join(opts: command_execute_options): Promise { +export async function join( + opts: command_execute_options, +): Promise { + const result = await _lightning_bridge_add_common(opts); + + if (typeof result === 'string') return result; + const target_bridge = await opts.lightning.data.get_bridge_by_id( opts.arguments.id, ); if (!target_bridge) { - return create_message( - `Bridge with id \`${opts.arguments.id}\` not found. Make sure you have the correct id.`, - ); + return `Bridge with id \`${opts.arguments.id}\` not found. Make sure you have the correct id.`; } - const result = await _lightning_bridge_add_common(opts, 'id'); - - if (!('data' in result)) return result; - target_bridge.channels.push(result); try { await opts.lightning.data.edit_bridge(target_bridge); - return create_message( - `Bridge joined successfully!`, - ); + return `Bridge joined successfully!`; } catch (e) { - return (await log_error( + throw await log_error( new Error('Failed to update bridge in database', { cause: e, }), { bridge: target_bridge, }, - )).message; + ); } } async function _lightning_bridge_add_common( opts: command_execute_options, - option_name: 'name' | 'id', -): Promise { +): Promise { const existing_bridge = await opts.lightning.data.get_bridge_by_channel( opts.channel, ); if (existing_bridge) { - return create_message( - `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.lightning.config.cmd_prefix}leave\` or \`${opts.lightning.config.cmd_prefix}help\` commands.`, - ); - } - - if (!opts.arguments[option_name]) { - return create_message( - `Please provide the \`${option_name}\` argument. Try using \`${opts.lightning.config.cmd_prefix}help\` command.`, - ); + return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.lightning.config.cmd_prefix}leave\` or \`${opts.lightning.config.cmd_prefix}help\` commands.` } const plugin = opts.lightning.plugins.get(opts.plugin); if (!plugin) { - return (await log_error( + throw await log_error( new Error('Internal error: platform support not found'), { plugin: opts.plugin, }, - )).message; + ); } let bridge_data; @@ -102,13 +90,13 @@ async function _lightning_bridge_add_common( try { bridge_data = await plugin.create_bridge(opts.channel); } catch (e) { - return (await log_error( + throw await log_error( new Error('Failed to create bridge using plugin', { cause: e }), { channel: opts.channel, plugin_name: opts.plugin, }, - )).message; + ); } return { diff --git a/packages/lightning/src/commands_v2/bridge/mod.ts b/packages/lightning/src/commands/bridge/mod.ts similarity index 90% rename from packages/lightning/src/commands_v2/bridge/mod.ts rename to packages/lightning/src/commands/bridge/mod.ts index b3fec425..fd9fba79 100644 --- a/packages/lightning/src/commands_v2/bridge/mod.ts +++ b/packages/lightning/src/commands/bridge/mod.ts @@ -2,13 +2,11 @@ import { create, join } from './add.ts'; import { leave, toggle } from './modify.ts'; import { status } from './status.ts'; import type { command } from '../mod.ts'; -import { create_message } from '../../messages.ts'; export const bridge_command = { name: 'bridge', description: 'bridge commands', - execute: () => - create_message('take a look at the subcommands of this command'), + execute: () => 'take a look at the subcommands of this command', subcommands: [ { name: 'create', diff --git a/packages/lightning/src/commands_v2/bridge/modify.ts b/packages/lightning/src/commands/bridge/modify.ts similarity index 70% rename from packages/lightning/src/commands_v2/bridge/modify.ts rename to packages/lightning/src/commands/bridge/modify.ts index e51844e0..2d6c4b8d 100644 --- a/packages/lightning/src/commands_v2/bridge/modify.ts +++ b/packages/lightning/src/commands/bridge/modify.ts @@ -1,13 +1,12 @@ import { log_error } from '../../errors.ts'; -import { create_message, type message } from '../../messages.ts'; import type { command_execute_options } from '../mod.ts'; -export async function leave(opts: command_execute_options): Promise { +export async function leave(opts: command_execute_options): Promise { const bridge = await opts.lightning.data.get_bridge_by_channel( opts.channel, ); - if (!bridge) return create_message(`You are not in a bridge`); + if (!bridge) return `You are not in a bridge`; bridge.channels = bridge.channels.filter(( ch, @@ -17,28 +16,28 @@ export async function leave(opts: command_execute_options): Promise { await opts.lightning.data.edit_bridge( bridge, ); - return create_message(`Bridge left successfully`); + return `Bridge left successfully`; } catch (e) { - return (await log_error( + throw await log_error( new Error('Error updating bridge', { cause: e }), { bridge, }, - )).message; + ); } } const settings = ['allow_editing', 'allow_everyone', 'use_rawname']; -export async function toggle(opts: command_execute_options): Promise { +export async function toggle(opts: command_execute_options): Promise { const bridge = await opts.lightning.data.get_bridge_by_channel( opts.channel, ); - if (!bridge) return create_message(`You are not in a bridge`); + if (!bridge) return `You are not in a bridge`; if (!settings.includes(opts.arguments.setting)) { - return create_message(`That setting does not exist`); + return `That setting does not exist`; } const key = opts.arguments.setting as keyof typeof bridge.settings; @@ -49,13 +48,13 @@ export async function toggle(opts: command_execute_options): Promise { await opts.lightning.data.edit_bridge( bridge, ); - return create_message(`Bridge settings updated successfully`); + return `Bridge settings updated successfully`; } catch (e) { - return (await log_error( + throw await log_error( new Error('Error updating bridge', { cause: e }), { bridge, }, - )).message; + ); } } diff --git a/packages/lightning/src/commands_v2/bridge/status.ts b/packages/lightning/src/commands/bridge/status.ts similarity index 54% rename from packages/lightning/src/commands_v2/bridge/status.ts rename to packages/lightning/src/commands/bridge/status.ts index c700b55f..27fefc02 100644 --- a/packages/lightning/src/commands_v2/bridge/status.ts +++ b/packages/lightning/src/commands/bridge/status.ts @@ -1,26 +1,23 @@ -import { create_message, type message } from '../../messages.ts'; import type { command_execute_options } from '../mod.ts'; -export async function status(opts: command_execute_options): Promise { +export async function status(opts: command_execute_options): Promise { const bridge = await opts.lightning.data.get_bridge_by_channel( opts.channel, ); - if (!bridge) return create_message(`You are not in a bridge`); + if (!bridge) return `You are not in a bridge`; - let str = `*Bridge status:*\n\n`; - str += `**Name:** ${bridge.name}\n`; - str += `**Channels:**\n`; + let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; for (const [i, value] of bridge.channels.entries()) { str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`\n`; } - str += `\n**Settings:**\n`; + str += `\nSettings:\n`; for (const [key, value] of Object.entries(bridge.settings)) { - str += `\`${key}: ${value}\`\n`; + str += `- \`${key}\` ${value ? "โœ”" : "โŒ"}\n`; } - return create_message(str); + return str; } \ No newline at end of file diff --git a/packages/lightning/src/commands/default.ts b/packages/lightning/src/commands/default.ts new file mode 100644 index 00000000..14058767 --- /dev/null +++ b/packages/lightning/src/commands/default.ts @@ -0,0 +1,26 @@ +import { bridge_command } from './bridge/mod.ts'; +import type { command } from './mod.ts'; + +export const default_commands = new Map([ + ['help', { + name: 'help', + description: 'get help with the bot', + execute: () => + 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', + }], + ['ping', { + name: 'ping', + description: 'check if the bot is alive', + execute: ({ timestamp }) => + `Pong! ๐Ÿ“ ${ + Temporal.Now.instant().since(timestamp).round('millisecond') + .total('milliseconds') + }ms`, + }], + ['version', { + name: 'version', + description: 'get the bots version', + execute: () => 'hello from v0.8.0!', + }], + ['bridge', bridge_command], +]) as Map; diff --git a/packages/lightning/src/commands/mod.ts b/packages/lightning/src/commands/mod.ts new file mode 100644 index 00000000..23d9be91 --- /dev/null +++ b/packages/lightning/src/commands/mod.ts @@ -0,0 +1,113 @@ +import type { lightning } from '../lightning.ts'; +import { create_message, type message } from '../messages.ts'; +import { parseArgs } from '@std/cli/parse-args'; +import { type err, log_error } from '../errors.ts'; + +export interface command_execute_options { + channel: string; + plugin: string; + timestamp: Temporal.Instant; + arguments: Record; + lightning: lightning; + id: string; +} + +export interface command { + name: string; + description: string; + arguments?: { + name: string; + description: string; + required: boolean; + }[]; + subcommands?: Omit[]; + execute: ( + opts: command_execute_options, + ) => Promise | string | err; +} + +export async function execute_text_command(msg: message, lightning: lightning) { + if (!msg.content?.startsWith(lightning.config.cmd_prefix)) return; + + const { + _: [cmd, ...rest], + ...args + } = parseArgs( + msg.content.replace(lightning.config.cmd_prefix, '').split(' '), + ); + + return await run_command({ + ...msg, + lightning, + command: cmd as string, + rest: rest as string[], + args, + }); +} + +export interface run_command_options + extends Omit { + command: string; + subcommand?: string; + args?: Record; + rest?: string[]; + reply: message['reply']; +} + +export async function run_command( + opts: run_command_options, +) { + let command = opts.lightning.commands.get(opts.command) ?? + opts.lightning.commands.get('help')!; + + const subcommand_name = opts.subcommand ?? opts.rest?.shift(); + + if (command.subcommands && subcommand_name) { + const subcommand = command.subcommands.find((i) => + i.name === subcommand_name + ); + + if (subcommand) command = subcommand; + } + + if (!opts.args) opts.args = {}; + + for (const arg of command.arguments || []) { + if (!opts.args[arg.name]) { + opts.args[arg.name] = opts.rest?.shift() as string; + } + + if (!opts.args[arg.name]) { + return opts.reply( + create_message( + `Please provide the \`${arg.name}\` argument. Try using the \`${opts.lightning.config.cmd_prefix}help\` command.`, + ), + false, + ); + } + } + + let resp: string | err; + + try { + resp = await command.execute({ + ...opts, + arguments: opts.args, + }); + } catch (e) { + // TODO(jersey): we should have err be a class that extends Error so checking this is easier + if (typeof e === 'object' && e !== null && 'cause' in e) { + resp = e as err; + } else { + resp = await log_error(e, { ...opts, reply: undefined }); + } + } + + try { + if (typeof resp === 'string') { + await opts.reply(create_message(resp), false); + } else await opts.reply(resp.message, false); + } catch (e) { + await log_error(e, { ...opts, reply: undefined }); + } +} diff --git a/packages/lightning/src/commands/run.ts b/packages/lightning/src/commands/run.ts deleted file mode 100644 index f803b073..00000000 --- a/packages/lightning/src/commands/run.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { command_arguments } from './mod.ts'; -import { create_message, type message } from '../messages.ts'; -import { log_error } from '../errors.ts'; -import type { lightning } from '../lightning.ts'; -import { parseArgs } from '@std/cli/parse-args'; - -// TODO(jersey): migrate over to commands_v2 - -export function handle_command_message(m: message, l: lightning) { - if (!m.content?.startsWith(l.config.cmd_prefix)) return; - - const { - _: [cmd, subcmd], - ...opts - } = parseArgs(m.content.replace(l.config.cmd_prefix, '').split(' ')); - - run_command({ - lightning: l, - cmd: cmd as string, - subcmd: subcmd as string, - opts, - ...m, - }); -} - -export async function run_command(args: command_arguments) { - let reply; - - try { - const cmd = args.lightning.commands.get(args.cmd) || - args.lightning.commands.get('help')!; - - const exec = cmd.options?.subcommands?.find((i) => - i.name === args.subcmd - )?.execute || - cmd.execute; - - reply = create_message(await exec(args)); - } catch (e) { - reply = (await log_error(e, { ...args, reply: undefined })).message; - } - - try { - await args.reply(reply, false); - } catch (e) { - await log_error(e, { ...args, reply: undefined }); - } -} diff --git a/packages/lightning/src/commands_v2/mod.ts b/packages/lightning/src/commands_v2/mod.ts deleted file mode 100644 index 4bdd45a8..00000000 --- a/packages/lightning/src/commands_v2/mod.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { bridge_command } from './bridge/mod.ts'; -import type { lightning } from '../lightning.ts'; -import { create_message, type message } from '../messages.ts'; - -export interface command_execute_options { - channel: string; - plugin: string; - timestamp: Temporal.Instant; - arguments: Record; - lightning: lightning; - id: string; -} - -export interface command { - name: string; - description: string; - arguments?: { - name: string; - description: string; - required: boolean; - }[]; - subcommands?: command[]; - // TODO(jersey): message | string | Promise | Promise - execute: (opts: command_execute_options) => Promise | message; -} - -export const default_commands = [ - ['help', { - name: 'help', - description: 'get help with the bot', - execute: () => - create_message( - 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', - ), - }], - ['ping', { - name: 'ping', - description: 'check if the bot is alive', - execute: ({ timestamp }) => { - const diff = Temporal.Now.instant().since(timestamp).total( - 'milliseconds', - ); - return create_message(`Pong! ๐Ÿ“ ${diff}ms`); - }, - }], - ['version', { - name: 'version', - description: 'get the bots version', - execute: () => create_message('hello from v0.8.0!'), - }], - ['bridge', bridge_command], -] as [string, command][]; - -// TODO(jersey): make command runners diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index 8bc50a41..44fa2cea 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -1,12 +1,15 @@ import type { ClientOptions } from '@db/postgres'; import { type command, - default_commands, -} from './commands_v2/mod.ts'; + execute_text_command, + run_command, + type run_command_options, +} from './commands/mod.ts'; import type { create_plugin, plugin } from './plugins.ts'; import { bridge_data } from './bridge/data.ts'; import { handle_message } from './bridge/msg.ts'; import type { message } from './messages.ts'; +import { default_commands } from './commands/default.ts'; /** configuration options for lightning */ export interface config { @@ -24,7 +27,7 @@ export class lightning { /** bridge data handling */ data: bridge_data; /** the commands registered */ - commands: Map = new Map(default_commands); + commands: Map = default_commands; /** the config used */ config: config; /** the plugins loaded */ @@ -49,16 +52,21 @@ export class lightning { for await (const { name, value } of plugin) { await new Promise((res) => setTimeout(res, 150)); - if (sessionStorage.getItem(`${value[0].plugin}-${value[0].id}`)) continue; + if (sessionStorage.getItem(`${value[0].plugin}-${value[0].id}`)) { + continue; + } if (name === 'run_command') { - // TODO(jersey): migrate over to commands_v2 + run_command({ + ...value[0], + lightning: this, + } as run_command_options); continue; } if (name === 'create_message') { - // TODO(jersey): migrate over to commands_v2 + execute_text_command(value[0] as message, this); } handle_message( diff --git a/packages/lightning/src/plugins.ts b/packages/lightning/src/plugins.ts index 8b421e31..19c6d76b 100644 --- a/packages/lightning/src/plugins.ts +++ b/packages/lightning/src/plugins.ts @@ -6,7 +6,7 @@ import type { message_options, process_result, } from './messages.ts'; -import type { command_execute_options } from './commands_v2/mod.ts'; +import type { run_command_options } from './commands/mod.ts'; /** the way to make a plugin */ export interface create_plugin< @@ -29,7 +29,7 @@ export type plugin_events = { /** when a message is deleted */ delete_message: [deleted_message]; /** when a command is run */ - run_command: [Omit]; + run_command: [Omit]; }; /** a plugin for lightning */ From 7ba9ec31f8183564706c836e2e13c7de7df8c62b Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 28 Nov 2024 18:40:28 -0500 Subject: [PATCH 12/97] replace log_error in most places --- packages/lightning/src/cli/mod.ts | 15 +- packages/lightning/src/commands/bridge/add.ts | 42 ++---- .../lightning/src/commands/bridge/modify.ts | 22 ++- packages/lightning/src/commands/mod.ts | 33 ++--- packages/lightning/src/errors.ts | 137 ++++++++++++------ packages/lightning/src/mod.ts | 2 +- 6 files changed, 137 insertions(+), 114 deletions(-) diff --git a/packages/lightning/src/cli/mod.ts b/packages/lightning/src/cli/mod.ts index 02ba47e6..8d1560af 100644 --- a/packages/lightning/src/cli/mod.ts +++ b/packages/lightning/src/cli/mod.ts @@ -1,6 +1,6 @@ import { parseArgs } from '@std/cli/parse-args'; import { join, toFileUrl } from '@std/path'; -import { log_error } from '../errors.ts'; +import { logError } from '../errors.ts'; import { type config, lightning } from '../lightning.ts'; const version = '0.8.0'; @@ -16,21 +16,18 @@ if (_.v || _.version) { const config: config = (await import(toFileUrl(_.config).toString())).default; - addEventListener('error', async (ev) => { - await log_error(ev.error, { type: 'global error' }); - Deno.exit(1); + addEventListener('error', (ev) => { + logError(ev.error, { extra: { type: 'global error' } }); }); - addEventListener('unhandledrejection', async (ev) => { - await log_error(ev.reason, { type: 'global rejection' }); - Deno.exit(1); + addEventListener('unhandledrejection', (ev) => { + logError(ev.reason, { extra: { type: 'global rejection' } }); }); try { await lightning.create(config); } catch (e) { - await log_error(e, { type: 'global class error' }); - Deno.exit(1); + logError(e, { extra: { type: 'global class error' } }); } } else if (_._[0] === 'migrations') { // TODO(jersey): implement migrations diff --git a/packages/lightning/src/commands/bridge/add.ts b/packages/lightning/src/commands/bridge/add.ts index 3f2faaaa..9b7c14d2 100644 --- a/packages/lightning/src/commands/bridge/add.ts +++ b/packages/lightning/src/commands/bridge/add.ts @@ -1,5 +1,5 @@ import type { bridge_channel } from '../../bridge/data.ts'; -import { log_error } from '../../errors.ts'; +import { logError } from '../../errors.ts'; import type { command_execute_options } from '../mod.ts'; export async function create( @@ -23,10 +23,10 @@ export async function create( await opts.lightning.data.create_bridge(bridge_data); return `Bridge created successfully! You can now join it using \`${opts.lightning.config.cmd_prefix}join ${result.id}\`. Keep this id safe, don't share it with anyone, and delete this message.`; } catch (e) { - throw await log_error( - new Error('Failed to insert bridge into database', { cause: e }), - bridge_data, - ); + logError(e, { + message: 'Failed to insert bridge into database', + extra: bridge_data + }); } } @@ -52,14 +52,10 @@ export async function join( return `Bridge joined successfully!`; } catch (e) { - throw await log_error( - new Error('Failed to update bridge in database', { - cause: e, - }), - { - bridge: target_bridge, - }, - ); + logError(e, { + message: 'Failed to update bridge in database', + extra: { target_bridge } + }) } } @@ -77,12 +73,9 @@ async function _lightning_bridge_add_common( const plugin = opts.lightning.plugins.get(opts.plugin); if (!plugin) { - throw await log_error( - new Error('Internal error: platform support not found'), - { - plugin: opts.plugin, - }, - ); + logError('Internal error: platform support not found', { + extra: { plugin: opts.plugin } + }) } let bridge_data; @@ -90,13 +83,10 @@ async function _lightning_bridge_add_common( try { bridge_data = await plugin.create_bridge(opts.channel); } catch (e) { - throw await log_error( - new Error('Failed to create bridge using plugin', { cause: e }), - { - channel: opts.channel, - plugin_name: opts.plugin, - }, - ); + logError(e, { + message: 'Failed to create bridge using plugin', + extra: { channel: opts.channel, plugin_name: opts.plugin } + }) } return { diff --git a/packages/lightning/src/commands/bridge/modify.ts b/packages/lightning/src/commands/bridge/modify.ts index 2d6c4b8d..8cd33d6d 100644 --- a/packages/lightning/src/commands/bridge/modify.ts +++ b/packages/lightning/src/commands/bridge/modify.ts @@ -1,4 +1,4 @@ -import { log_error } from '../../errors.ts'; +import { logError } from '../../errors.ts'; import type { command_execute_options } from '../mod.ts'; export async function leave(opts: command_execute_options): Promise { @@ -18,12 +18,10 @@ export async function leave(opts: command_execute_options): Promise { ); return `Bridge left successfully`; } catch (e) { - throw await log_error( - new Error('Error updating bridge', { cause: e }), - { - bridge, - }, - ); + logError(e, { + message: 'Failed to update bridge in database', + extra: { bridge }, + }) } } @@ -50,11 +48,9 @@ export async function toggle(opts: command_execute_options): Promise { ); return `Bridge settings updated successfully`; } catch (e) { - throw await log_error( - new Error('Error updating bridge', { cause: e }), - { - bridge, - }, - ); + logError(e, { + message: 'Failed to update bridge in database', + extra: { bridge }, + }) } } diff --git a/packages/lightning/src/commands/mod.ts b/packages/lightning/src/commands/mod.ts index 23d9be91..1fd4152b 100644 --- a/packages/lightning/src/commands/mod.ts +++ b/packages/lightning/src/commands/mod.ts @@ -1,7 +1,6 @@ import type { lightning } from '../lightning.ts'; import { create_message, type message } from '../messages.ts'; -import { parseArgs } from '@std/cli/parse-args'; -import { type err, log_error } from '../errors.ts'; +import { LightningError } from '../errors.ts'; export interface command_execute_options { channel: string; @@ -23,25 +22,19 @@ export interface command { subcommands?: Omit[]; execute: ( opts: command_execute_options, - ) => Promise | string | err; + ) => Promise | string; } export async function execute_text_command(msg: message, lightning: lightning) { if (!msg.content?.startsWith(lightning.config.cmd_prefix)) return; - const { - _: [cmd, ...rest], - ...args - } = parseArgs( - msg.content.replace(lightning.config.cmd_prefix, '').split(' '), - ); + const [cmd, ...rest] = msg.content.replace(lightning.config.cmd_prefix, '').split(' '); return await run_command({ ...msg, lightning, command: cmd as string, rest: rest as string[], - args, }); } @@ -87,7 +80,7 @@ export async function run_command( } } - let resp: string | err; + let resp: string | LightningError; try { resp = await command.execute({ @@ -95,19 +88,21 @@ export async function run_command( arguments: opts.args, }); } catch (e) { - // TODO(jersey): we should have err be a class that extends Error so checking this is easier - if (typeof e === 'object' && e !== null && 'cause' in e) { - resp = e as err; - } else { - resp = await log_error(e, { ...opts, reply: undefined }); - } + if (e instanceof LightningError) resp = e; + else resp = new LightningError(e, { + message: 'An error occurred while executing the command', + extra: { command: command.name }, + }) } try { if (typeof resp === 'string') { await opts.reply(create_message(resp), false); - } else await opts.reply(resp.message, false); + } else await opts.reply(resp.msg, false); } catch (e) { - await log_error(e, { ...opts, reply: undefined }); + new LightningError(e, { + message: 'An error occurred while sending the command response', + extra: { command: command.name }, + }) } } diff --git a/packages/lightning/src/errors.ts b/packages/lightning/src/errors.ts index 2a764236..78aafd7e 100644 --- a/packages/lightning/src/errors.ts +++ b/packages/lightning/src/errors.ts @@ -1,5 +1,90 @@ import { create_message, type message } from './messages.ts'; +export interface LightningErrorOptions { + /** the user-facing message of the error */ + message?: string; + /** the extra data to log */ + extra?: Record; +} + +export class LightningError extends Error { + id: string; + override cause: Error; + extra: Record; + msg: message; + + constructor(e: unknown, options?: LightningErrorOptions) { + if (e instanceof LightningError) { + super(e.message, { cause: e.cause }); + this.id = e.id; + this.cause = e.cause; + this.extra = e.extra; + this.msg = e.msg; + return; + } + + const cause = e instanceof Error + ? e + : e instanceof Object + ? new Error(JSON.stringify(e)) + : new Error(String(e)); + + super(options?.message ?? cause.message, { cause }); + + this.name = 'LightningError'; + this.id = crypto.randomUUID(); + this.cause = cause; + this.extra = options?.extra ?? {}; + this.msg = create_message( + `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\`` + ); + this.log(); + } + + log() { + console.error(`%clightning error ${this.id}`, 'color: red'); + console.error(this.cause, this.extra); + + const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); + + for (const key in this.extra) { + if (key === 'lightning') { + delete this.extra[key]; + } + + if (typeof this.extra[key] === 'object' && this.extra[key] !== null) { + if ('lightning' in this.extra[key]) { + delete this.extra[key].lightning; + } + } + } + + if (webhook && webhook.length > 0) { + let json_str = `\`\`\`json\n${JSON.stringify(this.extra, null, 2)}\n\`\`\``; + + if (json_str.length > 2000) json_str = '*see console*'; + + fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: `# ${this.cause.message}\n*${this.id}*`, + embeds: [ + { + title: 'extra', + description: json_str, + }, + ], + }), + }); + } + } +} + +export function logError(e: unknown, options?: LightningErrorOptions): never { + throw new LightningError(e, options); +} + /** the error returned from log_error */ export interface err { /** id of the error */ @@ -21,52 +106,12 @@ export async function log_error( e: unknown, extra: Record = {}, ): Promise { - const id = crypto.randomUUID(); - const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); - const cause = e instanceof Error - ? e - : e instanceof Object - ? new Error(JSON.stringify(e)) - : new Error(String(e)); - const user_facing_text = - `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${cause.message}\n${id}\n\`\`\``; - - for (const key in extra) { - if (key === 'lightning') { - delete extra[key]; - } + const error = new LightningError(e, { extra }); - if (typeof extra[key] === 'object' && extra[key] !== null) { - if ('lightning' in extra[key]) { - delete extra[key].lightning; - } - } - } - - // TODO(jersey): this is a really bad way of error handling-especially given it doesn't do a lot of stuff that would help debug errors-but it'll be replaced - - console.error(`%clightning error ${id}`, 'color: red'); - console.error(cause, extra); - - if (webhook && webhook.length > 0) { - let json_str = `\`\`\`json\n${JSON.stringify(extra, null, 2)}\n\`\`\``; - - if (json_str.length > 2000) json_str = '*see console*'; - - await fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: `# ${cause.message}\n*${id}*`, - embeds: [ - { - title: 'extra', - description: json_str, - }, - ], - }), - }); + return { + id: error.id, + cause: error.cause, + extra: error.extra, + message: error.msg, } - - return { id, cause, extra, message: create_message(user_facing_text) }; } diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index 61629083..0d4a02f3 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -1,5 +1,5 @@ // TODO(jersey): add exports from commands_v2 -export { log_error } from './errors.ts'; +export { LightningError, log_error } from './errors.ts'; export { type config, lightning } from './lightning.ts'; export * from './messages.ts'; export * from './plugins.ts'; From aa323de716899152ef728681fdb6efdcb22ba4df Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 28 Nov 2024 19:10:44 -0500 Subject: [PATCH 13/97] discord commands_v2 --- .../lightning-plugin-discord/src/commands.ts | 76 +++++++++---------- packages/lightning-plugin-discord/src/mod.ts | 2 +- packages/lightning/src/lightning.ts | 6 +- packages/lightning/src/mod.ts | 2 +- packages/lightning/src/plugins.ts | 2 +- 5 files changed, 40 insertions(+), 48 deletions(-) diff --git a/packages/lightning-plugin-discord/src/commands.ts b/packages/lightning-plugin-discord/src/commands.ts index 8b6e24ff..267b007f 100644 --- a/packages/lightning-plugin-discord/src/commands.ts +++ b/packages/lightning-plugin-discord/src/commands.ts @@ -1,15 +1,13 @@ import type { API } from '@discordjs/core'; -import type { command, command_arguments } from '@jersey/lightning'; +import type { command, run_command_options, lightning } from '@jersey/lightning'; import type { APIInteraction } from 'discord-api-types'; import { to_discord } from './discord.ts'; import { instant } from './lightning.ts'; -// TODO(jersey): migrate over to commands_v2 - -export function to_command(interaction: { api: API; data: APIInteraction }) { +export function to_command(interaction: { api: API; data: APIInteraction }, lightning: lightning) { if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; const opts = {} as Record; - let subcmd = ''; + let subcmd; for (const opt of interaction.data.data.options || []) { if (opt.type === 1) subcmd = opt.name; @@ -17,8 +15,13 @@ export function to_command(interaction: { api: API; data: APIInteraction }) { } return { - cmd: interaction.data.data.name, - subcmd, + command: interaction.data.data.name, + subcommand: subcmd, + channel: interaction.data.channel.id, + id: interaction.data.id, + timestamp: instant(interaction.data.id), + lightning, + plugin: 'bolt-discord', reply: async (msg) => { await interaction.api.interactions.reply( interaction.data.id, @@ -26,45 +29,38 @@ export function to_command(interaction: { api: API; data: APIInteraction }) { await to_discord(msg), ); }, - channel: interaction.data.channel.id, - plugin: 'bolt-discord', - opts, - timestamp: instant(interaction.data.id), - } as command_arguments; + args: opts, + } as run_command_options; } -export function to_intent_opts({ options }: command) { +export function to_intent_opts({ arguments: args, subcommands }: command) { const opts = []; - if (options?.argument_name) { - opts.push({ - name: options.argument_name, - description: 'option to pass to this command', - type: 3, - required: options.argument_required, - }); + if (args) { + for (const arg of args) { + opts.push({ + name: arg.name, + description: arg.description, + type: 3, + required: arg.required, + }); + } } - if (options?.subcommands) { - opts.push( - ...options.subcommands.map((i) => { - return { - name: i.name, - description: i.description || i.name, - type: 1, - options: i.options?.argument_name - ? [ - { - name: i.options.argument_name, - description: i.options.argument_name, - type: 3, - required: i.options.argument_required || false, - }, - ] - : undefined, - }; - }), - ); + if (subcommands) { + for (const sub of subcommands) { + opts.push({ + name: sub.name, + description: sub.description, + type: 1, + options: sub.arguments?.map((opt) => ({ + name: opt.name, + description: opt.description, + type: 3, + required: opt.required, + })), + }); + } } return opts; diff --git a/packages/lightning-plugin-discord/src/mod.ts b/packages/lightning-plugin-discord/src/mod.ts index 74d24b9f..75e5da92 100644 --- a/packages/lightning-plugin-discord/src/mod.ts +++ b/packages/lightning-plugin-discord/src/mod.ts @@ -69,7 +69,7 @@ export class discord_plugin extends plugin { }); this.bot.on(GatewayDispatchEvents.InteractionCreate, (interaction) => { - const cmd = to_command(interaction); + const cmd = to_command(interaction, this.lightning); if (cmd) this.emit('run_command', cmd); }); } diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index 44fa2cea..cf75c439 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -57,11 +57,7 @@ export class lightning { } if (name === 'run_command') { - run_command({ - ...value[0], - lightning: this, - } as run_command_options); - + run_command(value[0] as run_command_options); continue; } diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index 0d4a02f3..48cb71ec 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -1,4 +1,4 @@ -// TODO(jersey): add exports from commands_v2 +export { type command, type run_command_options } from './commands/mod.ts' export { LightningError, log_error } from './errors.ts'; export { type config, lightning } from './lightning.ts'; export * from './messages.ts'; diff --git a/packages/lightning/src/plugins.ts b/packages/lightning/src/plugins.ts index 19c6d76b..a9a2aecb 100644 --- a/packages/lightning/src/plugins.ts +++ b/packages/lightning/src/plugins.ts @@ -29,7 +29,7 @@ export type plugin_events = { /** when a message is deleted */ delete_message: [deleted_message]; /** when a command is run */ - run_command: [Omit]; + run_command: [run_command_options]; }; /** a plugin for lightning */ From 845d7a00d1d075f875097c2a6e495836ff52769d Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 28 Nov 2024 20:35:59 -0500 Subject: [PATCH 14/97] errors v2 and pluginz v8 --- packages/lightning/src/bridge/data.ts | 8 +- packages/lightning/src/bridge/msg.ts | 334 +++++++++--------- packages/lightning/src/commands/bridge/add.ts | 2 +- packages/lightning/src/errors.ts | 40 +-- packages/lightning/src/lightning.ts | 8 +- packages/lightning/src/messages.ts | 76 +--- packages/lightning/src/mod.ts | 2 +- packages/lightning/src/plugins.ts | 30 +- 8 files changed, 214 insertions(+), 286 deletions(-) diff --git a/packages/lightning/src/bridge/data.ts b/packages/lightning/src/bridge/data.ts index fdd9af82..12b123ae 100644 --- a/packages/lightning/src/bridge/data.ts +++ b/packages/lightning/src/bridge/data.ts @@ -115,13 +115,13 @@ export class bridge_data { return res.rows[0]; } - async create_bridge_message(msg: bridge_message): Promise { + async create_message(msg: bridge_message): Promise { await this.pg.queryArray`INSERT INTO bridge_messages (id, bridge_id, channels, messages, settings) VALUES (${msg.id}, ${msg.bridge_id}, ${JSON.stringify(msg.channels)}, ${JSON.stringify(msg.messages)}, ${JSON.stringify(msg.settings)})`; } - async edit_bridge_message(msg: bridge_message): Promise { + async edit_message(msg: bridge_message): Promise { await this.pg.queryArray` UPDATE bridge_messages SET messages = ${JSON.stringify(msg.messages)}, channels = ${JSON.stringify(msg.channels)}, settings = ${JSON.stringify(msg.settings)} @@ -129,13 +129,13 @@ export class bridge_data { `; } - async delete_bridge_message({ id }: bridge_message): Promise { + async delete_message({ id }: bridge_message): Promise { await this.pg.queryArray` DELETE FROM bridge_messages WHERE id = ${id} `; } - async get_bridge_message(id: string): Promise { + async get_message(id: string): Promise { const res = await this.pg.queryObject(` SELECT * FROM bridge_messages WHERE id = '${id}' OR EXISTS ( diff --git a/packages/lightning/src/bridge/msg.ts b/packages/lightning/src/bridge/msg.ts index 47525d30..56f366ef 100644 --- a/packages/lightning/src/bridge/msg.ts +++ b/packages/lightning/src/bridge/msg.ts @@ -1,178 +1,176 @@ -import type { lightning } from '../lightning.ts'; -import { log_error } from '../errors.ts'; +import type { deleted_message, message } from '../messages.ts'; +import { type lightning, LightningError } from '../mod.ts'; import type { - deleted_message, - message, - unprocessed_message, -} from '../messages.ts'; -import type { - bridge, - bridge_channel, - bridge_message, - bridged_message, + bridge, + bridge_channel, + bridge_message, + bridged_message, } from './data.ts'; export async function handle_message( - core: lightning, - msg: message | deleted_message, - type: 'create' | 'edit' | 'delete', -): Promise { - const br = type === 'create' - ? await core.data.get_bridge_by_channel(msg.channel) - : await core.data.get_bridge_message(msg.id); - - if (!br) return; - - if (type !== 'create' && br.settings.allow_editing !== true) return; - - if ( - br.channels.find((i) => - i.id === msg.channel && i.plugin === msg.plugin && i.disabled - ) - ) return; - - const channels = br.channels.filter( - (i) => i.id !== msg.channel || i.plugin !== msg.plugin, - ); - - if (channels.length < 1) return; - - const messages = [] as bridged_message[]; - - for (const ch of channels) { - if (!ch.data || ch.disabled) continue; - - const bridged_id = (br as Partial).messages?.find((i) => - i.channel === ch.id && i.plugin === ch.plugin - ); - - if ((type !== 'create' && !bridged_id)) { - continue; - } - - const plugin = core.plugins.get(ch.plugin); - - if (!plugin) { - await disable_channel( - ch, - br, - core, - (await log_error( - new Error(`plugin ${ch.plugin} doesn't exist`), - { channel: ch, bridged_id }, - )).cause, - ); - - continue; - } - - const reply_id = await get_reply_id(core, msg as message, ch); - - let res; - - try { - res = await plugin.process_message({ - action: type as 'edit', - channel: ch, - message: msg as message, - edit_id: bridged_id?.id as string[], - reply_id, - }); - - if (res.error) throw res.error; - } catch (e) { - if (type === 'delete') continue; - - if ((res as unprocessed_message).disable) { - await disable_channel(ch, br, core, e); - - continue; - } - - const err = await log_error(e, { - channel: ch, - bridged_id, - message: msg, - }); - - try { - res = await plugin.process_message({ - action: type as 'edit', - channel: ch, - message: err.message as message, - edit_id: bridged_id?.id as string[], - reply_id, - }); - - if (res.error) throw res.error; - } catch (e) { - await log_error( - new Error(`failed to log error`, { cause: e }), - { channel: ch, bridged_id, original_id: err.id }, - ); - - continue; - } - } - - for (const id of res.id) { - sessionStorage.setItem(`${ch.plugin}-${id}`, '1'); - } - - messages.push({ - id: res.id, - channel: ch.id, - plugin: ch.plugin, - }); - } - - await core.data[`${type}_bridge_message`]({ - ...br, - id: msg.id, - messages, - bridge_id: br.id, - }); -} - -async function disable_channel( - channel: bridge_channel, - bridge: bridge | bridge_message, - core: lightning, - error: unknown, -): Promise { - await log_error(error, { channel, bridge }); - - await core.data.edit_bridge({ - id: "bridge_id" in bridge ? bridge.bridge_id : bridge.id, - channels: bridge.channels.map((i) => - i.id === channel.id && i.plugin === channel.plugin - ? { ...i, disabled: true, data: error } - : i - ), - settings: bridge.settings - }); + lightning: lightning, + event: 'create_message' | 'edit_message' | 'delete_message', + data: message | deleted_message, +) { + // get the bridge and return if it doesn't exist + let bridge; + + if (event === 'create_message') { + bridge = await lightning.data.get_bridge_by_channel(data.channel); + } else { + bridge = await lightning.data.get_message(data.id); + } + + if (!bridge) return; + + // if editing isn't allowed, return + if (event !== 'create_message' && bridge.settings.allow_editing !== true) { + return; + } + + // if the channel this event is from is disabled, return + if ( + bridge.channels.find((channel) => + channel.id === data.channel && channel.plugin === data.plugin && + channel.disabled + ) + ) return; + + // filter out the channel this event is from and any disabled channels + const channels = bridge.channels.filter( + (i) => i.id !== data.channel || i.plugin !== data.plugin, + ).filter((i) => !i.disabled || !i.data); + + // if there are no more channels, return + if (channels.length < 1) return; + + const messages = [] as bridged_message[]; + + for (const channel of channels) { + let prior_bridged_ids; + + if (event !== 'create_message') { + prior_bridged_ids = (bridge as bridge_message).messages?.find((i) => + i.channel === channel.id && i.plugin === channel.plugin + ); + + if (!prior_bridged_ids) continue; // the message wasn't bridged previously + } + + const plugin = lightning.plugins.get(channel.plugin); + + if (!plugin) { + await disable_channel( + channel, + bridge, + new LightningError(`plugin ${channel.plugin} doesn't exist`), + lightning, + ); + continue; + } + + const reply_id = await get_reply_id(lightning, data, channel); + + let result_ids: string[]; + + try { + result_ids = await plugin[event]({ + channel, + reply_id, + edit_ids: prior_bridged_ids?.id as string[], + msg: data as message, + }); + } catch (e) { + if (e instanceof LightningError && e.disable_channel) { + await disable_channel(channel, bridge, e, lightning); + continue; + } + + // try sending an error message + + const err = e instanceof LightningError + ? e + : new LightningError(e, { + message: + `An error occurred while processing a message in the bridge.`, + }); + + try { + result_ids = await plugin[event]({ + channel, + reply_id, + edit_ids: prior_bridged_ids?.id as string[], + msg: err.msg, + }); + } catch (e) { + new LightningError(e, { + message: `Failed to log error message in bridge`, + extra: { channel, original_error: err.id }, + }); + + continue; + } + } + + for (const result_id of result_ids) { + sessionStorage.setItem(`${channel.plugin}-${result_id}`, '1'); + } + + messages.push({ + id: result_ids, + channel: channel.id, + plugin: channel.plugin, + }); + } + + await lightning.data[event]({ + ...bridge, + id: data.id, + messages, + bridge_id: bridge.id, + }); } async function get_reply_id( - core: lightning, - msg: message, - channel: bridge_channel, + core: lightning, + msg: message | deleted_message, + channel: bridge_channel, ): Promise { - if (msg.reply_id) { - try { - const bridged = await core.data.get_bridge_message(msg.reply_id); - - if (!bridged) return; - - const br_ch = bridged.channels.find((i) => - i.id === channel.id && i.plugin === channel.plugin - ); - - if (!br_ch) return; + if ('reply_id' in msg && msg.reply_id) { + try { + const bridged = await core.data.get_message(msg.reply_id); + + const bridged_message = bridged?.messages?.find((i) => + i.channel === channel.id && i.plugin === channel.plugin + ); + + return bridged_message?.id[0]; + } catch { + return; + } + } +} - return br_ch.id; - } catch { - return; - } - } +async function disable_channel( + channel: bridge_channel, + bridge: bridge | bridge_message, + error: LightningError, + lightning: lightning, +) { + new LightningError( + `disabling channel ${channel.id} in bridge ${bridge.id}`, + { + extra: { original_error: error.id }, + }, + ); + + await lightning.data.edit_bridge({ + id: 'bridge_id' in bridge ? bridge.bridge_id : bridge.id, + channels: bridge.channels.map((i) => + i.id === channel.id && i.plugin === channel.plugin + ? { ...i, disabled: true, data: error } + : i + ), + settings: bridge.settings, + }); } diff --git a/packages/lightning/src/commands/bridge/add.ts b/packages/lightning/src/commands/bridge/add.ts index 9b7c14d2..dba9a4bd 100644 --- a/packages/lightning/src/commands/bridge/add.ts +++ b/packages/lightning/src/commands/bridge/add.ts @@ -81,7 +81,7 @@ async function _lightning_bridge_add_common( let bridge_data; try { - bridge_data = await plugin.create_bridge(opts.channel); + bridge_data = await plugin.setup_channel(opts.channel); } catch (e) { logError(e, { message: 'Failed to create bridge using plugin', diff --git a/packages/lightning/src/errors.ts b/packages/lightning/src/errors.ts index 78aafd7e..485de6ce 100644 --- a/packages/lightning/src/errors.ts +++ b/packages/lightning/src/errors.ts @@ -5,6 +5,8 @@ export interface LightningErrorOptions { message?: string; /** the extra data to log */ extra?: Record; + /** whether to disable the channel */ + disable?: boolean; } export class LightningError extends Error { @@ -12,14 +14,16 @@ export class LightningError extends Error { override cause: Error; extra: Record; msg: message; + disable_channel?: boolean; - constructor(e: unknown, options?: LightningErrorOptions) { + constructor(e: unknown, public options?: LightningErrorOptions) { if (e instanceof LightningError) { super(e.message, { cause: e.cause }); this.id = e.id; this.cause = e.cause; this.extra = e.extra; this.msg = e.msg; + this.disable_channel = e.disable_channel; return; } @@ -35,6 +39,7 @@ export class LightningError extends Error { this.id = crypto.randomUUID(); this.cause = cause; this.extra = options?.extra ?? {}; + this.disable_channel = options?.disable; this.msg = create_message( `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\`` ); @@ -43,7 +48,7 @@ export class LightningError extends Error { log() { console.error(`%clightning error ${this.id}`, 'color: red'); - console.error(this.cause, this.extra); + console.error(this.cause, this.options); const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); @@ -84,34 +89,3 @@ export class LightningError extends Error { export function logError(e: unknown, options?: LightningErrorOptions): never { throw new LightningError(e, options); } - -/** the error returned from log_error */ -export interface err { - /** id of the error */ - id: string; - /** the original error */ - cause: Error; - /** extra information about the error */ - extra: Record; - /** the message associated with the error */ - message: message; -} - -/** - * 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 - */ -export async function log_error( - e: unknown, - extra: Record = {}, -): Promise { - const error = new LightningError(e, { extra }); - - return { - id: error.id, - cause: error.cause, - extra: error.extra, - message: error.msg, - } -} diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index cf75c439..d2c93093 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -40,7 +40,7 @@ export class lightning { this.plugins = new Map>(); for (const p of this.config.plugins || []) { - if (p.support.some((v) => ['0.7.3'].includes(v))) { + if (p.support.includes('0.8.0')) { const plugin = new p.type(this, p.config); this.plugins.set(plugin.name, plugin); this._handle_events(plugin); @@ -65,11 +65,7 @@ export class lightning { execute_text_command(value[0] as message, this); } - handle_message( - this, - value[0] as message, - name.split('_')[0] as 'create', - ); + handle_message(this, name, value[0]); } } diff --git a/packages/lightning/src/messages.ts b/packages/lightning/src/messages.ts index c8a6ba96..d2c1f6d3 100644 --- a/packages/lightning/src/messages.ts +++ b/packages/lightning/src/messages.ts @@ -132,72 +132,24 @@ export interface message extends deleted_message { reply_id?: string; } -/** the options given to plugins when a message needs to be sent */ +/** a message to be bridged */ export interface create_message_opts { - /** the action to take */ - action: 'create'; - /** the channel to send the message to */ - channel: bridge_channel; - /** the message to send */ - message: message; - /** the id of the message to reply to */ - reply_id?: string; + msg: message, + channel: bridge_channel, + reply_id?: string, } -/** the options given to plugins when a message needs to be edited */ +/** a message to be edited */ export interface edit_message_opts { - /** the action to take */ - action: 'edit'; - /** the channel to send the message to */ - channel: bridge_channel; - /** the message to send */ - message: message; - /** the id of the message to edit */ - edit_id: string[]; - /** the id of the message to reply to */ - reply_id?: string; + msg: message, + channel: bridge_channel, + reply_id?: string, + edit_ids: string[], } -/** the options given to plugins when a message needs to be deleted */ +/** a message to be deleted */ export interface delete_message_opts { - /** the action to take */ - action: 'delete'; - /** the channel to send the message to */ - channel: bridge_channel; - /** the message to send */ - message: deleted_message; - /** the id of the message to delete */ - edit_id: string[]; - /** the id of the message to reply to */ - reply_id?: string; -} - -/** the options given to plugins when a message needs to be processed */ -export type message_options = - | create_message_opts - | edit_message_opts - | delete_message_opts; - -/** successfully processed message */ -export interface processed_message { - /** whether there was an error */ - error?: undefined; - /** the message that was processed */ - id: string[]; - /** the channel the message was sent to */ - channel: bridge_channel; -} - -/** messages not processed */ -export interface unprocessed_message { - /** the channel the message was to be sent to */ - channel: bridge_channel; - /** whether the channel should be disabled */ - disable?: boolean; - /** the error causing this */ - // TODO(jersey): make this unknown ideally - error: Error; -} - -/** process result */ -export type process_result = processed_message | unprocessed_message; + msg: deleted_message, + channel: bridge_channel, + edit_ids: string[], +} \ No newline at end of file diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index 48cb71ec..76a566c1 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -1,5 +1,5 @@ export { type command, type run_command_options } from './commands/mod.ts' -export { LightningError, log_error } from './errors.ts'; +export { LightningError, logError } from './errors.ts'; export { type config, lightning } from './lightning.ts'; export * from './messages.ts'; export * from './plugins.ts'; diff --git a/packages/lightning/src/plugins.ts b/packages/lightning/src/plugins.ts index a9a2aecb..2be6c0aa 100644 --- a/packages/lightning/src/plugins.ts +++ b/packages/lightning/src/plugins.ts @@ -1,10 +1,11 @@ import { EventEmitter } from '@denosaurs/event'; import type { lightning } from './lightning.ts'; import type { + create_message_opts, + delete_message_opts, deleted_message, + edit_message_opts, message, - message_options, - process_result, } from './messages.ts'; import type { run_command_options } from './commands/mod.ts'; @@ -40,24 +41,31 @@ export abstract class plugin extends EventEmitter { config: cfg; /** the name of your plugin */ abstract name: string; - /** create a new plugin instance */ static new>( this: new (l: lightning, config: T['config']) => T, config: T['config'], ): create_plugin { - return { type: this, config, support: ['0.7.3'] }; + return { type: this, config, support: ['0.8.0'] }; } - + /** initialize a plugin with the given lightning instance and config */ constructor(l: lightning, config: cfg) { super(); 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; - - /** processes a message and returns information */ - abstract process_message(opts: message_options): Promise; + /** setup a channel to be used in a bridge */ + abstract setup_channel(channel: string): Promise | unknown; + /** send a message to a given channel */ + abstract create_message( + opts: create_message_opts, + ): Promise; + /** edit a message in a given channel */ + abstract edit_message( + opts: edit_message_opts, + ): Promise; + /** delete a message in a given channel */ + abstract delete_message( + opts: delete_message_opts, + ): Promise; } From 460b9c041c5bd7beefcd0bfa6381f66b6a56ebde Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 29 Nov 2024 00:19:50 -0500 Subject: [PATCH 15/97] change structure a bit --- .gitignore | 1 - deno.jsonc | 3 +- packages/lightning/{readme.md => README.md} | 5 +- packages/lightning/deno.jsonc | 5 +- packages/lightning/src/bridge.ts | 175 +++++++++++++++++ packages/lightning/src/bridge/msg.ts | 176 ------------------ packages/lightning/src/{cli/mod.ts => cli.ts} | 16 +- .../src/commands/bridge/_internal.ts | 40 ++++ packages/lightning/src/commands/bridge/add.ts | 98 ---------- .../lightning/src/commands/bridge/create.ts | 31 +++ .../lightning/src/commands/bridge/join.ts | 32 ++++ .../lightning/src/commands/bridge/leave.ts | 26 +++ packages/lightning/src/commands/bridge/mod.ts | 52 ------ .../lightning/src/commands/bridge/modify.ts | 56 ------ .../lightning/src/commands/bridge/status.ts | 32 ++-- .../lightning/src/commands/bridge/toggle.ts | 31 +++ packages/lightning/src/commands/default.ts | 96 +++++++--- packages/lightning/src/commands/mod.ts | 108 ----------- packages/lightning/src/commands/runners.ts | 81 ++++++++ .../src/{bridge/data.ts => database.ts} | 69 +++---- packages/lightning/src/errors.ts | 91 --------- packages/lightning/src/lightning.ts | 35 ++-- packages/lightning/src/messages.ts | 155 --------------- packages/lightning/src/mod.ts | 11 +- packages/lightning/src/structures/bridge.ts | 100 ++++++++++ packages/lightning/src/structures/commands.ts | 41 ++++ packages/lightning/src/structures/errors.ts | 107 +++++++++++ packages/lightning/src/structures/events.ts | 30 +++ packages/lightning/src/structures/media.ts | 68 +++++++ packages/lightning/src/structures/messages.ts | 67 +++++++ packages/lightning/src/structures/mod.ts | 7 + .../lightning/src/{ => structures}/plugins.ts | 20 +- 32 files changed, 989 insertions(+), 876 deletions(-) rename packages/lightning/{readme.md => README.md} (71%) create mode 100644 packages/lightning/src/bridge.ts delete mode 100644 packages/lightning/src/bridge/msg.ts rename packages/lightning/src/{cli/mod.ts => cli.ts} (71%) create mode 100644 packages/lightning/src/commands/bridge/_internal.ts delete mode 100644 packages/lightning/src/commands/bridge/add.ts create mode 100644 packages/lightning/src/commands/bridge/create.ts create mode 100644 packages/lightning/src/commands/bridge/join.ts create mode 100644 packages/lightning/src/commands/bridge/leave.ts delete mode 100644 packages/lightning/src/commands/bridge/mod.ts delete mode 100644 packages/lightning/src/commands/bridge/modify.ts create mode 100644 packages/lightning/src/commands/bridge/toggle.ts delete mode 100644 packages/lightning/src/commands/mod.ts create mode 100644 packages/lightning/src/commands/runners.ts rename packages/lightning/src/{bridge/data.ts => database.ts} (58%) delete mode 100644 packages/lightning/src/errors.ts delete mode 100644 packages/lightning/src/messages.ts create mode 100644 packages/lightning/src/structures/bridge.ts create mode 100644 packages/lightning/src/structures/commands.ts create mode 100644 packages/lightning/src/structures/errors.ts create mode 100644 packages/lightning/src/structures/events.ts create mode 100644 packages/lightning/src/structures/media.ts create mode 100644 packages/lightning/src/structures/messages.ts create mode 100644 packages/lightning/src/structures/mod.ts rename packages/lightning/src/{ => structures}/plugins.ts (77%) diff --git a/.gitignore b/.gitignore index c3da77e8..031f26e3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ /.env /config /config.ts -packages/lightning-old packages/postgres \ No newline at end of file diff --git a/deno.jsonc b/deno.jsonc index b375ef02..1cf437e7 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -21,8 +21,7 @@ }, "workspace": [ "./packages/lightning", - // TODO(jersey): remove these two - "./packages/lightning-old", + // TODO(jersey): contribute upstream "./packages/postgres", "./packages/lightning-plugin-telegram", "./packages/lightning-plugin-revolt", diff --git a/packages/lightning/readme.md b/packages/lightning/README.md similarity index 71% rename from packages/lightning/readme.md rename to packages/lightning/README.md index cc12d72b..a61f60c7 100644 --- a/packages/lightning/readme.md +++ b/packages/lightning/README.md @@ -7,4 +7,7 @@ apps via plugins ## [docs](https://williamhorning.eu.org/bolt) - +```ts +import {} from "@jersey/lightning"; +// TODO(jersey): add example +``` \ No newline at end of file diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index 3c2c21fb..4169f01f 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -1,10 +1,7 @@ { "name": "@jersey/lightning", "version": "0.8.0", - "exports": { - ".": "./src/mod.ts", - "./cli": "./src/cli/mod.ts" - }, + "exports": "./src/mod.ts", "imports": { "@db/postgres": "jsr:@db/postgres@^0.19.4", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", diff --git a/packages/lightning/src/bridge.ts b/packages/lightning/src/bridge.ts new file mode 100644 index 00000000..3cca33de --- /dev/null +++ b/packages/lightning/src/bridge.ts @@ -0,0 +1,175 @@ +import type { lightning } from './lightning.ts'; +import { LightningError } from './structures/errors.ts'; +import type { + bridge, + bridge_channel, + bridge_message, + bridged_message, + deleted_message, + message, +} from './structures/mod.ts'; + +export async function bridge_message( + lightning: lightning, + event: 'create_message' | 'edit_message' | 'delete_message', + data: message | deleted_message, +) { + // get the bridge and return if it doesn't exist + let bridge; + + if (event === 'create_message') { + bridge = await lightning.data.get_bridge_by_channel(data.channel); + } else { + bridge = await lightning.data.get_message(data.id); + } + + if (!bridge) return; + + // if editing isn't allowed, return + if (event !== 'create_message' && bridge.settings.allow_editing !== true) { + return; + } + + // if the channel this event is from is disabled, return + if ( + bridge.channels.find((channel) => + channel.id === data.channel && channel.plugin === data.plugin && + channel.disabled + ) + ) return; + + // filter out the channel this event is from and any disabled channels + const channels = bridge.channels.filter( + (i) => i.id !== data.channel || i.plugin !== data.plugin, + ).filter((i) => !i.disabled || !i.data); + + // if there are no more channels, return + if (channels.length < 1) return; + + const messages = [] as bridged_message[]; + + for (const channel of channels) { + let prior_bridged_ids; + + if (event !== 'create_message') { + prior_bridged_ids = (bridge as bridge_message).messages?.find((i) => + i.channel === channel.id && i.plugin === channel.plugin + ); + + if (!prior_bridged_ids) continue; // the message wasn't bridged previously + } + + const plugin = lightning.plugins.get(channel.plugin); + + if (!plugin) { + await disable_channel( + channel, + bridge, + new LightningError(`plugin ${channel.plugin} doesn't exist`), + lightning, + ); + continue; + } + + const reply_id = await get_reply_id(lightning, data, channel); + + let result_ids: string[]; + + try { + result_ids = await plugin[event]({ + channel, + reply_id, + edit_ids: prior_bridged_ids?.id as string[], + msg: data as message, + }); + } catch (e) { + if (e instanceof LightningError && e.disable_channel) { + await disable_channel(channel, bridge, e, lightning); + continue; + } + + // try sending an error message + + const err = e instanceof LightningError ? e : new LightningError(e, { + message: `An error occurred while processing a message in the bridge.`, + }); + + try { + result_ids = await plugin[event]({ + channel, + reply_id, + edit_ids: prior_bridged_ids?.id as string[], + msg: err.msg, + }); + } catch (e) { + new LightningError(e, { + message: `Failed to log error message in bridge`, + extra: { channel, original_error: err.id }, + }); + + continue; + } + } + + for (const result_id of result_ids) { + sessionStorage.setItem(`${channel.plugin}-${result_id}`, '1'); + } + + messages.push({ + id: result_ids, + channel: channel.id, + plugin: channel.plugin, + }); + } + + await lightning.data[event]({ + ...bridge, + id: data.id, + messages, + bridge_id: bridge.id, + }); +} + +async function get_reply_id( + core: lightning, + msg: message | deleted_message, + channel: bridge_channel, +): Promise { + if ('reply_id' in msg && msg.reply_id) { + try { + const bridged = await core.data.get_message(msg.reply_id); + + const bridged_message = bridged?.messages?.find((i) => + i.channel === channel.id && i.plugin === channel.plugin + ); + + return bridged_message?.id[0]; + } catch { + return; + } + } +} + +async function disable_channel( + channel: bridge_channel, + bridge: bridge | bridge_message, + error: LightningError, + lightning: lightning, +) { + new LightningError( + `disabling channel ${channel.id} in bridge ${bridge.id}`, + { + extra: { original_error: error.id }, + }, + ); + + await lightning.data.edit_bridge({ + id: 'bridge_id' in bridge ? bridge.bridge_id : bridge.id, + channels: bridge.channels.map((i) => + i.id === channel.id && i.plugin === channel.plugin + ? { ...i, disabled: true, data: error } + : i + ), + settings: bridge.settings, + }); +} diff --git a/packages/lightning/src/bridge/msg.ts b/packages/lightning/src/bridge/msg.ts deleted file mode 100644 index 56f366ef..00000000 --- a/packages/lightning/src/bridge/msg.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type { deleted_message, message } from '../messages.ts'; -import { type lightning, LightningError } from '../mod.ts'; -import type { - bridge, - bridge_channel, - bridge_message, - bridged_message, -} from './data.ts'; - -export async function handle_message( - lightning: lightning, - event: 'create_message' | 'edit_message' | 'delete_message', - data: message | deleted_message, -) { - // get the bridge and return if it doesn't exist - let bridge; - - if (event === 'create_message') { - bridge = await lightning.data.get_bridge_by_channel(data.channel); - } else { - bridge = await lightning.data.get_message(data.id); - } - - if (!bridge) return; - - // if editing isn't allowed, return - if (event !== 'create_message' && bridge.settings.allow_editing !== true) { - return; - } - - // if the channel this event is from is disabled, return - if ( - bridge.channels.find((channel) => - channel.id === data.channel && channel.plugin === data.plugin && - channel.disabled - ) - ) return; - - // filter out the channel this event is from and any disabled channels - const channels = bridge.channels.filter( - (i) => i.id !== data.channel || i.plugin !== data.plugin, - ).filter((i) => !i.disabled || !i.data); - - // if there are no more channels, return - if (channels.length < 1) return; - - const messages = [] as bridged_message[]; - - for (const channel of channels) { - let prior_bridged_ids; - - if (event !== 'create_message') { - prior_bridged_ids = (bridge as bridge_message).messages?.find((i) => - i.channel === channel.id && i.plugin === channel.plugin - ); - - if (!prior_bridged_ids) continue; // the message wasn't bridged previously - } - - const plugin = lightning.plugins.get(channel.plugin); - - if (!plugin) { - await disable_channel( - channel, - bridge, - new LightningError(`plugin ${channel.plugin} doesn't exist`), - lightning, - ); - continue; - } - - const reply_id = await get_reply_id(lightning, data, channel); - - let result_ids: string[]; - - try { - result_ids = await plugin[event]({ - channel, - reply_id, - edit_ids: prior_bridged_ids?.id as string[], - msg: data as message, - }); - } catch (e) { - if (e instanceof LightningError && e.disable_channel) { - await disable_channel(channel, bridge, e, lightning); - continue; - } - - // try sending an error message - - const err = e instanceof LightningError - ? e - : new LightningError(e, { - message: - `An error occurred while processing a message in the bridge.`, - }); - - try { - result_ids = await plugin[event]({ - channel, - reply_id, - edit_ids: prior_bridged_ids?.id as string[], - msg: err.msg, - }); - } catch (e) { - new LightningError(e, { - message: `Failed to log error message in bridge`, - extra: { channel, original_error: err.id }, - }); - - continue; - } - } - - for (const result_id of result_ids) { - sessionStorage.setItem(`${channel.plugin}-${result_id}`, '1'); - } - - messages.push({ - id: result_ids, - channel: channel.id, - plugin: channel.plugin, - }); - } - - await lightning.data[event]({ - ...bridge, - id: data.id, - messages, - bridge_id: bridge.id, - }); -} - -async function get_reply_id( - core: lightning, - msg: message | deleted_message, - channel: bridge_channel, -): Promise { - if ('reply_id' in msg && msg.reply_id) { - try { - const bridged = await core.data.get_message(msg.reply_id); - - const bridged_message = bridged?.messages?.find((i) => - i.channel === channel.id && i.plugin === channel.plugin - ); - - return bridged_message?.id[0]; - } catch { - return; - } - } -} - -async function disable_channel( - channel: bridge_channel, - bridge: bridge | bridge_message, - error: LightningError, - lightning: lightning, -) { - new LightningError( - `disabling channel ${channel.id} in bridge ${bridge.id}`, - { - extra: { original_error: error.id }, - }, - ); - - await lightning.data.edit_bridge({ - id: 'bridge_id' in bridge ? bridge.bridge_id : bridge.id, - channels: bridge.channels.map((i) => - i.id === channel.id && i.plugin === channel.plugin - ? { ...i, disabled: true, data: error } - : i - ), - settings: bridge.settings, - }); -} diff --git a/packages/lightning/src/cli/mod.ts b/packages/lightning/src/cli.ts similarity index 71% rename from packages/lightning/src/cli/mod.ts rename to packages/lightning/src/cli.ts index 8d1560af..f89d0a77 100644 --- a/packages/lightning/src/cli/mod.ts +++ b/packages/lightning/src/cli.ts @@ -1,7 +1,7 @@ import { parseArgs } from '@std/cli/parse-args'; import { join, toFileUrl } from '@std/path'; -import { logError } from '../errors.ts'; -import { type config, lightning } from '../lightning.ts'; +import { lightning, type config } from './lightning.ts'; +import { log_error } from './structures/errors.ts'; const version = '0.8.0'; const _ = parseArgs(Deno.args); @@ -14,23 +14,25 @@ if (_.v || _.version) { // TODO(jersey): this is somewhat broken when acting as a JSR package if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); - const config: config = (await import(toFileUrl(_.config).toString())).default; + const config = (await import(toFileUrl(_.config).toString())).default as config; + + if (config?.error_url) Deno.env.set('LIGHTNING_ERROR_WEBHOOK', config.error_url); addEventListener('error', (ev) => { - logError(ev.error, { extra: { type: 'global error' } }); + log_error(ev.error, { extra: { type: 'global error' } }); }); addEventListener('unhandledrejection', (ev) => { - logError(ev.reason, { extra: { type: 'global rejection' } }); + log_error(ev.reason, { extra: { type: 'global rejection' } }); }); try { await lightning.create(config); } catch (e) { - logError(e, { extra: { type: 'global class error' } }); + log_error(e, { extra: { type: 'global class error' } }); } } else if (_._[0] === 'migrations') { - // TODO(jersey): implement migrations + // TODO(jersey): implement migrations (separate module?) } else { console.log('[lightning] command not found, showing help'); run_help(); diff --git a/packages/lightning/src/commands/bridge/_internal.ts b/packages/lightning/src/commands/bridge/_internal.ts new file mode 100644 index 00000000..df3c6560 --- /dev/null +++ b/packages/lightning/src/commands/bridge/_internal.ts @@ -0,0 +1,40 @@ +import { log_error } from '../../structures/errors.ts'; +import type { bridge_channel, command_opts } from '../../structures/mod.ts'; + +export async function bridge_add_common( + opts: command_opts, +): Promise { + const existing_bridge = await opts.lightning.data.get_bridge_by_channel( + opts.channel, + ); + + if (existing_bridge) { + return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.lightning.config.prefix}leave\` or \`${opts.lightning.config.prefix}help\` commands.`; + } + + const plugin = opts.lightning.plugins.get(opts.plugin); + + if (!plugin) { + log_error('Internal error: platform support not found', { + extra: { plugin: opts.plugin }, + }); + } + + let bridge_data; + + try { + bridge_data = await plugin.setup_channel(opts.channel); + } catch (e) { + log_error(e, { + message: 'Failed to create bridge using plugin', + extra: { channel: opts.channel, plugin_name: opts.plugin }, + }); + } + + return { + id: opts.channel, + data: bridge_data, + disabled: false, + plugin: opts.plugin, + }; +} diff --git a/packages/lightning/src/commands/bridge/add.ts b/packages/lightning/src/commands/bridge/add.ts deleted file mode 100644 index dba9a4bd..00000000 --- a/packages/lightning/src/commands/bridge/add.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { bridge_channel } from '../../bridge/data.ts'; -import { logError } from '../../errors.ts'; -import type { command_execute_options } from '../mod.ts'; - -export async function create( - opts: command_execute_options, -): Promise { - const result = await _lightning_bridge_add_common(opts); - - if (typeof result === 'string') return result; - - const bridge_data = { - name: opts.arguments.name, - channels: [result], - settings: { - allow_editing: true, - allow_everyone: false, - use_rawname: false, - }, - }; - - try { - await opts.lightning.data.create_bridge(bridge_data); - return `Bridge created successfully! You can now join it using \`${opts.lightning.config.cmd_prefix}join ${result.id}\`. Keep this id safe, don't share it with anyone, and delete this message.`; - } catch (e) { - logError(e, { - message: 'Failed to insert bridge into database', - extra: bridge_data - }); - } -} - -export async function join( - opts: command_execute_options, -): Promise { - const result = await _lightning_bridge_add_common(opts); - - if (typeof result === 'string') return result; - - const target_bridge = await opts.lightning.data.get_bridge_by_id( - opts.arguments.id, - ); - - if (!target_bridge) { - return `Bridge with id \`${opts.arguments.id}\` not found. Make sure you have the correct id.`; - } - - target_bridge.channels.push(result); - - try { - await opts.lightning.data.edit_bridge(target_bridge); - - return `Bridge joined successfully!`; - } catch (e) { - logError(e, { - message: 'Failed to update bridge in database', - extra: { target_bridge } - }) - } -} - -async function _lightning_bridge_add_common( - opts: command_execute_options, -): Promise { - const existing_bridge = await opts.lightning.data.get_bridge_by_channel( - opts.channel, - ); - - if (existing_bridge) { - return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.lightning.config.cmd_prefix}leave\` or \`${opts.lightning.config.cmd_prefix}help\` commands.` - } - - const plugin = opts.lightning.plugins.get(opts.plugin); - - if (!plugin) { - logError('Internal error: platform support not found', { - extra: { plugin: opts.plugin } - }) - } - - let bridge_data; - - try { - bridge_data = await plugin.setup_channel(opts.channel); - } catch (e) { - logError(e, { - message: 'Failed to create bridge using plugin', - extra: { channel: opts.channel, plugin_name: opts.plugin } - }) - } - - return { - id: opts.channel, - data: bridge_data, - disabled: false, - plugin: opts.plugin, - }; -} diff --git a/packages/lightning/src/commands/bridge/create.ts b/packages/lightning/src/commands/bridge/create.ts new file mode 100644 index 00000000..62b1ed06 --- /dev/null +++ b/packages/lightning/src/commands/bridge/create.ts @@ -0,0 +1,31 @@ +import type { command_opts } from '../../structures/commands.ts'; +import { log_error } from '../../structures/errors.ts'; +import { bridge_add_common } from './_internal.ts'; + +export async function create( + opts: command_opts, +): Promise { + const result = await bridge_add_common(opts); + + if (typeof result === 'string') return result; + + const bridge_data = { + name: opts.args.name, + channels: [result], + settings: { + allow_editing: true, + allow_everyone: false, + use_rawname: false, + }, + }; + + try { + await opts.lightning.data.create_bridge(bridge_data); + return `Bridge created successfully! You can now join it using \`${opts.lightning.config.prefix}join ${result.id}\`. Keep this id safe, don't share it with anyone, and delete this message.`; + } catch (e) { + log_error(e, { + message: 'Failed to insert bridge into database', + extra: bridge_data, + }); + } +} diff --git a/packages/lightning/src/commands/bridge/join.ts b/packages/lightning/src/commands/bridge/join.ts new file mode 100644 index 00000000..9cf02bb9 --- /dev/null +++ b/packages/lightning/src/commands/bridge/join.ts @@ -0,0 +1,32 @@ +import type { command_opts } from '../../structures/commands.ts'; +import { log_error } from '../../structures/errors.ts'; +import { bridge_add_common } from './_internal.ts'; + +export async function join( + opts: command_opts, +): Promise { + const result = await bridge_add_common(opts); + + if (typeof result === 'string') return result; + + const target_bridge = await opts.lightning.data.get_bridge_by_id( + opts.args.id, + ); + + if (!target_bridge) { + return `Bridge with id \`${opts.args.id}\` not found. Make sure you have the correct id.`; + } + + target_bridge.channels.push(result); + + try { + await opts.lightning.data.edit_bridge(target_bridge); + + return `Bridge joined successfully!`; + } catch (e) { + log_error(e, { + message: 'Failed to update bridge in database', + extra: { target_bridge }, + }); + } +} diff --git a/packages/lightning/src/commands/bridge/leave.ts b/packages/lightning/src/commands/bridge/leave.ts new file mode 100644 index 00000000..327c069e --- /dev/null +++ b/packages/lightning/src/commands/bridge/leave.ts @@ -0,0 +1,26 @@ +import type { command_opts } from '../../structures/commands.ts'; +import { log_error } from '../../structures/errors.ts'; + +export async function leave(opts: command_opts): Promise { + const bridge = await opts.lightning.data.get_bridge_by_channel( + opts.channel, + ); + + if (!bridge) return `You are not in a bridge`; + + bridge.channels = bridge.channels.filter(( + ch, + ) => ch.id !== opts.channel); + + try { + await opts.lightning.data.edit_bridge( + bridge, + ); + return `Bridge left successfully`; + } catch (e) { + log_error(e, { + message: 'Failed to update bridge in database', + extra: { bridge }, + }); + } +} diff --git a/packages/lightning/src/commands/bridge/mod.ts b/packages/lightning/src/commands/bridge/mod.ts deleted file mode 100644 index fd9fba79..00000000 --- a/packages/lightning/src/commands/bridge/mod.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { create, join } from './add.ts'; -import { leave, toggle } from './modify.ts'; -import { status } from './status.ts'; -import type { command } from '../mod.ts'; - -export const bridge_command = { - name: 'bridge', - description: 'bridge commands', - execute: () => 'take a look at the subcommands of this command', - subcommands: [ - { - name: 'create', - description: 'create a new bridge', - arguments: [{ - name: 'name', - description: 'name of the bridge', - required: true, - }], - execute: create, - }, - { - name: 'join', - description: 'join an existing bridge', - arguments: [{ - name: 'id', - description: 'id of the bridge', - required: true, - }], - execute: join, - }, - { - name: 'leave', - description: 'leave the current bridge', - execute: leave, - }, - { - name: 'toggle', - description: 'toggle a setting on the current bridge', - arguments: [{ - name: 'setting', - description: 'setting to toggle', - required: true, - }], - execute: toggle, - }, - { - name: 'status', - description: 'get the status of the current bridge', - execute: status, - }, - ], -} as command; diff --git a/packages/lightning/src/commands/bridge/modify.ts b/packages/lightning/src/commands/bridge/modify.ts deleted file mode 100644 index 8cd33d6d..00000000 --- a/packages/lightning/src/commands/bridge/modify.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { logError } from '../../errors.ts'; -import type { command_execute_options } from '../mod.ts'; - -export async function leave(opts: command_execute_options): Promise { - const bridge = await opts.lightning.data.get_bridge_by_channel( - opts.channel, - ); - - if (!bridge) return `You are not in a bridge`; - - bridge.channels = bridge.channels.filter(( - ch, - ) => ch.id !== opts.channel); - - try { - await opts.lightning.data.edit_bridge( - bridge, - ); - return `Bridge left successfully`; - } catch (e) { - logError(e, { - message: 'Failed to update bridge in database', - extra: { bridge }, - }) - } -} - -const settings = ['allow_editing', 'allow_everyone', 'use_rawname']; - -export async function toggle(opts: command_execute_options): Promise { - const bridge = await opts.lightning.data.get_bridge_by_channel( - opts.channel, - ); - - if (!bridge) return `You are not in a bridge`; - - if (!settings.includes(opts.arguments.setting)) { - return `That setting does not exist`; - } - - const key = opts.arguments.setting as keyof typeof bridge.settings; - - bridge.settings[key] = !bridge.settings[key]; - - try { - await opts.lightning.data.edit_bridge( - bridge, - ); - return `Bridge settings updated successfully`; - } catch (e) { - logError(e, { - message: 'Failed to update bridge in database', - extra: { bridge }, - }) - } -} diff --git a/packages/lightning/src/commands/bridge/status.ts b/packages/lightning/src/commands/bridge/status.ts index 27fefc02..9db79f8f 100644 --- a/packages/lightning/src/commands/bridge/status.ts +++ b/packages/lightning/src/commands/bridge/status.ts @@ -1,23 +1,23 @@ -import type { command_execute_options } from '../mod.ts'; +import type { command_opts } from '../../structures/commands.ts'; -export async function status(opts: command_execute_options): Promise { - const bridge = await opts.lightning.data.get_bridge_by_channel( - opts.channel, - ); +export async function status(opts: command_opts): Promise { + const bridge = await opts.lightning.data.get_bridge_by_channel( + opts.channel, + ); - if (!bridge) return `You are not in a bridge`; + if (!bridge) return `You are not in a bridge`; - let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; + let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; - for (const [i, value] of bridge.channels.entries()) { - str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`\n`; - } + for (const [i, value] of bridge.channels.entries()) { + str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`\n`; + } - str += `\nSettings:\n`; + str += `\nSettings:\n`; - for (const [key, value] of Object.entries(bridge.settings)) { - str += `- \`${key}\` ${value ? "โœ”" : "โŒ"}\n`; - } + for (const [key, value] of Object.entries(bridge.settings)) { + str += `- \`${key}\` ${value ? 'โœ”' : 'โŒ'}\n`; + } - return str; -} \ No newline at end of file + return str; +} diff --git a/packages/lightning/src/commands/bridge/toggle.ts b/packages/lightning/src/commands/bridge/toggle.ts new file mode 100644 index 00000000..5c8944ba --- /dev/null +++ b/packages/lightning/src/commands/bridge/toggle.ts @@ -0,0 +1,31 @@ +import type { command_opts } from '../../structures/commands.ts'; +import { log_error } from '../../structures/errors.ts'; +import { bridge_settings_list } from '../../structures/bridge.ts'; + +export async function toggle(opts: command_opts): Promise { + const bridge = await opts.lightning.data.get_bridge_by_channel( + opts.channel, + ); + + if (!bridge) return `You are not in a bridge`; + + if (!bridge_settings_list.includes(opts.args.setting)) { + return `That setting does not exist`; + } + + const key = opts.args.setting as keyof typeof bridge.settings; + + bridge.settings[key] = !bridge.settings[key]; + + try { + await opts.lightning.data.edit_bridge( + bridge, + ); + return `Bridge settings updated successfully`; + } catch (e) { + log_error(e, { + message: 'Failed to update bridge in database', + extra: { bridge }, + }); + } +} diff --git a/packages/lightning/src/commands/default.ts b/packages/lightning/src/commands/default.ts index 14058767..2c741b5e 100644 --- a/packages/lightning/src/commands/default.ts +++ b/packages/lightning/src/commands/default.ts @@ -1,26 +1,76 @@ -import { bridge_command } from './bridge/mod.ts'; -import type { command } from './mod.ts'; +import type { command, command_opts } from '../structures/commands.ts'; +import { create } from './bridge/create.ts'; +import { join } from './bridge/join.ts'; +import { leave } from './bridge/leave.ts'; +import { status } from './bridge/status.ts'; +import { toggle } from './bridge/toggle.ts'; export const default_commands = new Map([ - ['help', { - name: 'help', - description: 'get help with the bot', - execute: () => - 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', - }], - ['ping', { - name: 'ping', - description: 'check if the bot is alive', - execute: ({ timestamp }) => - `Pong! ๐Ÿ“ ${ - Temporal.Now.instant().since(timestamp).round('millisecond') - .total('milliseconds') - }ms`, - }], - ['version', { - name: 'version', - description: 'get the bots version', - execute: () => 'hello from v0.8.0!', - }], - ['bridge', bridge_command], + ['help', { + name: 'help', + description: 'get help with the bot', + execute: () => + 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', + }], + ['ping', { + name: 'ping', + description: 'check if the bot is alive', + execute: ({ timestamp }: command_opts) => + `Pong! ๐Ÿ“ ${ + Temporal.Now.instant().since(timestamp).round('millisecond') + .total('milliseconds') + }ms`, + }], + ['version', { + name: 'version', + description: 'get the bots version', + execute: () => 'hello from v0.8.0!', + }], + ['bridge', { + name: 'bridge', + description: 'bridge commands', + execute: () => 'take a look at the subcommands of this command', + subcommands: [ + { + name: 'create', + description: 'create a new bridge', + arguments: [{ + name: 'name', + description: 'name of the bridge', + required: true, + }], + execute: create, + }, + { + name: 'join', + description: 'join an existing bridge', + arguments: [{ + name: 'id', + description: 'id of the bridge', + required: true, + }], + execute: join, + }, + { + name: 'leave', + description: 'leave the current bridge', + execute: leave, + }, + { + name: 'toggle', + description: 'toggle a setting on the current bridge', + arguments: [{ + name: 'setting', + description: 'setting to toggle', + required: true, + }], + execute: toggle, + }, + { + name: 'status', + description: 'get the status of the current bridge', + execute: status, + }, + ], + }], ]) as Map; diff --git a/packages/lightning/src/commands/mod.ts b/packages/lightning/src/commands/mod.ts deleted file mode 100644 index 1fd4152b..00000000 --- a/packages/lightning/src/commands/mod.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { lightning } from '../lightning.ts'; -import { create_message, type message } from '../messages.ts'; -import { LightningError } from '../errors.ts'; - -export interface command_execute_options { - channel: string; - plugin: string; - timestamp: Temporal.Instant; - arguments: Record; - lightning: lightning; - id: string; -} - -export interface command { - name: string; - description: string; - arguments?: { - name: string; - description: string; - required: boolean; - }[]; - subcommands?: Omit[]; - execute: ( - opts: command_execute_options, - ) => Promise | string; -} - -export async function execute_text_command(msg: message, lightning: lightning) { - if (!msg.content?.startsWith(lightning.config.cmd_prefix)) return; - - const [cmd, ...rest] = msg.content.replace(lightning.config.cmd_prefix, '').split(' '); - - return await run_command({ - ...msg, - lightning, - command: cmd as string, - rest: rest as string[], - }); -} - -export interface run_command_options - extends Omit { - command: string; - subcommand?: string; - args?: Record; - rest?: string[]; - reply: message['reply']; -} - -export async function run_command( - opts: run_command_options, -) { - let command = opts.lightning.commands.get(opts.command) ?? - opts.lightning.commands.get('help')!; - - const subcommand_name = opts.subcommand ?? opts.rest?.shift(); - - if (command.subcommands && subcommand_name) { - const subcommand = command.subcommands.find((i) => - i.name === subcommand_name - ); - - if (subcommand) command = subcommand; - } - - if (!opts.args) opts.args = {}; - - for (const arg of command.arguments || []) { - if (!opts.args[arg.name]) { - opts.args[arg.name] = opts.rest?.shift() as string; - } - - if (!opts.args[arg.name]) { - return opts.reply( - create_message( - `Please provide the \`${arg.name}\` argument. Try using the \`${opts.lightning.config.cmd_prefix}help\` command.`, - ), - false, - ); - } - } - - let resp: string | LightningError; - - try { - resp = await command.execute({ - ...opts, - arguments: opts.args, - }); - } catch (e) { - if (e instanceof LightningError) resp = e; - else resp = new LightningError(e, { - message: 'An error occurred while executing the command', - extra: { command: command.name }, - }) - } - - try { - if (typeof resp === 'string') { - await opts.reply(create_message(resp), false); - } else await opts.reply(resp.msg, false); - } catch (e) { - new LightningError(e, { - message: 'An error occurred while sending the command response', - extra: { command: command.name }, - }) - } -} diff --git a/packages/lightning/src/commands/runners.ts b/packages/lightning/src/commands/runners.ts new file mode 100644 index 00000000..d37290ae --- /dev/null +++ b/packages/lightning/src/commands/runners.ts @@ -0,0 +1,81 @@ +import type { lightning } from '../lightning.ts'; +import { + type create_command, + create_message, + LightningError, + type message, +} from '../structures/mod.ts'; + +export async function execute_text_command(msg: message, lightning: lightning) { + if (!msg.content?.startsWith(lightning.config.prefix)) return; + + const [cmd, ...rest] = msg.content.replace(lightning.config.prefix, '') + .split(' '); + + return await run_command({ + ...msg, + lightning, + command: cmd as string, + rest: rest as string[], + }); +} + +export async function run_command( + opts: create_command, +) { + let command = opts.lightning.commands.get(opts.command) ?? + opts.lightning.commands.get('help')!; + + const subcommand_name = opts.subcommand ?? opts.rest?.shift(); + + if (command.subcommands && subcommand_name) { + const subcommand = command.subcommands.find((i) => + i.name === subcommand_name + ); + + if (subcommand) command = subcommand; + } + + if (!opts.args) opts.args = {}; + + for (const arg of command.arguments || []) { + if (!opts.args[arg.name]) { + opts.args[arg.name] = opts.rest?.shift() as string; + } + + if (!opts.args[arg.name]) { + return opts.reply( + create_message( + `Please provide the \`${arg.name}\` argument. Try using the \`${opts.lightning.config.prefix}help\` command.`, + ), + false, + ); + } + } + + let resp: string | LightningError; + + try { + resp = await command.execute({ + ...opts, + args: opts.args, + }); + } catch (e) { + if (e instanceof LightningError) resp = e; + else {resp = new LightningError(e, { + message: 'An error occurred while executing the command', + extra: { command: command.name }, + });} + } + + try { + if (typeof resp === 'string') { + await opts.reply(create_message(resp), false); + } else await opts.reply(resp.msg, false); + } catch (e) { + new LightningError(e, { + message: 'An error occurred while sending the command response', + extra: { command: command.name }, + }); + } +} diff --git a/packages/lightning/src/bridge/data.ts b/packages/lightning/src/database.ts similarity index 58% rename from packages/lightning/src/bridge/data.ts rename to packages/lightning/src/database.ts index 12b123ae..32bfa354 100644 --- a/packages/lightning/src/bridge/data.ts +++ b/packages/lightning/src/database.ts @@ -1,39 +1,6 @@ import { Client, type ClientOptions } from '@db/postgres'; import { ulid } from '@std/ulid'; - -export interface bridge { - id: string; /* ulid */ - name: string; /* name of the bridge */ - channels: bridge_channel[]; /* channels bridged */ - settings: bridge_settings; /* settings for the bridge */ -} - -export interface bridge_channel { - id: string; /* from the platform */ - data: unknown; /* data needed to bridge this channel */ - disabled: boolean; /* whether the channel is disabled */ - plugin: string; /* the plugin used to bridge this channel */ -} - -export interface bridge_settings { - allow_editing: boolean; /* allow editing/deletion */ - allow_everyone: boolean; /* @everyone/@here/@room */ - use_rawname: boolean; /* rawname = username */ -} - -export interface bridge_message { - id: string; /* original message id */ - bridge_id: string; /* bridge id */ - channels: bridge_channel[]; /* channels bridged */ - messages: bridged_message[]; /* bridged messages */ - settings: bridge_settings; /* settings for the bridge */ -} - -export interface bridged_message { - id: string[]; /* message id */ - channel: string; /* channel id */ - plugin: string; /* plugin id */ -} +import type { bridge, bridge_message } from './structures/bridge.ts'; export class bridge_data { private pg: Client; @@ -41,15 +8,15 @@ export class bridge_data { static async create(pg_options: ClientOptions): Promise { const pg = new Client(pg_options); await pg.connect(); - await bridge_data.create_table(pg); - return new bridge_data(pg); } private static async create_table(pg: Client) { - const exists = (await pg.queryArray`SELECT relname FROM pg_class - WHERE relname = 'bridges'`).rows.length > 0; + const exists = (await pg.queryArray` + SELECT relname FROM pg_class + WHERE relname = 'bridges' + `).rows.length > 0; if (exists) return; @@ -75,29 +42,31 @@ export class bridge_data { this.pg = pg_client; } - async create_bridge(br: Omit): Promise { + async create_bridge(br: Omit): Promise { const id = ulid(); await this.pg.queryArray` INSERT INTO bridges (id, name, channels, settings) - VALUES (${id}, ${br.name}, ${JSON.stringify(br.channels)}, ${JSON.stringify(br.settings)}) + VALUES (${id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ + JSON.stringify(br.settings) + }) `; return { id, ...br }; } - async edit_bridge(br: Omit): Promise { + async edit_bridge(br: Omit): Promise { await this.pg.queryArray` UPDATE bridges - SET channels = ${JSON.stringify(br.channels)}, settings = ${JSON.stringify(br.settings)} + SET channels = ${JSON.stringify(br.channels)}, + settings = ${JSON.stringify(br.settings)} WHERE id = ${br.id} `; } async get_bridge_by_id(id: string): Promise { const res = await this.pg.queryObject` - SELECT * FROM bridges - WHERE id = ${id} + SELECT * FROM bridges WHERE id = ${id} `; return res.rows[0]; @@ -105,8 +74,7 @@ export class bridge_data { async get_bridge_by_channel(ch: string): Promise { const res = await this.pg.queryObject(` - SELECT * FROM bridges - WHERE EXISTS ( + SELECT * FROM bridges WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements(channels) AS ch WHERE ch->>'id' = '${ch}' ) @@ -118,13 +86,18 @@ export class bridge_data { async create_message(msg: bridge_message): Promise { await this.pg.queryArray`INSERT INTO bridge_messages (id, bridge_id, channels, messages, settings) VALUES - (${msg.id}, ${msg.bridge_id}, ${JSON.stringify(msg.channels)}, ${JSON.stringify(msg.messages)}, ${JSON.stringify(msg.settings)})`; + (${msg.id}, ${msg.bridge_id}, ${JSON.stringify(msg.channels)}, ${ + JSON.stringify(msg.messages) + }, ${JSON.stringify(msg.settings)}) + `; } async edit_message(msg: bridge_message): Promise { await this.pg.queryArray` UPDATE bridge_messages - SET messages = ${JSON.stringify(msg.messages)}, channels = ${JSON.stringify(msg.channels)}, settings = ${JSON.stringify(msg.settings)} + SET messages = ${JSON.stringify(msg.messages)}, + channels = ${JSON.stringify(msg.channels)}, + settings = ${JSON.stringify(msg.settings)} WHERE id = ${msg.id} `; } diff --git a/packages/lightning/src/errors.ts b/packages/lightning/src/errors.ts deleted file mode 100644 index 485de6ce..00000000 --- a/packages/lightning/src/errors.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { create_message, type message } from './messages.ts'; - -export interface LightningErrorOptions { - /** the user-facing message of the error */ - message?: string; - /** the extra data to log */ - extra?: Record; - /** whether to disable the channel */ - disable?: boolean; -} - -export class LightningError extends Error { - id: string; - override cause: Error; - extra: Record; - msg: message; - disable_channel?: boolean; - - constructor(e: unknown, public options?: LightningErrorOptions) { - if (e instanceof LightningError) { - super(e.message, { cause: e.cause }); - this.id = e.id; - this.cause = e.cause; - this.extra = e.extra; - this.msg = e.msg; - this.disable_channel = e.disable_channel; - return; - } - - const cause = e instanceof Error - ? e - : e instanceof Object - ? new Error(JSON.stringify(e)) - : new Error(String(e)); - - super(options?.message ?? cause.message, { cause }); - - this.name = 'LightningError'; - this.id = crypto.randomUUID(); - this.cause = cause; - this.extra = options?.extra ?? {}; - this.disable_channel = options?.disable; - this.msg = create_message( - `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\`` - ); - this.log(); - } - - log() { - console.error(`%clightning error ${this.id}`, 'color: red'); - console.error(this.cause, this.options); - - const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); - - for (const key in this.extra) { - if (key === 'lightning') { - delete this.extra[key]; - } - - if (typeof this.extra[key] === 'object' && this.extra[key] !== null) { - if ('lightning' in this.extra[key]) { - delete this.extra[key].lightning; - } - } - } - - if (webhook && webhook.length > 0) { - let json_str = `\`\`\`json\n${JSON.stringify(this.extra, null, 2)}\n\`\`\``; - - if (json_str.length > 2000) json_str = '*see console*'; - - fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: `# ${this.cause.message}\n*${this.id}*`, - embeds: [ - { - title: 'extra', - description: json_str, - }, - ], - }), - }); - } - } -} - -export function logError(e: unknown, options?: LightningErrorOptions): never { - throw new LightningError(e, options); -} diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index d2c93093..37178599 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -1,25 +1,27 @@ import type { ClientOptions } from '@db/postgres'; -import { - type command, - execute_text_command, - run_command, - type run_command_options, -} from './commands/mod.ts'; -import type { create_plugin, plugin } from './plugins.ts'; -import { bridge_data } from './bridge/data.ts'; -import { handle_message } from './bridge/msg.ts'; -import type { message } from './messages.ts'; +import { bridge_message } from './bridge.ts'; import { default_commands } from './commands/default.ts'; +import { execute_text_command, run_command } from './commands/runners.ts'; +import { bridge_data } from './database.ts'; +import type { + command, + create_command, + create_plugin, + message, + plugin, +} from './structures/mod.ts'; /** configuration options for lightning */ export interface config { + /** error URL */ + error_url?: string; /** database options */ - postgres_options: ClientOptions; + postgres: ClientOptions; /** a list of plugins */ // deno-lint-ignore no-explicit-any plugins?: create_plugin[]; /** the prefix used for commands */ - cmd_prefix: string; + prefix: string; } /** an instance of lightning */ @@ -48,6 +50,7 @@ export class lightning { } } + /** event handler */ private async _handle_events(plugin: plugin) { for await (const { name, value } of plugin) { await new Promise((res) => setTimeout(res, 150)); @@ -56,8 +59,8 @@ export class lightning { continue; } - if (name === 'run_command') { - run_command(value[0] as run_command_options); + if (name === 'create_command') { + run_command(value[0] as create_command); continue; } @@ -65,13 +68,13 @@ export class lightning { execute_text_command(value[0] as message, this); } - handle_message(this, name, value[0]); + bridge_message(this, name, value[0]); } } /** create a new instance of lightning */ static async create(config: config): Promise { - const data = await bridge_data.create(config.postgres_options); + const data = await bridge_data.create(config.postgres); return new lightning(data, config); } diff --git a/packages/lightning/src/messages.ts b/packages/lightning/src/messages.ts deleted file mode 100644 index d2c1f6d3..00000000 --- a/packages/lightning/src/messages.ts +++ /dev/null @@ -1,155 +0,0 @@ -import type { bridge_channel } from './bridge/data.ts'; - -/** - * 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 { - const data = { - author: { - username: 'lightning', - profile: 'https://williamhorning.eu.org/assets/lightning.png', - rawname: 'lightning', - id: 'lightning', - }, - content: text, - channel: '', - id: '', - reply: async () => {}, - timestamp: Temporal.Now.instant(), - plugin: 'lightning', - }; - return data; -} - -/** attachments within a message */ -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 */ - size: number; -} - -/** a representation of a message that has been deleted */ -export interface deleted_message { - /** the message's id */ - id: string; - /** the channel the message was sent in */ - channel: string; - /** the plugin that recieved the message */ - plugin: string; - /** the time the message was sent/edited as a temporal instant */ - timestamp: Temporal.Instant; -} - -/** a discord-style embed */ -export interface embed { - /** the author of the embed */ - author?: { - /** the name of the author */ - name: string; - /** the url of the author */ - url?: string; - /** the icon of the author */ - icon_url?: string; - }; - /** the color of the embed */ - color?: number; - /** the text in an embed */ - description?: string; - /** fields within the embed */ - fields?: { - /** the name of the field */ - name: string; - /** the value of the field */ - value: string; - /** whether or not the field is inline */ - inline?: boolean; - }[]; - /** a footer shown in the embed */ - footer?: { - /** the footer text */ - text: string; - /** the icon of the footer */ - icon_url?: string; - }; - /** an image shown in the embed */ - image?: media; - /** a thumbnail shown in the embed */ - thumbnail?: media; - /** the time (in epoch ms) shown in the embed */ - timestamp?: number; - /** the title of the embed */ - title?: string; - /** a site linked to by the embed */ - url?: string; - /** a video inside of the embed */ - video?: media; -} - -/** media inside of an embed */ -export interface media { - /** the height of the media */ - height?: number; - /** the url of the media */ - url: string; - /** the width of the media */ - width?: number; -} - -/** a message recieved by a plugin */ -export interface message extends deleted_message { - /** the attachments sent with the message */ - attachments?: attachment[]; - /** the author of the message */ - 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 */ - 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, optional?: unknown) => Promise; - /** the id of the message replied to */ - reply_id?: string; -} - -/** a message to be bridged */ -export interface create_message_opts { - msg: message, - channel: bridge_channel, - reply_id?: string, -} - -/** a message to be edited */ -export interface edit_message_opts { - msg: message, - channel: bridge_channel, - reply_id?: string, - edit_ids: string[], -} - -/** a message to be deleted */ -export interface delete_message_opts { - msg: deleted_message, - channel: bridge_channel, - edit_ids: string[], -} \ No newline at end of file diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index 76a566c1..4cb23fc9 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -1,5 +1,6 @@ -export { type command, type run_command_options } from './commands/mod.ts' -export { LightningError, logError } from './errors.ts'; -export { type config, lightning } from './lightning.ts'; -export * from './messages.ts'; -export * from './plugins.ts'; +if (import.meta.main) { + await import('./cli.ts'); +} + +export * from './lightning.ts'; +export * from './structures/mod.ts'; diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts new file mode 100644 index 00000000..d5d20cde --- /dev/null +++ b/packages/lightning/src/structures/bridge.ts @@ -0,0 +1,100 @@ +import type { deleted_message, message } from './messages.ts'; + +/** representation of a bridge */ +export interface bridge { + /** ulid secret used as primary key */ + id: string; + /** user-facing name of the bridge */ + name: string; + /** channels in the bridge */ + channels: bridge_channel[]; + /** settings for the bridge */ + settings: bridge_settings; +} + +/** a channel within a bridge */ +export interface bridge_channel { + /** from the platform */ + id: string; + /** data needed to bridge this channel */ + data: unknown; + /** whether the channel is disabled */ + disabled: boolean; + /** the plugin used to bridge this channel */ + plugin: string; +} + +// TODO(jersey): implement allow_everyone and use_rawname settings + +/** possible settings for a bridge */ +export interface bridge_settings { + /** allow editing/deletion */ + allow_editing: boolean; + /** @everyone/@here/@room */ + allow_everyone: boolean; + /** rawname = username */ + use_rawname: boolean; +} + +/** list of settings for a bridge */ +export const bridge_settings_list = [ + 'allow_editing', + 'allow_everyone', + 'use_rawname', +]; + +/** representation of a bridged message collection */ +export interface bridge_message { + /** original message id */ + id: string; + /** original bridge id */ + bridge_id: string; + /** channels in the bridge */ + channels: bridge_channel[]; + /** messages bridged */ + messages: bridged_message[]; + /** settings for the bridge */ + settings: bridge_settings; +} + +/** representation of an individual bridged message */ +export interface bridged_message { + /** ids of the message */ + id: string[]; + /** the channel id sent to */ + channel: string; + /** the plugin used */ + plugin: string; +} + +/** a message to be bridged */ +export interface create_message_opts { + /** the actual message */ + msg: message; + /** the channel to use */ + channel: bridge_channel; + /** message to reply to, if any */ + reply_id?: string; +} + +/** a message to be edited */ +export interface edit_message_opts { + /** the actual message */ + msg: message; + /** the channel to use */ + channel: bridge_channel; + /** message to reply to, if any */ + reply_id?: string; + /** ids of messages to edit */ + edit_ids: string[]; +} + +/** a message to be deleted */ +export interface delete_message_opts { + /** the actual deleted message */ + msg: deleted_message; + /** the channel to use */ + channel: bridge_channel; + /** ids of messages to delete */ + edit_ids: string[]; +} diff --git a/packages/lightning/src/structures/commands.ts b/packages/lightning/src/structures/commands.ts new file mode 100644 index 00000000..f6258b3d --- /dev/null +++ b/packages/lightning/src/structures/commands.ts @@ -0,0 +1,41 @@ +import type { lightning } from '../lightning.ts'; + +/** representation of a command */ +export interface command { + /** user-facing command name */ + name: string; + /** user-facing command description */ + description: string; + /** possible arguments */ + arguments?: command_argument[]; + /** possible subcommands (use `${prefix}${cmd} ${subcmd}` if run as text command) */ + subcommands?: Omit[]; + /** the functionality of the command, returning text */ + execute: ( + opts: command_opts, + ) => Promise | string; +} + +/** argument for a command */ +export interface command_argument { + /** user-facing name for the argument */ + name: string; + /** description of the argument */ + description: string; + /** whether the argument is required */ + required: boolean; +} + +/** options passed to command#execute */ +export interface command_opts { + /** the channel the command was run in */ + channel: string; + /** the plugin the command was run with */ + plugin: string; + /** the time the command was sent */ + timestamp: Temporal.Instant; + /** arguments for the command */ + args: Record; + /** a lightning instance */ + lightning: lightning; +} diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts new file mode 100644 index 00000000..b49c418f --- /dev/null +++ b/packages/lightning/src/structures/errors.ts @@ -0,0 +1,107 @@ +import { create_message, type message } from './messages.ts'; + +/** options used to create an error */ +export interface error_options { + /** the user-facing message of the error */ + message?: string; + /** the extra data to log */ + extra?: Record; + /** whether to disable the associated channel (when bridging) */ + disable?: boolean; +} + +/** logs an error */ +export function log_error(e: unknown, options?: error_options): never { + throw new LightningError(e, options); +} + +/** lightning error */ +export class LightningError extends Error { + /** the id associated with the error */ + id: string; + /** the cause of the error */ + override cause: Error; + /** extra information associated with the error */ + extra: Record; + /** the user-facing error message */ + msg: message; + /** whether to disable the associated channel (when bridging) */ + disable_channel?: boolean; + + /** create and log an error */ + constructor(e: unknown, public options?: error_options) { + if (e instanceof LightningError) { + super(e.message, { cause: e.cause }); + this.id = e.id; + this.cause = e.cause; + this.extra = e.extra; + this.msg = e.msg; + this.disable_channel = e.disable_channel; + return; + } + + const cause = e instanceof Error + ? e + : e instanceof Object + ? new Error(JSON.stringify(e)) + : new Error(String(e)); + + const id = crypto.randomUUID(); + + super(options?.message ?? cause.message, { cause }); + + this.name = 'LightningError'; + this.id = id; + this.cause = cause; + this.extra = options?.extra ?? {}; + this.disable_channel = options?.disable; + this.msg = create_message( + `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\``, + ); + + // the error-logging async fun + (async () => { + console.error(`%clightning error ${id}`, 'color: red'); + console.error(cause, this.options); + + const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); + + for (const key in this.options?.extra) { + if (key === 'lightning') { + delete this.options.extra[key]; + } + + if ( + typeof this.options.extra[key] === 'object' && + this.options.extra[key] !== null + ) { + if ('lightning' in this.options.extra[key]) { + delete this.options.extra[key].lightning; + } + } + } + + if (webhook && webhook.length > 0) { + let json_str = `\`\`\`json\n${ + JSON.stringify(this.options?.extra, null, 2) + }\n\`\`\``; + + if (json_str.length > 2000) json_str = '*see console*'; + + await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: `# ${cause.message}\n*${id}*`, + embeds: [ + { + title: 'extra', + description: json_str, + }, + ], + }), + }); + } + })(); + } +} diff --git a/packages/lightning/src/structures/events.ts b/packages/lightning/src/structures/events.ts new file mode 100644 index 00000000..6f433eb5 --- /dev/null +++ b/packages/lightning/src/structures/events.ts @@ -0,0 +1,30 @@ +import type { command_opts } from './commands.ts'; +import type { deleted_message, message } from './messages.ts'; + +/** command execution event */ +export interface create_command extends Omit { + /** the command to run */ + command: string; + /** the subcommand, if any, to use */ + subcommand?: string; + /** arguments, if any, to use */ + args?: Record; + /** extra string options */ + rest?: string[]; + /** event reply function */ + reply: message['reply']; + /** id of the associated event */ + id: string; +} + +/** the events emitted by a plugin */ +export type plugin_events = { + /** when a message is created */ + create_message: [message]; + /** when a message is edited */ + edit_message: [message]; + /** when a message is deleted */ + delete_message: [deleted_message]; + /** when a command is run */ + create_command: [create_command]; +}; diff --git a/packages/lightning/src/structures/media.ts b/packages/lightning/src/structures/media.ts new file mode 100644 index 00000000..e4ef5c7c --- /dev/null +++ b/packages/lightning/src/structures/media.ts @@ -0,0 +1,68 @@ +/** attachments within a message */ +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 */ + size: number; +} + +/** a discord-style embed */ +export interface embed { + /** the author of the embed */ + author?: { + /** the name of the author */ + name: string; + /** the url of the author */ + url?: string; + /** the icon of the author */ + icon_url?: string; + }; + /** the color of the embed */ + color?: number; + /** the text in an embed */ + description?: string; + /** fields within the embed */ + fields?: { + /** the name of the field */ + name: string; + /** the value of the field */ + value: string; + /** whether or not the field is inline */ + inline?: boolean; + }[]; + /** a footer shown in the embed */ + footer?: { + /** the footer text */ + text: string; + /** the icon of the footer */ + icon_url?: string; + }; + /** an image shown in the embed */ + image?: media; + /** a thumbnail shown in the embed */ + thumbnail?: media; + /** the time (in epoch ms) shown in the embed */ + timestamp?: number; + /** the title of the embed */ + title?: string; + /** a site linked to by the embed */ + url?: string; + /** a video inside of the embed */ + video?: media; +} + +/** media inside of an embed */ +export interface media { + /** the height of the media */ + height?: number; + /** the url of the media */ + url: string; + /** the width of the media */ + width?: number; +} diff --git a/packages/lightning/src/structures/messages.ts b/packages/lightning/src/structures/messages.ts new file mode 100644 index 00000000..20eaf840 --- /dev/null +++ b/packages/lightning/src/structures/messages.ts @@ -0,0 +1,67 @@ +import type { attachment, embed } from './media.ts'; + +/** + * 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 { + const data = { + author: { + username: 'lightning', + profile: 'https://williamhorning.eu.org/assets/lightning.png', + rawname: 'lightning', + id: 'lightning', + }, + content: text, + channel: '', + id: '', + reply: async () => {}, + timestamp: Temporal.Now.instant(), + plugin: 'lightning', + }; + return data; +} + +/** a representation of a message that has been deleted */ +export interface deleted_message { + /** the message's id */ + id: string; + /** the channel the message was sent in */ + channel: string; + /** the plugin that recieved the message */ + plugin: string; + /** the time the message was sent/edited as a temporal instant */ + timestamp: Temporal.Instant; +} + +/** a message recieved by a plugin */ +export interface message extends deleted_message { + /** the attachments sent with the message */ + attachments?: attachment[]; + /** the author of the message */ + author: message_author; + /** message content (can be markdown) */ + content?: string; + /** discord-style embeds */ + embeds?: embed[]; + /** a function to reply to a message */ + reply: (message: message, optional?: unknown) => Promise; + /** the id of the message replied to */ + reply_id?: string; +} + +/** an author of a message */ +export interface message_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 */ + id: string; + /** the color of an author */ + color?: string; +} diff --git a/packages/lightning/src/structures/mod.ts b/packages/lightning/src/structures/mod.ts new file mode 100644 index 00000000..89145a13 --- /dev/null +++ b/packages/lightning/src/structures/mod.ts @@ -0,0 +1,7 @@ +export * from './bridge.ts'; +export * from './commands.ts'; +export * from './errors.ts'; +export * from './events.ts'; +export * from './media.ts'; +export * from './messages.ts'; +export * from './plugins.ts'; diff --git a/packages/lightning/src/plugins.ts b/packages/lightning/src/structures/plugins.ts similarity index 77% rename from packages/lightning/src/plugins.ts rename to packages/lightning/src/structures/plugins.ts index 2be6c0aa..b8540b64 100644 --- a/packages/lightning/src/plugins.ts +++ b/packages/lightning/src/structures/plugins.ts @@ -1,13 +1,11 @@ import { EventEmitter } from '@denosaurs/event'; -import type { lightning } from './lightning.ts'; +import type { lightning } from '../lightning.ts'; import type { create_message_opts, delete_message_opts, - deleted_message, edit_message_opts, - message, -} from './messages.ts'; -import type { run_command_options } from './commands/mod.ts'; +} from './bridge.ts'; +import type { plugin_events } from './events.ts'; /** the way to make a plugin */ export interface create_plugin< @@ -21,18 +19,6 @@ export interface create_plugin< support: string[]; } -/** the events emitted by a plugin */ -export type plugin_events = { - /** when a message is created */ - create_message: [message]; - /** when a message is edited */ - edit_message: [message]; - /** when a message is deleted */ - delete_message: [deleted_message]; - /** when a command is run */ - run_command: [run_command_options]; -}; - /** a plugin for lightning */ export abstract class plugin extends EventEmitter { /** access the instance of lightning you're connected to */ From ae7fd398fba6861b5ee0ad85a2b1e77ee2d45218 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 29 Nov 2024 21:54:24 -0500 Subject: [PATCH 16/97] discord plugin to v8 --- packages/lightning-plugin-discord/deno.json | 8 +- .../src/bridge_to_discord.ts | 86 ++++++++ .../lightning-plugin-discord/src/commands.ts | 67 ------- .../lightning-plugin-discord/src/discord.ts | 83 -------- .../src/discord_message/files.ts | 31 +++ .../src/discord_message/get_author.ts | 23 +++ .../src/discord_message/mod.ts | 52 +++++ .../src/discord_message/reply_embed.ts | 24 +++ .../src/error_handler.ts | 38 ++++ .../lightning-plugin-discord/src/lightning.ts | 94 --------- packages/lightning-plugin-discord/src/mod.ts | 187 ++++++++---------- .../src/process_message.ts | 73 ------- .../src/slash_commands.ts | 58 ++++++ .../src/to_lightning/command.ts | 38 ++++ .../src/to_lightning/deleted.ts | 13 ++ .../src/to_lightning/message.ts | 91 +++++++++ packages/lightning/src/structures/media.ts | 2 +- 17 files changed, 544 insertions(+), 424 deletions(-) create mode 100644 packages/lightning-plugin-discord/src/bridge_to_discord.ts delete mode 100644 packages/lightning-plugin-discord/src/commands.ts delete mode 100644 packages/lightning-plugin-discord/src/discord.ts create mode 100644 packages/lightning-plugin-discord/src/discord_message/files.ts create mode 100644 packages/lightning-plugin-discord/src/discord_message/get_author.ts create mode 100644 packages/lightning-plugin-discord/src/discord_message/mod.ts create mode 100644 packages/lightning-plugin-discord/src/discord_message/reply_embed.ts create mode 100644 packages/lightning-plugin-discord/src/error_handler.ts delete mode 100644 packages/lightning-plugin-discord/src/lightning.ts delete mode 100644 packages/lightning-plugin-discord/src/process_message.ts create mode 100644 packages/lightning-plugin-discord/src/slash_commands.ts create mode 100644 packages/lightning-plugin-discord/src/to_lightning/command.ts create mode 100644 packages/lightning-plugin-discord/src/to_lightning/deleted.ts create mode 100644 packages/lightning-plugin-discord/src/to_lightning/message.ts diff --git a/packages/lightning-plugin-discord/deno.json b/packages/lightning-plugin-discord/deno.json index b22054a0..57c07605 100644 --- a/packages/lightning-plugin-discord/deno.json +++ b/packages/lightning-plugin-discord/deno.json @@ -4,9 +4,9 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "@discordjs/core": "npm:@discordjs/core@^1.2.0", - "@discordjs/rest": "npm:@discordjs/rest@^2.3.0", - "@discordjs/ws": "npm:@discordjs/ws@^1.1.1", - "discord-api-types": "npm:discord-api-types@0.37.83/v10" + "@discordjs/core": "npm:@discordjs/core@^2.0.0", + "@discordjs/rest": "npm:@discordjs/rest@^2.4.0", + "@discordjs/ws": "npm:@discordjs/ws@^2.0.0", + "discord-api-types": "npm:discord-api-types@^0.37.110/v10" } } diff --git a/packages/lightning-plugin-discord/src/bridge_to_discord.ts b/packages/lightning-plugin-discord/src/bridge_to_discord.ts new file mode 100644 index 00000000..c3b6ba30 --- /dev/null +++ b/packages/lightning-plugin-discord/src/bridge_to_discord.ts @@ -0,0 +1,86 @@ +import type { + create_message_opts, + delete_message_opts, + edit_message_opts, +} from '@jersey/lightning'; +import { message_to_discord } from './discord_message/mod.ts'; +import { error_handler } from './error_handler.ts'; +import type { API } from '@discordjs/core'; + +type data = { id: string; token: string }; + +export async function setup_bridge(api: API, channel: string) { + try { + const { id, token } = await api.channels.createWebhook( + channel, + { + name: 'lightning bridge', + }, + ); + + return { id, token }; + } catch (e) { + return error_handler(e, channel, 'setting up channel'); + } +} + +export async function create_message(api: API, opts: create_message_opts) { + const data = opts.channel.data as data; + const transformed = await message_to_discord( + opts.msg, + api, + opts.channel.id, + opts.reply_id, + ); + + try { + const res = await api.webhooks.execute( + data.id, + data.token, + transformed, + ); + + return [res.id]; + } catch (e) { + return error_handler(e, opts.channel.id, 'creating message'); + } +} + +export async function edit_message(api: API, opts: edit_message_opts) { + const data = opts.channel.data as data; + const transformed = await message_to_discord( + opts.msg, + api, + opts.channel.id, + opts.reply_id, + ); + + try { + await api.webhooks.editMessage( + data.id, + data.token, + opts.edit_ids[0], + transformed, + ); + + return opts.edit_ids; + } catch (e) { + return error_handler(e, opts.channel.id, 'editing message'); + } +} + +export async function delete_message(api: API, opts: delete_message_opts) { + const data = opts.channel.data as data; + + try { + await api.webhooks.deleteMessage( + data.id, + data.token, + opts.edit_ids[0], + ); + + return opts.edit_ids; + } catch (e) { + return error_handler(e, opts.channel.id, 'editing message'); + } +} diff --git a/packages/lightning-plugin-discord/src/commands.ts b/packages/lightning-plugin-discord/src/commands.ts deleted file mode 100644 index 267b007f..00000000 --- a/packages/lightning-plugin-discord/src/commands.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { command, run_command_options, lightning } from '@jersey/lightning'; -import type { APIInteraction } from 'discord-api-types'; -import { to_discord } from './discord.ts'; -import { instant } from './lightning.ts'; - -export function to_command(interaction: { api: API; data: APIInteraction }, lightning: lightning) { - if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; - const opts = {} as Record; - let subcmd; - - for (const opt of interaction.data.data.options || []) { - if (opt.type === 1) subcmd = opt.name; - if (opt.type === 3) opts[opt.name] = opt.value; - } - - return { - command: interaction.data.data.name, - subcommand: subcmd, - channel: interaction.data.channel.id, - id: interaction.data.id, - timestamp: instant(interaction.data.id), - lightning, - plugin: 'bolt-discord', - reply: async (msg) => { - await interaction.api.interactions.reply( - interaction.data.id, - interaction.data.token, - await to_discord(msg), - ); - }, - args: opts, - } as run_command_options; -} - -export function to_intent_opts({ arguments: args, subcommands }: command) { - const opts = []; - - if (args) { - for (const arg of args) { - opts.push({ - name: arg.name, - description: arg.description, - type: 3, - required: arg.required, - }); - } - } - - if (subcommands) { - for (const sub of subcommands) { - opts.push({ - name: sub.name, - description: sub.description, - type: 1, - options: sub.arguments?.map((opt) => ({ - name: opt.name, - description: opt.description, - type: 3, - required: opt.required, - })), - }); - } - } - - return opts; -} diff --git a/packages/lightning-plugin-discord/src/discord.ts b/packages/lightning-plugin-discord/src/discord.ts deleted file mode 100644 index 9fe95ad7..00000000 --- a/packages/lightning-plugin-discord/src/discord.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { RawFile } from '@discordjs/rest'; -import type { message } from '@jersey/lightning'; -import type { - GatewayMessageUpdateDispatchData as update_data, - RESTPostAPIWebhookWithTokenJSONBody as wh_token, - RESTPostAPIWebhookWithTokenQuery as wh_query, -} from 'discord-api-types'; - -async function async_flat(arr: A[], f: (a: A) => Promise) { - return (await Promise.all(arr.map(f))).flat(); -} - -export type discord_message = Omit; - -type webhook_message = wh_query & wh_token & { files?: RawFile[]; wait: true }; - -export async function to_discord( - message: message, - replied_message?: discord_message, -): Promise { - if (message.reply_id && replied_message) { - if (!message.embeds) message.embeds = []; - message.embeds.push( - { - author: { - name: `replying to ${ - replied_message.member?.nick || - replied_message.author?.global_name || - replied_message.author?.username || - 'a user' - }`, - icon_url: - `https://cdn.discordapp.com/avatars/${replied_message.author?.id}/${replied_message.author?.avatar}.png`, - }, - description: replied_message.content, - }, - ...(replied_message.embeds || []).map( - (i: Exclude[0]) => { - return { - ...i, - timestamp: i.timestamp ? Number(i.timestamp) : undefined, - video: i.video ? { ...i.video, url: i.video.url || '' } : undefined, - }; - }, - ), - ); - } - - if ((!message.content || message.content.length < 1) && !message.embeds) { - message.content = '*empty message*'; - } - - if (!message.author.username || message.author.username.length < 1) { - message.author.username = message.author.id; - } - - return { - avatar_url: message.author.profile, - content: message.content, - embeds: message.embeds?.map((i) => { - return { - ...i, - timestamp: i.timestamp ? String(i.timestamp) : undefined, - }; - }), - files: message.attachments - ? await async_flat(message.attachments, async (a) => { - if (a.size > 25) return []; - if (!a.name) a.name = a.file.split('/').pop(); - return [ - { - name: a.name || 'file', - data: new Uint8Array( - await (await fetch(a.file)).arrayBuffer(), - ), - }, - ]; - }) - : undefined, - username: message.author.username, - wait: true, - }; -} diff --git a/packages/lightning-plugin-discord/src/discord_message/files.ts b/packages/lightning-plugin-discord/src/discord_message/files.ts new file mode 100644 index 00000000..daf4d30d --- /dev/null +++ b/packages/lightning-plugin-discord/src/discord_message/files.ts @@ -0,0 +1,31 @@ +import type { attachment } from '@jersey/lightning'; +import type { RawFile } from '@discordjs/rest'; + +export async function files_up_to_25MiB(attachments: attachment[] | undefined) { + if (!attachments) return; + + const files: RawFile[] = []; + const total_size = 0; + + for (const attachment of attachments) { + if (attachment.size >= 25) continue; + if (total_size + attachment.size >= 25) break; + + try { + const data = new Uint8Array( + await (await fetch(attachment.file, { + signal: AbortSignal.timeout(5000), + })).arrayBuffer(), + ); + + files.push({ + name: attachment.name ?? attachment.file.split('/').pop()!, + data, + }); + } catch { + continue; + } + } + + return files; +} diff --git a/packages/lightning-plugin-discord/src/discord_message/get_author.ts b/packages/lightning-plugin-discord/src/discord_message/get_author.ts new file mode 100644 index 00000000..31bdff92 --- /dev/null +++ b/packages/lightning-plugin-discord/src/discord_message/get_author.ts @@ -0,0 +1,23 @@ +import type { APIMessage } from 'discord-api-types'; +import type { API } from '@discordjs/core'; +import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; + +export async function get_author(api: API, message: APIMessage) { + let name = message.author.global_name || message.author.username; + let avatar = message.author.avatar ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png` : `https://cdn.discordapp.com/embed/avatars/${calculateUserDefaultAvatarIndex(message.author.id)}.png`; + + const channel = await api.channels.get(message.channel_id); + + if ("guild_id" in channel) { + try { + const member = await api.guilds.getMember(channel.guild_id, message.author.id); + + name ??= member.nick; + avatar = member.avatar ? `https://cdn.discordapp.com/guilds/${channel.guild_id}/users/${message.author.id}/avatars/${member.avatar}.png` : avatar; + } catch { + // safe to ignore + } + } + + return { name, avatar } +} diff --git a/packages/lightning-plugin-discord/src/discord_message/mod.ts b/packages/lightning-plugin-discord/src/discord_message/mod.ts new file mode 100644 index 00000000..eb28b44c --- /dev/null +++ b/packages/lightning-plugin-discord/src/discord_message/mod.ts @@ -0,0 +1,52 @@ +import type { message } from '@jersey/lightning'; +import type { API } from '@discordjs/core'; +import type { + RESTPostAPIWebhookWithTokenJSONBody, + RESTPostAPIWebhookWithTokenQuery, +} from 'discord-api-types'; +import type { RawFile } from '@discordjs/rest'; +import { reply_embed } from './reply_embed.ts'; +import { files_up_to_25MiB } from './files.ts'; + +export interface discord_message_send + extends + RESTPostAPIWebhookWithTokenJSONBody, + RESTPostAPIWebhookWithTokenQuery { + files?: RawFile[]; + wait: true; +} + +export async function message_to_discord( + msg: message, + api?: API, + channel?: string, + reply_id?: string, +): Promise { + const discord: discord_message_send = { + avatar_url: msg.author.profile, + content: (msg.content?.length || 0) > 2000 + ? `${msg.content?.substring(0, 1997)}...` + : msg.content, + embeds: msg.embeds?.map((e) => { + return { ...e, timestamp: e.timestamp?.toString() }; + }), + username: msg.author.username, + wait: true, + }; + + if (api && channel && reply_id) { + const embed = await reply_embed(api, channel, reply_id); + if (embed) { + if (!discord.embeds) discord.embeds = []; + discord.embeds.push(embed); + } + } + + discord.files = await files_up_to_25MiB(msg.attachments); + + if (!discord.content && (!discord.embeds || discord.embeds.length === 0)) { + discord.content = '*empty message*'; + } + + return discord; +} diff --git a/packages/lightning-plugin-discord/src/discord_message/reply_embed.ts b/packages/lightning-plugin-discord/src/discord_message/reply_embed.ts new file mode 100644 index 00000000..39d48cf2 --- /dev/null +++ b/packages/lightning-plugin-discord/src/discord_message/reply_embed.ts @@ -0,0 +1,24 @@ +import type { API } from '@discordjs/core'; +import type { APIMessage } from 'discord-api-types'; +import { get_author } from './get_author.ts'; + +export async function reply_embed(api: API, channel: string, id: string) { + try { + const message = await api.channels.getMessage( + channel, + id, + ) as APIMessage; + + const { name, avatar } = await get_author(api, message); + + return { + author: { + name: `replying to ${name}`, + icon_url: avatar, + }, + description: message.content, + }; + } catch { + return; + } +} diff --git a/packages/lightning-plugin-discord/src/error_handler.ts b/packages/lightning-plugin-discord/src/error_handler.ts new file mode 100644 index 00000000..0486ff02 --- /dev/null +++ b/packages/lightning-plugin-discord/src/error_handler.ts @@ -0,0 +1,38 @@ +import { DiscordAPIError } from '@discordjs/rest'; +import { log_error } from '@jersey/lightning'; + +export function error_handler(e: unknown, channel_id: string, action: string) { + if (e instanceof DiscordAPIError) { + if (e.code === 30007 || e.code === 30058) { + log_error(e, { + message: + 'too many webhooks in channel/guild. try deleting some', + extra: { channel_id }, + }); + } else if (e.code === 50013) { + log_error(e, { + message: + 'missing permissions to create webhook. check bot permissions', + extra: { channel_id }, + }); + } else if (e.code === 10003 || e.code === 10015 || e.code === 50027) { + log_error(e, { + disable: true, + message: `disabling channel due to error code ${e.code}`, + extra: { channel_id }, + }); + } else if (action === 'editing message' && e.code === 10008) { + return []; // message already deleted or non-existent + } else { + log_error(e, { + message: `unknown error ${action}`, + extra: { channel_id, code: e.code }, + }); + } + } else { + log_error(e, { + message: `unknown error ${action}`, + extra: { channel_id }, + }); + } +} diff --git a/packages/lightning-plugin-discord/src/lightning.ts b/packages/lightning-plugin-discord/src/lightning.ts deleted file mode 100644 index feeaa821..00000000 --- a/packages/lightning-plugin-discord/src/lightning.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { message } from '@jersey/lightning'; -import { type discord_message, to_discord } from './discord.ts'; - -export function instant(id: string) { - return Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(id) >> 22n) + 1420070400000, - ); -} - -export async function to_message( - api: API, - message: discord_message, -): Promise { - if (message.flags && message.flags & 128) message.content = 'Loading...'; - - if (message.type === 7) message.content = '*joined on discord*'; - - if (message.sticker_items) { - if (!message.attachments) message.attachments = []; - for (const sticker of message.sticker_items) { - let type; - if (sticker.format_type === 1) type = 'png'; - if (sticker.format_type === 2) type = 'apng'; - if (sticker.format_type === 3) type = 'lottie'; - if (sticker.format_type === 4) type = 'gif'; - const url = `https://media.discordapp.net/stickers/${sticker.id}.${type}`; - const req = await fetch(url, { method: 'HEAD' }); - if (req.ok) { - message.attachments.push({ - url, - description: sticker.name, - filename: `${sticker.name}.${type}`, - size: 0, - id: sticker.id, - proxy_url: url, - }); - } else { - message.content = '*used sticker*'; - } - } - } - - const data = { - author: { - profile: - `https://cdn.discordapp.com/avatars/${message.author?.id}/${message.author?.avatar}.png`, - username: message.member?.nick || - message.author?.global_name || - message.author?.username || - 'discord user', - rawname: message.author?.username || 'discord user', - id: message.author?.id || message.webhook_id || '', - color: '#5865F2', - }, - channel: message.channel_id, - content: (message.content?.length || 0) > 2000 - ? `${message.content?.substring(0, 1997)}...` - : message.content, - id: message.id, - timestamp: instant(message.id), - embeds: message.embeds?.map( - (i: Exclude[0]) => { - return { - ...i, - timestamp: i.timestamp ? Number(i.timestamp) : undefined, - }; - }, - ), - reply: async (msg: message) => { - if (!data.author.id || data.author.id === '') return; - await api.channels.createMessage(message.channel_id, { - ...(await to_discord(msg)), - message_reference: { - message_id: message.id, - }, - }); - }, - plugin: 'bolt-discord', - attachments: message.attachments?.map( - (i: Exclude[0]) => { - return { - file: i.url, - alt: i.description, - name: i.filename, - size: i.size / 1000000, - }; - }, - ), - reply_id: message.referenced_message?.id, - }; - - return data as message; -} diff --git a/packages/lightning-plugin-discord/src/mod.ts b/packages/lightning-plugin-discord/src/mod.ts index 75e5da92..3a301e50 100644 --- a/packages/lightning-plugin-discord/src/mod.ts +++ b/packages/lightning-plugin-discord/src/mod.ts @@ -2,110 +2,93 @@ import { Client } from '@discordjs/core'; import { REST } from '@discordjs/rest'; import { WebSocketManager } from '@discordjs/ws'; import { - type lightning, - type message_options, - plugin, - type process_result, + type create_message_opts, + type delete_message_opts, + type edit_message_opts, + type lightning, + plugin, } from '@jersey/lightning'; import { GatewayDispatchEvents } from 'discord-api-types'; -import { to_command, to_intent_opts } from './commands.ts'; -import { to_message } from './lightning.ts'; -import { process_message } from './process_message.ts'; - -/** options for the discord plugin */ -export type discord_config = { - /** your bot's application id */ - app_id: string; - /** the token for your bot */ - token: string; - /** whether or not to enable slash commands */ - slash_cmds?: boolean; -}; +import * as bridge from './bridge_to_discord.ts'; +import { setup_slash_commands } from './slash_commands.ts'; +import { command_to } from './to_lightning/command.ts'; +import { deleted } from './to_lightning/deleted.ts'; +import { message } from './to_lightning/message.ts'; + +/** configuration for the discord plugin */ +export interface discord_config { + /** the discord bot token */ + token: string; + /** whether to enable slash commands */ + slash_commands: boolean; + /** discord application id */ + application_id: string; +} -/** the plugin to use */ export class discord_plugin extends plugin { - bot: Client; - name = 'bolt-discord'; - - /** setup the plugin */ - constructor(l: lightning, config: discord_config) { - super(l, config); - this.config = config; - this.bot = this.setup_client(); - this.setup_events(); - this.setup_commands(); - } - - private setup_client() { - const rest = new REST({ - version: '10', - /* @ts-ignore this works */ - makeRequest: fetch, - }).setToken(this.config.token); - - const gateway = new WebSocketManager({ - rest, - token: this.config.token, - intents: 0 | 33281, - }); - - gateway.connect(); - - // @ts-ignore this works? - return new Client({ rest, gateway }); - } - - private setup_events() { - this.bot.on(GatewayDispatchEvents.MessageCreate, async (msg) => { - this.emit('create_message', await to_message(msg.api, msg.data)); - }); - - this.bot.on(GatewayDispatchEvents.MessageUpdate, async (msg) => { - this.emit('edit_message', await to_message(msg.api, msg.data)); - }); - - this.bot.on(GatewayDispatchEvents.MessageDelete, async (msg) => { - this.emit('delete_message', await to_message(msg.api, msg.data)); - }); - - this.bot.on(GatewayDispatchEvents.InteractionCreate, (interaction) => { - const cmd = to_command(interaction, this.lightning); - if (cmd) this.emit('run_command', cmd); - }); - } - - private setup_commands() { - if (!this.config.slash_cmds) return; - - this.bot.api.applicationCommands.bulkOverwriteGlobalCommands( - this.config.app_id, - [...this.lightning.commands.values()].map((command) => { - return { - name: command.name, - type: 1, - description: command.description || 'a command', - options: to_intent_opts(command), - }; - }), - ); - } - - /** creates a webhook in the channel for a bridge */ - async create_bridge( - channel: string, - ): Promise<{ id: string; token?: string }> { - const { id, token } = await this.bot.api.channels.createWebhook( - channel, - { - name: 'lightning bridge', - }, - ); - - return { id, token }; - } - - /** process a message event */ - async process_message(opts: message_options): Promise { - return await process_message(this.bot.api, opts); - } + name = 'bolt-discord'; + private api: Client['api']; + private client: Client; + + constructor(l: lightning, config: discord_config) { + super(l, config); + // @ts-ignore their type for makeRequest is funky + const rest = new REST({ version: '10', makeRequest: fetch }).setToken( + config.token, + ); + const gateway = new WebSocketManager({ + token: config.token, + intents: 0 | 33281, + rest, + }); + // @ts-ignore Deno is wrong here. + this.client = new Client({ rest, gateway }); + this.api = this.client.api; + + setup_slash_commands(this.api, config, l); + this.setup_events(); + gateway.connect(); + } + + private setup_events() { + // @ts-ignore I'm going to file an issue against Deno because this is so annoying + this.client.once(GatewayDispatchEvents.Ready, (ev) => { + console.log( + `bolt-discord: ready as ${ev.user.username}#${ev.user.discriminator} in ${ev.guilds.length} guilds`, + ); + }); + // @ts-ignore see above + this.client.on(GatewayDispatchEvents.MessageCreate, async (msg) => { + this.emit('create_message', await message(msg.api, msg.data)); + }); + // @ts-ignore see above + this.client.on(GatewayDispatchEvents.MessageUpdate, async (msg) => { + this.emit('edit_message', await message(msg.api, msg.data)); + }); + // @ts-ignore see above + this.client.on(GatewayDispatchEvents.MessageDelete, (msg) => { + this.emit('delete_message', deleted(msg.data)); + }); + // @ts-ignore see above + this.client.on(GatewayDispatchEvents.InteractionCreate, (cmd) => { + const command = command_to(cmd, this.lightning); + if (command) this.emit('create_command', command); + }); + } + + async setup_channel(channel: string) { + return await bridge.setup_bridge(this.api, channel); + } + + async create_message(opts: create_message_opts) { + return await bridge.create_message(this.api, opts); + } + + async edit_message(opts: edit_message_opts) { + return await bridge.edit_message(this.api, opts); + } + + async delete_message(opts: delete_message_opts) { + return await bridge.delete_message(this.api, opts); + } } diff --git a/packages/lightning-plugin-discord/src/process_message.ts b/packages/lightning-plugin-discord/src/process_message.ts deleted file mode 100644 index 04327ca1..00000000 --- a/packages/lightning-plugin-discord/src/process_message.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { message_options } from '@jersey/lightning'; -import { to_discord } from './discord.ts'; - -export async function process_message(api: API, opts: message_options) { - const data = opts.channel.data as { token: string; id: string }; - - if (opts.action !== 'delete') { - let replied_message; - - if (opts.reply_id) { - try { - replied_message = await api.channels - .getMessage(opts.channel.id, opts.reply_id); - } catch { - // safe to ignore - } - } - - const msg = await to_discord( - opts.message, - replied_message, - ); - - try { - let wh; - - if (opts.action === 'edit') { - wh = await api.webhooks.editMessage( - data.id, - data.token, - opts.edit_id[0], - msg, - ); - } else { - wh = await api.webhooks.execute( - data.id, - data.token, - msg, - ); - } - - return { - id: [wh.id], - channel: opts.channel, - }; - } catch (e) { - if ( - (e as { status: number }).status === 404 && - opts.action !== 'edit' - ) { - return { - channel: opts.channel, - error: e as Error, - disable: true, - }; - } else { - throw e; - } - } - } else { - await api.webhooks.deleteMessage( - data.id, - data.token, - opts.edit_id[0], - ); - - return { - id: opts.edit_id, - channel: opts.channel, - }; - } -} diff --git a/packages/lightning-plugin-discord/src/slash_commands.ts b/packages/lightning-plugin-discord/src/slash_commands.ts new file mode 100644 index 00000000..bdbffe43 --- /dev/null +++ b/packages/lightning-plugin-discord/src/slash_commands.ts @@ -0,0 +1,58 @@ +import type { command, lightning } from '@jersey/lightning'; +import type { API } from '@discordjs/core'; +import type { discord_config } from './mod.ts'; + +export async function setup_slash_commands( + api: API, + config: discord_config, + lightning: lightning, +) { + if (!config.slash_commands) return; + + const commands = lightning.commands.values().toArray(); + + await api.applicationCommands.bulkOverwriteGlobalCommands( + config.application_id, + commands_to_discord(commands) + ); +} + +function commands_to_discord(commands: command[]) { + return commands.map((command) => { + const opts = []; + + if (command.arguments) { + for (const argument of command.arguments) { + opts.push({ + name: argument.name, + description: argument.description, + type: 3, + required: argument.required, + }); + } + } + + if (command.subcommands) { + for (const subcommand of command.subcommands) { + opts.push({ + name: subcommand.name, + description: subcommand.description, + type: 1, + options: subcommand.arguments?.map((opt) => ({ + name: opt.name, + description: opt.description, + type: 3, + required: opt.required, + })), + }); + } + } + + return { + name: command.name, + type: 1, + description: command.description, + options: opts, + }; + }); +} diff --git a/packages/lightning-plugin-discord/src/to_lightning/command.ts b/packages/lightning-plugin-discord/src/to_lightning/command.ts new file mode 100644 index 00000000..24dc9712 --- /dev/null +++ b/packages/lightning-plugin-discord/src/to_lightning/command.ts @@ -0,0 +1,38 @@ +import type { API } from '@discordjs/core'; +import type { APIInteraction } from 'discord-api-types'; +import type { create_command, lightning } from '@jersey/lightning'; +import { message_to_discord } from '../discord_message/mod.ts'; + +export function command_to( + interaction: { api: API; data: APIInteraction }, + lightning: lightning, +) { + if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; + const opts = {} as Record; + let subcmd; + + for (const opt of interaction.data.data.options || []) { + if (opt.type === 1) subcmd = opt.name; + if (opt.type === 3) opts[opt.name] = opt.value; + } + + return { + command: interaction.data.data.name, + subcommand: subcmd, + channel: interaction.data.channel.id, + id: interaction.data.id, + timestamp: Temporal.Instant.fromEpochMilliseconds( + Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, + ), + lightning, + plugin: 'bolt-discord', + reply: async (msg) => { + await interaction.api.interactions.reply( + interaction.data.id, + interaction.data.token, + await message_to_discord(msg), + ); + }, + args: opts, + } as create_command; +} diff --git a/packages/lightning-plugin-discord/src/to_lightning/deleted.ts b/packages/lightning-plugin-discord/src/to_lightning/deleted.ts new file mode 100644 index 00000000..93059c24 --- /dev/null +++ b/packages/lightning-plugin-discord/src/to_lightning/deleted.ts @@ -0,0 +1,13 @@ +import type { GatewayMessageDeleteDispatchData } from 'discord-api-types'; +import type { deleted_message } from '@jersey/lightning'; + +export function deleted( + message: GatewayMessageDeleteDispatchData, +): deleted_message { + return { + channel: message.channel_id, + id: message.id, + plugin: 'bolt-discord', + timestamp: Temporal.Now.instant(), + } +} diff --git a/packages/lightning-plugin-discord/src/to_lightning/message.ts b/packages/lightning-plugin-discord/src/to_lightning/message.ts new file mode 100644 index 00000000..370d116c --- /dev/null +++ b/packages/lightning-plugin-discord/src/to_lightning/message.ts @@ -0,0 +1,91 @@ +import type { API } from '@discordjs/core'; +import type { APIMessage } from 'discord-api-types'; +import { get_author } from '../discord_message/get_author.ts'; +import { message_to_discord } from '../discord_message/mod.ts'; +import type { message } from '@jersey/lightning'; + +export async function message( + api: API, + message: APIMessage, +): Promise { + if (message.flags && message.flags & 128) message.content = 'Loading...'; + + if (message.type === 7) message.content = '*joined on discord*'; + + if (message.sticker_items) { + if (!message.attachments) message.attachments = []; + for (const sticker of message.sticker_items) { + let type; + if (sticker.format_type === 1) type = 'png'; + if (sticker.format_type === 2) type = 'apng'; + if (sticker.format_type === 3) type = 'lottie'; + if (sticker.format_type === 4) type = 'gif'; + const url = + `https://media.discordapp.net/stickers/${sticker.id}.${type}`; + const req = await fetch(url, { method: 'HEAD' }); + if (req.ok) { + message.attachments.push({ + url, + description: sticker.name, + filename: `${sticker.name}.${type}`, + size: 0, + id: sticker.id, + proxy_url: url, + }); + } else { + message.content = '*used sticker*'; + } + } + } + + const { name, avatar } = await get_author(api, message); + + const data = { + author: { + profile: avatar, + username: name, + rawname: message.author.username, + id: message.author.id, + color: '#5865F2', + }, + channel: message.channel_id, + content: (message.content?.length || 0) > 2000 + ? `${message.content?.substring(0, 1997)}...` + : message.content, + id: message.id, + timestamp: Temporal.Instant.fromEpochMilliseconds( + Number(BigInt(message.id) >> 22n) + 1420070400000, + ), + embeds: message.embeds?.map( + (i: Exclude[0]) => { + return { + ...i, + timestamp: i.timestamp ? Number(i.timestamp) : undefined, + }; + }, + ), + reply: async (msg: message) => { + if (!data.author.id || data.author.id === '') return; + await api.channels.createMessage(message.channel_id, { + ...(await message_to_discord(msg)), + message_reference: { + message_id: message.id, + }, + }); + }, + plugin: 'bolt-discord', + attachments: message.attachments?.map( + (i: Exclude[0]) => { + return { + file: i.url, + alt: i.description, + name: i.filename, + size: i.size / 1048576, // bytes -> MiB + }; + }, + ), + reply_id: message.referenced_message?.id, + }; + + return data as message; +} diff --git a/packages/lightning/src/structures/media.ts b/packages/lightning/src/structures/media.ts index e4ef5c7c..f3564d4a 100644 --- a/packages/lightning/src/structures/media.ts +++ b/packages/lightning/src/structures/media.ts @@ -8,7 +8,7 @@ export interface attachment { name?: string; /** whether or not the file has a spoiler */ spoiler?: boolean; - /** file size */ + /** file size in MiB */ size: number; } From 17f2a95a27a7b8883658141ac5461d7ec62982f0 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 29 Nov 2024 21:58:55 -0500 Subject: [PATCH 17/97] quick fix but otherwise it works i think --- packages/lightning-plugin-discord/src/mod.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/lightning-plugin-discord/src/mod.ts b/packages/lightning-plugin-discord/src/mod.ts index 3a301e50..c29be96a 100644 --- a/packages/lightning-plugin-discord/src/mod.ts +++ b/packages/lightning-plugin-discord/src/mod.ts @@ -41,7 +41,7 @@ export class discord_plugin extends plugin { intents: 0 | 33281, rest, }); - // @ts-ignore Deno is wrong here. + this.client = new Client({ rest, gateway }); this.api = this.client.api; @@ -51,25 +51,20 @@ export class discord_plugin extends plugin { } private setup_events() { - // @ts-ignore I'm going to file an issue against Deno because this is so annoying - this.client.once(GatewayDispatchEvents.Ready, (ev) => { + this.client.once(GatewayDispatchEvents.Ready, ({data}) => { console.log( - `bolt-discord: ready as ${ev.user.username}#${ev.user.discriminator} in ${ev.guilds.length} guilds`, + `bolt-discord: ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, ); }); - // @ts-ignore see above this.client.on(GatewayDispatchEvents.MessageCreate, async (msg) => { this.emit('create_message', await message(msg.api, msg.data)); }); - // @ts-ignore see above this.client.on(GatewayDispatchEvents.MessageUpdate, async (msg) => { this.emit('edit_message', await message(msg.api, msg.data)); }); - // @ts-ignore see above this.client.on(GatewayDispatchEvents.MessageDelete, (msg) => { this.emit('delete_message', deleted(msg.data)); }); - // @ts-ignore see above this.client.on(GatewayDispatchEvents.InteractionCreate, (cmd) => { const command = command_to(cmd, this.lightning); if (command) this.emit('create_command', command); From 7701d516b14f1b228b4d1708ed7294359205733a Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 29 Nov 2024 22:28:50 -0500 Subject: [PATCH 18/97] telegram plugin v8 --- packages/lightning-plugin-telegram/README.md | 13 +-- packages/lightning-plugin-telegram/deno.json | 3 +- packages/lightning-plugin-telegram/src/mod.ts | 103 +++++++++--------- 3 files changed, 55 insertions(+), 64 deletions(-) diff --git a/packages/lightning-plugin-telegram/README.md b/packages/lightning-plugin-telegram/README.md index 02f12aa5..fedede19 100644 --- a/packages/lightning-plugin-telegram/README.md +++ b/packages/lightning-plugin-telegram/README.md @@ -2,17 +2,15 @@ lightning-plugin-telegram is a plugin for [lightning](https://williamhroning.eu.org/lightning) that adds support for -telegram +telegram (including attachments via the included file proxy) ## example config ```ts -import type { config } from 'jsr:@jersey/lightning@0.7.4'; -import { telegram_plugin } from 'jsr:@jersey/lightning-plugin-telegram@0.7.4'; +import type { config } from 'jsr:@jersey/lightning@0.8.0'; +import { telegram_plugin } from 'jsr:@jersey/lightning-plugin-telegram@0.8.0'; export default { - redis_host: 'localhost', - redis_port: 6379, plugins: [ telegram_plugin.new({ bot_token: 'your_token', @@ -22,8 +20,3 @@ export default { ], } as config; ``` - -## notes - -this plugin has a telegram file proxy, which should be publically accessible so -that you don't leak your bot token when bridging attachments or profile pictures diff --git a/packages/lightning-plugin-telegram/deno.json b/packages/lightning-plugin-telegram/deno.json index 82137b38..ceb7cdb0 100644 --- a/packages/lightning-plugin-telegram/deno.json +++ b/packages/lightning-plugin-telegram/deno.json @@ -5,7 +5,6 @@ "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "telegramify-markdown": "npm:telegramify-markdown@^1.2.2", - "grammy": "npm:grammy@^1.28.0", - "grammy/types": "npm:grammy@^1.28.0/types" + "grammy": "npm:grammy@^1.32.0" } } diff --git a/packages/lightning-plugin-telegram/src/mod.ts b/packages/lightning-plugin-telegram/src/mod.ts index b4610715..b7429114 100644 --- a/packages/lightning-plugin-telegram/src/mod.ts +++ b/packages/lightning-plugin-telegram/src/mod.ts @@ -1,8 +1,9 @@ import { + type create_message_opts, + type delete_message_opts, + type edit_message_opts, type lightning, - type message_options, plugin, - type process_result, } from '@jersey/lightning'; import { Bot } from 'grammy'; import { from_lightning, from_telegram } from './messages.ts'; @@ -20,7 +21,7 @@ export type telegram_config = { /** the plugin to use */ export class telegram_plugin extends plugin { name = 'bolt-telegram'; - bot: Bot; + private bot: Bot; constructor(l: lightning, cfg: telegram_config) { super(l, cfg); @@ -41,7 +42,15 @@ export class telegram_plugin extends plugin { } private serve_proxy() { - Deno.serve({ port: this.config.plugin_port }, (req: Request) => { + Deno.serve({ + port: this.config.plugin_port, + onListen: (addr) => { + console.log( + `bolt-telegram: file proxy listening on http://localhost:${addr.port}`, + `bolt-telegram: also available at: ${this.config.plugin_url}`, + ); + }, + }, (req: Request) => { const { pathname } = new URL(req.url); return fetch( `https://api.telegram.org/file/bot${this.bot.token}/${ @@ -52,67 +61,57 @@ export class telegram_plugin extends plugin { } /** create a bridge */ - create_bridge(channel: string): string { + setup_channel(channel: string) { return channel; } - /** process a message event */ - async process_message(opts: message_options): Promise { - if (opts.action === 'delete') { - for (const id of opts.edit_id) { - await this.bot.api.deleteMessage( - opts.channel.id, - Number(id), - ); - } - - return { - id: opts.edit_id, - channel: opts.channel, - }; - } else if (opts.action === 'edit') { - const content = from_lightning(opts.message)[0]; + async create_message(opts: create_message_opts) { + const content = from_lightning(opts.msg); + const messages = []; - await this.bot.api.editMessageText( + for (const msg of content) { + const result = await this.bot.api[msg.function]( opts.channel.id, - Number(opts.edit_id[0]), - content.value, + msg.value, { + reply_parameters: opts.reply_id + ? { + message_id: Number(opts.reply_id), + } + : undefined, parse_mode: 'MarkdownV2', }, ); - return { - id: opts.edit_id, - channel: opts.channel, - }; - } else if (opts.action === 'create') { - const content = from_lightning(opts.message); - const messages = []; + messages.push(String(result.message_id)); + } - for (const msg of content) { - const result = await this.bot.api[msg.function]( - opts.channel.id, - msg.value, - { - reply_parameters: opts.reply_id - ? { - message_id: Number(opts.reply_id), - } - : undefined, - parse_mode: 'MarkdownV2', - }, - ); + return messages; + } - messages.push(String(result.message_id)); - } + async edit_message(opts: edit_message_opts) { + const content = from_lightning(opts.msg)[0]; - return { - id: messages, - channel: opts.channel, - }; - } else { - throw new Error('unknown action'); + await this.bot.api.editMessageText( + opts.channel.id, + Number(opts.edit_ids[0]), + content.value, + { + parse_mode: 'MarkdownV2', + }, + ); + + return opts.edit_ids; + } + + async delete_message(opts: delete_message_opts) { + for (const id of opts.edit_ids) { + await this.bot.api.deleteMessage( + opts.channel.id, + Number(id), + ); } + + return opts.edit_ids; } } From f2e26a3b975e595a913ad2382d8f0d18be2a5de7 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 29 Nov 2024 22:37:27 -0500 Subject: [PATCH 19/97] rename thingies --- packages/lightning-plugin-discord/deno.json | 2 +- .../src/bridge_to_discord.ts | 12 ++++++------ .../src/discord_message/get_author.ts | 4 ++-- packages/lightning-plugin-discord/src/mod.ts | 12 ++++++------ packages/lightning-plugin-telegram/src/mod.ts | 12 ++++++------ packages/lightning/src/structures/bridge.ts | 6 +++--- packages/lightning/src/structures/plugins.ts | 12 ++++-------- 7 files changed, 28 insertions(+), 32 deletions(-) diff --git a/packages/lightning-plugin-discord/deno.json b/packages/lightning-plugin-discord/deno.json index 57c07605..e38c1819 100644 --- a/packages/lightning-plugin-discord/deno.json +++ b/packages/lightning-plugin-discord/deno.json @@ -7,6 +7,6 @@ "@discordjs/core": "npm:@discordjs/core@^2.0.0", "@discordjs/rest": "npm:@discordjs/rest@^2.4.0", "@discordjs/ws": "npm:@discordjs/ws@^2.0.0", - "discord-api-types": "npm:discord-api-types@^0.37.110/v10" + "discord-api-types": "npm:discord-api-types@0.37.97/v10" } } diff --git a/packages/lightning-plugin-discord/src/bridge_to_discord.ts b/packages/lightning-plugin-discord/src/bridge_to_discord.ts index c3b6ba30..f91ae21a 100644 --- a/packages/lightning-plugin-discord/src/bridge_to_discord.ts +++ b/packages/lightning-plugin-discord/src/bridge_to_discord.ts @@ -1,7 +1,7 @@ import type { - create_message_opts, - delete_message_opts, - edit_message_opts, + create_opts, + delete_opts, + edit_opts, } from '@jersey/lightning'; import { message_to_discord } from './discord_message/mod.ts'; import { error_handler } from './error_handler.ts'; @@ -24,7 +24,7 @@ export async function setup_bridge(api: API, channel: string) { } } -export async function create_message(api: API, opts: create_message_opts) { +export async function create_message(api: API, opts: create_opts) { const data = opts.channel.data as data; const transformed = await message_to_discord( opts.msg, @@ -46,7 +46,7 @@ export async function create_message(api: API, opts: create_message_opts) { } } -export async function edit_message(api: API, opts: edit_message_opts) { +export async function edit_message(api: API, opts: edit_opts) { const data = opts.channel.data as data; const transformed = await message_to_discord( opts.msg, @@ -69,7 +69,7 @@ export async function edit_message(api: API, opts: edit_message_opts) { } } -export async function delete_message(api: API, opts: delete_message_opts) { +export async function delete_message(api: API, opts: delete_opts) { const data = opts.channel.data as data; try { diff --git a/packages/lightning-plugin-discord/src/discord_message/get_author.ts b/packages/lightning-plugin-discord/src/discord_message/get_author.ts index 31bdff92..1ecab812 100644 --- a/packages/lightning-plugin-discord/src/discord_message/get_author.ts +++ b/packages/lightning-plugin-discord/src/discord_message/get_author.ts @@ -8,11 +8,11 @@ export async function get_author(api: API, message: APIMessage) { const channel = await api.channels.get(message.channel_id); - if ("guild_id" in channel) { + if ("guild_id" in channel && channel.guild_id) { try { const member = await api.guilds.getMember(channel.guild_id, message.author.id); - name ??= member.nick; + if (member.nick !== null && member.nick !== undefined) name = member.nick; avatar = member.avatar ? `https://cdn.discordapp.com/guilds/${channel.guild_id}/users/${message.author.id}/avatars/${member.avatar}.png` : avatar; } catch { // safe to ignore diff --git a/packages/lightning-plugin-discord/src/mod.ts b/packages/lightning-plugin-discord/src/mod.ts index c29be96a..c4446c8b 100644 --- a/packages/lightning-plugin-discord/src/mod.ts +++ b/packages/lightning-plugin-discord/src/mod.ts @@ -2,9 +2,9 @@ import { Client } from '@discordjs/core'; import { REST } from '@discordjs/rest'; import { WebSocketManager } from '@discordjs/ws'; import { - type create_message_opts, - type delete_message_opts, - type edit_message_opts, + type create_opts, + type delete_opts, + type edit_opts, type lightning, plugin, } from '@jersey/lightning'; @@ -75,15 +75,15 @@ export class discord_plugin extends plugin { return await bridge.setup_bridge(this.api, channel); } - async create_message(opts: create_message_opts) { + async create_message(opts: create_opts) { return await bridge.create_message(this.api, opts); } - async edit_message(opts: edit_message_opts) { + async edit_message(opts: edit_opts) { return await bridge.edit_message(this.api, opts); } - async delete_message(opts: delete_message_opts) { + async delete_message(opts: delete_opts) { return await bridge.delete_message(this.api, opts); } } diff --git a/packages/lightning-plugin-telegram/src/mod.ts b/packages/lightning-plugin-telegram/src/mod.ts index b7429114..d73763eb 100644 --- a/packages/lightning-plugin-telegram/src/mod.ts +++ b/packages/lightning-plugin-telegram/src/mod.ts @@ -1,7 +1,7 @@ import { - type create_message_opts, - type delete_message_opts, - type edit_message_opts, + type create_opts, + type delete_opts, + type edit_opts, type lightning, plugin, } from '@jersey/lightning'; @@ -65,7 +65,7 @@ export class telegram_plugin extends plugin { return channel; } - async create_message(opts: create_message_opts) { + async create_message(opts: create_opts) { const content = from_lightning(opts.msg); const messages = []; @@ -89,7 +89,7 @@ export class telegram_plugin extends plugin { return messages; } - async edit_message(opts: edit_message_opts) { + async edit_message(opts: edit_opts) { const content = from_lightning(opts.msg)[0]; await this.bot.api.editMessageText( @@ -104,7 +104,7 @@ export class telegram_plugin extends plugin { return opts.edit_ids; } - async delete_message(opts: delete_message_opts) { + async delete_message(opts: delete_opts) { for (const id of opts.edit_ids) { await this.bot.api.deleteMessage( opts.channel.id, diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index d5d20cde..d02b59a3 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -68,7 +68,7 @@ export interface bridged_message { } /** a message to be bridged */ -export interface create_message_opts { +export interface create_opts { /** the actual message */ msg: message; /** the channel to use */ @@ -78,7 +78,7 @@ export interface create_message_opts { } /** a message to be edited */ -export interface edit_message_opts { +export interface edit_opts { /** the actual message */ msg: message; /** the channel to use */ @@ -90,7 +90,7 @@ export interface edit_message_opts { } /** a message to be deleted */ -export interface delete_message_opts { +export interface delete_opts { /** the actual deleted message */ msg: deleted_message; /** the channel to use */ diff --git a/packages/lightning/src/structures/plugins.ts b/packages/lightning/src/structures/plugins.ts index b8540b64..61557d07 100644 --- a/packages/lightning/src/structures/plugins.ts +++ b/packages/lightning/src/structures/plugins.ts @@ -1,10 +1,6 @@ import { EventEmitter } from '@denosaurs/event'; import type { lightning } from '../lightning.ts'; -import type { - create_message_opts, - delete_message_opts, - edit_message_opts, -} from './bridge.ts'; +import type { create_opts, delete_opts, edit_opts } from './bridge.ts'; import type { plugin_events } from './events.ts'; /** the way to make a plugin */ @@ -44,14 +40,14 @@ export abstract class plugin extends EventEmitter { abstract setup_channel(channel: string): Promise | unknown; /** send a message to a given channel */ abstract create_message( - opts: create_message_opts, + opts: create_opts, ): Promise; /** edit a message in a given channel */ abstract edit_message( - opts: edit_message_opts, + opts: edit_opts, ): Promise; /** delete a message in a given channel */ abstract delete_message( - opts: delete_message_opts, + opts: delete_opts, ): Promise; } From 0a156867684953426995dcfa7e7eaae2132ac9f0 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 29 Nov 2024 22:38:31 -0500 Subject: [PATCH 20/97] formatting --- .../src/bridge_to_discord.ts | 120 +++++++------- .../src/discord_message/files.ts | 42 ++--- .../src/discord_message/get_author.ts | 37 +++-- .../src/discord_message/mod.ts | 68 ++++---- .../src/discord_message/reply_embed.ts | 32 ++-- .../src/error_handler.ts | 64 ++++---- packages/lightning-plugin-discord/src/mod.ts | 128 +++++++-------- .../src/slash_commands.ts | 86 +++++----- .../src/to_lightning/command.ts | 56 +++---- .../src/to_lightning/deleted.ts | 14 +- .../src/to_lightning/message.ts | 153 +++++++++--------- packages/lightning/README.md | 4 +- packages/lightning/src/cli.ts | 9 +- packages/lightning/src/mod.ts | 2 +- 14 files changed, 410 insertions(+), 405 deletions(-) diff --git a/packages/lightning-plugin-discord/src/bridge_to_discord.ts b/packages/lightning-plugin-discord/src/bridge_to_discord.ts index f91ae21a..d9668d94 100644 --- a/packages/lightning-plugin-discord/src/bridge_to_discord.ts +++ b/packages/lightning-plugin-discord/src/bridge_to_discord.ts @@ -1,8 +1,4 @@ -import type { - create_opts, - delete_opts, - edit_opts, -} from '@jersey/lightning'; +import type { create_opts, delete_opts, edit_opts } from '@jersey/lightning'; import { message_to_discord } from './discord_message/mod.ts'; import { error_handler } from './error_handler.ts'; import type { API } from '@discordjs/core'; @@ -10,77 +6,77 @@ import type { API } from '@discordjs/core'; type data = { id: string; token: string }; export async function setup_bridge(api: API, channel: string) { - try { - const { id, token } = await api.channels.createWebhook( - channel, - { - name: 'lightning bridge', - }, - ); + try { + const { id, token } = await api.channels.createWebhook( + channel, + { + name: 'lightning bridge', + }, + ); - return { id, token }; - } catch (e) { - return error_handler(e, channel, 'setting up channel'); - } + return { id, token }; + } catch (e) { + return error_handler(e, channel, 'setting up channel'); + } } export async function create_message(api: API, opts: create_opts) { - const data = opts.channel.data as data; - const transformed = await message_to_discord( - opts.msg, - api, - opts.channel.id, - opts.reply_id, - ); + const data = opts.channel.data as data; + const transformed = await message_to_discord( + opts.msg, + api, + opts.channel.id, + opts.reply_id, + ); - try { - const res = await api.webhooks.execute( - data.id, - data.token, - transformed, - ); + try { + const res = await api.webhooks.execute( + data.id, + data.token, + transformed, + ); - return [res.id]; - } catch (e) { - return error_handler(e, opts.channel.id, 'creating message'); - } + return [res.id]; + } catch (e) { + return error_handler(e, opts.channel.id, 'creating message'); + } } export async function edit_message(api: API, opts: edit_opts) { - const data = opts.channel.data as data; - const transformed = await message_to_discord( - opts.msg, - api, - opts.channel.id, - opts.reply_id, - ); + const data = opts.channel.data as data; + const transformed = await message_to_discord( + opts.msg, + api, + opts.channel.id, + opts.reply_id, + ); - try { - await api.webhooks.editMessage( - data.id, - data.token, - opts.edit_ids[0], - transformed, - ); + try { + await api.webhooks.editMessage( + data.id, + data.token, + opts.edit_ids[0], + transformed, + ); - return opts.edit_ids; - } catch (e) { - return error_handler(e, opts.channel.id, 'editing message'); - } + return opts.edit_ids; + } catch (e) { + return error_handler(e, opts.channel.id, 'editing message'); + } } export async function delete_message(api: API, opts: delete_opts) { - const data = opts.channel.data as data; + const data = opts.channel.data as data; - try { - await api.webhooks.deleteMessage( - data.id, - data.token, - opts.edit_ids[0], - ); + try { + await api.webhooks.deleteMessage( + data.id, + data.token, + opts.edit_ids[0], + ); - return opts.edit_ids; - } catch (e) { - return error_handler(e, opts.channel.id, 'editing message'); - } + return opts.edit_ids; + } catch (e) { + return error_handler(e, opts.channel.id, 'editing message'); + } } diff --git a/packages/lightning-plugin-discord/src/discord_message/files.ts b/packages/lightning-plugin-discord/src/discord_message/files.ts index daf4d30d..b5e5e2b8 100644 --- a/packages/lightning-plugin-discord/src/discord_message/files.ts +++ b/packages/lightning-plugin-discord/src/discord_message/files.ts @@ -2,30 +2,30 @@ import type { attachment } from '@jersey/lightning'; import type { RawFile } from '@discordjs/rest'; export async function files_up_to_25MiB(attachments: attachment[] | undefined) { - if (!attachments) return; + if (!attachments) return; - const files: RawFile[] = []; - const total_size = 0; + const files: RawFile[] = []; + const total_size = 0; - for (const attachment of attachments) { - if (attachment.size >= 25) continue; - if (total_size + attachment.size >= 25) break; + for (const attachment of attachments) { + if (attachment.size >= 25) continue; + if (total_size + attachment.size >= 25) break; - try { - const data = new Uint8Array( - await (await fetch(attachment.file, { - signal: AbortSignal.timeout(5000), - })).arrayBuffer(), - ); + try { + const data = new Uint8Array( + await (await fetch(attachment.file, { + signal: AbortSignal.timeout(5000), + })).arrayBuffer(), + ); - files.push({ - name: attachment.name ?? attachment.file.split('/').pop()!, - data, - }); - } catch { - continue; - } - } + files.push({ + name: attachment.name ?? attachment.file.split('/').pop()!, + data, + }); + } catch { + continue; + } + } - return files; + return files; } diff --git a/packages/lightning-plugin-discord/src/discord_message/get_author.ts b/packages/lightning-plugin-discord/src/discord_message/get_author.ts index 1ecab812..1b805bd9 100644 --- a/packages/lightning-plugin-discord/src/discord_message/get_author.ts +++ b/packages/lightning-plugin-discord/src/discord_message/get_author.ts @@ -3,21 +3,30 @@ import type { API } from '@discordjs/core'; import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; export async function get_author(api: API, message: APIMessage) { - let name = message.author.global_name || message.author.username; - let avatar = message.author.avatar ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png` : `https://cdn.discordapp.com/embed/avatars/${calculateUserDefaultAvatarIndex(message.author.id)}.png`; + let name = message.author.global_name || message.author.username; + let avatar = message.author.avatar + ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png` + : `https://cdn.discordapp.com/embed/avatars/${ + calculateUserDefaultAvatarIndex(message.author.id) + }.png`; - const channel = await api.channels.get(message.channel_id); - - if ("guild_id" in channel && channel.guild_id) { - try { - const member = await api.guilds.getMember(channel.guild_id, message.author.id); + const channel = await api.channels.get(message.channel_id); - if (member.nick !== null && member.nick !== undefined) name = member.nick; - avatar = member.avatar ? `https://cdn.discordapp.com/guilds/${channel.guild_id}/users/${message.author.id}/avatars/${member.avatar}.png` : avatar; - } catch { - // safe to ignore - } - } + if ('guild_id' in channel && channel.guild_id) { + try { + const member = await api.guilds.getMember( + channel.guild_id, + message.author.id, + ); - return { name, avatar } + if (member.nick !== null && member.nick !== undefined) name = member.nick; + avatar = member.avatar + ? `https://cdn.discordapp.com/guilds/${channel.guild_id}/users/${message.author.id}/avatars/${member.avatar}.png` + : avatar; + } catch { + // safe to ignore + } + } + + return { name, avatar }; } diff --git a/packages/lightning-plugin-discord/src/discord_message/mod.ts b/packages/lightning-plugin-discord/src/discord_message/mod.ts index eb28b44c..3931cdaa 100644 --- a/packages/lightning-plugin-discord/src/discord_message/mod.ts +++ b/packages/lightning-plugin-discord/src/discord_message/mod.ts @@ -1,52 +1,52 @@ import type { message } from '@jersey/lightning'; import type { API } from '@discordjs/core'; import type { - RESTPostAPIWebhookWithTokenJSONBody, - RESTPostAPIWebhookWithTokenQuery, + RESTPostAPIWebhookWithTokenJSONBody, + RESTPostAPIWebhookWithTokenQuery, } from 'discord-api-types'; import type { RawFile } from '@discordjs/rest'; import { reply_embed } from './reply_embed.ts'; import { files_up_to_25MiB } from './files.ts'; export interface discord_message_send - extends - RESTPostAPIWebhookWithTokenJSONBody, - RESTPostAPIWebhookWithTokenQuery { - files?: RawFile[]; - wait: true; + extends + RESTPostAPIWebhookWithTokenJSONBody, + RESTPostAPIWebhookWithTokenQuery { + files?: RawFile[]; + wait: true; } export async function message_to_discord( - msg: message, - api?: API, - channel?: string, - reply_id?: string, + msg: message, + api?: API, + channel?: string, + reply_id?: string, ): Promise { - const discord: discord_message_send = { - avatar_url: msg.author.profile, - content: (msg.content?.length || 0) > 2000 - ? `${msg.content?.substring(0, 1997)}...` - : msg.content, - embeds: msg.embeds?.map((e) => { - return { ...e, timestamp: e.timestamp?.toString() }; - }), - username: msg.author.username, - wait: true, - }; + const discord: discord_message_send = { + avatar_url: msg.author.profile, + content: (msg.content?.length || 0) > 2000 + ? `${msg.content?.substring(0, 1997)}...` + : msg.content, + embeds: msg.embeds?.map((e) => { + return { ...e, timestamp: e.timestamp?.toString() }; + }), + username: msg.author.username, + wait: true, + }; - if (api && channel && reply_id) { - const embed = await reply_embed(api, channel, reply_id); - if (embed) { - if (!discord.embeds) discord.embeds = []; - discord.embeds.push(embed); - } - } + if (api && channel && reply_id) { + const embed = await reply_embed(api, channel, reply_id); + if (embed) { + if (!discord.embeds) discord.embeds = []; + discord.embeds.push(embed); + } + } - discord.files = await files_up_to_25MiB(msg.attachments); + discord.files = await files_up_to_25MiB(msg.attachments); - if (!discord.content && (!discord.embeds || discord.embeds.length === 0)) { - discord.content = '*empty message*'; - } + if (!discord.content && (!discord.embeds || discord.embeds.length === 0)) { + discord.content = '*empty message*'; + } - return discord; + return discord; } diff --git a/packages/lightning-plugin-discord/src/discord_message/reply_embed.ts b/packages/lightning-plugin-discord/src/discord_message/reply_embed.ts index 39d48cf2..debe8249 100644 --- a/packages/lightning-plugin-discord/src/discord_message/reply_embed.ts +++ b/packages/lightning-plugin-discord/src/discord_message/reply_embed.ts @@ -3,22 +3,22 @@ import type { APIMessage } from 'discord-api-types'; import { get_author } from './get_author.ts'; export async function reply_embed(api: API, channel: string, id: string) { - try { - const message = await api.channels.getMessage( - channel, - id, - ) as APIMessage; + try { + const message = await api.channels.getMessage( + channel, + id, + ) as APIMessage; - const { name, avatar } = await get_author(api, message); + const { name, avatar } = await get_author(api, message); - return { - author: { - name: `replying to ${name}`, - icon_url: avatar, - }, - description: message.content, - }; - } catch { - return; - } + return { + author: { + name: `replying to ${name}`, + icon_url: avatar, + }, + description: message.content, + }; + } catch { + return; + } } diff --git a/packages/lightning-plugin-discord/src/error_handler.ts b/packages/lightning-plugin-discord/src/error_handler.ts index 0486ff02..6f07d129 100644 --- a/packages/lightning-plugin-discord/src/error_handler.ts +++ b/packages/lightning-plugin-discord/src/error_handler.ts @@ -2,37 +2,35 @@ import { DiscordAPIError } from '@discordjs/rest'; import { log_error } from '@jersey/lightning'; export function error_handler(e: unknown, channel_id: string, action: string) { - if (e instanceof DiscordAPIError) { - if (e.code === 30007 || e.code === 30058) { - log_error(e, { - message: - 'too many webhooks in channel/guild. try deleting some', - extra: { channel_id }, - }); - } else if (e.code === 50013) { - log_error(e, { - message: - 'missing permissions to create webhook. check bot permissions', - extra: { channel_id }, - }); - } else if (e.code === 10003 || e.code === 10015 || e.code === 50027) { - log_error(e, { - disable: true, - message: `disabling channel due to error code ${e.code}`, - extra: { channel_id }, - }); - } else if (action === 'editing message' && e.code === 10008) { - return []; // message already deleted or non-existent - } else { - log_error(e, { - message: `unknown error ${action}`, - extra: { channel_id, code: e.code }, - }); - } - } else { - log_error(e, { - message: `unknown error ${action}`, - extra: { channel_id }, - }); - } + if (e instanceof DiscordAPIError) { + if (e.code === 30007 || e.code === 30058) { + log_error(e, { + message: 'too many webhooks in channel/guild. try deleting some', + extra: { channel_id }, + }); + } else if (e.code === 50013) { + log_error(e, { + message: 'missing permissions to create webhook. check bot permissions', + extra: { channel_id }, + }); + } else if (e.code === 10003 || e.code === 10015 || e.code === 50027) { + log_error(e, { + disable: true, + message: `disabling channel due to error code ${e.code}`, + extra: { channel_id }, + }); + } else if (action === 'editing message' && e.code === 10008) { + return []; // message already deleted or non-existent + } else { + log_error(e, { + message: `unknown error ${action}`, + extra: { channel_id, code: e.code }, + }); + } + } else { + log_error(e, { + message: `unknown error ${action}`, + extra: { channel_id }, + }); + } } diff --git a/packages/lightning-plugin-discord/src/mod.ts b/packages/lightning-plugin-discord/src/mod.ts index c4446c8b..2f1471b3 100644 --- a/packages/lightning-plugin-discord/src/mod.ts +++ b/packages/lightning-plugin-discord/src/mod.ts @@ -2,11 +2,11 @@ import { Client } from '@discordjs/core'; import { REST } from '@discordjs/rest'; import { WebSocketManager } from '@discordjs/ws'; import { - type create_opts, - type delete_opts, - type edit_opts, - type lightning, - plugin, + type create_opts, + type delete_opts, + type edit_opts, + type lightning, + plugin, } from '@jersey/lightning'; import { GatewayDispatchEvents } from 'discord-api-types'; import * as bridge from './bridge_to_discord.ts'; @@ -17,73 +17,73 @@ import { message } from './to_lightning/message.ts'; /** configuration for the discord plugin */ export interface discord_config { - /** the discord bot token */ - token: string; - /** whether to enable slash commands */ - slash_commands: boolean; - /** discord application id */ - application_id: string; + /** the discord bot token */ + token: string; + /** whether to enable slash commands */ + slash_commands: boolean; + /** discord application id */ + application_id: string; } export class discord_plugin extends plugin { - name = 'bolt-discord'; - private api: Client['api']; - private client: Client; + name = 'bolt-discord'; + private api: Client['api']; + private client: Client; - constructor(l: lightning, config: discord_config) { - super(l, config); - // @ts-ignore their type for makeRequest is funky - const rest = new REST({ version: '10', makeRequest: fetch }).setToken( - config.token, - ); - const gateway = new WebSocketManager({ - token: config.token, - intents: 0 | 33281, - rest, - }); - - this.client = new Client({ rest, gateway }); - this.api = this.client.api; + constructor(l: lightning, config: discord_config) { + super(l, config); + // @ts-ignore their type for makeRequest is funky + const rest = new REST({ version: '10', makeRequest: fetch }).setToken( + config.token, + ); + const gateway = new WebSocketManager({ + token: config.token, + intents: 0 | 33281, + rest, + }); - setup_slash_commands(this.api, config, l); - this.setup_events(); - gateway.connect(); - } + this.client = new Client({ rest, gateway }); + this.api = this.client.api; - private setup_events() { - this.client.once(GatewayDispatchEvents.Ready, ({data}) => { - console.log( - `bolt-discord: ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, - ); - }); - this.client.on(GatewayDispatchEvents.MessageCreate, async (msg) => { - this.emit('create_message', await message(msg.api, msg.data)); - }); - this.client.on(GatewayDispatchEvents.MessageUpdate, async (msg) => { - this.emit('edit_message', await message(msg.api, msg.data)); - }); - this.client.on(GatewayDispatchEvents.MessageDelete, (msg) => { - this.emit('delete_message', deleted(msg.data)); - }); - this.client.on(GatewayDispatchEvents.InteractionCreate, (cmd) => { - const command = command_to(cmd, this.lightning); - if (command) this.emit('create_command', command); - }); - } + setup_slash_commands(this.api, config, l); + this.setup_events(); + gateway.connect(); + } - async setup_channel(channel: string) { - return await bridge.setup_bridge(this.api, channel); - } + private setup_events() { + this.client.once(GatewayDispatchEvents.Ready, ({ data }) => { + console.log( + `bolt-discord: ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, + ); + }); + this.client.on(GatewayDispatchEvents.MessageCreate, async (msg) => { + this.emit('create_message', await message(msg.api, msg.data)); + }); + this.client.on(GatewayDispatchEvents.MessageUpdate, async (msg) => { + this.emit('edit_message', await message(msg.api, msg.data)); + }); + this.client.on(GatewayDispatchEvents.MessageDelete, (msg) => { + this.emit('delete_message', deleted(msg.data)); + }); + this.client.on(GatewayDispatchEvents.InteractionCreate, (cmd) => { + const command = command_to(cmd, this.lightning); + if (command) this.emit('create_command', command); + }); + } - async create_message(opts: create_opts) { - return await bridge.create_message(this.api, opts); - } + async setup_channel(channel: string) { + return await bridge.setup_bridge(this.api, channel); + } - async edit_message(opts: edit_opts) { - return await bridge.edit_message(this.api, opts); - } + async create_message(opts: create_opts) { + return await bridge.create_message(this.api, opts); + } - async delete_message(opts: delete_opts) { - return await bridge.delete_message(this.api, opts); - } + async edit_message(opts: edit_opts) { + return await bridge.edit_message(this.api, opts); + } + + async delete_message(opts: delete_opts) { + return await bridge.delete_message(this.api, opts); + } } diff --git a/packages/lightning-plugin-discord/src/slash_commands.ts b/packages/lightning-plugin-discord/src/slash_commands.ts index bdbffe43..caf52c40 100644 --- a/packages/lightning-plugin-discord/src/slash_commands.ts +++ b/packages/lightning-plugin-discord/src/slash_commands.ts @@ -3,56 +3,56 @@ import type { API } from '@discordjs/core'; import type { discord_config } from './mod.ts'; export async function setup_slash_commands( - api: API, - config: discord_config, - lightning: lightning, + api: API, + config: discord_config, + lightning: lightning, ) { - if (!config.slash_commands) return; + if (!config.slash_commands) return; - const commands = lightning.commands.values().toArray(); + const commands = lightning.commands.values().toArray(); - await api.applicationCommands.bulkOverwriteGlobalCommands( - config.application_id, - commands_to_discord(commands) - ); + await api.applicationCommands.bulkOverwriteGlobalCommands( + config.application_id, + commands_to_discord(commands), + ); } function commands_to_discord(commands: command[]) { - return commands.map((command) => { - const opts = []; + return commands.map((command) => { + const opts = []; - if (command.arguments) { - for (const argument of command.arguments) { - opts.push({ - name: argument.name, - description: argument.description, - type: 3, - required: argument.required, - }); - } - } + if (command.arguments) { + for (const argument of command.arguments) { + opts.push({ + name: argument.name, + description: argument.description, + type: 3, + required: argument.required, + }); + } + } - if (command.subcommands) { - for (const subcommand of command.subcommands) { - opts.push({ - name: subcommand.name, - description: subcommand.description, - type: 1, - options: subcommand.arguments?.map((opt) => ({ - name: opt.name, - description: opt.description, - type: 3, - required: opt.required, - })), - }); - } - } + if (command.subcommands) { + for (const subcommand of command.subcommands) { + opts.push({ + name: subcommand.name, + description: subcommand.description, + type: 1, + options: subcommand.arguments?.map((opt) => ({ + name: opt.name, + description: opt.description, + type: 3, + required: opt.required, + })), + }); + } + } - return { - name: command.name, - type: 1, - description: command.description, - options: opts, - }; - }); + return { + name: command.name, + type: 1, + description: command.description, + options: opts, + }; + }); } diff --git a/packages/lightning-plugin-discord/src/to_lightning/command.ts b/packages/lightning-plugin-discord/src/to_lightning/command.ts index 24dc9712..aaf56e58 100644 --- a/packages/lightning-plugin-discord/src/to_lightning/command.ts +++ b/packages/lightning-plugin-discord/src/to_lightning/command.ts @@ -4,35 +4,35 @@ import type { create_command, lightning } from '@jersey/lightning'; import { message_to_discord } from '../discord_message/mod.ts'; export function command_to( - interaction: { api: API; data: APIInteraction }, - lightning: lightning, + interaction: { api: API; data: APIInteraction }, + lightning: lightning, ) { - if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; - const opts = {} as Record; - let subcmd; + if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; + const opts = {} as Record; + let subcmd; - for (const opt of interaction.data.data.options || []) { - if (opt.type === 1) subcmd = opt.name; - if (opt.type === 3) opts[opt.name] = opt.value; - } + for (const opt of interaction.data.data.options || []) { + if (opt.type === 1) subcmd = opt.name; + if (opt.type === 3) opts[opt.name] = opt.value; + } - return { - command: interaction.data.data.name, - subcommand: subcmd, - channel: interaction.data.channel.id, - id: interaction.data.id, - timestamp: Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, - ), - lightning, - plugin: 'bolt-discord', - reply: async (msg) => { - await interaction.api.interactions.reply( - interaction.data.id, - interaction.data.token, - await message_to_discord(msg), - ); - }, - args: opts, - } as create_command; + return { + command: interaction.data.data.name, + subcommand: subcmd, + channel: interaction.data.channel.id, + id: interaction.data.id, + timestamp: Temporal.Instant.fromEpochMilliseconds( + Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, + ), + lightning, + plugin: 'bolt-discord', + reply: async (msg) => { + await interaction.api.interactions.reply( + interaction.data.id, + interaction.data.token, + await message_to_discord(msg), + ); + }, + args: opts, + } as create_command; } diff --git a/packages/lightning-plugin-discord/src/to_lightning/deleted.ts b/packages/lightning-plugin-discord/src/to_lightning/deleted.ts index 93059c24..30fe17c7 100644 --- a/packages/lightning-plugin-discord/src/to_lightning/deleted.ts +++ b/packages/lightning-plugin-discord/src/to_lightning/deleted.ts @@ -2,12 +2,12 @@ import type { GatewayMessageDeleteDispatchData } from 'discord-api-types'; import type { deleted_message } from '@jersey/lightning'; export function deleted( - message: GatewayMessageDeleteDispatchData, + message: GatewayMessageDeleteDispatchData, ): deleted_message { - return { - channel: message.channel_id, - id: message.id, - plugin: 'bolt-discord', - timestamp: Temporal.Now.instant(), - } + return { + channel: message.channel_id, + id: message.id, + plugin: 'bolt-discord', + timestamp: Temporal.Now.instant(), + }; } diff --git a/packages/lightning-plugin-discord/src/to_lightning/message.ts b/packages/lightning-plugin-discord/src/to_lightning/message.ts index 370d116c..62d551b9 100644 --- a/packages/lightning-plugin-discord/src/to_lightning/message.ts +++ b/packages/lightning-plugin-discord/src/to_lightning/message.ts @@ -5,87 +5,86 @@ import { message_to_discord } from '../discord_message/mod.ts'; import type { message } from '@jersey/lightning'; export async function message( - api: API, - message: APIMessage, + api: API, + message: APIMessage, ): Promise { - if (message.flags && message.flags & 128) message.content = 'Loading...'; + if (message.flags && message.flags & 128) message.content = 'Loading...'; - if (message.type === 7) message.content = '*joined on discord*'; + if (message.type === 7) message.content = '*joined on discord*'; - if (message.sticker_items) { - if (!message.attachments) message.attachments = []; - for (const sticker of message.sticker_items) { - let type; - if (sticker.format_type === 1) type = 'png'; - if (sticker.format_type === 2) type = 'apng'; - if (sticker.format_type === 3) type = 'lottie'; - if (sticker.format_type === 4) type = 'gif'; - const url = - `https://media.discordapp.net/stickers/${sticker.id}.${type}`; - const req = await fetch(url, { method: 'HEAD' }); - if (req.ok) { - message.attachments.push({ - url, - description: sticker.name, - filename: `${sticker.name}.${type}`, - size: 0, - id: sticker.id, - proxy_url: url, - }); - } else { - message.content = '*used sticker*'; - } - } - } + if (message.sticker_items) { + if (!message.attachments) message.attachments = []; + for (const sticker of message.sticker_items) { + let type; + if (sticker.format_type === 1) type = 'png'; + if (sticker.format_type === 2) type = 'apng'; + if (sticker.format_type === 3) type = 'lottie'; + if (sticker.format_type === 4) type = 'gif'; + const url = `https://media.discordapp.net/stickers/${sticker.id}.${type}`; + const req = await fetch(url, { method: 'HEAD' }); + if (req.ok) { + message.attachments.push({ + url, + description: sticker.name, + filename: `${sticker.name}.${type}`, + size: 0, + id: sticker.id, + proxy_url: url, + }); + } else { + message.content = '*used sticker*'; + } + } + } - const { name, avatar } = await get_author(api, message); + const { name, avatar } = await get_author(api, message); - const data = { - author: { - profile: avatar, - username: name, - rawname: message.author.username, - id: message.author.id, - color: '#5865F2', - }, - channel: message.channel_id, - content: (message.content?.length || 0) > 2000 - ? `${message.content?.substring(0, 1997)}...` - : message.content, - id: message.id, - timestamp: Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(message.id) >> 22n) + 1420070400000, - ), - embeds: message.embeds?.map( - (i: Exclude[0]) => { - return { - ...i, - timestamp: i.timestamp ? Number(i.timestamp) : undefined, - }; - }, - ), - reply: async (msg: message) => { - if (!data.author.id || data.author.id === '') return; - await api.channels.createMessage(message.channel_id, { - ...(await message_to_discord(msg)), - message_reference: { - message_id: message.id, - }, - }); - }, - plugin: 'bolt-discord', - attachments: message.attachments?.map( - (i: Exclude[0]) => { - return { - file: i.url, - alt: i.description, - name: i.filename, - size: i.size / 1048576, // bytes -> MiB - }; - }, - ), - reply_id: message.referenced_message?.id, - }; + const data = { + author: { + profile: avatar, + username: name, + rawname: message.author.username, + id: message.author.id, + color: '#5865F2', + }, + channel: message.channel_id, + content: (message.content?.length || 0) > 2000 + ? `${message.content?.substring(0, 1997)}...` + : message.content, + id: message.id, + timestamp: Temporal.Instant.fromEpochMilliseconds( + Number(BigInt(message.id) >> 22n) + 1420070400000, + ), + embeds: message.embeds?.map( + (i: Exclude[0]) => { + return { + ...i, + timestamp: i.timestamp ? Number(i.timestamp) : undefined, + }; + }, + ), + reply: async (msg: message) => { + if (!data.author.id || data.author.id === '') return; + await api.channels.createMessage(message.channel_id, { + ...(await message_to_discord(msg)), + message_reference: { + message_id: message.id, + }, + }); + }, + plugin: 'bolt-discord', + attachments: message.attachments?.map( + (i: Exclude[0]) => { + return { + file: i.url, + alt: i.description, + name: i.filename, + size: i.size / 1048576, // bytes -> MiB + }; + }, + ), + reply_id: message.referenced_message?.id, + }; - return data as message; + return data as message; } diff --git a/packages/lightning/README.md b/packages/lightning/README.md index a61f60c7..5778ea78 100644 --- a/packages/lightning/README.md +++ b/packages/lightning/README.md @@ -8,6 +8,6 @@ apps via plugins ## [docs](https://williamhorning.eu.org/bolt) ```ts -import {} from "@jersey/lightning"; +import {} from '@jersey/lightning'; // TODO(jersey): add example -``` \ No newline at end of file +``` diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index f89d0a77..69381d99 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -1,6 +1,6 @@ import { parseArgs } from '@std/cli/parse-args'; import { join, toFileUrl } from '@std/path'; -import { lightning, type config } from './lightning.ts'; +import { type config, lightning } from './lightning.ts'; import { log_error } from './structures/errors.ts'; const version = '0.8.0'; @@ -14,9 +14,12 @@ if (_.v || _.version) { // TODO(jersey): this is somewhat broken when acting as a JSR package if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); - const config = (await import(toFileUrl(_.config).toString())).default as config; + const config = (await import(toFileUrl(_.config).toString())) + .default as config; - if (config?.error_url) Deno.env.set('LIGHTNING_ERROR_WEBHOOK', config.error_url); + if (config?.error_url) { + Deno.env.set('LIGHTNING_ERROR_WEBHOOK', config.error_url); + } addEventListener('error', (ev) => { log_error(ev.error, { extra: { type: 'global error' } }); diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index 4cb23fc9..e95da6ec 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -1,5 +1,5 @@ if (import.meta.main) { - await import('./cli.ts'); + await import('./cli.ts'); } export * from './lightning.ts'; From aae813579fc8a9f80e81128bef519ad88f5e0603 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 29 Nov 2024 23:22:41 -0500 Subject: [PATCH 21/97] split file proxy --- .../src/file_proxy.ts | 20 ++++++++++++++++ packages/lightning-plugin-telegram/src/mod.ts | 24 +++---------------- 2 files changed, 23 insertions(+), 21 deletions(-) create mode 100644 packages/lightning-plugin-telegram/src/file_proxy.ts diff --git a/packages/lightning-plugin-telegram/src/file_proxy.ts b/packages/lightning-plugin-telegram/src/file_proxy.ts new file mode 100644 index 00000000..f33c82e3 --- /dev/null +++ b/packages/lightning-plugin-telegram/src/file_proxy.ts @@ -0,0 +1,20 @@ +import type { telegram_config } from './mod.ts'; + +export function file_proxy(config: telegram_config) { + Deno.serve({ + port: config.plugin_port, + onListen: (addr) => { + console.log( + `bolt-telegram: file proxy listening on http://localhost:${addr.port}`, + `\nbolt-telegram: also available at: ${config.plugin_url}`, + ); + }, + }, (req: Request) => { + const { pathname } = new URL(req.url); + return fetch( + `https://api.telegram.org/file/bot${config.bot_token}/${ + pathname.replace('/telegram/', '') + }`, + ); + }); +} \ No newline at end of file diff --git a/packages/lightning-plugin-telegram/src/mod.ts b/packages/lightning-plugin-telegram/src/mod.ts index d73763eb..8e0aed5e 100644 --- a/packages/lightning-plugin-telegram/src/mod.ts +++ b/packages/lightning-plugin-telegram/src/mod.ts @@ -7,6 +7,7 @@ import { } from '@jersey/lightning'; import { Bot } from 'grammy'; import { from_lightning, from_telegram } from './messages.ts'; +import { file_proxy } from './file_proxy.ts'; /** options for the telegram plugin */ export type telegram_config = { @@ -21,7 +22,7 @@ export type telegram_config = { /** the plugin to use */ export class telegram_plugin extends plugin { name = 'bolt-telegram'; - private bot: Bot; + bot: Bot; constructor(l: lightning, cfg: telegram_config) { super(l, cfg); @@ -37,29 +38,10 @@ export class telegram_plugin extends plugin { this.emit('edit_message', msg); }); // turns out it's impossible to deal with messages being deleted due to tdlib/telegram-bot-api#286 - this.serve_proxy(); + file_proxy(cfg); this.bot.start(); } - private serve_proxy() { - Deno.serve({ - port: this.config.plugin_port, - onListen: (addr) => { - console.log( - `bolt-telegram: file proxy listening on http://localhost:${addr.port}`, - `bolt-telegram: also available at: ${this.config.plugin_url}`, - ); - }, - }, (req: Request) => { - const { pathname } = new URL(req.url); - return fetch( - `https://api.telegram.org/file/bot${this.bot.token}/${ - pathname.replace('/telegram/', '') - }`, - ); - }); - } - /** create a bridge */ setup_channel(channel: string) { return channel; From c1e27e5a061ab46dc865bc3e1397ce45180a6c91 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 29 Nov 2024 23:26:24 -0500 Subject: [PATCH 22/97] fixes and stuff --- .../src/discord_message/get_author.ts | 22 +++++++++++++------ .../src/to_lightning/message.ts | 8 +++---- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/packages/lightning-plugin-discord/src/discord_message/get_author.ts b/packages/lightning-plugin-discord/src/discord_message/get_author.ts index 1b805bd9..fbc262c9 100644 --- a/packages/lightning-plugin-discord/src/discord_message/get_author.ts +++ b/packages/lightning-plugin-discord/src/discord_message/get_author.ts @@ -1,25 +1,33 @@ -import type { APIMessage } from 'discord-api-types'; +import type { GatewayMessageUpdateDispatchData } from 'discord-api-types'; import type { API } from '@discordjs/core'; import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; -export async function get_author(api: API, message: APIMessage) { - let name = message.author.global_name || message.author.username; - let avatar = message.author.avatar +export async function get_author( + api: API, + message: GatewayMessageUpdateDispatchData, +) { + let name = message.author?.global_name || message.author?.username || + 'discord user'; + let avatar = message.author?.avatar ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png` : `https://cdn.discordapp.com/embed/avatars/${ - calculateUserDefaultAvatarIndex(message.author.id) + calculateUserDefaultAvatarIndex( + message.author?.id || '360005875697582081', + ) }.png`; const channel = await api.channels.get(message.channel_id); - if ('guild_id' in channel && channel.guild_id) { + if ('guild_id' in channel && channel.guild_id && message.author) { try { const member = await api.guilds.getMember( channel.guild_id, message.author.id, ); - if (member.nick !== null && member.nick !== undefined) name = member.nick; + if (member.nick !== null && member.nick !== undefined) { + name = member.nick; + } avatar = member.avatar ? `https://cdn.discordapp.com/guilds/${channel.guild_id}/users/${message.author.id}/avatars/${member.avatar}.png` : avatar; diff --git a/packages/lightning-plugin-discord/src/to_lightning/message.ts b/packages/lightning-plugin-discord/src/to_lightning/message.ts index 62d551b9..b038058c 100644 --- a/packages/lightning-plugin-discord/src/to_lightning/message.ts +++ b/packages/lightning-plugin-discord/src/to_lightning/message.ts @@ -1,12 +1,12 @@ import type { API } from '@discordjs/core'; -import type { APIMessage } from 'discord-api-types'; +import type { GatewayMessageUpdateDispatchData } from 'discord-api-types'; import { get_author } from '../discord_message/get_author.ts'; import { message_to_discord } from '../discord_message/mod.ts'; import type { message } from '@jersey/lightning'; export async function message( api: API, - message: APIMessage, + message: GatewayMessageUpdateDispatchData, ): Promise { if (message.flags && message.flags & 128) message.content = 'Loading...'; @@ -43,8 +43,8 @@ export async function message( author: { profile: avatar, username: name, - rawname: message.author.username, - id: message.author.id, + rawname: message.author?.username || 'discord user', + id: message.author?.id || message.webhook_id || '', color: '#5865F2', }, channel: message.channel_id, From 71741ac2f8eec62e2746f99c735650ccc13be5de Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 1 Dec 2024 13:32:41 -0500 Subject: [PATCH 23/97] use_rawname --- packages/lightning/src/bridge.ts | 6 +++++- packages/lightning/src/structures/bridge.ts | 2 -- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/lightning/src/bridge.ts b/packages/lightning/src/bridge.ts index 3cca33de..605380aa 100644 --- a/packages/lightning/src/bridge.ts +++ b/packages/lightning/src/bridge.ts @@ -25,11 +25,15 @@ export async function bridge_message( if (!bridge) return; - // if editing isn't allowed, return + // handle bridge settings if (event !== 'create_message' && bridge.settings.allow_editing !== true) { return; } + if (bridge.settings.use_rawname && "author" in data) data.author.username = data.author.rawname; + + // TODO(jersey): implement allow_everyone here + // if the channel this event is from is disabled, return if ( bridge.channels.find((channel) => diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index d02b59a3..4f847f3c 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -24,8 +24,6 @@ export interface bridge_channel { plugin: string; } -// TODO(jersey): implement allow_everyone and use_rawname settings - /** possible settings for a bridge */ export interface bridge_settings { /** allow editing/deletion */ From d5eb7c2abc0abde703bfd8f6003540b6c85063bf Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 15 Dec 2024 20:15:15 -0500 Subject: [PATCH 24/97] guilded and revolt stuff --- packages/lightning-plugin-discord/src/mod.ts | 2 +- .../src/error_handler.ts | 32 +++ .../lightning-plugin-guilded/src/guilded.ts | 197 ++++++++---------- .../src/guilded_message/author.ts | 53 +++++ .../src/guilded_message/map_embed.ts | 30 +++ .../src/guilded_message/mod.ts | 36 ++++ .../lightning-plugin-guilded/src/messages.ts | 66 ------ packages/lightning-plugin-guilded/src/mod.ts | 121 ++++++----- packages/lightning-plugin-revolt/deno.json | 2 +- .../src/error_handler.ts | 32 +++ .../lightning-plugin-revolt/src/messages.ts | 160 -------------- packages/lightning-plugin-revolt/src/mod.ts | 155 ++++++-------- .../src/permissions.ts | 141 +++++++------ .../src/to_lightning.ts | 87 ++++++++ .../lightning-plugin-revolt/src/to_revolt.ts | 76 +++++++ .../src/file_proxy.ts | 4 +- packages/lightning/src/bridge.ts | 4 +- packages/lightning/src/structures/bridge.ts | 6 + packages/lightning/src/structures/errors.ts | 2 +- 19 files changed, 645 insertions(+), 561 deletions(-) create mode 100644 packages/lightning-plugin-guilded/src/error_handler.ts create mode 100644 packages/lightning-plugin-guilded/src/guilded_message/author.ts create mode 100644 packages/lightning-plugin-guilded/src/guilded_message/map_embed.ts create mode 100644 packages/lightning-plugin-guilded/src/guilded_message/mod.ts delete mode 100644 packages/lightning-plugin-guilded/src/messages.ts create mode 100644 packages/lightning-plugin-revolt/src/error_handler.ts delete mode 100644 packages/lightning-plugin-revolt/src/messages.ts create mode 100644 packages/lightning-plugin-revolt/src/to_lightning.ts create mode 100644 packages/lightning-plugin-revolt/src/to_revolt.ts diff --git a/packages/lightning-plugin-discord/src/mod.ts b/packages/lightning-plugin-discord/src/mod.ts index 2f1471b3..d0050e73 100644 --- a/packages/lightning-plugin-discord/src/mod.ts +++ b/packages/lightning-plugin-discord/src/mod.ts @@ -53,7 +53,7 @@ export class discord_plugin extends plugin { private setup_events() { this.client.once(GatewayDispatchEvents.Ready, ({ data }) => { console.log( - `bolt-discord: ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, + `[bolt-discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, ); }); this.client.on(GatewayDispatchEvents.MessageCreate, async (msg) => { diff --git a/packages/lightning-plugin-guilded/src/error_handler.ts b/packages/lightning-plugin-guilded/src/error_handler.ts new file mode 100644 index 00000000..fb6dade7 --- /dev/null +++ b/packages/lightning-plugin-guilded/src/error_handler.ts @@ -0,0 +1,32 @@ +import { log_error } from '@jersey/lightning'; +import { GuildedAPIError } from 'guilded.js'; + +export function error_handler(e: unknown, channel_id: string, action: string) { + if (e instanceof GuildedAPIError) { + if (e.response.status === 404) { + if (action === 'deleting message') return []; + + log_error(e, { + message: 'resource not found! if you\'re trying to make a bridge, this is likely an issue with Guilded', + extra: { channel_id, response: e.response }, + disable: true, + }); + } else if (e.response.status === 403) { + log_error(e, { + message: 'no permission to send/delete messages! check bot permissions', + extra: { channel_id, response: e.response }, + disable: true, + }); + } else { + log_error(e, { + message: `unknown guilded error ${action} with status code ${e.response.status}`, + extra: { channel_id, response: e.response } + }) + } + } else { + log_error(e, { + message: `unknown error ${action}`, + extra: { channel_id }, + }); + } +} diff --git a/packages/lightning-plugin-guilded/src/guilded.ts b/packages/lightning-plugin-guilded/src/guilded.ts index b365bd9d..9026e58f 100644 --- a/packages/lightning-plugin-guilded/src/guilded.ts +++ b/packages/lightning-plugin-guilded/src/guilded.ts @@ -1,135 +1,106 @@ -import type { EmbedPayload, RESTPostWebhookBody } from '@guildedjs/api'; import type { embed, message } from '@jersey/lightning'; -import type { Client } from 'guilded.js'; -import type { guilded_plugin } from './mod.ts'; +import type { Client, EmbedPayload, WebhookMessageContent } from 'guilded.js'; -export async function create_webhook( - bot: Client, - channel: string, - token: string, -) { - const ch = await bot.channels.fetch(channel); - const resp = await fetch( - `https://www.guilded.gg/api/v1/servers/${ch.serverId}/webhooks`, - { - body: `{"name":"Lightning Bridges","channelId":"${channel}"}`, - headers: { - Accept: 'application/json', - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - method: 'POST', - }, - ); - if (!resp.ok) { - throw new Error('Webhook creation failed!', { - cause: await resp.text(), - }); - } - const wh = await resp.json(); - return { id: wh.webhook.id, token: wh.webhook.token }; -} - -type guilded_msg = RESTPostWebhookBody & { replyMessageIds?: string[] }; +type guilded_msg = Exclude & { replyMessageIds?: string[] }; export async function convert_msg( - msg: message, - channel?: string, - plugin?: guilded_plugin, + msg: message, + channel?: string, + bot?: Client, ): Promise { - const message = { - content: msg.content, - avatar_url: msg.author.profile, - username: get_valid_username(msg), - embeds: [ - ...fix_embed(msg.embeds), - ...(await get_reply_embeds(msg, channel, plugin)), - ], - } as guilded_msg; + const message = { + content: msg.content, + avatar_url: msg.author.profile, + username: get_valid_username(msg), + embeds: [ + ...fix_embed(msg.embeds), + ...(await get_reply_embeds(msg, channel, bot)), + ], + } as guilded_msg; - if (msg.reply_id) message.replyMessageIds = [msg.reply_id]; + if (msg.reply_id) message.replyMessageIds = [msg.reply_id]; - if (msg.attachments?.length) { - if (!message.embeds) message.embeds = []; - message.embeds.push({ - title: 'attachments', - description: msg.attachments - .slice(0, 5) - .map((a) => { - return `![${a.alt || a.name}](${a.file})`; - }) - .join('\n'), - }); - } + if (msg.attachments?.length) { + if (!message.embeds) message.embeds = []; + message.embeds.push({ + title: 'attachments', + description: msg.attachments + .slice(0, 5) + .map((a) => { + return `![${a.alt || a.name}](${a.file})`; + }) + .join('\n'), + }); + } - if (message.embeds?.length === 0 || !message.embeds) delete message.embeds; + if (message.embeds?.length === 0 || !message.embeds) delete message.embeds; - if (!message.content && !message.embeds) message.content = '*empty message*'; + if (!message.content && !message.embeds) message.content = '*empty message*'; - return message; + return message; } function get_valid_username(msg: message) { - function valid(e: string) { - if (!e || e.length === 0 || e.length > 25) return false; - return /^[a-zA-Z0-9_ ()-]*$/gms.test(e); - } + function valid(e: string) { + if (!e || e.length === 0 || e.length > 25) return false; + return /^[a-zA-Z0-9_ ()-]*$/gms.test(e); + } - if (valid(msg.author.username)) { - return msg.author.username; - } else if (valid(msg.author.rawname)) { - return msg.author.rawname; - } else { - return `${msg.author.id}`; - } + if (valid(msg.author.username)) { + return msg.author.username; + } else if (valid(msg.author.rawname)) { + return msg.author.rawname; + } else { + return `${msg.author.id}`; + } } async function get_reply_embeds( - msg: message, - channel?: string, - plugin?: guilded_plugin, + msg: message, + channel?: string, + bot?: Client, ) { - if (!msg.reply_id || !channel || !plugin) return []; - try { - const msg_replied_to = await plugin.bot.messages.fetch( - channel, - msg.reply_id, - ); - let author; - if (!msg_replied_to.createdByWebhookId) { - author = await plugin.bot.members.fetch( - msg_replied_to.serverId!, - msg_replied_to.authorId, - ); - } - return [ - { - author: { - name: `reply to ${author?.nickname || author?.username || 'a user'}`, - icon_url: author?.user?.avatar || undefined, - }, - description: msg_replied_to.content, - }, - ...(msg_replied_to.embeds || []), - ] as EmbedPayload[]; - } catch { - return []; - } + if (!msg.reply_id || !channel || !bot) return []; + try { + const msg_replied_to = await bot.messages.fetch( + channel, + msg.reply_id, + ); + let author; + if (!msg_replied_to.createdByWebhookId) { + author = await bot.members.fetch( + msg_replied_to.serverId!, + msg_replied_to.authorId, + ); + } + return [ + { + author: { + name: `reply to ${author?.nickname || author?.username || 'a user'}`, + icon_url: author?.user?.avatar || undefined, + }, + description: msg_replied_to.content, + }, + ...(msg_replied_to.embeds || []), + ] as EmbedPayload[]; + } catch { + return []; + } } function fix_embed(embeds: embed[] = []) { - return embeds.flatMap((embed) => { - Object.keys(embed).forEach((key) => { - embed[key as keyof embed] === null - ? (embed[key as keyof embed] = undefined) - : embed[key as keyof embed]; - }); - if (!embed.description || embed.description === '') return []; - return [ - { - ...embed, - timestamp: embed.timestamp ? String(embed.timestamp) : undefined, - }, - ]; - }) as (EmbedPayload & { timestamp: string })[]; + return embeds.flatMap((embed) => { + Object.keys(embed).forEach((key) => { + embed[key as keyof embed] === null + ? (embed[key as keyof embed] = undefined) + : embed[key as keyof embed]; + }); + if (!embed.description || embed.description === '') return []; + return [ + { + ...embed, + timestamp: embed.timestamp ? String(embed.timestamp) : undefined, + }, + ]; + }) as (EmbedPayload & { timestamp: string })[]; } diff --git a/packages/lightning-plugin-guilded/src/guilded_message/author.ts b/packages/lightning-plugin-guilded/src/guilded_message/author.ts new file mode 100644 index 00000000..3ee7cb4f --- /dev/null +++ b/packages/lightning-plugin-guilded/src/guilded_message/author.ts @@ -0,0 +1,53 @@ +import type { message_author } from '@jersey/lightning'; +import type { Client, Message } from 'guilded.js'; + +export async function get_author( + msg: Message, + bot: Client, +): Promise { + if (!msg.createdByWebhookId && msg.authorId !== 'Ann6LewA') { + try { + const au = await bot.members.fetch( + msg.serverId!, + msg.authorId, + ); + + return { + username: au.nickname || au.username || au.user?.name || 'Guilded User', + rawname: au.username || au.user?.name || 'Guilded User', + id: msg.authorId, + profile: au.user?.avatar || undefined, + }; + } catch { + return { + username: 'Guilded User', + rawname: 'GuildedUser', + id: msg.authorId, + }; + } + } else if (msg.createdByWebhookId) { + // try to fetch webhook? + try { + const wh = await bot.webhooks.fetch(msg.serverId!, msg.createdByWebhookId); + + return { + username: wh.name, + rawname: wh.name, + id: wh.id, + profile: wh.raw.avatar, + }; + } catch { + return { + username: 'Guilded Webhook', + rawname: 'GuildedWebhook', + id: msg.createdByWebhookId, + }; + } + } else { + return { + username: 'Guilded User', + rawname: 'GuildedUser', + id: msg.authorId, + }; + } +} diff --git a/packages/lightning-plugin-guilded/src/guilded_message/map_embed.ts b/packages/lightning-plugin-guilded/src/guilded_message/map_embed.ts new file mode 100644 index 00000000..cdce8281 --- /dev/null +++ b/packages/lightning-plugin-guilded/src/guilded_message/map_embed.ts @@ -0,0 +1,30 @@ +import type { embed } from '@jersey/lightning'; +import type { Embed } from 'guilded.js'; + +export function map_embed(embed: Embed): embed { + return { + ...embed, + author: embed.author + ? { + name: embed.author.name || 'embed author', + icon_url: embed.author.iconURL || undefined, + url: embed.author.url || undefined, + } + : undefined, + image: embed.image || undefined, + thumbnail: embed.thumbnail || undefined, + timestamp: embed.timestamp ? Number(embed.timestamp) : undefined, + color: embed.color || undefined, + description: embed.description || undefined, + fields: embed.fields.map((i) => { + return { + ...i, + inline: i.inline || undefined, + }; + }), + footer: embed.footer || undefined, + title: embed.title || undefined, + url: embed.url || undefined, + video: embed.video || undefined, + }; +} diff --git a/packages/lightning-plugin-guilded/src/guilded_message/mod.ts b/packages/lightning-plugin-guilded/src/guilded_message/mod.ts new file mode 100644 index 00000000..a9e52d57 --- /dev/null +++ b/packages/lightning-plugin-guilded/src/guilded_message/mod.ts @@ -0,0 +1,36 @@ +import type { message } from '@jersey/lightning'; +import type { Client, Message } from 'guilded.js'; +import { convert_msg } from '../guilded.ts'; +import { get_author } from './author.ts'; +import { map_embed } from './map_embed.ts'; + +export async function guilded_to_message( + msg: Message, + bot: Client, +): Promise { + if (msg.serverId === null) return; + + const author = await get_author(msg, bot); + + const timestamp = Temporal.Instant.fromEpochMilliseconds( + msg.createdAt.valueOf(), + ); + + return { + author: { + ...author, + color: '#F5C400', + }, + channel: msg.channelId, + id: msg.id, + timestamp, + embeds: msg.embeds?.map(map_embed), + plugin: 'bolt-guilded', + reply: async (reply: message) => { + await msg.reply(await convert_msg(reply)); + }, + content: msg.content.replaceAll('\n```\n```\n', '\n'), + reply_id: msg.isReply ? msg.replyMessageIds[0] : undefined, + }; + +} diff --git a/packages/lightning-plugin-guilded/src/messages.ts b/packages/lightning-plugin-guilded/src/messages.ts deleted file mode 100644 index ee9db7ed..00000000 --- a/packages/lightning-plugin-guilded/src/messages.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { message } from '@jersey/lightning'; -import type { Message } from 'guilded.js'; -import { convert_msg } from './guilded.ts'; -import type { guilded_plugin } from './mod.ts'; - -export async function tocore( - message: Message, - plugin: guilded_plugin, -): Promise { - if (!message.serverId) return; - let author; - if (!message.createdByWebhookId && message.authorId !== 'Ann6LewA') { - author = await plugin.bot.members.fetch( - message.serverId, - message.authorId, - ); - } - const update_content = message.content.replaceAll('\n```\n```\n', '\n'); - return { - author: { - username: author?.nickname || author?.username || 'user on guilded', - rawname: author?.username || 'user on guilded', - profile: author?.user?.avatar || undefined, - id: message.authorId, - color: '#F5C400', - }, - channel: message.channelId, - id: message.id, - timestamp: Temporal.Instant.fromEpochMilliseconds( - message.createdAt.valueOf(), - ), - embeds: message.embeds?.map((embed) => { - return { - ...embed, - author: embed.author - ? { - name: embed.author.name || 'embed author', - iconUrl: embed.author.iconURL || undefined, - url: embed.author.url || undefined, - } - : undefined, - image: embed.image || undefined, - thumbnail: embed.thumbnail || undefined, - timestamp: embed.timestamp ? Number(embed.timestamp) : undefined, - color: embed.color || undefined, - description: embed.description || undefined, - fields: embed.fields.map((i) => { - return { - ...i, - inline: i.inline || undefined, - }; - }), - footer: embed.footer || undefined, - title: embed.title || undefined, - url: embed.url || undefined, - video: embed.video || undefined, - }; - }), - plugin: 'bolt-guilded', - reply: async (msg: message) => { - await message.reply(await convert_msg(msg)); - }, - content: update_content, - reply_id: message.isReply ? message.replyMessageIds[0] : undefined, - }; -} diff --git a/packages/lightning-plugin-guilded/src/mod.ts b/packages/lightning-plugin-guilded/src/mod.ts index 90b34c58..47ae3241 100644 --- a/packages/lightning-plugin-guilded/src/mod.ts +++ b/packages/lightning-plugin-guilded/src/mod.ts @@ -1,12 +1,17 @@ import { + type create_opts, + type delete_opts, + type edit_opts, type lightning, - type message_options, + log_error, plugin, - type process_result, } from '@jersey/lightning'; import { Client, WebhookClient } from 'guilded.js'; -import { convert_msg, create_webhook } from './guilded.ts'; -import { tocore } from './messages.ts'; +import { error_handler } from './error_handler.ts'; +import { convert_msg } from './guilded.ts'; +import { guilded_to_message } from './guilded_message/mod.ts'; + +// TODO(jersey): TEST THIS CODE /** options for the guilded plugin */ export interface guilded_config { @@ -16,28 +21,33 @@ export interface guilded_config { /** the plugin to use */ export class guilded_plugin extends plugin { - bot: Client; name = 'bolt-guilded'; + bot: Client; constructor(l: lightning, c: guilded_config) { super(l, c); - const h = { + + const opts = { headers: { 'x-guilded-bot-api-use-official-markdown': 'true', }, }; - this.bot = new Client({ token: c.token, rest: h, ws: h }); + + this.bot = new Client({ token: c.token, ws: opts, rest: opts }); this.setup_events(); this.bot.login(); } private setup_events() { + this.bot.on('ready', () => { + console.log(`[bolt-guilded] logged in as ${this.bot.user?.name}`); + }); this.bot.on('messageCreated', async (message) => { - const msg = await tocore(message, this); + const msg = await guilded_to_message(message, this.bot); if (msg) this.emit('create_message', msg); }); this.bot.on('messageUpdated', async (message) => { - const msg = await tocore(message, this); + const msg = await guilded_to_message(message, this.bot); if (msg) this.emit('edit_message', msg); }); this.bot.on('messageDeleted', (del) => { @@ -53,64 +63,53 @@ export class guilded_plugin extends plugin { }); } - /** create a bridge in this channel */ - create_bridge(channel: string): Promise<{ id: string; token: string }> { - return create_webhook(this.bot, channel, this.config.token); + async setup_channel(channel: string) { + try { + // TODO(jersey): it may be worth it to add server/guild id to the message type... + const { serverId } = await this.bot.channels.fetch(channel); + const webhook = await this.bot.webhooks.create(serverId, { + channelId: channel, + name: 'Lightning Bridges', + }); + if (!webhook.id || !webhook.token) { + log_error('failed to create webhook: missing id or token', { + extra: { webhook: webhook.raw }, + }); + } + } catch (e) { + return error_handler(e, channel, 'creating webhook'); + } } - async process_message(opts: message_options): Promise { - if (opts.action === 'create') { - try { - const { id } = await new WebhookClient( - opts.channel.data as { token: string; id: string }, - ).send( - await convert_msg(opts.message, opts.channel.id, this), - ); + async create_message(opts: create_opts) { + try { + const webhook = new WebhookClient( + opts.channel.data as { id: string; token: string }, + ); - return { - id: [id], - channel: opts.channel, - }; - } catch (e) { - if ( - (e as { response: { status: number } }).response - .status === 404 - ) { - return { - channel: opts.channel, - disable: true, - error: new Error('webhook not found!'), - }; - } else if ( - (e as { response: { status: number } }).response - .status === 403 - ) { - return { - channel: opts.channel, - disable: true, - error: new Error('no permission to send messages!'), - }; - } else { - throw e; - } - } - } else if (opts.action === 'delete') { - const msg = await this.bot.messages.fetch( - opts.channel.id, - opts.edit_id[0], + const res = await webhook.send( + await convert_msg(opts.msg, opts.channel.id, this.bot), ); - await msg.delete(); + return [res.id]; + } catch (e) { + return error_handler(e, opts.channel.id, 'creating message'); + } + } + + // deno-lint-ignore require-await + async edit_message(opts: edit_opts) { + // guilded does not support editing messages + return opts.edit_ids; + } + + async delete_message(opts: delete_opts) { + try { + await this.bot.messages.delete(opts.channel.id, opts.edit_ids[0]); - return { - channel: opts.channel, - id: opts.edit_id, - }; - } else { - return { - channel: opts.channel, - id: opts.edit_id, - }; + return opts.edit_ids; + } catch (e) { + return error_handler(e, opts.channel.id, 'deleting message'); } } } diff --git a/packages/lightning-plugin-revolt/deno.json b/packages/lightning-plugin-revolt/deno.json index 36faa946..287f102b 100644 --- a/packages/lightning-plugin-revolt/deno.json +++ b/packages/lightning-plugin-revolt/deno.json @@ -4,7 +4,7 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.3", + "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.4", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.7.14", "@std/ulid": "jsr:@std/ulid@^1.0.0" } diff --git a/packages/lightning-plugin-revolt/src/error_handler.ts b/packages/lightning-plugin-revolt/src/error_handler.ts new file mode 100644 index 00000000..77bb3617 --- /dev/null +++ b/packages/lightning-plugin-revolt/src/error_handler.ts @@ -0,0 +1,32 @@ +import { RequestError, MediaError } from '@jersey/rvapi'; +import { log_error } from '@jersey/lightning'; + +export function handle_error(e: unknown, edit?: boolean) { + if (e instanceof MediaError) { + log_error(e, { + message: e.message + }) + } else if (e instanceof RequestError) { + if (e.cause.status === 403) { + log_error(e, { + message: "Insufficient permissions", + disable: true, + }) + } else if (e.cause.status === 404) { + if (edit) return []; + + log_error(e, { + message: "Resource not found", + disable: true, + }) + } else { + log_error(e, { + message: "unknown error", + }); + } + } else { + log_error(e, { + message: "unknown error", + }); + } +} \ No newline at end of file diff --git a/packages/lightning-plugin-revolt/src/messages.ts b/packages/lightning-plugin-revolt/src/messages.ts deleted file mode 100644 index c91b3d0c..00000000 --- a/packages/lightning-plugin-revolt/src/messages.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { type embed, log_error, type message } from '@jersey/lightning'; -import type { - Channel, - DataMessageSend, - Embed, - Member, - Message, - SendableEmbed, - User, -} from '@jersey/revolt-api-types'; -import type { Client } from '@jersey/rvapi'; -import { decodeTime } from '@std/ulid'; - -export async function torvapi( - api: Client, - message: message, - masquerade = true, -): Promise { - if ( - !message.content && (!message.embeds || message.embeds.length < 1) && - (!message.attachments || message.attachments.length < 1) - ) { - message.content = '*empty message*'; - } - - return { - attachments: message.attachments && message.attachments.length > 0 - ? (await Promise.all( - message.attachments.slice(0, 5).map(async ({ file }) => { - const blob = await (await fetch(file)).blob(); - try { - return [ - await api.media.upload_file('attachments', blob), - ]; - } catch (e) { - await log_error(e, { file }); - return []; - } - }), - )).flat() - : undefined, - content: message.content - ? message.content - : message.embeds - ? undefined - : 'empty message', - embeds: message.embeds?.map((embed) => { - if (embed.fields) { - for (const field of embed.fields) { - embed.description += `\n\n**${field.name}**\n${field.value}`; - } - } - return { - colour: embed.color, - description: embed.description, - icon_url: embed.author?.icon_url, - media: embed.image?.url, - title: embed.title, - url: embed.url, - } as SendableEmbed; - }), - masquerade: masquerade - ? { - avatar: message.author.profile, - name: message.author.username.slice(0, 32), - colour: message.author.color, - } - : undefined, - replies: message.reply_id - ? [{ id: message.reply_id, mention: true }] - : undefined, - }; -} - -export async function fromrvapi( - api: Client, - message: Message, -): Promise { - let channel: Channel & { type: 'TextChannel' | 'GroupChannel' }; - let user: User; - let member: Member | undefined; - - try { - channel = await api.request( - 'get', - `/channels/${message.channel}`, - undefined, - ) as typeof channel; - - user = await api.request( - 'get', - `/users/${message.author}`, - undefined, - ) as typeof user; - - member = channel.server - ? await api.request( - 'get', - `/servers/${channel.server}/members/${message.author}`, - undefined, - ) as typeof member - : undefined; - } catch (e) { - const err = await log_error(e, { - message: 'Failed to fetch user or channel data', - message_id: message._id, - }); - - return err.message; - } - - return { - author: { - id: message.author, - rawname: message.webhook?.name || user.username, - username: message.webhook?.name || member?.nickname || - user.username, - color: '#FF4654', - profile: message.webhook?.avatar || user.avatar - ? `https://autumn.revolt.chat/avatars/${user.avatar?._id}` - : undefined, - }, - channel: message.channel, - id: message._id, - timestamp: message.edited - ? Temporal.Instant.from(message.edited) - : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), - embeds: (message.embeds as Embed[] | undefined)?.map((i) => { - return { - color: i.colour ? parseInt(i.colour.replace('#', ''), 16) : undefined, - ...i, - } as embed; - }), - plugin: 'bolt-revolt', - attachments: message.attachments?.map((i) => { - return { - file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, - name: i.filename, - size: i.size, - }; - }), - content: message.content ?? undefined, - reply_id: message.replies && message.replies.length > 0 - ? message.replies[0] - : undefined, - reply: async (msg: message, masquerade = true) => { - await api.request( - 'post', - `/channels/${message.channel}/messages`, - { - ...(await torvapi( - api, - { ...msg, reply_id: message._id }, - masquerade as boolean, - )), - }, - ); - }, - }; -} diff --git a/packages/lightning-plugin-revolt/src/mod.ts b/packages/lightning-plugin-revolt/src/mod.ts index 8d035be7..c1943031 100644 --- a/packages/lightning-plugin-revolt/src/mod.ts +++ b/packages/lightning-plugin-revolt/src/mod.ts @@ -1,13 +1,16 @@ import { + type create_opts, + type delete_opts, + type edit_opts, type lightning, - type message_options, plugin, - type process_result, } from '@jersey/lightning'; -import type { Message } from '@jersey/revolt-api-types'; import { type Client, createClient } from '@jersey/rvapi'; -import { fromrvapi, torvapi } from './messages.ts'; -import { revolt_perms } from './permissions.ts'; +import type { Message } from '@jersey/revolt-api-types'; +import { handle_error } from './error_handler.ts'; +import { check_permissions } from './permissions.ts'; +import { to_revolt } from './to_revolt.ts'; +import { to_lightning } from './to_lightning.ts'; /** the config for the revolt plugin */ export interface revolt_config { @@ -28,105 +31,83 @@ export class revolt_plugin extends plugin { this.setup_events(); } - /** handle revolt events */ private setup_events() { - this.bot.bonfire.on('Message', async (message) => { - if ( - message.system || !message.channel || - message.channel === 'undefined' - ) return; - this.emit('create_message', await fromrvapi(this.bot, message)); - }); - this.bot.bonfire.on('MessageUpdate', async (message) => { - if ( - message.data.system || !message.channel || - message.channel === 'undefined' - ) return; - this.emit( - 'edit_message', - await fromrvapi(this.bot, message.data as Message), - ); - }); - this.bot.bonfire.on('MessageDelete', (message) => { - this.emit('delete_message', { - channel: message.channel, - id: message.id, - plugin: 'bolt-revolt', - timestamp: Temporal.Now.instant(), - }); - }); - this.bot.bonfire.on('socket_close', (info) => { - console.warn('Revolt socket closed', info); + this.bot.bonfire.on('Ready', (ready) => { + console.log(`[bolt-revolt] ready in ${ready.channels.length} channels`) + console.log(`[bolt-revolt] and ${ready.servers.length} servers`) + }) + + this.bot.bonfire.on("Message", async (msg) => { + if (!msg.channel || msg.channel === 'undefined') return; + + this.emit("create_message", await to_lightning(this.bot, msg)); + }) + + this.bot.bonfire.on('MessageUpdate', async (msg) => { + if (!msg.channel || msg.channel === 'undefined') return; + + this.emit("edit_message", await to_lightning(this.bot, msg.data as Message)); + }) + + this.bot.bonfire.on('MessageDelete', (msg) => { + this.emit('delete_message', { + channel: msg.channel, + id: msg.id, + timestamp: Temporal.Now.instant(), + plugin: 'bolt-revolt' + }) + }) + + this.bot.bonfire.on('socket_close', (info) => { + console.warn('[bolt-revolt] socket closed', info); this.bot = createClient(this.config); this.setup_events(); }); + } + + async setup_channel(channel: string) { + return await check_permissions(channel, this.bot, this.config.user_id) } - /** create a bridge */ - async create_bridge(channel: string): Promise { - return await revolt_perms(this.bot, channel, this.config.user_id); + async create_message(opts: create_opts) { + try { + const { _id } = (await this.bot.request( + 'post', + `/channels/${opts.channel.id}/messages`, + await to_revolt(this.bot, opts.msg, true), + )) as Message; + + return [_id]; + } catch (e) { + return handle_error(e); + } } - /** process a message */ - async process_message(opts: message_options): Promise { - if (opts.action === 'create') { - try { - const msg = await torvapi(this.bot, { - ...opts.message, - reply_id: opts.reply_id, - }); - - const resp = (await this.bot.request( - 'post', - `/channels/${opts.channel.id}/messages`, - msg, - )) as Message; - - return { - channel: opts.channel, - id: [resp._id], - }; - } catch (e) { - if ( - (e as { cause: { status: number } }).cause.status === - 403 || - (e as { cause: { status: number } }).cause.status === - 404 - ) { - return { - channel: opts.channel, - disable: true, - error: e as Error, - }; - } else { - throw e; - } - } - } else if (opts.action === 'edit') { + async edit_message(opts: edit_opts) { + try { await this.bot.request( 'patch', - `/channels/${opts.channel.id}/messages/${opts.edit_id[0]}`, - await torvapi(this.bot, { - ...opts.message, - reply_id: opts.reply_id, - }), + `/channels/${opts.channel.id}/messages/${opts.edit_ids[0]}`, + await to_revolt(this.bot, opts.msg, true), ); - return { - channel: opts.channel, - id: opts.edit_id, - }; - } else { + return opts.edit_ids; + } catch (e) { + return handle_error(e, true); + } + } + + async delete_message(opts: delete_opts) { + try { await this.bot.request( 'delete', - `/channels/${opts.channel.id}/messages/${opts.edit_id[0]}`, + `/channels/${opts.channel.id}/messages/${opts.edit_ids[0]}`, undefined, ); - return { - channel: opts.channel, - id: opts.edit_id, - }; + return opts.edit_ids; + } catch (e) { + return handle_error(e, true); } } } diff --git a/packages/lightning-plugin-revolt/src/permissions.ts b/packages/lightning-plugin-revolt/src/permissions.ts index 667e0e7c..aedc6e6e 100644 --- a/packages/lightning-plugin-revolt/src/permissions.ts +++ b/packages/lightning-plugin-revolt/src/permissions.ts @@ -1,75 +1,82 @@ import type { Client } from '@jersey/rvapi'; import type { Channel, Member, Role, Server } from '@jersey/revolt-api-types'; +import { LightningError, log_error } from '@jersey/lightning'; +import { handle_error } from './error_handler.ts'; -export async function revolt_perms( - client: Client, - channel: string, - self_id: string, -) { - const ch = await client.request( - 'get', - `/channels/${channel}`, - undefined, - ) as Channel; - - const permissions_to_check = [ - 1 << 23, // ManageMessages - 1 << 28, // Masquerade - ]; - - // see https://developers.revolt.chat/assets/api/Permission%20Hierarchy.svg - const permissions = permissions_to_check.reduce((a, b) => a | b, 0); - - if (ch.channel_type === 'Group') { - if (ch.permissions && ch.permissions & permissions) return channel; - } else if (ch.channel_type === 'TextChannel') { - const srvr = await client.request( - 'get', - `/servers/${ch.server}`, - undefined, - ) as Server; +const permissions_to_check = [ + 1 << 23, // ManageMessages + 1 << 28, // Masquerade +]; + +const permissions = permissions_to_check.reduce((a, b) => a | b, 0); - const member = await client.request( +export async function check_permissions(channel_id: string, client: Client, bot_id: string) { + try { + const channel = await client.request( 'get', - `/servers/${ch.server}/members/${self_id}`, + `/channels/${channel_id}`, undefined, - ) as Member; - - // check server permissions - let perms = srvr.default_permissions; - - for (const role of (member.roles || [])) { - const { permissions: role_perms } = await client.request( - 'get', - `/servers/${ch.server}/roles/${role}`, - undefined, - ) as Role; - - perms |= role_perms.a || 0; - perms &= ~role_perms.d || 0; - } - - // apply default allow/denies - if (ch.default_permissions) { - perms |= ch.default_permissions.a; - perms &= ~ch.default_permissions.d; - } - - // apply role permissions - if (ch.role_permissions) { - for (const role of (member.roles || [])) { - perms |= ch.role_permissions[role]?.a || 0; - perms &= ~ch.role_permissions[role]?.d || 0; - } - } - - // check permissions - if (perms & permissions) return channel; - } else { - throw new Error(`Unsupported channel type: ${ch.channel_type}`); - } + ) as Channel; + + if (channel.channel_type === 'Group') { + if (channel.permissions && (channel.permissions & permissions)) return channel; - throw new Error( - 'Insufficient permissions! Please enable ManageMessages and Masquerade permissions.', - ); + log_error('insufficient group permissions: missing ManageMessages and/or Masquerade'); + } else if (channel.channel_type === 'TextChannel') { + return await server_permissions(channel, client, bot_id); + } else { + log_error(`unsupported channel type: ${channel.channel_type}`) + } + + } catch (e) { + if (e instanceof LightningError) throw e; + + handle_error(e); + } } + +async function server_permissions(channel: Channel, client: Client, bot_id: string) { + const server = await client.request( + 'get', + `/servers/${channel.server}`, + undefined, + ) as Server; + + const member = await client.request( + 'get', + `/servers/${channel.server}/members/${bot_id}`, + undefined, + ) as Member; + + // check server permissions + let total_permissions = server.default_permissions; + + for (const role of (member.roles || [])) { + const { permissions: role_perms } = await client.request( + 'get', + `/servers/${channel.server}/roles/${role}`, + undefined, + ) as Role; + + total_permissions |= role_perms.a || 0; + total_permissions &= ~role_perms.d || 0; + } + + // apply default allow/denies + if (channel.default_permissions) { + total_permissions |= channel.default_permissions.a; + total_permissions &= ~channel.default_permissions.d; + } + + // apply role permissions + if (channel.role_permissions) { + for (const role of (member.roles || [])) { + total_permissions |= channel.role_permissions[role]?.a || 0; + total_permissions &= ~channel.role_permissions[role]?.d || 0; + } + } + + if (total_permissions & permissions) return channel; + + log_error('insufficient group permissions: missing ManageMessages and/or Masquerade'); +} \ No newline at end of file diff --git a/packages/lightning-plugin-revolt/src/to_lightning.ts b/packages/lightning-plugin-revolt/src/to_lightning.ts new file mode 100644 index 00000000..f1b643cf --- /dev/null +++ b/packages/lightning-plugin-revolt/src/to_lightning.ts @@ -0,0 +1,87 @@ +import type { Channel, Embed, Member, Message, User } from '@jersey/revolt-api-types'; +import type { Client } from '@jersey/rvapi'; +import type { embed, message, message_author } from '@jersey/lightning'; +import { decodeTime } from '@std/ulid'; +import { to_revolt } from './to_revolt.ts'; + +export async function to_lightning( + api: Client, + message: Message, +): Promise { + return { + attachments: message.attachments?.map((i) => { + return { + file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, + name: i.filename, + size: i.size, + }; + }), + author: await get_author(api, message.author, message.channel), + channel: message.channel, + content: message.content ?? undefined, + embeds: (message.embeds as Embed[] | undefined)?.map((i) => { + return { + color: i.colour ? parseInt(i.colour.replace('#', ''), 16) : undefined, + ...i, + } as embed; + }), + id: message._id, + plugin: 'bolt-revolt', + reply: async (msg: message, masquerade = true) => { + await api.request( + 'post', + `/channels/${message.channel}/messages`, + { + ...(await to_revolt( + api, + { ...msg, reply_id: message._id }, + masquerade as boolean, + )), + }, + ); + }, + timestamp: message.edited + ? Temporal.Instant.from(message.edited) + : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), + reply_id: message.replies?.[0] ?? undefined, + }; +} + +async function get_author(api: Client, author_id: string, channel_id: string): Promise { + try { + const channel = await api.request('get', `/channels/${channel_id}`, undefined) as Channel; + + const author = await api.request('get', `/users/${author_id}`, undefined) as User; + + const author_data = { + id: author_id, + rawname: author.username, + username: author.username, + color: '#FF4654', + profile: author.avatar ? `https://autumn.revolt.chat/avatars/${author.avatar._id}` : undefined, + } + + if (channel.channel_type !== 'TextChannel') { + return author_data + } else { + try { + const member = await api.request('get', `/servers/${channel.server}/members/${author_id}`, undefined) as Member; + + return { + ...author_data, + username: member.nickname ?? author_data.username, + profile: member.avatar ? `https://autumn.revolt.chat/avatars/${member.avatar._id}` : author_data.profile, + } + } catch { + return author_data + } + } + } catch { + return { + id: author_id, + rawname: 'RevoltUser', + username: 'Revolt User', + color: '#FF4654', + } + } +} \ No newline at end of file diff --git a/packages/lightning-plugin-revolt/src/to_revolt.ts b/packages/lightning-plugin-revolt/src/to_revolt.ts new file mode 100644 index 00000000..9741f653 --- /dev/null +++ b/packages/lightning-plugin-revolt/src/to_revolt.ts @@ -0,0 +1,76 @@ +import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; +import { LightningError, type attachment, type embed, type message } from '@jersey/lightning'; +import type { Client } from '@jersey/rvapi'; + +export async function to_revolt( + api: Client, + message: message, + masquerade = true, +): Promise { + if ( + !message.content && (!message.embeds || message.embeds.length < 1) && + (!message.attachments || message.attachments.length < 1) + ) { + message.content = '*empty message*'; + } + + return { + attachments: await upload_attachments(api, message.attachments), + content: message.content, + embeds: map_embeds(message.embeds), + replies: message.reply_id + ? [{ id: message.reply_id, mention: true }] + : undefined, + masquerade: masquerade + ? { + name: message.author.username, + avatar: message.author.profile, + colour: message.author.color, + } + : undefined, + }; +} + +function map_embeds(embeds?: embed[]): SendableEmbed[] | undefined { + if (!embeds) return undefined; + + return embeds.map((embed) => { + const data: SendableEmbed = { + colour: `#${embed.color?.toString(16)}`, + description: embed.description, + icon_url: embed.author?.icon_url, + media: embed.image?.url, + title: embed.title, + url: embed.url, + }; + + if (embed.fields) { + for (const field of embed.fields) { + data.description += `\n\n**${field.name}**\n${field.value}`; + } + } + + return data; + }); +} + +async function upload_attachments(api: Client, attachments?: attachment[]) { + if (!attachments) return undefined; + + return (await Promise.all( + attachments.map(async (attachment) => + api.media.upload_file( + 'attachments', + await (await fetch(attachment.file)).blob(), + ) + .then((id) => [id]) + .catch((e) => { + new LightningError(e, { + message: 'Failed to upload attachment', + extra: { original: e }, + }) + return [] as string[]; + }) + ), + )).flat(); +} diff --git a/packages/lightning-plugin-telegram/src/file_proxy.ts b/packages/lightning-plugin-telegram/src/file_proxy.ts index f33c82e3..f810f7a1 100644 --- a/packages/lightning-plugin-telegram/src/file_proxy.ts +++ b/packages/lightning-plugin-telegram/src/file_proxy.ts @@ -5,8 +5,8 @@ export function file_proxy(config: telegram_config) { port: config.plugin_port, onListen: (addr) => { console.log( - `bolt-telegram: file proxy listening on http://localhost:${addr.port}`, - `\nbolt-telegram: also available at: ${config.plugin_url}`, + `[bolt-telegram] file proxy listening on http://localhost:${addr.port}`, + `\n[bolt-telegram] also available at: ${config.plugin_url}`, ); }, }, (req: Request) => { diff --git a/packages/lightning/src/bridge.ts b/packages/lightning/src/bridge.ts index 605380aa..e054d07f 100644 --- a/packages/lightning/src/bridge.ts +++ b/packages/lightning/src/bridge.ts @@ -32,8 +32,6 @@ export async function bridge_message( if (bridge.settings.use_rawname && "author" in data) data.author.username = data.author.rawname; - // TODO(jersey): implement allow_everyone here - // if the channel this event is from is disabled, return if ( bridge.channels.find((channel) => @@ -82,6 +80,7 @@ export async function bridge_message( try { result_ids = await plugin[event]({ channel, + settings: bridge.settings, reply_id, edit_ids: prior_bridged_ids?.id as string[], msg: data as message, @@ -101,6 +100,7 @@ export async function bridge_message( try { result_ids = await plugin[event]({ channel, + settings: bridge.settings, reply_id, edit_ids: prior_bridged_ids?.id as string[], msg: err.msg, diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index 4f847f3c..948da554 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -71,6 +71,8 @@ export interface create_opts { msg: message; /** the channel to use */ channel: bridge_channel; + /** the settings to use */ + settings: bridge_settings; /** message to reply to, if any */ reply_id?: string; } @@ -81,6 +83,8 @@ export interface edit_opts { msg: message; /** the channel to use */ channel: bridge_channel; + /** the settings to use */ + settings: bridge_settings; /** message to reply to, if any */ reply_id?: string; /** ids of messages to edit */ @@ -93,6 +97,8 @@ export interface delete_opts { msg: deleted_message; /** the channel to use */ channel: bridge_channel; + /** the settings to use */ + settings: bridge_settings; /** ids of messages to delete */ edit_ids: string[]; } diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index b49c418f..9dee06a1 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -61,7 +61,7 @@ export class LightningError extends Error { // the error-logging async fun (async () => { - console.error(`%clightning error ${id}`, 'color: red'); + console.error(`%c[lightning] error ${id}`, 'color: red'); console.error(cause, this.options); const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); From 4eae73dca99352d3851d4fa0a4e95fcd04e71412 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 15 Dec 2024 20:41:46 -0500 Subject: [PATCH 25/97] implement allow_everyone setting --- .../lightning-plugin-discord/src/bridge_to_discord.ts | 2 ++ .../src/discord_message/mod.ts | 11 ++++++++--- packages/lightning-plugin-guilded/src/guilded.ts | 6 ++++++ packages/lightning-plugin-guilded/src/mod.ts | 7 ++++++- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/lightning-plugin-discord/src/bridge_to_discord.ts b/packages/lightning-plugin-discord/src/bridge_to_discord.ts index d9668d94..fd243818 100644 --- a/packages/lightning-plugin-discord/src/bridge_to_discord.ts +++ b/packages/lightning-plugin-discord/src/bridge_to_discord.ts @@ -27,6 +27,7 @@ export async function create_message(api: API, opts: create_opts) { api, opts.channel.id, opts.reply_id, + opts.settings.allow_everyone, ); try { @@ -49,6 +50,7 @@ export async function edit_message(api: API, opts: edit_opts) { api, opts.channel.id, opts.reply_id, + opts.settings.allow_everyone, ); try { diff --git a/packages/lightning-plugin-discord/src/discord_message/mod.ts b/packages/lightning-plugin-discord/src/discord_message/mod.ts index 3931cdaa..277fdd67 100644 --- a/packages/lightning-plugin-discord/src/discord_message/mod.ts +++ b/packages/lightning-plugin-discord/src/discord_message/mod.ts @@ -1,8 +1,9 @@ import type { message } from '@jersey/lightning'; import type { API } from '@discordjs/core'; -import type { - RESTPostAPIWebhookWithTokenJSONBody, - RESTPostAPIWebhookWithTokenQuery, +import { + AllowedMentionsTypes, + type RESTPostAPIWebhookWithTokenJSONBody, + type RESTPostAPIWebhookWithTokenQuery, } from 'discord-api-types'; import type { RawFile } from '@discordjs/rest'; import { reply_embed } from './reply_embed.ts'; @@ -21,6 +22,7 @@ export async function message_to_discord( api?: API, channel?: string, reply_id?: string, + suppress_everyone?: boolean, ): Promise { const discord: discord_message_send = { avatar_url: msg.author.profile, @@ -32,6 +34,9 @@ export async function message_to_discord( }), username: msg.author.username, wait: true, + allowed_mentions: suppress_everyone ? { + parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User], + } : undefined, }; if (api && channel && reply_id) { diff --git a/packages/lightning-plugin-guilded/src/guilded.ts b/packages/lightning-plugin-guilded/src/guilded.ts index 9026e58f..8522c34d 100644 --- a/packages/lightning-plugin-guilded/src/guilded.ts +++ b/packages/lightning-plugin-guilded/src/guilded.ts @@ -7,6 +7,7 @@ export async function convert_msg( msg: message, channel?: string, bot?: Client, + allow_everyone = true, ): Promise { const message = { content: msg.content, @@ -37,6 +38,11 @@ export async function convert_msg( if (!message.content && !message.embeds) message.content = '*empty message*'; + if (!allow_everyone && message.content) { + message.content = message.content.replace(/@everyone/g, '(a)everyone'); + message.content = message.content.replace(/@here/g, '(a)here'); + } + return message; } diff --git a/packages/lightning-plugin-guilded/src/mod.ts b/packages/lightning-plugin-guilded/src/mod.ts index 47ae3241..eb244847 100644 --- a/packages/lightning-plugin-guilded/src/mod.ts +++ b/packages/lightning-plugin-guilded/src/mod.ts @@ -88,7 +88,12 @@ export class guilded_plugin extends plugin { ); const res = await webhook.send( - await convert_msg(opts.msg, opts.channel.id, this.bot), + await convert_msg( + opts.msg, + opts.channel.id, + this.bot, + opts.settings.allow_everyone, + ), ); return [res.id]; From 70f44d9100e9e7c097ae861e2400efbb603d7c89 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 15 Dec 2024 20:43:29 -0500 Subject: [PATCH 26/97] format code --- .../src/discord_message/mod.ts | 8 +- .../src/error_handler.ts | 38 ++-- .../lightning-plugin-guilded/src/guilded.ts | 178 +++++++++--------- .../src/guilded_message/author.ts | 5 +- .../src/guilded_message/mod.ts | 47 +++-- .../src/error_handler.ts | 54 +++--- packages/lightning-plugin-revolt/src/mod.ts | 61 +++--- .../src/permissions.ts | 121 ++++++------ .../src/to_lightning.ts | 96 ++++++---- .../lightning-plugin-revolt/src/to_revolt.ts | 13 +- .../src/file_proxy.ts | 34 ++-- packages/lightning/src/bridge.ts | 4 +- 12 files changed, 358 insertions(+), 301 deletions(-) diff --git a/packages/lightning-plugin-discord/src/discord_message/mod.ts b/packages/lightning-plugin-discord/src/discord_message/mod.ts index 277fdd67..d09397e0 100644 --- a/packages/lightning-plugin-discord/src/discord_message/mod.ts +++ b/packages/lightning-plugin-discord/src/discord_message/mod.ts @@ -34,9 +34,11 @@ export async function message_to_discord( }), username: msg.author.username, wait: true, - allowed_mentions: suppress_everyone ? { - parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User], - } : undefined, + allowed_mentions: suppress_everyone + ? { + parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User], + } + : undefined, }; if (api && channel && reply_id) { diff --git a/packages/lightning-plugin-guilded/src/error_handler.ts b/packages/lightning-plugin-guilded/src/error_handler.ts index fb6dade7..6fde0bdd 100644 --- a/packages/lightning-plugin-guilded/src/error_handler.ts +++ b/packages/lightning-plugin-guilded/src/error_handler.ts @@ -4,25 +4,27 @@ import { GuildedAPIError } from 'guilded.js'; export function error_handler(e: unknown, channel_id: string, action: string) { if (e instanceof GuildedAPIError) { if (e.response.status === 404) { - if (action === 'deleting message') return []; + if (action === 'deleting message') return []; - log_error(e, { - message: 'resource not found! if you\'re trying to make a bridge, this is likely an issue with Guilded', - extra: { channel_id, response: e.response }, - disable: true, - }); - } else if (e.response.status === 403) { - log_error(e, { - message: 'no permission to send/delete messages! check bot permissions', - extra: { channel_id, response: e.response }, - disable: true, - }); - } else { - log_error(e, { - message: `unknown guilded error ${action} with status code ${e.response.status}`, - extra: { channel_id, response: e.response } - }) - } + log_error(e, { + message: + "resource not found! if you're trying to make a bridge, this is likely an issue with Guilded", + extra: { channel_id, response: e.response }, + disable: true, + }); + } else if (e.response.status === 403) { + log_error(e, { + message: 'no permission to send/delete messages! check bot permissions', + extra: { channel_id, response: e.response }, + disable: true, + }); + } else { + log_error(e, { + message: + `unknown guilded error ${action} with status code ${e.response.status}`, + extra: { channel_id, response: e.response }, + }); + } } else { log_error(e, { message: `unknown error ${action}`, diff --git a/packages/lightning-plugin-guilded/src/guilded.ts b/packages/lightning-plugin-guilded/src/guilded.ts index 8522c34d..956e95a0 100644 --- a/packages/lightning-plugin-guilded/src/guilded.ts +++ b/packages/lightning-plugin-guilded/src/guilded.ts @@ -1,112 +1,114 @@ import type { embed, message } from '@jersey/lightning'; import type { Client, EmbedPayload, WebhookMessageContent } from 'guilded.js'; -type guilded_msg = Exclude & { replyMessageIds?: string[] }; +type guilded_msg = Exclude & { + replyMessageIds?: string[]; +}; export async function convert_msg( - msg: message, - channel?: string, - bot?: Client, - allow_everyone = true, + msg: message, + channel?: string, + bot?: Client, + allow_everyone = true, ): Promise { - const message = { - content: msg.content, - avatar_url: msg.author.profile, - username: get_valid_username(msg), - embeds: [ - ...fix_embed(msg.embeds), - ...(await get_reply_embeds(msg, channel, bot)), - ], - } as guilded_msg; + const message = { + content: msg.content, + avatar_url: msg.author.profile, + username: get_valid_username(msg), + embeds: [ + ...fix_embed(msg.embeds), + ...(await get_reply_embeds(msg, channel, bot)), + ], + } as guilded_msg; - if (msg.reply_id) message.replyMessageIds = [msg.reply_id]; + if (msg.reply_id) message.replyMessageIds = [msg.reply_id]; - if (msg.attachments?.length) { - if (!message.embeds) message.embeds = []; - message.embeds.push({ - title: 'attachments', - description: msg.attachments - .slice(0, 5) - .map((a) => { - return `![${a.alt || a.name}](${a.file})`; - }) - .join('\n'), - }); - } + if (msg.attachments?.length) { + if (!message.embeds) message.embeds = []; + message.embeds.push({ + title: 'attachments', + description: msg.attachments + .slice(0, 5) + .map((a) => { + return `![${a.alt || a.name}](${a.file})`; + }) + .join('\n'), + }); + } - if (message.embeds?.length === 0 || !message.embeds) delete message.embeds; + if (message.embeds?.length === 0 || !message.embeds) delete message.embeds; - if (!message.content && !message.embeds) message.content = '*empty message*'; + if (!message.content && !message.embeds) message.content = '*empty message*'; - if (!allow_everyone && message.content) { - message.content = message.content.replace(/@everyone/g, '(a)everyone'); - message.content = message.content.replace(/@here/g, '(a)here'); - } + if (!allow_everyone && message.content) { + message.content = message.content.replace(/@everyone/g, '(a)everyone'); + message.content = message.content.replace(/@here/g, '(a)here'); + } - return message; + return message; } function get_valid_username(msg: message) { - function valid(e: string) { - if (!e || e.length === 0 || e.length > 25) return false; - return /^[a-zA-Z0-9_ ()-]*$/gms.test(e); - } + function valid(e: string) { + if (!e || e.length === 0 || e.length > 25) return false; + return /^[a-zA-Z0-9_ ()-]*$/gms.test(e); + } - if (valid(msg.author.username)) { - return msg.author.username; - } else if (valid(msg.author.rawname)) { - return msg.author.rawname; - } else { - return `${msg.author.id}`; - } + if (valid(msg.author.username)) { + return msg.author.username; + } else if (valid(msg.author.rawname)) { + return msg.author.rawname; + } else { + return `${msg.author.id}`; + } } async function get_reply_embeds( - msg: message, - channel?: string, - bot?: Client, + msg: message, + channel?: string, + bot?: Client, ) { - if (!msg.reply_id || !channel || !bot) return []; - try { - const msg_replied_to = await bot.messages.fetch( - channel, - msg.reply_id, - ); - let author; - if (!msg_replied_to.createdByWebhookId) { - author = await bot.members.fetch( - msg_replied_to.serverId!, - msg_replied_to.authorId, - ); - } - return [ - { - author: { - name: `reply to ${author?.nickname || author?.username || 'a user'}`, - icon_url: author?.user?.avatar || undefined, - }, - description: msg_replied_to.content, - }, - ...(msg_replied_to.embeds || []), - ] as EmbedPayload[]; - } catch { - return []; - } + if (!msg.reply_id || !channel || !bot) return []; + try { + const msg_replied_to = await bot.messages.fetch( + channel, + msg.reply_id, + ); + let author; + if (!msg_replied_to.createdByWebhookId) { + author = await bot.members.fetch( + msg_replied_to.serverId!, + msg_replied_to.authorId, + ); + } + return [ + { + author: { + name: `reply to ${author?.nickname || author?.username || 'a user'}`, + icon_url: author?.user?.avatar || undefined, + }, + description: msg_replied_to.content, + }, + ...(msg_replied_to.embeds || []), + ] as EmbedPayload[]; + } catch { + return []; + } } function fix_embed(embeds: embed[] = []) { - return embeds.flatMap((embed) => { - Object.keys(embed).forEach((key) => { - embed[key as keyof embed] === null - ? (embed[key as keyof embed] = undefined) - : embed[key as keyof embed]; - }); - if (!embed.description || embed.description === '') return []; - return [ - { - ...embed, - timestamp: embed.timestamp ? String(embed.timestamp) : undefined, - }, - ]; - }) as (EmbedPayload & { timestamp: string })[]; + return embeds.flatMap((embed) => { + Object.keys(embed).forEach((key) => { + embed[key as keyof embed] === null + ? (embed[key as keyof embed] = undefined) + : embed[key as keyof embed]; + }); + if (!embed.description || embed.description === '') return []; + return [ + { + ...embed, + timestamp: embed.timestamp ? String(embed.timestamp) : undefined, + }, + ]; + }) as (EmbedPayload & { timestamp: string })[]; } diff --git a/packages/lightning-plugin-guilded/src/guilded_message/author.ts b/packages/lightning-plugin-guilded/src/guilded_message/author.ts index 3ee7cb4f..634b6a60 100644 --- a/packages/lightning-plugin-guilded/src/guilded_message/author.ts +++ b/packages/lightning-plugin-guilded/src/guilded_message/author.ts @@ -28,7 +28,10 @@ export async function get_author( } else if (msg.createdByWebhookId) { // try to fetch webhook? try { - const wh = await bot.webhooks.fetch(msg.serverId!, msg.createdByWebhookId); + const wh = await bot.webhooks.fetch( + msg.serverId!, + msg.createdByWebhookId, + ); return { username: wh.name, diff --git a/packages/lightning-plugin-guilded/src/guilded_message/mod.ts b/packages/lightning-plugin-guilded/src/guilded_message/mod.ts index a9e52d57..634d892d 100644 --- a/packages/lightning-plugin-guilded/src/guilded_message/mod.ts +++ b/packages/lightning-plugin-guilded/src/guilded_message/mod.ts @@ -5,32 +5,31 @@ import { get_author } from './author.ts'; import { map_embed } from './map_embed.ts'; export async function guilded_to_message( - msg: Message, - bot: Client, + msg: Message, + bot: Client, ): Promise { - if (msg.serverId === null) return; + if (msg.serverId === null) return; - const author = await get_author(msg, bot); + const author = await get_author(msg, bot); - const timestamp = Temporal.Instant.fromEpochMilliseconds( - msg.createdAt.valueOf(), - ); + const timestamp = Temporal.Instant.fromEpochMilliseconds( + msg.createdAt.valueOf(), + ); - return { - author: { - ...author, - color: '#F5C400', - }, - channel: msg.channelId, - id: msg.id, - timestamp, - embeds: msg.embeds?.map(map_embed), - plugin: 'bolt-guilded', - reply: async (reply: message) => { - await msg.reply(await convert_msg(reply)); - }, - content: msg.content.replaceAll('\n```\n```\n', '\n'), - reply_id: msg.isReply ? msg.replyMessageIds[0] : undefined, - }; - + return { + author: { + ...author, + color: '#F5C400', + }, + channel: msg.channelId, + id: msg.id, + timestamp, + embeds: msg.embeds?.map(map_embed), + plugin: 'bolt-guilded', + reply: async (reply: message) => { + await msg.reply(await convert_msg(reply)); + }, + content: msg.content.replaceAll('\n```\n```\n', '\n'), + reply_id: msg.isReply ? msg.replyMessageIds[0] : undefined, + }; } diff --git a/packages/lightning-plugin-revolt/src/error_handler.ts b/packages/lightning-plugin-revolt/src/error_handler.ts index 77bb3617..576f8ef0 100644 --- a/packages/lightning-plugin-revolt/src/error_handler.ts +++ b/packages/lightning-plugin-revolt/src/error_handler.ts @@ -1,32 +1,32 @@ -import { RequestError, MediaError } from '@jersey/rvapi'; +import { MediaError, RequestError } from '@jersey/rvapi'; import { log_error } from '@jersey/lightning'; export function handle_error(e: unknown, edit?: boolean) { - if (e instanceof MediaError) { - log_error(e, { - message: e.message - }) - } else if (e instanceof RequestError) { - if (e.cause.status === 403) { - log_error(e, { - message: "Insufficient permissions", - disable: true, - }) - } else if (e.cause.status === 404) { - if (edit) return []; + if (e instanceof MediaError) { + log_error(e, { + message: e.message, + }); + } else if (e instanceof RequestError) { + if (e.cause.status === 403) { + log_error(e, { + message: 'Insufficient permissions', + disable: true, + }); + } else if (e.cause.status === 404) { + if (edit) return []; - log_error(e, { - message: "Resource not found", - disable: true, - }) - } else { - log_error(e, { - message: "unknown error", - }); - } - } else { - log_error(e, { - message: "unknown error", + log_error(e, { + message: 'Resource not found', + disable: true, + }); + } else { + log_error(e, { + message: 'unknown error', + }); + } + } else { + log_error(e, { + message: 'unknown error', }); - } -} \ No newline at end of file + } +} diff --git a/packages/lightning-plugin-revolt/src/mod.ts b/packages/lightning-plugin-revolt/src/mod.ts index c1943031..0d80604c 100644 --- a/packages/lightning-plugin-revolt/src/mod.ts +++ b/packages/lightning-plugin-revolt/src/mod.ts @@ -32,41 +32,44 @@ export class revolt_plugin extends plugin { } private setup_events() { - this.bot.bonfire.on('Ready', (ready) => { - console.log(`[bolt-revolt] ready in ${ready.channels.length} channels`) - console.log(`[bolt-revolt] and ${ready.servers.length} servers`) - }) - - this.bot.bonfire.on("Message", async (msg) => { - if (!msg.channel || msg.channel === 'undefined') return; - - this.emit("create_message", await to_lightning(this.bot, msg)); - }) - - this.bot.bonfire.on('MessageUpdate', async (msg) => { - if (!msg.channel || msg.channel === 'undefined') return; - - this.emit("edit_message", await to_lightning(this.bot, msg.data as Message)); - }) - - this.bot.bonfire.on('MessageDelete', (msg) => { - this.emit('delete_message', { - channel: msg.channel, - id: msg.id, - timestamp: Temporal.Now.instant(), - plugin: 'bolt-revolt' - }) - }) - - this.bot.bonfire.on('socket_close', (info) => { + this.bot.bonfire.on('Ready', (ready) => { + console.log(`[bolt-revolt] ready in ${ready.channels.length} channels`); + console.log(`[bolt-revolt] and ${ready.servers.length} servers`); + }); + + this.bot.bonfire.on('Message', async (msg) => { + if (!msg.channel || msg.channel === 'undefined') return; + + this.emit('create_message', await to_lightning(this.bot, msg)); + }); + + this.bot.bonfire.on('MessageUpdate', async (msg) => { + if (!msg.channel || msg.channel === 'undefined') return; + + this.emit( + 'edit_message', + await to_lightning(this.bot, msg.data as Message), + ); + }); + + this.bot.bonfire.on('MessageDelete', (msg) => { + this.emit('delete_message', { + channel: msg.channel, + id: msg.id, + timestamp: Temporal.Now.instant(), + plugin: 'bolt-revolt', + }); + }); + + this.bot.bonfire.on('socket_close', (info) => { console.warn('[bolt-revolt] socket closed', info); this.bot = createClient(this.config); this.setup_events(); }); - } + } async setup_channel(channel: string) { - return await check_permissions(channel, this.bot, this.config.user_id) + return await check_permissions(channel, this.bot, this.config.user_id); } async create_message(opts: create_opts) { diff --git a/packages/lightning-plugin-revolt/src/permissions.ts b/packages/lightning-plugin-revolt/src/permissions.ts index aedc6e6e..75d38b01 100644 --- a/packages/lightning-plugin-revolt/src/permissions.ts +++ b/packages/lightning-plugin-revolt/src/permissions.ts @@ -4,13 +4,17 @@ import { LightningError, log_error } from '@jersey/lightning'; import { handle_error } from './error_handler.ts'; const permissions_to_check = [ - 1 << 23, // ManageMessages - 1 << 28, // Masquerade + 1 << 23, // ManageMessages + 1 << 28, // Masquerade ]; const permissions = permissions_to_check.reduce((a, b) => a | b, 0); -export async function check_permissions(channel_id: string, client: Client, bot_id: string) { +export async function check_permissions( + channel_id: string, + client: Client, + bot_id: string, +) { try { const channel = await client.request( 'get', @@ -18,65 +22,74 @@ export async function check_permissions(channel_id: string, client: Client, bot_ undefined, ) as Channel; - if (channel.channel_type === 'Group') { - if (channel.permissions && (channel.permissions & permissions)) return channel; - - log_error('insufficient group permissions: missing ManageMessages and/or Masquerade'); - } else if (channel.channel_type === 'TextChannel') { - return await server_permissions(channel, client, bot_id); - } else { - log_error(`unsupported channel type: ${channel.channel_type}`) - } - + if (channel.channel_type === 'Group') { + if (channel.permissions && (channel.permissions & permissions)) { + return channel; + } + + log_error( + 'insufficient group permissions: missing ManageMessages and/or Masquerade', + ); + } else if (channel.channel_type === 'TextChannel') { + return await server_permissions(channel, client, bot_id); + } else { + log_error(`unsupported channel type: ${channel.channel_type}`); + } } catch (e) { - if (e instanceof LightningError) throw e; + if (e instanceof LightningError) throw e; handle_error(e); } } -async function server_permissions(channel: Channel, client: Client, bot_id: string) { - const server = await client.request( - 'get', - `/servers/${channel.server}`, - undefined, - ) as Server; - - const member = await client.request( - 'get', - `/servers/${channel.server}/members/${bot_id}`, - undefined, - ) as Member; - - // check server permissions - let total_permissions = server.default_permissions; - - for (const role of (member.roles || [])) { - const { permissions: role_perms } = await client.request( - 'get', - `/servers/${channel.server}/roles/${role}`, - undefined, - ) as Role; +async function server_permissions( + channel: Channel, + client: Client, + bot_id: string, +) { + const server = await client.request( + 'get', + `/servers/${channel.server}`, + undefined, + ) as Server; + + const member = await client.request( + 'get', + `/servers/${channel.server}/members/${bot_id}`, + undefined, + ) as Member; + + // check server permissions + let total_permissions = server.default_permissions; + + for (const role of (member.roles || [])) { + const { permissions: role_perms } = await client.request( + 'get', + `/servers/${channel.server}/roles/${role}`, + undefined, + ) as Role; - total_permissions |= role_perms.a || 0; - total_permissions &= ~role_perms.d || 0; - } + total_permissions |= role_perms.a || 0; + total_permissions &= ~role_perms.d || 0; + } - // apply default allow/denies - if (channel.default_permissions) { - total_permissions |= channel.default_permissions.a; - total_permissions &= ~channel.default_permissions.d; - } + // apply default allow/denies + if (channel.default_permissions) { + total_permissions |= channel.default_permissions.a; + total_permissions &= ~channel.default_permissions.d; + } - // apply role permissions - if (channel.role_permissions) { - for (const role of (member.roles || [])) { - total_permissions |= channel.role_permissions[role]?.a || 0; - total_permissions &= ~channel.role_permissions[role]?.d || 0; - } - } + // apply role permissions + if (channel.role_permissions) { + for (const role of (member.roles || [])) { + total_permissions |= channel.role_permissions[role]?.a || 0; + total_permissions &= ~channel.role_permissions[role]?.d || 0; + } + } - if (total_permissions & permissions) return channel; + if (total_permissions & permissions) return channel; - log_error('insufficient group permissions: missing ManageMessages and/or Masquerade'); -} \ No newline at end of file + log_error( + 'insufficient group permissions: missing ManageMessages and/or Masquerade', + ); +} diff --git a/packages/lightning-plugin-revolt/src/to_lightning.ts b/packages/lightning-plugin-revolt/src/to_lightning.ts index f1b643cf..ee3df1d3 100644 --- a/packages/lightning-plugin-revolt/src/to_lightning.ts +++ b/packages/lightning-plugin-revolt/src/to_lightning.ts @@ -1,4 +1,10 @@ -import type { Channel, Embed, Member, Message, User } from '@jersey/revolt-api-types'; +import type { + Channel, + Embed, + Member, + Message, + User, +} from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; import type { embed, message, message_author } from '@jersey/lightning'; import { decodeTime } from '@std/ulid'; @@ -47,41 +53,61 @@ export async function to_lightning( }; } -async function get_author(api: Client, author_id: string, channel_id: string): Promise { - try { - const channel = await api.request('get', `/channels/${channel_id}`, undefined) as Channel; +async function get_author( + api: Client, + author_id: string, + channel_id: string, +): Promise { + try { + const channel = await api.request( + 'get', + `/channels/${channel_id}`, + undefined, + ) as Channel; - const author = await api.request('get', `/users/${author_id}`, undefined) as User; + const author = await api.request( + 'get', + `/users/${author_id}`, + undefined, + ) as User; - const author_data = { - id: author_id, - rawname: author.username, - username: author.username, - color: '#FF4654', - profile: author.avatar ? `https://autumn.revolt.chat/avatars/${author.avatar._id}` : undefined, - } + const author_data = { + id: author_id, + rawname: author.username, + username: author.username, + color: '#FF4654', + profile: author.avatar + ? `https://autumn.revolt.chat/avatars/${author.avatar._id}` + : undefined, + }; - if (channel.channel_type !== 'TextChannel') { - return author_data - } else { - try { - const member = await api.request('get', `/servers/${channel.server}/members/${author_id}`, undefined) as Member; + if (channel.channel_type !== 'TextChannel') { + return author_data; + } else { + try { + const member = await api.request( + 'get', + `/servers/${channel.server}/members/${author_id}`, + undefined, + ) as Member; - return { - ...author_data, - username: member.nickname ?? author_data.username, - profile: member.avatar ? `https://autumn.revolt.chat/avatars/${member.avatar._id}` : author_data.profile, - } - } catch { - return author_data - } - } - } catch { - return { - id: author_id, - rawname: 'RevoltUser', - username: 'Revolt User', - color: '#FF4654', - } - } -} \ No newline at end of file + return { + ...author_data, + username: member.nickname ?? author_data.username, + profile: member.avatar + ? `https://autumn.revolt.chat/avatars/${member.avatar._id}` + : author_data.profile, + }; + } catch { + return author_data; + } + } + } catch { + return { + id: author_id, + rawname: 'RevoltUser', + username: 'Revolt User', + color: '#FF4654', + }; + } +} diff --git a/packages/lightning-plugin-revolt/src/to_revolt.ts b/packages/lightning-plugin-revolt/src/to_revolt.ts index 9741f653..bc8582f0 100644 --- a/packages/lightning-plugin-revolt/src/to_revolt.ts +++ b/packages/lightning-plugin-revolt/src/to_revolt.ts @@ -1,5 +1,10 @@ import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; -import { LightningError, type attachment, type embed, type message } from '@jersey/lightning'; +import { + type attachment, + type embed, + LightningError, + type message, +} from '@jersey/lightning'; import type { Client } from '@jersey/rvapi'; export async function to_revolt( @@ -66,9 +71,9 @@ async function upload_attachments(api: Client, attachments?: attachment[]) { .then((id) => [id]) .catch((e) => { new LightningError(e, { - message: 'Failed to upload attachment', - extra: { original: e }, - }) + message: 'Failed to upload attachment', + extra: { original: e }, + }); return [] as string[]; }) ), diff --git a/packages/lightning-plugin-telegram/src/file_proxy.ts b/packages/lightning-plugin-telegram/src/file_proxy.ts index f810f7a1..539e7e07 100644 --- a/packages/lightning-plugin-telegram/src/file_proxy.ts +++ b/packages/lightning-plugin-telegram/src/file_proxy.ts @@ -1,20 +1,20 @@ import type { telegram_config } from './mod.ts'; export function file_proxy(config: telegram_config) { - Deno.serve({ - port: config.plugin_port, - onListen: (addr) => { - console.log( - `[bolt-telegram] file proxy listening on http://localhost:${addr.port}`, - `\n[bolt-telegram] also available at: ${config.plugin_url}`, - ); - }, - }, (req: Request) => { - const { pathname } = new URL(req.url); - return fetch( - `https://api.telegram.org/file/bot${config.bot_token}/${ - pathname.replace('/telegram/', '') - }`, - ); - }); -} \ No newline at end of file + Deno.serve({ + port: config.plugin_port, + onListen: (addr) => { + console.log( + `[bolt-telegram] file proxy listening on http://localhost:${addr.port}`, + `\n[bolt-telegram] also available at: ${config.plugin_url}`, + ); + }, + }, (req: Request) => { + const { pathname } = new URL(req.url); + return fetch( + `https://api.telegram.org/file/bot${config.bot_token}/${ + pathname.replace('/telegram/', '') + }`, + ); + }); +} diff --git a/packages/lightning/src/bridge.ts b/packages/lightning/src/bridge.ts index e054d07f..b8eb4f16 100644 --- a/packages/lightning/src/bridge.ts +++ b/packages/lightning/src/bridge.ts @@ -30,7 +30,9 @@ export async function bridge_message( return; } - if (bridge.settings.use_rawname && "author" in data) data.author.username = data.author.rawname; + if (bridge.settings.use_rawname && 'author' in data) { + data.author.username = data.author.rawname; + } // if the channel this event is from is disabled, return if ( From 98ae09aae977d154a3579dbe5d559d3536be4442 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 21 Dec 2024 21:24:43 -0500 Subject: [PATCH 27/97] move folders around, make multiple database adapters --- deno.jsonc | 10 +- .../README.md | 0 .../deno.json | 0 .../license | 0 .../src/bridge_to_discord.ts | 0 .../src/discord_message/files.ts | 0 .../src/discord_message/get_author.ts | 0 .../src/discord_message/mod.ts | 0 .../src/discord_message/reply_embed.ts | 0 .../src/error_handler.ts | 0 .../src/mod.ts | 0 .../src/slash_commands.ts | 0 .../src/to_lightning/command.ts | 0 .../src/to_lightning/deleted.ts | 0 .../src/to_lightning/message.ts | 0 .../README.md | 0 .../deno.json | 0 .../license | 0 .../src/error_handler.ts | 0 .../src/guilded.ts | 0 .../src/guilded_message/author.ts | 0 .../src/guilded_message/map_embed.ts | 0 .../src/guilded_message/mod.ts | 0 .../src/mod.ts | 0 packages/lightning/deno.jsonc | 2 + packages/lightning/src/bridge.ts | 1 + packages/lightning/src/database.ts | 125 ----------------- packages/lightning/src/database/mod.ts | 41 ++++++ packages/lightning/src/database/mongo.ts | 69 ++++++++++ packages/lightning/src/database/postgres.ts | 128 ++++++++++++++++++ packages/lightning/src/database/redis.ts | 74 ++++++++++ .../lightning/src/database/redis_message.ts | 71 ++++++++++ packages/lightning/src/lightning.ts | 11 +- .../README.md | 0 .../deno.json | 0 .../license | 0 .../src/error_handler.ts | 0 .../src/mod.ts | 0 .../src/permissions.ts | 0 .../src/to_lightning.ts | 0 .../src/to_revolt.ts | 0 .../README.md | 0 .../deno.json | 0 .../license | 0 .../src/file_proxy.ts | 0 .../src/messages.ts | 0 .../src/mod.ts | 0 47 files changed, 398 insertions(+), 134 deletions(-) rename packages/{lightning-plugin-discord => discord}/README.md (100%) rename packages/{lightning-plugin-discord => discord}/deno.json (100%) rename packages/{lightning-plugin-discord => discord}/license (100%) rename packages/{lightning-plugin-discord => discord}/src/bridge_to_discord.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/discord_message/files.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/discord_message/get_author.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/discord_message/mod.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/discord_message/reply_embed.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/error_handler.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/mod.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/slash_commands.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/to_lightning/command.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/to_lightning/deleted.ts (100%) rename packages/{lightning-plugin-discord => discord}/src/to_lightning/message.ts (100%) rename packages/{lightning-plugin-guilded => guilded}/README.md (100%) rename packages/{lightning-plugin-guilded => guilded}/deno.json (100%) rename packages/{lightning-plugin-guilded => guilded}/license (100%) rename packages/{lightning-plugin-guilded => guilded}/src/error_handler.ts (100%) rename packages/{lightning-plugin-guilded => guilded}/src/guilded.ts (100%) rename packages/{lightning-plugin-guilded => guilded}/src/guilded_message/author.ts (100%) rename packages/{lightning-plugin-guilded => guilded}/src/guilded_message/map_embed.ts (100%) rename packages/{lightning-plugin-guilded => guilded}/src/guilded_message/mod.ts (100%) rename packages/{lightning-plugin-guilded => guilded}/src/mod.ts (100%) delete mode 100644 packages/lightning/src/database.ts create mode 100644 packages/lightning/src/database/mod.ts create mode 100644 packages/lightning/src/database/mongo.ts create mode 100644 packages/lightning/src/database/postgres.ts create mode 100644 packages/lightning/src/database/redis.ts create mode 100644 packages/lightning/src/database/redis_message.ts rename packages/{lightning-plugin-revolt => revolt}/README.md (100%) rename packages/{lightning-plugin-revolt => revolt}/deno.json (100%) rename packages/{lightning-plugin-revolt => revolt}/license (100%) rename packages/{lightning-plugin-revolt => revolt}/src/error_handler.ts (100%) rename packages/{lightning-plugin-revolt => revolt}/src/mod.ts (100%) rename packages/{lightning-plugin-revolt => revolt}/src/permissions.ts (100%) rename packages/{lightning-plugin-revolt => revolt}/src/to_lightning.ts (100%) rename packages/{lightning-plugin-revolt => revolt}/src/to_revolt.ts (100%) rename packages/{lightning-plugin-telegram => telegram}/README.md (100%) rename packages/{lightning-plugin-telegram => telegram}/deno.json (100%) rename packages/{lightning-plugin-telegram => telegram}/license (100%) rename packages/{lightning-plugin-telegram => telegram}/src/file_proxy.ts (100%) rename packages/{lightning-plugin-telegram => telegram}/src/messages.ts (100%) rename packages/{lightning-plugin-telegram => telegram}/src/mod.ts (100%) diff --git a/deno.jsonc b/deno.jsonc index 1cf437e7..1c01784a 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -21,12 +21,12 @@ }, "workspace": [ "./packages/lightning", - // TODO(jersey): contribute upstream + // TODO(jersey): upstream pr "./packages/postgres", - "./packages/lightning-plugin-telegram", - "./packages/lightning-plugin-revolt", - "./packages/lightning-plugin-guilded", - "./packages/lightning-plugin-discord" + "./packages/telegram", + "./packages/revolt", + "./packages/guilded", + "./packages/discord" ], "lock": false, "unstable": ["temporal"] diff --git a/packages/lightning-plugin-discord/README.md b/packages/discord/README.md similarity index 100% rename from packages/lightning-plugin-discord/README.md rename to packages/discord/README.md diff --git a/packages/lightning-plugin-discord/deno.json b/packages/discord/deno.json similarity index 100% rename from packages/lightning-plugin-discord/deno.json rename to packages/discord/deno.json diff --git a/packages/lightning-plugin-discord/license b/packages/discord/license similarity index 100% rename from packages/lightning-plugin-discord/license rename to packages/discord/license diff --git a/packages/lightning-plugin-discord/src/bridge_to_discord.ts b/packages/discord/src/bridge_to_discord.ts similarity index 100% rename from packages/lightning-plugin-discord/src/bridge_to_discord.ts rename to packages/discord/src/bridge_to_discord.ts diff --git a/packages/lightning-plugin-discord/src/discord_message/files.ts b/packages/discord/src/discord_message/files.ts similarity index 100% rename from packages/lightning-plugin-discord/src/discord_message/files.ts rename to packages/discord/src/discord_message/files.ts diff --git a/packages/lightning-plugin-discord/src/discord_message/get_author.ts b/packages/discord/src/discord_message/get_author.ts similarity index 100% rename from packages/lightning-plugin-discord/src/discord_message/get_author.ts rename to packages/discord/src/discord_message/get_author.ts diff --git a/packages/lightning-plugin-discord/src/discord_message/mod.ts b/packages/discord/src/discord_message/mod.ts similarity index 100% rename from packages/lightning-plugin-discord/src/discord_message/mod.ts rename to packages/discord/src/discord_message/mod.ts diff --git a/packages/lightning-plugin-discord/src/discord_message/reply_embed.ts b/packages/discord/src/discord_message/reply_embed.ts similarity index 100% rename from packages/lightning-plugin-discord/src/discord_message/reply_embed.ts rename to packages/discord/src/discord_message/reply_embed.ts diff --git a/packages/lightning-plugin-discord/src/error_handler.ts b/packages/discord/src/error_handler.ts similarity index 100% rename from packages/lightning-plugin-discord/src/error_handler.ts rename to packages/discord/src/error_handler.ts diff --git a/packages/lightning-plugin-discord/src/mod.ts b/packages/discord/src/mod.ts similarity index 100% rename from packages/lightning-plugin-discord/src/mod.ts rename to packages/discord/src/mod.ts diff --git a/packages/lightning-plugin-discord/src/slash_commands.ts b/packages/discord/src/slash_commands.ts similarity index 100% rename from packages/lightning-plugin-discord/src/slash_commands.ts rename to packages/discord/src/slash_commands.ts diff --git a/packages/lightning-plugin-discord/src/to_lightning/command.ts b/packages/discord/src/to_lightning/command.ts similarity index 100% rename from packages/lightning-plugin-discord/src/to_lightning/command.ts rename to packages/discord/src/to_lightning/command.ts diff --git a/packages/lightning-plugin-discord/src/to_lightning/deleted.ts b/packages/discord/src/to_lightning/deleted.ts similarity index 100% rename from packages/lightning-plugin-discord/src/to_lightning/deleted.ts rename to packages/discord/src/to_lightning/deleted.ts diff --git a/packages/lightning-plugin-discord/src/to_lightning/message.ts b/packages/discord/src/to_lightning/message.ts similarity index 100% rename from packages/lightning-plugin-discord/src/to_lightning/message.ts rename to packages/discord/src/to_lightning/message.ts diff --git a/packages/lightning-plugin-guilded/README.md b/packages/guilded/README.md similarity index 100% rename from packages/lightning-plugin-guilded/README.md rename to packages/guilded/README.md diff --git a/packages/lightning-plugin-guilded/deno.json b/packages/guilded/deno.json similarity index 100% rename from packages/lightning-plugin-guilded/deno.json rename to packages/guilded/deno.json diff --git a/packages/lightning-plugin-guilded/license b/packages/guilded/license similarity index 100% rename from packages/lightning-plugin-guilded/license rename to packages/guilded/license diff --git a/packages/lightning-plugin-guilded/src/error_handler.ts b/packages/guilded/src/error_handler.ts similarity index 100% rename from packages/lightning-plugin-guilded/src/error_handler.ts rename to packages/guilded/src/error_handler.ts diff --git a/packages/lightning-plugin-guilded/src/guilded.ts b/packages/guilded/src/guilded.ts similarity index 100% rename from packages/lightning-plugin-guilded/src/guilded.ts rename to packages/guilded/src/guilded.ts diff --git a/packages/lightning-plugin-guilded/src/guilded_message/author.ts b/packages/guilded/src/guilded_message/author.ts similarity index 100% rename from packages/lightning-plugin-guilded/src/guilded_message/author.ts rename to packages/guilded/src/guilded_message/author.ts diff --git a/packages/lightning-plugin-guilded/src/guilded_message/map_embed.ts b/packages/guilded/src/guilded_message/map_embed.ts similarity index 100% rename from packages/lightning-plugin-guilded/src/guilded_message/map_embed.ts rename to packages/guilded/src/guilded_message/map_embed.ts diff --git a/packages/lightning-plugin-guilded/src/guilded_message/mod.ts b/packages/guilded/src/guilded_message/mod.ts similarity index 100% rename from packages/lightning-plugin-guilded/src/guilded_message/mod.ts rename to packages/guilded/src/guilded_message/mod.ts diff --git a/packages/lightning-plugin-guilded/src/mod.ts b/packages/guilded/src/mod.ts similarity index 100% rename from packages/lightning-plugin-guilded/src/mod.ts rename to packages/guilded/src/mod.ts diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index 4169f01f..bf6e3c1f 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -3,8 +3,10 @@ "version": "0.8.0", "exports": "./src/mod.ts", "imports": { + "@db/mongo": "jsr:@db/mongo@^0.33.0", "@db/postgres": "jsr:@db/postgres@^0.19.4", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", + "@iuioiua/r2d2": "jsr:@iuioiua/r2d2@2.1.2", "@std/cli/parse-args": "jsr:@std/cli@^1.0.3/parse-args", "@std/path": "jsr:@std/path@^1.0.0", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/lightning/src/bridge.ts b/packages/lightning/src/bridge.ts index b8eb4f16..398ff5c8 100644 --- a/packages/lightning/src/bridge.ts +++ b/packages/lightning/src/bridge.ts @@ -170,6 +170,7 @@ async function disable_channel( ); await lightning.data.edit_bridge({ + name: 'name' in bridge ? bridge.name : bridge.id, id: 'bridge_id' in bridge ? bridge.bridge_id : bridge.id, channels: bridge.channels.map((i) => i.id === channel.id && i.plugin === channel.plugin diff --git a/packages/lightning/src/database.ts b/packages/lightning/src/database.ts deleted file mode 100644 index 32bfa354..00000000 --- a/packages/lightning/src/database.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Client, type ClientOptions } from '@db/postgres'; -import { ulid } from '@std/ulid'; -import type { bridge, bridge_message } from './structures/bridge.ts'; - -export class bridge_data { - private pg: Client; - - static async create(pg_options: ClientOptions): Promise { - const pg = new Client(pg_options); - await pg.connect(); - await bridge_data.create_table(pg); - return new bridge_data(pg); - } - - private static async create_table(pg: Client) { - const exists = (await pg.queryArray` - SELECT relname FROM pg_class - WHERE relname = 'bridges' - `).rows.length > 0; - - if (exists) return; - - await pg.queryArray` - CREATE TABLE bridges ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - channels JSONB NOT NULL, - settings JSONB NOT NULL - ); - - CREATE TABLE bridge_messages ( - id TEXT PRIMARY KEY, - bridge_id TEXT NOT NULL, - channels JSONB NOT NULL, - messages JSONB NOT NULL, - settings JSONB NOT NULL - ); - `; - } - - private constructor(pg_client: Client) { - this.pg = pg_client; - } - - async create_bridge(br: Omit): Promise { - const id = ulid(); - - await this.pg.queryArray` - INSERT INTO bridges (id, name, channels, settings) - VALUES (${id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ - JSON.stringify(br.settings) - }) - `; - - return { id, ...br }; - } - - async edit_bridge(br: Omit): Promise { - await this.pg.queryArray` - UPDATE bridges - SET channels = ${JSON.stringify(br.channels)}, - settings = ${JSON.stringify(br.settings)} - WHERE id = ${br.id} - `; - } - - async get_bridge_by_id(id: string): Promise { - const res = await this.pg.queryObject` - SELECT * FROM bridges WHERE id = ${id} - `; - - return res.rows[0]; - } - - async get_bridge_by_channel(ch: string): Promise { - const res = await this.pg.queryObject(` - SELECT * FROM bridges WHERE EXISTS ( - SELECT 1 FROM jsonb_array_elements(channels) AS ch - WHERE ch->>'id' = '${ch}' - ) - `); - - return res.rows[0]; - } - - async create_message(msg: bridge_message): Promise { - await this.pg.queryArray`INSERT INTO bridge_messages - (id, bridge_id, channels, messages, settings) VALUES - (${msg.id}, ${msg.bridge_id}, ${JSON.stringify(msg.channels)}, ${ - JSON.stringify(msg.messages) - }, ${JSON.stringify(msg.settings)}) - `; - } - - async edit_message(msg: bridge_message): Promise { - await this.pg.queryArray` - UPDATE bridge_messages - SET messages = ${JSON.stringify(msg.messages)}, - channels = ${JSON.stringify(msg.channels)}, - settings = ${JSON.stringify(msg.settings)} - WHERE id = ${msg.id} - `; - } - - async delete_message({ id }: bridge_message): Promise { - await this.pg.queryArray` - DELETE FROM bridge_messages WHERE id = ${id} - `; - } - - async get_message(id: string): Promise { - const res = await this.pg.queryObject(` - SELECT * FROM bridge_messages - WHERE id = '${id}' OR EXISTS ( - SELECT 1 FROM jsonb_array_elements(messages) AS msg - WHERE EXISTS ( - SELECT 1 FROM jsonb_array_elements(msg->'id') AS id - WHERE id = '${id}' - ) - ) - `); - - return res.rows[0]; - } -} diff --git a/packages/lightning/src/database/mod.ts b/packages/lightning/src/database/mod.ts new file mode 100644 index 00000000..c13fc79e --- /dev/null +++ b/packages/lightning/src/database/mod.ts @@ -0,0 +1,41 @@ +import type { bridge, bridge_message } from '../structures/bridge.ts'; +import { mongo, type mongo_config } from './mongo.ts'; +import { postgres, type postgres_config } from './postgres.ts'; +import { redis, type redis_config } from './redis.ts'; + +export interface bridge_data { + create_bridge(br: Omit): Promise; + edit_bridge(br: bridge): Promise; + get_bridge_by_id(id: string): Promise; + get_bridge_by_channel(ch: string): Promise; + create_message(msg: bridge_message): Promise; + edit_message(msg: bridge_message): Promise; + delete_message(msg: bridge_message): Promise; + get_message(id: string): Promise; +} + +export type database_config = { + type: 'postgres'; + config: postgres_config; +} | { + type: 'redis'; + config: redis_config; +} | { + type: 'mongo'; + config: mongo_config; +}; + +export async function create_database( + config: database_config, +): Promise { + switch (config.type) { + case 'postgres': + return await postgres.create(config.config); + case 'redis': + return await redis.create(config.config); + case 'mongo': + return await mongo.create(config.config); + default: + throw new Error('invalid database type'); + } +} diff --git a/packages/lightning/src/database/mongo.ts b/packages/lightning/src/database/mongo.ts new file mode 100644 index 00000000..d9a3289d --- /dev/null +++ b/packages/lightning/src/database/mongo.ts @@ -0,0 +1,69 @@ +import { ulid } from '@std/ulid'; +import type { bridge } from '../structures/bridge.ts'; +import type { bridge_data } from './mod.ts'; +import { type Collection, type ConnectOptions, MongoClient } from '@db/mongo'; +import { redis_bridge_message_handler } from './redis_message.ts'; +import { RedisClient } from '@iuioiua/r2d2'; + +export type mongo_config = { + database: ConnectOptions | string; + redis: Deno.ConnectOptions; +}; + +export class mongo extends redis_bridge_message_handler implements bridge_data { + static async create(opts: mongo_config) { + const client = new MongoClient(); + await client.connect(opts.database); + + const database = client.database(); + const db_data_version = await database.collection('lightning').findOne({ _id: 'db_data_version' }); + const bridge_collection_exists = (await database.listCollectionNames()).includes('bridges'); + + if (db_data_version?.version !== '0.8.0' && bridge_collection_exists) { + const version = db_data_version?.version ?? 'unknown'; + + console.warn(`[lightning-mongo] migrating database from ${version} to 0.8.0`); + + // TODO(jersey): use code to feature detect the version if not present and then migrate + // it may be worth it to just allow migrations from the last version before redisforeverything + // and have anything prior use the migration script from back then + + throw "not implemented"; + } + + const redis = new RedisClient(await Deno.connect(opts.redis)); + + return new this(database.collection('bridges'), redis); + } + + private constructor( + private bridges: Collection, + public redis: RedisClient, + ) { + super(); + } + + async create_bridge(br: Omit): Promise { + const id = ulid(); + await this.bridges.insertOne({ _id: id, id, ...br }); + return { id, ...br }; + } + + async edit_bridge(br: bridge): Promise { + await this.bridges.replaceOne({ _id: br.id }, br); + } + + async get_bridge_by_id(id: string): Promise { + return await this.bridges.findOne({ _id: id }); + } + + async get_bridge_by_channel(ch: string): Promise { + return await this.bridges.findOne({ + channels: { + $all: [{ + $elemMatch: { id: ch }, + }], + }, + }); + } +} diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts new file mode 100644 index 00000000..01a1e537 --- /dev/null +++ b/packages/lightning/src/database/postgres.ts @@ -0,0 +1,128 @@ +import { Client, type ClientOptions } from '@db/postgres'; +import { ulid } from '@std/ulid'; +import type { bridge, bridge_message } from '../structures/bridge.ts'; +import type { bridge_data } from './mod.ts'; + +export type { ClientOptions as postgres_config }; + +/** + * unfortunately this code only works with denodrivers/postgres#487 + * ideally jsr support and modernization would be merged but who knows + */ + +export class postgres implements bridge_data { + static async create(pg_options: ClientOptions): Promise { + const pg = new Client(pg_options); + await pg.connect(); + await pg.queryArray` + CREATE TABLE IF NOT EXISTS lightning ( + prop TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + INSERT INTO lightning (prop, value) + VALUES ('db_data_version', '0.8.0') + /* the database shouldn't have been created before 0.8.0 so this is safe */ + ON CONFLICT (prop) DO NOTHING; + + CREATE TABLE IF NOT EXISTS bridges ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + channels JSONB NOT NULL, + settings JSONB NOT NULL + ); + + CREATE TABLE IF NOT EXISTS bridge_messages ( + id TEXT PRIMARY KEY, + bridge_id TEXT NOT NULL, + channels JSONB NOT NULL, + messages JSONB NOT NULL, + settings JSONB NOT NULL + ); + `; + return new this(pg); + } + + private constructor(private pg: Client) {} + + async create_bridge(br: Omit): Promise { + const id = ulid(); + + await this.pg.queryArray` + INSERT INTO bridges (id, name, channels, settings) + VALUES (${id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ + JSON.stringify(br.settings) + }) + `; + + return { id, ...br }; + } + + async edit_bridge(br: bridge): Promise { + await this.pg.queryArray` + UPDATE bridges + SET channels = ${JSON.stringify(br.channels)}, + settings = ${JSON.stringify(br.settings)} + WHERE id = ${br.id} + `; + } + + async get_bridge_by_id(id: string): Promise { + const res = await this.pg.queryObject` + SELECT * FROM bridges WHERE id = ${id} + `; + + return res.rows[0]; + } + + async get_bridge_by_channel(ch: string): Promise { + const res = await this.pg.queryObject(` + SELECT * FROM bridges WHERE EXISTS ( + SELECT 1 FROM jsonb_array_elements(channels) AS ch + WHERE ch->>'id' = '${ch}' + ) + `); + + return res.rows[0]; + } + + async create_message(msg: bridge_message): Promise { + await this.pg.queryArray`INSERT INTO bridge_messages + (id, bridge_id, channels, messages, settings) VALUES + (${msg.id}, ${msg.bridge_id}, ${JSON.stringify(msg.channels)}, ${ + JSON.stringify(msg.messages) + }, ${JSON.stringify(msg.settings)}) + `; + } + + async edit_message(msg: bridge_message): Promise { + await this.pg.queryArray` + UPDATE bridge_messages + SET messages = ${JSON.stringify(msg.messages)}, + channels = ${JSON.stringify(msg.channels)}, + settings = ${JSON.stringify(msg.settings)} + WHERE id = ${msg.id} + `; + } + + async delete_message({ id }: bridge_message): Promise { + await this.pg.queryArray` + DELETE FROM bridge_messages WHERE id = ${id} + `; + } + + async get_message(id: string): Promise { + const res = await this.pg.queryObject(` + SELECT * FROM bridge_messages + WHERE id = '${id}' OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(messages) AS msg + WHERE EXISTS ( + SELECT 1 FROM jsonb_array_elements(msg->'id') AS id + WHERE id = '${id}' + ) + ) + `); + + return res.rows[0]; + } +} diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts new file mode 100644 index 00000000..69b6ecba --- /dev/null +++ b/packages/lightning/src/database/redis.ts @@ -0,0 +1,74 @@ +import { RedisClient } from '@iuioiua/r2d2'; +import { ulid } from '@std/ulid'; +import type { bridge } from '../structures/bridge.ts'; +import type { bridge_data } from './mod.ts'; +import { redis_bridge_message_handler } from './redis_message.ts'; + +export type redis_config = Deno.ConnectOptions; + +export class redis extends redis_bridge_message_handler implements bridge_data { + static async create(rd_options: Deno.ConnectOptions): Promise { + const conn = await Deno.connect(rd_options); + const client = new RedisClient(conn); + const db_data_version = await client.sendCommand([ + 'GET', + 'lightning-db-version', + ]); + + if (db_data_version !== '0.8.0') { + console.warn( + `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, + ); + + // TODO(jersey): use code to handle 0.7.x bridges + // basically just need to migrate anything that starts with lightning-bridge- + // allow_editing and use_rawname just get mvoed to the settings object + // and then everything else is all set + + throw 'not implemented'; + } + + return new this(client); + } + + private constructor(public redis: RedisClient) { + super(); + } + + async create_bridge(br: Omit): Promise { + const id = ulid(); + + await this.edit_bridge({ id, ...br }); + + return { id, ...br }; + } + + async edit_bridge(br: bridge): Promise { + await this.redis.sendCommand([ + 'SET', + `lightning-bridge-${br.id}`, + JSON.stringify({ ...br, name }), + ]); + + for (const channel of br.channels) { + await this.redis.sendCommand([ + 'SET', + `lightning-bchannel-${channel.id}`, + br.id, + ]); + } + } + + async get_bridge_by_id(id: string): Promise { + return await this.get_json(`lightning-bridge-${id}`); + } + + async get_bridge_by_channel(ch: string): Promise { + const channel = await this.redis.sendCommand([ + 'GET', + `lightning-bchannel-${ch}`, + ]); + if (!channel || channel === 'OK') return; + return await this.get_json(`lightning-bridge-${channel}`); + } +} diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts new file mode 100644 index 00000000..eaf7605a --- /dev/null +++ b/packages/lightning/src/database/redis_message.ts @@ -0,0 +1,71 @@ +import type { RedisClient } from '@iuioiua/r2d2'; +import type { bridge_channel, bridge_message, bridged_message } from '../structures/bridge.ts'; + +interface redis_bridge_message { + allow_editing: boolean; + channels: bridge_channel[]; + id: string; + messages?: bridged_message[]; + use_rawname: boolean; +} + +export abstract class redis_bridge_message_handler { + abstract redis: RedisClient; + + async get_json(key: string): Promise { + const reply = await this.redis.sendCommand(['GET', key]); + if (!reply || reply === 'OK') return; + return JSON.parse(reply as string) as T; + } + + async create_message(msg: bridge_message): Promise { + const redis_msg: redis_bridge_message = { + allow_editing: msg.settings.allow_editing, + channels: msg.channels, + id: msg.bridge_id, + use_rawname: msg.settings.use_rawname, + messages: msg.messages, + }; + + await this.redis.sendCommand([ + 'SET', + 'lightning-message-${msg.id}', + JSON.stringify(redis_msg), + ]); + + for (const message of msg.messages) { + await this.redis.sendCommand([ + 'SET', + `lightning-message-${message.id}`, + JSON.stringify(redis_msg), + ]); + } + } + + async edit_message(msg: bridge_message): Promise { + await this.create_message(msg); + } + + async delete_message(msg: bridge_message): Promise { + await this.redis.sendCommand(['DEL', `lightning-message-${msg.id}`]); + } + + async get_message(id: string): Promise { + const msg = await this.get_json( + `lightning-message-${id}`, + ); + if (!msg) return; + + return { + bridge_id: msg.id, + channels: msg.channels, + messages: msg.messages || [], + settings: { + allow_editing: msg.allow_editing, + use_rawname: msg.use_rawname, + allow_everyone: true, + }, + id, + }; + } +} \ No newline at end of file diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index 37178599..ce864726 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -1,8 +1,11 @@ -import type { ClientOptions } from '@db/postgres'; import { bridge_message } from './bridge.ts'; import { default_commands } from './commands/default.ts'; import { execute_text_command, run_command } from './commands/runners.ts'; -import { bridge_data } from './database.ts'; +import { + type bridge_data, + create_database, + type database_config, +} from './database/mod.ts'; import type { command, create_command, @@ -16,7 +19,7 @@ export interface config { /** error URL */ error_url?: string; /** database options */ - postgres: ClientOptions; + database: database_config; /** a list of plugins */ // deno-lint-ignore no-explicit-any plugins?: create_plugin[]; @@ -74,7 +77,7 @@ export class lightning { /** create a new instance of lightning */ static async create(config: config): Promise { - const data = await bridge_data.create(config.postgres); + const data = await create_database(config.database); return new lightning(data, config); } diff --git a/packages/lightning-plugin-revolt/README.md b/packages/revolt/README.md similarity index 100% rename from packages/lightning-plugin-revolt/README.md rename to packages/revolt/README.md diff --git a/packages/lightning-plugin-revolt/deno.json b/packages/revolt/deno.json similarity index 100% rename from packages/lightning-plugin-revolt/deno.json rename to packages/revolt/deno.json diff --git a/packages/lightning-plugin-revolt/license b/packages/revolt/license similarity index 100% rename from packages/lightning-plugin-revolt/license rename to packages/revolt/license diff --git a/packages/lightning-plugin-revolt/src/error_handler.ts b/packages/revolt/src/error_handler.ts similarity index 100% rename from packages/lightning-plugin-revolt/src/error_handler.ts rename to packages/revolt/src/error_handler.ts diff --git a/packages/lightning-plugin-revolt/src/mod.ts b/packages/revolt/src/mod.ts similarity index 100% rename from packages/lightning-plugin-revolt/src/mod.ts rename to packages/revolt/src/mod.ts diff --git a/packages/lightning-plugin-revolt/src/permissions.ts b/packages/revolt/src/permissions.ts similarity index 100% rename from packages/lightning-plugin-revolt/src/permissions.ts rename to packages/revolt/src/permissions.ts diff --git a/packages/lightning-plugin-revolt/src/to_lightning.ts b/packages/revolt/src/to_lightning.ts similarity index 100% rename from packages/lightning-plugin-revolt/src/to_lightning.ts rename to packages/revolt/src/to_lightning.ts diff --git a/packages/lightning-plugin-revolt/src/to_revolt.ts b/packages/revolt/src/to_revolt.ts similarity index 100% rename from packages/lightning-plugin-revolt/src/to_revolt.ts rename to packages/revolt/src/to_revolt.ts diff --git a/packages/lightning-plugin-telegram/README.md b/packages/telegram/README.md similarity index 100% rename from packages/lightning-plugin-telegram/README.md rename to packages/telegram/README.md diff --git a/packages/lightning-plugin-telegram/deno.json b/packages/telegram/deno.json similarity index 100% rename from packages/lightning-plugin-telegram/deno.json rename to packages/telegram/deno.json diff --git a/packages/lightning-plugin-telegram/license b/packages/telegram/license similarity index 100% rename from packages/lightning-plugin-telegram/license rename to packages/telegram/license diff --git a/packages/lightning-plugin-telegram/src/file_proxy.ts b/packages/telegram/src/file_proxy.ts similarity index 100% rename from packages/lightning-plugin-telegram/src/file_proxy.ts rename to packages/telegram/src/file_proxy.ts diff --git a/packages/lightning-plugin-telegram/src/messages.ts b/packages/telegram/src/messages.ts similarity index 100% rename from packages/lightning-plugin-telegram/src/messages.ts rename to packages/telegram/src/messages.ts diff --git a/packages/lightning-plugin-telegram/src/mod.ts b/packages/telegram/src/mod.ts similarity index 100% rename from packages/lightning-plugin-telegram/src/mod.ts rename to packages/telegram/src/mod.ts From e09c9eb290d23f85c20f71c4cf98038283698bf2 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 21 Dec 2024 23:07:38 -0500 Subject: [PATCH 28/97] revolt fixes --- packages/revolt/deno.json | 2 +- packages/revolt/src/to_revolt.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index 287f102b..fd3dddb5 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -4,7 +4,7 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.4", + "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.5", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.7.14", "@std/ulid": "jsr:@std/ulid@^1.0.0" } diff --git a/packages/revolt/src/to_revolt.ts b/packages/revolt/src/to_revolt.ts index bc8582f0..493a4fcf 100644 --- a/packages/revolt/src/to_revolt.ts +++ b/packages/revolt/src/to_revolt.ts @@ -12,17 +12,23 @@ export async function to_revolt( message: message, masquerade = true, ): Promise { + const attachments = await upload_attachments(api, message.attachments); + const embeds = map_embeds(message.embeds); + if ( - !message.content && (!message.embeds || message.embeds.length < 1) && - (!message.attachments || message.attachments.length < 1) + (!message.content || message.content.length < 1) && + (!embeds || embeds.length < 1) && + (!attachments || attachments.length < 1) ) { message.content = '*empty message*'; } return { - attachments: await upload_attachments(api, message.attachments), - content: message.content, - embeds: map_embeds(message.embeds), + attachments, + content: (message.content?.length || 0) > 2000 + ? `${message.content?.substring(0, 1997)}...` + : message.content, + embeds, replies: message.reply_id ? [{ id: message.reply_id, mention: true }] : undefined, From 5512eadae7f52d939103a8398fee8e1a736f1752 Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 23 Dec 2024 23:24:11 -0500 Subject: [PATCH 29/97] updated documentation, explicit return types, string changes --- deno.jsonc | 2 +- packages/discord/README.md | 4 +- packages/discord/src/mod.ts | 8 +- packages/guilded/README.md | 4 +- packages/guilded/src/mod.ts | 12 +- packages/lightning/README.md | 33 ++++- packages/lightning/src/cli.ts | 3 - .../src/commands/bridge/_internal.ts | 2 +- .../lightning/src/commands/bridge/create.ts | 4 +- packages/lightning/src/commands/default.ts | 2 +- packages/lightning/src/database/mongo.ts | 54 +++++---- packages/lightning/src/database/postgres.ts | 5 - .../lightning/src/database/redis_message.ts | 113 ++++++++++-------- packages/lightning/src/structures/bridge.ts | 2 +- packages/revolt/README.md | 4 +- packages/revolt/src/mod.ts | 8 +- packages/telegram/README.md | 2 +- packages/telegram/src/mod.ts | 8 +- readme.md | 74 +++++++++++- 19 files changed, 220 insertions(+), 124 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index 1c01784a..05dc67d9 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -21,7 +21,7 @@ }, "workspace": [ "./packages/lightning", - // TODO(jersey): upstream pr + // TODO(jersey): denodrivers/postgres#487 "./packages/postgres", "./packages/telegram", "./packages/revolt", diff --git a/packages/discord/README.md b/packages/discord/README.md index 1a288dd8..a9828295 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -1,7 +1,7 @@ # lightning-plugin-discord lightning-plugin-discord is a plugin for -[lightning](https://williamhroning.eu.org/lightning) that adds support for +[lightning](https://williamhorning.eu.org/lightning) that adds support for discord ## example config @@ -11,8 +11,6 @@ import type { config } from 'jsr:@jersey/lightning@0.7.4'; import { discord_plugin } from 'jsr:@jersey/lightning-plugin-discord@0.7.4'; export default { - redis_host: 'localhost', - redis_port: 6379, plugins: [ discord_plugin.new({ app_id: 'your_application_id', diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index d0050e73..16a1557c 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -71,19 +71,19 @@ export class discord_plugin extends plugin { }); } - async setup_channel(channel: string) { + async setup_channel(channel: string): Promise { return await bridge.setup_bridge(this.api, channel); } - async create_message(opts: create_opts) { + async create_message(opts: create_opts): Promise { return await bridge.create_message(this.api, opts); } - async edit_message(opts: edit_opts) { + async edit_message(opts: edit_opts): Promise { return await bridge.edit_message(this.api, opts); } - async delete_message(opts: delete_opts) { + async delete_message(opts: delete_opts): Promise { return await bridge.delete_message(this.api, opts); } } diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 9d684645..bb95016b 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -1,7 +1,7 @@ # lightning-plugin-guilded lightning-plugin-guilded is a plugin for -[lightning](https://williamhroning.eu.org/lightning) that adds support for +[lightning](https://williamhorning.eu.org/lightning) that adds support for telegram ## example config @@ -11,8 +11,6 @@ import type { config } from 'jsr:@jersey/lightning@0.7.4'; import { guilded_plugin } from 'jsr:@jersey/lightning-plugin-guilded@0.7.4'; export default { - redis_host: 'localhost', - redis_port: 6379, plugins: [ guilded_plugin.new({ token: 'your_token', diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index eb244847..1cad770b 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -11,8 +11,6 @@ import { error_handler } from './error_handler.ts'; import { convert_msg } from './guilded.ts'; import { guilded_to_message } from './guilded_message/mod.ts'; -// TODO(jersey): TEST THIS CODE - /** options for the guilded plugin */ export interface guilded_config { /** the token to use */ @@ -63,7 +61,7 @@ export class guilded_plugin extends plugin { }); } - async setup_channel(channel: string) { + async setup_channel(channel: string): Promise { try { // TODO(jersey): it may be worth it to add server/guild id to the message type... const { serverId } = await this.bot.channels.fetch(channel); @@ -76,12 +74,14 @@ export class guilded_plugin extends plugin { extra: { webhook: webhook.raw }, }); } + + return { id: webhook.id, token: webhook.token }; } catch (e) { return error_handler(e, channel, 'creating webhook'); } } - async create_message(opts: create_opts) { + async create_message(opts: create_opts): Promise { try { const webhook = new WebhookClient( opts.channel.data as { id: string; token: string }, @@ -103,12 +103,12 @@ export class guilded_plugin extends plugin { } // deno-lint-ignore require-await - async edit_message(opts: edit_opts) { + async edit_message(opts: edit_opts): Promise { // guilded does not support editing messages return opts.edit_ids; } - async delete_message(opts: delete_opts) { + async delete_message(opts: delete_opts): Promise { try { await this.bot.messages.delete(opts.channel.id, opts.edit_ids[0]); diff --git a/packages/lightning/README.md b/packages/lightning/README.md index 5778ea78..9268cec0 100644 --- a/packages/lightning/README.md +++ b/packages/lightning/README.md @@ -5,9 +5,36 @@ lightning is a typescript-based chatbot that supports bridging multiple chat apps via plugins -## [docs](https://williamhorning.eu.org/bolt) +## [docs](https://williamhorning.eu.org/lightning) + +## example config ```ts -import {} from '@jersey/lightning'; -// TODO(jersey): add example +import { discord_plugin } from 'jsr:@jersey/lightning-plugin-discord@0.8.0'; +import { revolt_plugin } from 'jsr:@jersey/lightning-plugin-revolt@0.8.0'; + +export default { + prefix: '!', + database: { + type: 'postgres', + config: { + user: 'server', + database: 'lightning', + hostname: 'postgres', + port: 5432, + host_type: 'tcp', + }, + }, + plugins: [ + discord_plugin.new({ + token: 'your_token', + application_id: 'your_application_id', + slash_commands: true, + }), + revolt_plugin.new({ + token: 'your_token', + user_id: 'your_bot_user_id', + }), + ], +}; ``` diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 69381d99..2df4c52a 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -34,8 +34,6 @@ if (_.v || _.version) { } catch (e) { log_error(e, { extra: { type: 'global class error' } }); } -} else if (_._[0] === 'migrations') { - // TODO(jersey): implement migrations (separate module?) } else { console.log('[lightning] command not found, showing help'); run_help(); @@ -48,7 +46,6 @@ function run_help() { console.log(' Usage: lightning [subcommand] '); console.log(' Subcommands:'); console.log(' run: run a lightning instance'); - console.log(' migrations: run migration script'); console.log(' Options:'); console.log(' -h, --help: display this help message'); console.log(' -v, --version: display the version number'); diff --git a/packages/lightning/src/commands/bridge/_internal.ts b/packages/lightning/src/commands/bridge/_internal.ts index df3c6560..b2f1d4e7 100644 --- a/packages/lightning/src/commands/bridge/_internal.ts +++ b/packages/lightning/src/commands/bridge/_internal.ts @@ -9,7 +9,7 @@ export async function bridge_add_common( ); if (existing_bridge) { - return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.lightning.config.prefix}leave\` or \`${opts.lightning.config.prefix}help\` commands.`; + return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.lightning.config.prefix}bridge leave\` or \`${opts.lightning.config.prefix}help\` commands.`; } const plugin = opts.lightning.plugins.get(opts.plugin); diff --git a/packages/lightning/src/commands/bridge/create.ts b/packages/lightning/src/commands/bridge/create.ts index 62b1ed06..b16482de 100644 --- a/packages/lightning/src/commands/bridge/create.ts +++ b/packages/lightning/src/commands/bridge/create.ts @@ -20,8 +20,8 @@ export async function create( }; try { - await opts.lightning.data.create_bridge(bridge_data); - return `Bridge created successfully! You can now join it using \`${opts.lightning.config.prefix}join ${result.id}\`. Keep this id safe, don't share it with anyone, and delete this message.`; + const { id } = await opts.lightning.data.create_bridge(bridge_data); + return `Bridge created successfully!\nYou can now join it using \`${opts.lightning.config.prefix}bridge join ${id}\`.\nKeep this ID safe, don't share it with anyone, and delete this message.`; } catch (e) { log_error(e, { message: 'Failed to insert bridge into database', diff --git a/packages/lightning/src/commands/default.ts b/packages/lightning/src/commands/default.ts index 2c741b5e..880508b2 100644 --- a/packages/lightning/src/commands/default.ts +++ b/packages/lightning/src/commands/default.ts @@ -10,7 +10,7 @@ export const default_commands = new Map([ name: 'help', description: 'get help with the bot', execute: () => - 'check out [the docs](https://williamhorning.eu.org/bolt/) for help.', + 'check out [the docs](https://williamhorning.eu.org/lightning/) for help.', }], ['ping', { name: 'ping', diff --git a/packages/lightning/src/database/mongo.ts b/packages/lightning/src/database/mongo.ts index d9a3289d..4ee7538e 100644 --- a/packages/lightning/src/database/mongo.ts +++ b/packages/lightning/src/database/mongo.ts @@ -1,13 +1,14 @@ +import { type Collection, type ConnectOptions, MongoClient } from '@db/mongo'; +import { RedisClient } from '@iuioiua/r2d2'; import { ulid } from '@std/ulid'; import type { bridge } from '../structures/bridge.ts'; +import { log_error } from '../structures/errors.ts'; import type { bridge_data } from './mod.ts'; -import { type Collection, type ConnectOptions, MongoClient } from '@db/mongo'; import { redis_bridge_message_handler } from './redis_message.ts'; -import { RedisClient } from '@iuioiua/r2d2'; export type mongo_config = { - database: ConnectOptions | string; - redis: Deno.ConnectOptions; + database: ConnectOptions | string; + redis: Deno.ConnectOptions; }; export class mongo extends redis_bridge_message_handler implements bridge_data { @@ -15,33 +16,44 @@ export class mongo extends redis_bridge_message_handler implements bridge_data { const client = new MongoClient(); await client.connect(opts.database); - const database = client.database(); - const db_data_version = await database.collection('lightning').findOne({ _id: 'db_data_version' }); - const bridge_collection_exists = (await database.listCollectionNames()).includes('bridges'); - - if (db_data_version?.version !== '0.8.0' && bridge_collection_exists) { - const version = db_data_version?.version ?? 'unknown'; + const database = client.database(); + const db_data_version = await database.collection('lightning').findOne({ + _id: 'db_data_version', + }); + const bridge_collection_exists = (await database.listCollectionNames()) + .includes('bridges'); - console.warn(`[lightning-mongo] migrating database from ${version} to 0.8.0`); - - // TODO(jersey): use code to feature detect the version if not present and then migrate - // it may be worth it to just allow migrations from the last version before redisforeverything - // and have anything prior use the migration script from back then + if (db_data_version?.version !== '0.8.0' && bridge_collection_exists) { + log_error( + 'Please delete the bridge collection or follow the migrations process in the documentation', + { + extra: { + see: + 'https://williamhorning.eu.org/lightning/hosting/legacy-migrations', + }, + }, + ); + } else if (!db_data_version && !bridge_collection_exists) { + await database.collection('lightning').insertOne({ + _id: 'db_data_version', + version: '0.8.0', + }); + await database.createCollection('bridges'); + } - throw "not implemented"; - } + const redis = new RedisClient(await Deno.connect(opts.redis)); - const redis = new RedisClient(await Deno.connect(opts.redis)); + // TODO(jersey): handle redis migrations here if applicable? return new this(database.collection('bridges'), redis); } private constructor( private bridges: Collection, - public redis: RedisClient, + public redis: RedisClient, ) { - super(); - } + super(); + } async create_bridge(br: Omit): Promise { const id = ulid(); diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index 01a1e537..ec159449 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -5,11 +5,6 @@ import type { bridge_data } from './mod.ts'; export type { ClientOptions as postgres_config }; -/** - * unfortunately this code only works with denodrivers/postgres#487 - * ideally jsr support and modernization would be merged but who knows - */ - export class postgres implements bridge_data { static async create(pg_options: ClientOptions): Promise { const pg = new Client(pg_options); diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index eaf7605a..cb466519 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -1,71 +1,78 @@ import type { RedisClient } from '@iuioiua/r2d2'; -import type { bridge_channel, bridge_message, bridged_message } from '../structures/bridge.ts'; +import type { + bridge_channel, + bridge_message, + bridged_message, +} from '../structures/bridge.ts'; interface redis_bridge_message { - allow_editing: boolean; - channels: bridge_channel[]; - id: string; - messages?: bridged_message[]; - use_rawname: boolean; + allow_editing: boolean; + channels: bridge_channel[]; + id: string; + messages?: bridged_message[]; + use_rawname: boolean; } +// TODO(jersey): the redis_bridge_message structure sucks and if we're doing migrations +// (see ./redis), there's no point to not use the bridge_message structure + export abstract class redis_bridge_message_handler { - abstract redis: RedisClient; + abstract redis: RedisClient; - async get_json(key: string): Promise { + async get_json(key: string): Promise { const reply = await this.redis.sendCommand(['GET', key]); if (!reply || reply === 'OK') return; return JSON.parse(reply as string) as T; } - async create_message(msg: bridge_message): Promise { - const redis_msg: redis_bridge_message = { - allow_editing: msg.settings.allow_editing, - channels: msg.channels, - id: msg.bridge_id, - use_rawname: msg.settings.use_rawname, - messages: msg.messages, - }; + async create_message(msg: bridge_message): Promise { + const redis_msg: redis_bridge_message = { + allow_editing: msg.settings.allow_editing, + channels: msg.channels, + id: msg.bridge_id, + use_rawname: msg.settings.use_rawname, + messages: msg.messages, + }; - await this.redis.sendCommand([ - 'SET', - 'lightning-message-${msg.id}', - JSON.stringify(redis_msg), - ]); + await this.redis.sendCommand([ + 'SET', + 'lightning-message-${msg.id}', + JSON.stringify(redis_msg), + ]); - for (const message of msg.messages) { - await this.redis.sendCommand([ - 'SET', - `lightning-message-${message.id}`, - JSON.stringify(redis_msg), - ]); - } - } + for (const message of msg.messages) { + await this.redis.sendCommand([ + 'SET', + `lightning-message-${message.id}`, + JSON.stringify(redis_msg), + ]); + } + } - async edit_message(msg: bridge_message): Promise { - await this.create_message(msg); - } + async edit_message(msg: bridge_message): Promise { + await this.create_message(msg); + } - async delete_message(msg: bridge_message): Promise { - await this.redis.sendCommand(['DEL', `lightning-message-${msg.id}`]); - } + async delete_message(msg: bridge_message): Promise { + await this.redis.sendCommand(['DEL', `lightning-message-${msg.id}`]); + } - async get_message(id: string): Promise { - const msg = await this.get_json( - `lightning-message-${id}`, - ); - if (!msg) return; + async get_message(id: string): Promise { + const msg = await this.get_json( + `lightning-message-${id}`, + ); + if (!msg) return; - return { - bridge_id: msg.id, - channels: msg.channels, - messages: msg.messages || [], - settings: { - allow_editing: msg.allow_editing, - use_rawname: msg.use_rawname, - allow_everyone: true, - }, - id, - }; - } -} \ No newline at end of file + return { + bridge_id: msg.id, + channels: msg.channels, + messages: msg.messages || [], + settings: { + allow_editing: msg.allow_editing, + use_rawname: msg.use_rawname, + allow_everyone: true, + }, + id, + }; + } +} diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index 948da554..1c535059 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -28,7 +28,7 @@ export interface bridge_channel { export interface bridge_settings { /** allow editing/deletion */ allow_editing: boolean; - /** @everyone/@here/@room */ + /** `@everyone/@here/@room` */ allow_everyone: boolean; /** rawname = username */ use_rawname: boolean; diff --git a/packages/revolt/README.md b/packages/revolt/README.md index affab750..3e422d91 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -1,7 +1,7 @@ # lightning-plugin-revolt lightning-plugin-revolt is a plugin for -[lightning](https://williamhroning.eu.org/lightning) that adds support for +[lightning](https://williamhorning.eu.org/lightning) that adds support for telegram ## example config @@ -11,8 +11,6 @@ import type { config } from 'jsr:@jersey/lightning@0.7.4'; import { revolt_plugin } from 'jsr:@jersey/lightning-plugin-revolt@0.7.4'; export default { - redis_host: 'localhost', - redis_port: 6379, plugins: [ revolt_plugin.new({ token: 'your_token', diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 0d80604c..6d284e51 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -68,11 +68,11 @@ export class revolt_plugin extends plugin { }); } - async setup_channel(channel: string) { + async setup_channel(channel: string): Promise { return await check_permissions(channel, this.bot, this.config.user_id); } - async create_message(opts: create_opts) { + async create_message(opts: create_opts): Promise { try { const { _id } = (await this.bot.request( 'post', @@ -86,7 +86,7 @@ export class revolt_plugin extends plugin { } } - async edit_message(opts: edit_opts) { + async edit_message(opts: edit_opts): Promise { try { await this.bot.request( 'patch', @@ -100,7 +100,7 @@ export class revolt_plugin extends plugin { } } - async delete_message(opts: delete_opts) { + async delete_message(opts: delete_opts): Promise { try { await this.bot.request( 'delete', diff --git a/packages/telegram/README.md b/packages/telegram/README.md index fedede19..db2c1399 100644 --- a/packages/telegram/README.md +++ b/packages/telegram/README.md @@ -1,7 +1,7 @@ # lightning-plugin-telegram lightning-plugin-telegram is a plugin for -[lightning](https://williamhroning.eu.org/lightning) that adds support for +[lightning](https://williamhorning.eu.org/lightning) that adds support for telegram (including attachments via the included file proxy) ## example config diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 8e0aed5e..6a185d88 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -43,11 +43,11 @@ export class telegram_plugin extends plugin { } /** create a bridge */ - setup_channel(channel: string) { + setup_channel(channel: string): unknown { return channel; } - async create_message(opts: create_opts) { + async create_message(opts: create_opts): Promise { const content = from_lightning(opts.msg); const messages = []; @@ -71,7 +71,7 @@ export class telegram_plugin extends plugin { return messages; } - async edit_message(opts: edit_opts) { + async edit_message(opts: edit_opts): Promise { const content = from_lightning(opts.msg)[0]; await this.bot.api.editMessageText( @@ -86,7 +86,7 @@ export class telegram_plugin extends plugin { return opts.edit_ids; } - async delete_message(opts: delete_opts) { + async delete_message(opts: delete_opts): Promise { for (const id of opts.edit_ids) { await this.bot.api.deleteMessage( opts.channel.id, diff --git a/readme.md b/readme.md index d739d645..041692d4 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,70 @@ -# lightning +![lightning logo](./packages/lightning/logo.svg) -lightning is a typescript-based chatbot that supports bridging multiple chat -apps via plugins. this repo contains lightning and some of the plugins used in -bolt. to learn more, take a look at the -[docs](https://williamhorning.eu.org/bolt) +# lightning - a chatbot + +- **Connecting Communities**: bridges many popular messaging apps +- **Extensible**: support for messaging apps provided by plugins which can be + enabled/disabled by the user +- **Easy to run**: able to run in Docker with multiple database options +- **Based on TypeScript**: uses the flexibility of JavaScript along with the + safety provided by typing and Deno + +## documentation + +- [_User Guide_](https://williamhorning.eu.org/lightning/users) +- [_Hosting Docs_](https://williamhorning.eu.org/lightning/hosting) +- [_Development Docs_](https://williamhorning.eu.org/lightning/developer) + +## the problem - and solution + +If you've ever had a community, chances are you talk to them in many different +places, whether that be on Discord, Revolt, Telegram, or Guilded. Over time, +however, you end up with fragmentation as your community starts to grow and +change. Many people end up using multiple messaging apps only for various +versions of your community, people get upset about the differences between the +messaging apps in your community, and it becomes a mess. + +Now, you could just say "_X is the only chat app we're using from now on_", but +that risks alienating your community. + +What other options are there? Bridging! Everyone gets to use their prefered app +of choice, gets the same messages, and is on the same page. + +## prior art + +Many bridges have existed before the existance of lightning, however, many of +these solutions have had issues. Some bridges didn't play well with others, +others didn't handle attachments, others refused to handle embeded media, and it +was a mess. With lightning, part of the goal was to solve these issues by +bringing many platforms into one tool, having it become the handler of truth. + +## supported platforms + +Currently, the following platforms are supported: Discord, Guilded, Revolt, and +Telegram. Support for more platforms is possible to do, however, support for +these platforms should be up to par with support for other platforms and +messages should be presented as similarly to other messages as possible, subject +to platform limitations. + +### matrix notes + +The Matrix Specification is really difficult to correctly handle, especially +with the current state of JavaScript libraries. Solutions that work without a +reliance on `matrix-appservice-bridge` but still use JavaScript and are +_consistently reliable_ aren't easy to implement and currently I don't have time +to work on implementing this. If you would like to implement Matrix support, +please take a look at #66 for a prior attempt of mine. + +### requesting another platform + +If you would like support for another platform, please open an issue! I'd love +to add support for more platforms, though there are a few requirements they +should fulfil: + +1. having a pre-existing substantial user base +2. having JavaScript libraries with decent code quality +3. having rich-messaging support of some kind + +## licensing + +lightning is available under the MIT license From 2b7f60c71753ee16d3d8b51c6c740047537503bf Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 23 Dec 2024 23:36:36 -0500 Subject: [PATCH 30/97] remove leftover todos --- deno.jsonc | 2 +- packages/guilded/src/mod.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index 05dc67d9..125cf5a8 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -21,7 +21,7 @@ }, "workspace": [ "./packages/lightning", - // TODO(jersey): denodrivers/postgres#487 + // TODO(jersey) "./packages/postgres", "./packages/telegram", "./packages/revolt", diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 1cad770b..82520390 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -63,7 +63,6 @@ export class guilded_plugin extends plugin { async setup_channel(channel: string): Promise { try { - // TODO(jersey): it may be worth it to add server/guild id to the message type... const { serverId } = await this.bot.channels.fetch(channel); const webhook = await this.bot.webhooks.create(serverId, { channelId: channel, From 58b7b137bcd718154006f196307e125612c9f887 Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 23 Dec 2024 23:44:12 -0500 Subject: [PATCH 31/97] turns out the issue was fixed --- packages/lightning/src/cli.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 2df4c52a..4a37164b 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -11,7 +11,6 @@ if (_.v || _.version) { } else if (_.h || _.help) { run_help(); } else if (_._[0] === 'run') { - // TODO(jersey): this is somewhat broken when acting as a JSR package if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); const config = (await import(toFileUrl(_.config).toString())) From 58ccb7446f2001c1a7c99f90fb10054b82256b76 Mon Sep 17 00:00:00 2001 From: Jersey Date: Tue, 24 Dec 2024 16:22:54 -0500 Subject: [PATCH 32/97] changes for jsr --- packages/discord/src/mod.ts | 1 + packages/lightning/deno.jsonc | 3 ++- packages/revolt/deno.json | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 16a1557c..3e2ca1f9 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -25,6 +25,7 @@ export interface discord_config { application_id: string; } +/** the plugin to use */ export class discord_plugin extends plugin { name = 'bolt-discord'; private api: Client['api']; diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index bf6e3c1f..ad30623a 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -3,7 +3,8 @@ "version": "0.8.0", "exports": "./src/mod.ts", "imports": { - "@db/mongo": "jsr:@db/mongo@^0.33.0", + // TODO(jersey): get updated @db/mongo w/updated web_bson + "@db/mongo": "jsr:@db/mongo@^0.34.0", "@db/postgres": "jsr:@db/postgres@^0.19.4", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", "@iuioiua/r2d2": "jsr:@iuioiua/r2d2@2.1.2", diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index fd3dddb5..a5a56c00 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -4,7 +4,7 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.5", + "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.6", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.7.14", "@std/ulid": "jsr:@std/ulid@^1.0.0" } From 8d557220e531f0a348c70ccac8cddbd7e92868f3 Mon Sep 17 00:00:00 2001 From: Jersey Date: Tue, 24 Dec 2024 18:04:08 -0500 Subject: [PATCH 33/97] use Deno 2.1.4 --- .github/workflows/publish.yml | 2 +- packages/lightning/dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2324c4cf..b7d638c2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: - name: setup deno uses: denoland/setup-deno@v1 with: - deno-version: v1.45.5 + deno-version: v2.1.4 - name: setup qemu uses: docker/setup-qemu-action@v3 - name: setup buildx diff --git a/packages/lightning/dockerfile b/packages/lightning/dockerfile index 817f3e77..9a88c1be 100644 --- a/packages/lightning/dockerfile +++ b/packages/lightning/dockerfile @@ -1,4 +1,4 @@ -FROM docker.io/denoland/deno:2.0.3 +FROM docker.io/denoland/deno:2.1.4 # add lightning to the image RUN deno install -g -A --unstable-temporal ./cli/mod.ts From 1318d239ece8458be5447e037b73ebdd07b8bec5 Mon Sep 17 00:00:00 2001 From: Jersey Date: Tue, 24 Dec 2024 21:09:12 -0500 Subject: [PATCH 34/97] fix discord file size thing and formatting --- packages/discord/src/discord_message/files.ts | 4 +- packages/discord/src/mod.ts | 8 +- packages/discord/src/to_lightning/deleted.ts | 13 ---- packages/lightning/src/cli.ts | 5 +- packages/lightning/src/lightning.ts | 12 +-- packages/lightning/src/structures/errors.ts | 78 ++++++++++--------- packages/lightning/src/structures/messages.ts | 3 +- packages/telegram/src/file_proxy.ts | 8 +- 8 files changed, 62 insertions(+), 69 deletions(-) delete mode 100644 packages/discord/src/to_lightning/deleted.ts diff --git a/packages/discord/src/discord_message/files.ts b/packages/discord/src/discord_message/files.ts index b5e5e2b8..67c56528 100644 --- a/packages/discord/src/discord_message/files.ts +++ b/packages/discord/src/discord_message/files.ts @@ -5,7 +5,7 @@ export async function files_up_to_25MiB(attachments: attachment[] | undefined) { if (!attachments) return; const files: RawFile[] = []; - const total_size = 0; + let total_size = 0; for (const attachment of attachments) { if (attachment.size >= 25) continue; @@ -22,6 +22,8 @@ export async function files_up_to_25MiB(attachments: attachment[] | undefined) { name: attachment.name ?? attachment.file.split('/').pop()!, data, }); + + total_size += attachment.size; } catch { continue; } diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 3e2ca1f9..6a736c18 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -12,7 +12,6 @@ import { GatewayDispatchEvents } from 'discord-api-types'; import * as bridge from './bridge_to_discord.ts'; import { setup_slash_commands } from './slash_commands.ts'; import { command_to } from './to_lightning/command.ts'; -import { deleted } from './to_lightning/deleted.ts'; import { message } from './to_lightning/message.ts'; /** configuration for the discord plugin */ @@ -64,7 +63,12 @@ export class discord_plugin extends plugin { this.emit('edit_message', await message(msg.api, msg.data)); }); this.client.on(GatewayDispatchEvents.MessageDelete, (msg) => { - this.emit('delete_message', deleted(msg.data)); + this.emit('delete_message', { + channel: msg.data.channel_id, + id: msg.data.id, + plugin: 'bolt-discord', + timestamp: Temporal.Now.instant(), + }); }); this.client.on(GatewayDispatchEvents.InteractionCreate, (cmd) => { const command = command_to(cmd, this.lightning); diff --git a/packages/discord/src/to_lightning/deleted.ts b/packages/discord/src/to_lightning/deleted.ts deleted file mode 100644 index 30fe17c7..00000000 --- a/packages/discord/src/to_lightning/deleted.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { GatewayMessageDeleteDispatchData } from 'discord-api-types'; -import type { deleted_message } from '@jersey/lightning'; - -export function deleted( - message: GatewayMessageDeleteDispatchData, -): deleted_message { - return { - channel: message.channel_id, - id: message.id, - plugin: 'bolt-discord', - timestamp: Temporal.Now.instant(), - }; -} diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 4a37164b..9772aece 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -13,8 +13,9 @@ if (_.v || _.version) { } else if (_._[0] === 'run') { if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); - const config = (await import(toFileUrl(_.config).toString())) - .default as config; + const config_url = toFileUrl(_.config).toString(); + + const config = (await import(config_url)).default as config; if (config?.error_url) { Deno.env.set('LIGHTNING_ERROR_WEBHOOK', config.error_url); diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index ce864726..03f84590 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -44,17 +44,17 @@ export class lightning { this.config = config; this.plugins = new Map>(); - for (const p of this.config.plugins || []) { - if (p.support.includes('0.8.0')) { - const plugin = new p.type(this, p.config); - this.plugins.set(plugin.name, plugin); - this._handle_events(plugin); + for (const plugin of this.config.plugins || []) { + if (plugin.support.includes('0.8.0')) { + const plugin_instance = new plugin.type(this, plugin.config); + this.plugins.set(plugin_instance.name, plugin_instance); + this.handle_events(plugin_instance); } } } /** event handler */ - private async _handle_events(plugin: plugin) { + private async handle_events(plugin: plugin) { for await (const { name, value } of plugin) { await new Promise((res) => setTimeout(res, 150)); diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index 9dee06a1..56c105c3 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -59,49 +59,51 @@ export class LightningError extends Error { `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\``, ); - // the error-logging async fun - (async () => { - console.error(`%c[lightning] error ${id}`, 'color: red'); - console.error(cause, this.options); + this.log(); + } - const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); + /** log the error */ + private async log(): Promise { + console.error(`%c[lightning] error ${this.id}`, 'color: red'); + console.error(this.cause, this.options); - for (const key in this.options?.extra) { - if (key === 'lightning') { - delete this.options.extra[key]; - } + const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); - if ( - typeof this.options.extra[key] === 'object' && - this.options.extra[key] !== null - ) { - if ('lightning' in this.options.extra[key]) { - delete this.options.extra[key].lightning; - } - } + for (const key in this.options?.extra) { + if (key === 'lightning') { + delete this.options.extra[key]; } - if (webhook && webhook.length > 0) { - let json_str = `\`\`\`json\n${ - JSON.stringify(this.options?.extra, null, 2) - }\n\`\`\``; - - if (json_str.length > 2000) json_str = '*see console*'; - - await fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: `# ${cause.message}\n*${id}*`, - embeds: [ - { - title: 'extra', - description: json_str, - }, - ], - }), - }); + if ( + typeof this.options.extra[key] === 'object' && + this.options.extra[key] !== null + ) { + if ('lightning' in this.options.extra[key]) { + delete this.options.extra[key].lightning; + } } - })(); + } + + if (webhook && webhook.length > 0) { + let json_str = `\`\`\`json\n${ + JSON.stringify(this.options?.extra, null, 2) + }\n\`\`\``; + + if (json_str.length > 2000) json_str = '*see console*'; + + await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: `# ${this.cause.message}\n*${this.id}*`, + embeds: [ + { + title: 'extra', + description: json_str, + }, + ], + }), + }); + } } } diff --git a/packages/lightning/src/structures/messages.ts b/packages/lightning/src/structures/messages.ts index 20eaf840..c6e23b53 100644 --- a/packages/lightning/src/structures/messages.ts +++ b/packages/lightning/src/structures/messages.ts @@ -5,7 +5,7 @@ import type { attachment, embed } from './media.ts'; * @param text the text of the message (can be markdown) */ export function create_message(text: string): message { - const data = { + return { author: { username: 'lightning', profile: 'https://williamhorning.eu.org/assets/lightning.png', @@ -19,7 +19,6 @@ export function create_message(text: string): message { timestamp: Temporal.Now.instant(), plugin: 'lightning', }; - return data; } /** a representation of a message that has been deleted */ diff --git a/packages/telegram/src/file_proxy.ts b/packages/telegram/src/file_proxy.ts index 539e7e07..64fe3a78 100644 --- a/packages/telegram/src/file_proxy.ts +++ b/packages/telegram/src/file_proxy.ts @@ -3,11 +3,9 @@ import type { telegram_config } from './mod.ts'; export function file_proxy(config: telegram_config) { Deno.serve({ port: config.plugin_port, - onListen: (addr) => { - console.log( - `[bolt-telegram] file proxy listening on http://localhost:${addr.port}`, - `\n[bolt-telegram] also available at: ${config.plugin_url}`, - ); + onListen: ({ port }) => { + console.log(`[bolt-telegram] file proxy listening on localhost:${port}`); + console.log(`[bolt-telegram] also available at: ${config.plugin_url}`); }, }, (req: Request) => { const { pathname } = new URL(req.url); From 2ca1a1805f5cb95b9a0e07df08f36dfda320d536 Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 1 Jan 2025 15:52:42 -0500 Subject: [PATCH 35/97] implement redis migration logic --- packages/lightning/src/cli.ts | 1 + packages/lightning/src/database/mongo.ts | 10 +- packages/lightning/src/database/redis.ts | 25 +--- .../lightning/src/database/redis_message.ts | 126 ++++++++++++------ 4 files changed, 96 insertions(+), 66 deletions(-) diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 9772aece..4b1845aa 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -52,4 +52,5 @@ function run_help() { console.log(' -c, --config: the config file to use'); console.log(' Environment Variables:'); console.log(' LIGHTNING_ERROR_WEBHOOK: the webhook to send errors to'); + console.log(' LIGHTNING_MIGRATE_CONFIRM: confirm migration on startup'); } diff --git a/packages/lightning/src/database/mongo.ts b/packages/lightning/src/database/mongo.ts index 4ee7538e..e8d8ebc6 100644 --- a/packages/lightning/src/database/mongo.ts +++ b/packages/lightning/src/database/mongo.ts @@ -4,14 +4,14 @@ import { ulid } from '@std/ulid'; import type { bridge } from '../structures/bridge.ts'; import { log_error } from '../structures/errors.ts'; import type { bridge_data } from './mod.ts'; -import { redis_bridge_message_handler } from './redis_message.ts'; +import { redis_messages } from './redis_message.ts'; export type mongo_config = { database: ConnectOptions | string; redis: Deno.ConnectOptions; }; -export class mongo extends redis_bridge_message_handler implements bridge_data { +export class mongo extends redis_messages implements bridge_data { static async create(opts: mongo_config) { const client = new MongoClient(); await client.connect(opts.database); @@ -43,16 +43,16 @@ export class mongo extends redis_bridge_message_handler implements bridge_data { const redis = new RedisClient(await Deno.connect(opts.redis)); - // TODO(jersey): handle redis migrations here if applicable? + await redis_messages.migrate(redis); return new this(database.collection('bridges'), redis); } private constructor( private bridges: Collection, - public redis: RedisClient, + redis: RedisClient, ) { - super(); + super(redis); } async create_bridge(br: Omit): Promise { diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index 69b6ecba..457b71a0 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -2,37 +2,22 @@ import { RedisClient } from '@iuioiua/r2d2'; import { ulid } from '@std/ulid'; import type { bridge } from '../structures/bridge.ts'; import type { bridge_data } from './mod.ts'; -import { redis_bridge_message_handler } from './redis_message.ts'; +import { redis_messages } from './redis_message.ts'; export type redis_config = Deno.ConnectOptions; -export class redis extends redis_bridge_message_handler implements bridge_data { +export class redis extends redis_messages implements bridge_data { static async create(rd_options: Deno.ConnectOptions): Promise { const conn = await Deno.connect(rd_options); const client = new RedisClient(conn); - const db_data_version = await client.sendCommand([ - 'GET', - 'lightning-db-version', - ]); - - if (db_data_version !== '0.8.0') { - console.warn( - `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, - ); - // TODO(jersey): use code to handle 0.7.x bridges - // basically just need to migrate anything that starts with lightning-bridge- - // allow_editing and use_rawname just get mvoed to the settings object - // and then everything else is all set - - throw 'not implemented'; - } + await redis_messages.migrate(client); return new this(client); } - private constructor(public redis: RedisClient) { - super(); + private constructor(redis: RedisClient) { + super(redis); } async create_bridge(br: Omit): Promise { diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index cb466519..63913005 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -1,23 +1,88 @@ import type { RedisClient } from '@iuioiua/r2d2'; -import type { - bridge_channel, - bridge_message, - bridged_message, -} from '../structures/bridge.ts'; - -interface redis_bridge_message { - allow_editing: boolean; - channels: bridge_channel[]; - id: string; - messages?: bridged_message[]; - use_rawname: boolean; -} +import type { bridge, bridge_message } from '../structures/bridge.ts'; +import { log_error } from '../structures/errors.ts'; + +export class redis_messages { + static async migrate(rd: RedisClient): Promise { + const db_data_version = await rd.sendCommand([ + 'GET', + 'lightning-db-version', + ]); + + if (db_data_version !== '0.8.0') { + console.warn( + `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, + ); + + const all_keys = await rd.sendCommand([ + 'KEYS', + 'lightning-*', + ]) as string[]; + + const new_data = [] as [string, bridge | bridge_message][]; + + for (const key of all_keys) { + // TODO(jersey): this should probably not be done in memory + const type = await rd.sendCommand(['TYPE', key]) as string; + + const value = await rd.sendCommand([ + type === 'string' ? 'GET' : 'JSON.GET', + key, + ]) as string; + + try { + const parsed = JSON.parse(value); + 'failed to handle key, cancelling migration'; -// TODO(jersey): the redis_bridge_message structure sucks and if we're doing migrations -// (see ./redis), there's no point to not use the bridge_message structure + new_data.push([key, { + id: key.split('-')[2], + bridge_id: parsed.id, + channels: parsed.channels, + messages: parsed.messages, + name: `migrated bridge ${parsed.id}`, + settings: { + allow_editing: parsed.allow_editing, + use_rawname: parsed.use_rawname, + allow_everyone: true, + }, + }]); + } catch (e) { + log_error(e, { + extra: { + key, + type, + value, + }, + message: 'failed to handle key, cancelling migration', + }); + } + } -export abstract class redis_bridge_message_handler { - abstract redis: RedisClient; + console.warn('[lightning-redis] do you want to continue?'); + + const write = confirm('write the data to the database?'); + const env_confirm = Deno.env.get('LIGHTNING_MIGRATE_CONFIRM'); + + if (write || env_confirm === 'true') { + await rd.sendCommand(['DEL', ...all_keys]); + + const data = new_data.map(( + [key, value], + ) => [key, JSON.stringify(value)]); + + await rd.sendCommand(['MSET', ...data.flat()]); + await rd.sendCommand(['SET', 'lightning-db-version', '0.8.0']); + + console.warn('[lightning-redis] data written to database'); + return; + } else { + console.warn('[lightning-redis] data not written to database'); + log_error('migration cancelled'); + } + } + } + + constructor(public redis: RedisClient) {} async get_json(key: string): Promise { const reply = await this.redis.sendCommand(['GET', key]); @@ -26,25 +91,17 @@ export abstract class redis_bridge_message_handler { } async create_message(msg: bridge_message): Promise { - const redis_msg: redis_bridge_message = { - allow_editing: msg.settings.allow_editing, - channels: msg.channels, - id: msg.bridge_id, - use_rawname: msg.settings.use_rawname, - messages: msg.messages, - }; - await this.redis.sendCommand([ 'SET', 'lightning-message-${msg.id}', - JSON.stringify(redis_msg), + JSON.stringify(msg), ]); for (const message of msg.messages) { await this.redis.sendCommand([ 'SET', `lightning-message-${message.id}`, - JSON.stringify(redis_msg), + JSON.stringify(msg), ]); } } @@ -58,21 +115,8 @@ export abstract class redis_bridge_message_handler { } async get_message(id: string): Promise { - const msg = await this.get_json( + return await this.get_json( `lightning-message-${id}`, ); - if (!msg) return; - - return { - bridge_id: msg.id, - channels: msg.channels, - messages: msg.messages || [], - settings: { - allow_editing: msg.allow_editing, - use_rawname: msg.use_rawname, - allow_everyone: true, - }, - id, - }; } } From c22c412ec9323e2cf8ba0cdc55fbd0230449257b Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 5 Jan 2025 17:31:44 -0500 Subject: [PATCH 36/97] fix redis migration logic --- .../lightning/src/database/redis_message.ts | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index 63913005..9fcb8a94 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -19,20 +19,15 @@ export class redis_messages { 'lightning-*', ]) as string[]; - const new_data = [] as [string, bridge | bridge_message][]; + const new_data = [] as [string, bridge | bridge_message | string][]; for (const key of all_keys) { - // TODO(jersey): this should probably not be done in memory const type = await rd.sendCommand(['TYPE', key]) as string; - - const value = await rd.sendCommand([ - type === 'string' ? 'GET' : 'JSON.GET', - key, - ]) as string; + const action = type === 'string' ? 'GET' : 'JSON.GET'; + const value = await rd.sendCommand([action, key]) as string; try { const parsed = JSON.parse(value); - 'failed to handle key, cancelling migration'; new_data.push([key, { id: key.split('-')[2], @@ -46,15 +41,8 @@ export class redis_messages { allow_everyone: true, }, }]); - } catch (e) { - log_error(e, { - extra: { - key, - type, - value, - }, - message: 'failed to handle key, cancelling migration', - }); + } catch { + new_data.push([key, value]); } } From e772fab5ddea298a25bdcffde3a07036f12d4a92 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 1 Mar 2025 21:48:54 -0500 Subject: [PATCH 37/97] some more changes --- deno.jsonc | 2 - packages/discord/deno.json | 8 +-- packages/guilded/src/guilded_message/mod.ts | 50 ++++++++++++++++- packages/lightning/deno.jsonc | 6 +- packages/lightning/src/cli.ts | 4 ++ packages/lightning/src/database/mod.ts | 47 ++++++++++++++++ packages/lightning/src/database/mongo.ts | 32 ++++++++++- packages/lightning/src/database/postgres.ts | 56 +++++++++++++++++++ packages/lightning/src/database/redis.ts | 43 +++++++++++++- .../lightning/src/database/redis_message.ts | 32 ++++++++++- packages/revolt/deno.json | 4 +- packages/revolt/src/permissions.ts | 2 +- packages/revolt/src/to_lightning.ts | 2 +- packages/telegram/deno.json | 4 +- 14 files changed, 272 insertions(+), 20 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index 125cf5a8..2ca80d8e 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -21,8 +21,6 @@ }, "workspace": [ "./packages/lightning", - // TODO(jersey) - "./packages/postgres", "./packages/telegram", "./packages/revolt", "./packages/guilded", diff --git a/packages/discord/deno.json b/packages/discord/deno.json index e38c1819..f0256496 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -4,9 +4,9 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "@discordjs/core": "npm:@discordjs/core@^2.0.0", - "@discordjs/rest": "npm:@discordjs/rest@^2.4.0", - "@discordjs/ws": "npm:@discordjs/ws@^2.0.0", - "discord-api-types": "npm:discord-api-types@0.37.97/v10" + "@discordjs/core": "npm:@discordjs/core@^2.0.1", + "@discordjs/rest": "npm:@discordjs/rest@^2.4.3", + "@discordjs/ws": "npm:@discordjs/ws@^2.0.1", + "discord-api-types": "npm:discord-api-types@0.37.119/v10" } } diff --git a/packages/guilded/src/guilded_message/mod.ts b/packages/guilded/src/guilded_message/mod.ts index 634d892d..173ea616 100644 --- a/packages/guilded/src/guilded_message/mod.ts +++ b/packages/guilded/src/guilded_message/mod.ts @@ -16,11 +16,59 @@ export async function guilded_to_message( msg.createdAt.valueOf(), ); + let content = msg.content.replaceAll('\n```\n```\n', '\n'); + + // /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm + const urls = content.match( + /\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, + ) || []; + + content = content.replaceAll( + /\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, + '', + ); + + const attachments_urls = [] as [string, number][]; + + try { + const signed = await (await fetch("https://www.guilded.gg/api/v1/url-signatures", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${bot.token}`, + }, + body: JSON.stringify(urls.map((url) => ({ url }))), + })).json(); + + for (const url of signed) { + if (url.signature) { + // TODO(jersey): store the signed url somewhere and have our own proxy, like telegram + const resp = await fetch(url.signature, { + method: "HEAD" + }); + + const size = parseInt(resp.headers.get('Content-Length') || '0'); + + attachments_urls.push([url.url, size]); + } + } + } catch { + // ignore + } + return { author: { ...author, color: '#F5C400', }, + attachments: attachments_urls.map(([url, size]) => { + return { + name: url.split('/').pop()?.split('?')[0] || 'unknown', + file: url, + size, + }; + }), channel: msg.channelId, id: msg.id, timestamp, @@ -29,7 +77,7 @@ export async function guilded_to_message( reply: async (reply: message) => { await msg.reply(await convert_msg(reply)); }, - content: msg.content.replaceAll('\n```\n```\n', '\n'), + content, reply_id: msg.isReply ? msg.replyMessageIds[0] : undefined, }; } diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index ad30623a..e932fcfd 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -4,10 +4,10 @@ "exports": "./src/mod.ts", "imports": { // TODO(jersey): get updated @db/mongo w/updated web_bson - "@db/mongo": "jsr:@db/mongo@^0.34.0", - "@db/postgres": "jsr:@db/postgres@^0.19.4", + "@db/mongo": "jsr:@db/mongo@^0.33.0", + "@db/postgres": "jsr:@jersey/test@0.8.6", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", - "@iuioiua/r2d2": "jsr:@iuioiua/r2d2@2.1.2", + "@iuioiua/redis": "jsr:@iuioiua/redis@^1.0.1", "@std/cli/parse-args": "jsr:@std/cli@^1.0.3/parse-args", "@std/path": "jsr:@std/path@^1.0.0", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 4b1845aa..bd416a88 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -2,6 +2,7 @@ import { parseArgs } from '@std/cli/parse-args'; import { join, toFileUrl } from '@std/path'; import { type config, lightning } from './lightning.ts'; import { log_error } from './structures/errors.ts'; +import { handle_migration } from './database/mod.ts'; const version = '0.8.0'; const _ = parseArgs(Deno.args); @@ -34,6 +35,8 @@ if (_.v || _.version) { } catch (e) { log_error(e, { extra: { type: 'global class error' } }); } +} else if (_._[0] === 'migrate') { + handle_migration(); } else { console.log('[lightning] command not found, showing help'); run_help(); @@ -46,6 +49,7 @@ function run_help() { console.log(' Usage: lightning [subcommand] '); console.log(' Subcommands:'); console.log(' run: run a lightning instance'); + console.log(' migrate: migrate databases'); console.log(' Options:'); console.log(' -h, --help: display this help message'); console.log(' -v, --version: display the version number'); diff --git a/packages/lightning/src/database/mod.ts b/packages/lightning/src/database/mod.ts index c13fc79e..f046b387 100644 --- a/packages/lightning/src/database/mod.ts +++ b/packages/lightning/src/database/mod.ts @@ -12,6 +12,10 @@ export interface bridge_data { edit_message(msg: bridge_message): Promise; delete_message(msg: bridge_message): Promise; get_message(id: string): Promise; + migration_get_bridges(): Promise; + migration_get_messages(): Promise; + migration_set_bridges(bridges: bridge[]): Promise; + migration_set_messages(messages: bridge_message[]): Promise; } export type database_config = { @@ -39,3 +43,46 @@ export async function create_database( throw new Error('invalid database type'); } } + +function get_database( + type: string, +): typeof postgres | typeof redis | typeof mongo { + switch (type) { + case 'postgres': + return postgres; + case 'redis': + return redis; + case 'mongo': + return mongo; + default: + throw new Error('invalid database type'); + } +} + +export async function handle_migration() { + const start_type = prompt( + 'Please enter your starting database type (postgres, redis, mongo):', + ) ?? ''; + const start = await get_database(start_type).migration_get_instance(); + + const end_type = prompt( + 'Please enter your ending database type (postgres, redis, mongo):', + ) ?? ''; + const end = await get_database(end_type).migration_get_instance(); + + console.log('Downloading bridges...'); + let bridges = await start.migration_get_bridges(); + + console.log('Setting bridges...'); + await end.migration_set_bridges(bridges); + bridges = []; + + console.log('Downloading messages...'); + let messages = await start.migration_get_messages(); + + console.log('Setting messages...'); + await end.migration_set_messages(messages); + messages = []; + + console.log('Migration complete!'); +} diff --git a/packages/lightning/src/database/mongo.ts b/packages/lightning/src/database/mongo.ts index e8d8ebc6..94b76eb2 100644 --- a/packages/lightning/src/database/mongo.ts +++ b/packages/lightning/src/database/mongo.ts @@ -1,5 +1,5 @@ import { type Collection, type ConnectOptions, MongoClient } from '@db/mongo'; -import { RedisClient } from '@iuioiua/r2d2'; +import { RedisClient } from '@iuioiua/redis'; import { ulid } from '@std/ulid'; import type { bridge } from '../structures/bridge.ts'; import { log_error } from '../structures/errors.ts'; @@ -78,4 +78,34 @@ export class mongo extends redis_messages implements bridge_data { }, }); } + + async migration_get_bridges(): Promise { + return await this.bridges.find().toArray(); + } + + async migration_set_bridges(bridges: bridge[]): Promise { + await this.bridges.insertMany(bridges.map((b) => ({ _id: b.id, ...b }))); + } + + static async migration_get_instance(): Promise { + const redis_hostname = + prompt('Please enter your Redis hostname (localhost):') || 'localhost'; + const redis_port = prompt('Please enter your Redis port (6379):') || + '6379'; + const mongo_str = prompt( + 'Please enter your MongoDB connection string (mongodb://localhost:27017):', + ) || + 'mongodb://localhost:27017'; + + const redis = new RedisClient( + await Deno.connect({ + hostname: redis_hostname, + port: parseInt(redis_port), + }), + ); + const client = new MongoClient(); + await client.connect(mongo_str); + + return new mongo(client.database('lightning').collection('bridges'), redis); + } } diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index ec159449..e047cbbb 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -8,6 +8,7 @@ export type { ClientOptions as postgres_config }; export class postgres implements bridge_data { static async create(pg_options: ClientOptions): Promise { const pg = new Client(pg_options); + await pg.connect(); await pg.queryArray` CREATE TABLE IF NOT EXISTS lightning ( @@ -35,6 +36,7 @@ export class postgres implements bridge_data { settings JSONB NOT NULL ); `; + return new this(pg); } @@ -120,4 +122,58 @@ export class postgres implements bridge_data { return res.rows[0]; } + + async migration_get_bridges(): Promise { + const res = await this.pg.queryObject(` + SELECT * FROM bridges + `); + + return res.rows; + } + + async migration_get_messages(): Promise { + const res = await this.pg.queryObject(` + SELECT * FROM bridge_messages + `); + + return res.rows; + } + + async migration_set_messages(messages: bridge_message[]): Promise { + for (const msg of messages) { + await this.create_message(msg); + } + } + + async migration_set_bridges(bridges: bridge[]): Promise { + for (const br of bridges) { + await this.pg.queryArray` + INSERT INTO bridges (id, name, channels, settings) + VALUES (${br.id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ + JSON.stringify(br.settings) + }) + `; + } + } + + static async migration_get_instance(): Promise { + const pg_user = prompt('Please enter your Postgres username (server):') || + 'server'; + const pg_password = + prompt('Please enter your Postgres password (password):') || 'password'; + const pg_host = prompt('Please enter your Postgres host (localhost):') || + 'localhost'; + const pg_port = prompt('Please enter your Postgres port (5432):') || + '5432'; + const pg_db = prompt('Please enter your Postgres database (lightning):') || + 'lightning'; + + return await postgres.create({ + user: pg_user, + password: pg_password, + hostname: pg_host, + port: parseInt(pg_port), + database: pg_db, + }); + } } diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index 457b71a0..2de15b8b 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -1,4 +1,4 @@ -import { RedisClient } from '@iuioiua/r2d2'; +import { RedisClient } from '@iuioiua/redis'; import { ulid } from '@std/ulid'; import type { bridge } from '../structures/bridge.ts'; import type { bridge_data } from './mod.ts'; @@ -56,4 +56,45 @@ export class redis extends redis_messages implements bridge_data { if (!channel || channel === 'OK') return; return await this.get_json(`lightning-bridge-${channel}`); } + + async migration_get_bridges(): Promise { + const keys = await this.redis.sendCommand([ + 'KEYS', + 'lightning-bridge-*', + ]) as string[]; + + const bridges = [] as bridge[]; + + for (const key of keys) { + const bridge = await this.get_bridge_by_id( + key.replace('lightning-bridge-', ''), + ); + + if (bridge) bridges.push(bridge); + } + + return bridges; + } + + async migration_set_bridges(bridges: bridge[]): Promise { + for (const bridge of bridges) { + await this.redis.sendCommand([ + 'SET', + `lightning-bridge-${bridge.id}`, + JSON.stringify(bridge), + ]); + } + } + + static async migration_get_instance(): Promise { + const hostname = prompt('Please enter your Redis hostname (localhost):') || + 'localhost'; + const port = prompt('Please enter your Redis port (6379):') || '6379'; + + return await redis.create({ + hostname, + port: parseInt(port), + transport: 'tcp', + }); + } } diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index 9fcb8a94..31ef7202 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -1,14 +1,20 @@ -import type { RedisClient } from '@iuioiua/r2d2'; +import type { RedisClient } from '@iuioiua/redis'; import type { bridge, bridge_message } from '../structures/bridge.ts'; import { log_error } from '../structures/errors.ts'; export class redis_messages { static async migrate(rd: RedisClient): Promise { - const db_data_version = await rd.sendCommand([ + let db_data_version = await rd.sendCommand([ 'GET', 'lightning-db-version', ]); + if (db_data_version === null) { + const number_keys = await rd.sendCommand(["DBSIZE"]) as number; + + if (number_keys === 0) db_data_version = '0.8.0'; + } + if (db_data_version !== '0.8.0') { console.warn( `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, @@ -107,4 +113,26 @@ export class redis_messages { `lightning-message-${id}`, ); } + + async migration_get_messages(): Promise { + const keys = await this.redis.sendCommand([ + 'KEYS', + 'lightning-message-*', + ]) as string[]; + + const messages = [] as bridge_message[]; + + for (const key of keys) { + const message = await this.get_json(key); + if (message) messages.push(message); + } + + return messages; + } + + async migration_set_messages(messages: bridge_message[]): Promise { + for (const message of messages) { + await this.create_message(message); + } + } } diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index a5a56c00..6c1b533d 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -4,8 +4,8 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.6", - "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.7.14", + "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.7", + "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.3", "@std/ulid": "jsr:@std/ulid@^1.0.0" } } diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index 75d38b01..cfa4e828 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -43,7 +43,7 @@ export async function check_permissions( } async function server_permissions( - channel: Channel, + channel: Channel & { channel_type: 'TextChannel' }, client: Client, bot_id: string, ) { diff --git a/packages/revolt/src/to_lightning.ts b/packages/revolt/src/to_lightning.ts index ee3df1d3..a027ffb2 100644 --- a/packages/revolt/src/to_lightning.ts +++ b/packages/revolt/src/to_lightning.ts @@ -27,7 +27,7 @@ export async function to_lightning( content: message.content ?? undefined, embeds: (message.embeds as Embed[] | undefined)?.map((i) => { return { - color: i.colour ? parseInt(i.colour.replace('#', ''), 16) : undefined, + color: "colour" in i && i.colour ? parseInt(i.colour.replace('#', ''), 16) : undefined, ...i, } as embed; }), diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index ceb7cdb0..5a9cc2e9 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -4,7 +4,7 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "telegramify-markdown": "npm:telegramify-markdown@^1.2.2", - "grammy": "npm:grammy@^1.32.0" + "telegramify-markdown": "npm:telegramify-markdown@^1.2.4", + "grammy": "npm:grammy@^1.35.0" } } From 7135552c3013ff98cf15152b24801b5e535b4981 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 2 Mar 2025 12:32:17 -0500 Subject: [PATCH 38/97] some migration related fixes --- packages/lightning/src/database/mod.ts | 4 ++-- packages/lightning/src/database/postgres.ts | 14 +++++++++++--- packages/lightning/src/database/redis.ts | 8 ++++++++ packages/lightning/src/database/redis_message.ts | 2 ++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/lightning/src/database/mod.ts b/packages/lightning/src/database/mod.ts index f046b387..63652ec5 100644 --- a/packages/lightning/src/database/mod.ts +++ b/packages/lightning/src/database/mod.ts @@ -73,14 +73,14 @@ export async function handle_migration() { console.log('Downloading bridges...'); let bridges = await start.migration_get_bridges(); - console.log('Setting bridges...'); + console.log(`Copying ${bridges.length} bridges...`); await end.migration_set_bridges(bridges); bridges = []; console.log('Downloading messages...'); let messages = await start.migration_get_messages(); - console.log('Setting messages...'); + console.log(`Copying ${messages.length} messages...`); await end.migration_set_messages(messages); messages = []; diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index e047cbbb..30ebb96f 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -10,6 +10,12 @@ export class postgres implements bridge_data { const pg = new Client(pg_options); await pg.connect(); + await postgres.setup_schema(pg); + + return new this(pg); + } + + private static async setup_schema(pg: Client) { await pg.queryArray` CREATE TABLE IF NOT EXISTS lightning ( prop TEXT PRIMARY KEY, @@ -36,8 +42,6 @@ export class postgres implements bridge_data { settings JSONB NOT NULL ); `; - - return new this(pg); } private constructor(private pg: Client) {} @@ -141,7 +145,11 @@ export class postgres implements bridge_data { async migration_set_messages(messages: bridge_message[]): Promise { for (const msg of messages) { - await this.create_message(msg); + try { + await this.create_message(msg); + } catch { + console.warn(`failed to insert message ${msg.id}`); + } } } diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index 2de15b8b..f31c074d 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -83,6 +83,14 @@ export class redis extends redis_messages implements bridge_data { `lightning-bridge-${bridge.id}`, JSON.stringify(bridge), ]); + + for (const channel of bridge.channels) { + await this.redis.sendCommand([ + 'SET', + `lightning-bchannel-${channel.id}`, + bridge.id, + ]); + }; } } diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index 31ef7202..d4f5d605 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -134,5 +134,7 @@ export class redis_messages { for (const message of messages) { await this.create_message(message); } + + await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); } } From ed585b177710200643f03d205ce3ff507c14266e Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 2 Mar 2025 12:45:30 -0500 Subject: [PATCH 39/97] fix slash command arguments --- packages/discord/src/to_lightning/command.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/discord/src/to_lightning/command.ts b/packages/discord/src/to_lightning/command.ts index aaf56e58..76fb86c9 100644 --- a/packages/discord/src/to_lightning/command.ts +++ b/packages/discord/src/to_lightning/command.ts @@ -12,8 +12,14 @@ export function command_to( let subcmd; for (const opt of interaction.data.data.options || []) { - if (opt.type === 1) subcmd = opt.name; - if (opt.type === 3) opts[opt.name] = opt.value; + if (opt.type === 1) { + subcmd = opt.name; + for (const subopt of opt.options || []) { + if (subopt.type === 3) opts[subopt.name] = subopt.value; + } + } else if (opt.type === 3) { + opts[opt.name] = opt.value; + } } return { From ff82b5c5cc7a95eccfb4e2a2dee782c425120a31 Mon Sep 17 00:00:00 2001 From: Jersey Date: Tue, 11 Mar 2025 18:07:15 -0400 Subject: [PATCH 40/97] fix many revolt issues and make logs consistent - change guilded log to be consistent - fix leaving a bridge with redis - fix edited messages on revolt - change permission error messages - fix revolt embed handling --- packages/guilded/src/mod.ts | 2 +- packages/lightning/deno.jsonc | 3 +- packages/lightning/src/database/redis.ts | 11 +++++++- packages/revolt/src/fetch_member.ts | 36 ++++++++++++++++++++++++ packages/revolt/src/mod.ts | 22 +++++++++++++-- packages/revolt/src/permissions.ts | 21 +++++--------- packages/revolt/src/to_lightning.ts | 19 ++++--------- packages/revolt/src/to_revolt.ts | 16 +++++++---- 8 files changed, 91 insertions(+), 39 deletions(-) create mode 100644 packages/revolt/src/fetch_member.ts diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 82520390..e6f9164a 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -38,7 +38,7 @@ export class guilded_plugin extends plugin { private setup_events() { this.bot.on('ready', () => { - console.log(`[bolt-guilded] logged in as ${this.bot.user?.name}`); + console.log(`[bolt-guilded] ready as ${this.bot.user?.name}`); }); this.bot.on('messageCreated', async (message) => { const msg = await guilded_to_message(message, this.bot); diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index e932fcfd..b3d9404f 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -3,7 +3,8 @@ "version": "0.8.0", "exports": "./src/mod.ts", "imports": { - // TODO(jersey): get updated @db/mongo w/updated web_bson + // TODO(jersey): get @db/mongo@^0.34.0 on JSR + // TODO(jersey): get updated @db/postgres on JSR "@db/mongo": "jsr:@db/mongo@^0.33.0", "@db/postgres": "jsr:@jersey/test@0.8.6", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index f31c074d..0eddc38d 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -29,10 +29,19 @@ export class redis extends redis_messages implements bridge_data { } async edit_bridge(br: bridge): Promise { + const old_bridge = await this.get_bridge_by_id(br.id); + + for (const channel of old_bridge?.channels || []) { + await this.redis.sendCommand([ + 'DEL', + `lightning-bchannel-${channel.id}`, + ]); + } + await this.redis.sendCommand([ 'SET', `lightning-bridge-${br.id}`, - JSON.stringify({ ...br, name }), + JSON.stringify(br), ]); for (const channel of br.channels) { diff --git a/packages/revolt/src/fetch_member.ts b/packages/revolt/src/fetch_member.ts new file mode 100644 index 00000000..20bcdd5c --- /dev/null +++ b/packages/revolt/src/fetch_member.ts @@ -0,0 +1,36 @@ +import type { Channel, Member } from '@jersey/revolt-api-types'; +import type { Client } from '@jersey/rvapi'; + +interface member_map_value { + value: Member; + expiry: number; +} + +const member_map = new Map(); + +export async function fetch_member( + client: Client, + channel: Channel & { channel_type: 'TextChannel' }, + user_id: string, +): Promise { + const time_now = Temporal.Now.instant().epochMilliseconds; + + const member = member_map.get(user_id); + + if (member && member.expiry > time_now) { + return member.value; + } + + const member_resp = await client.request( + 'get', + `/servers/${channel.server}/members/${user_id}`, + undefined, + ); + + member_map.set(user_id, { + value: member_resp as Member, + expiry: time_now + 300000, + }); + + return member_resp as Member; +} diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 6d284e51..b777d54e 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -33,8 +33,9 @@ export class revolt_plugin extends plugin { private setup_events() { this.bot.bonfire.on('Ready', (ready) => { - console.log(`[bolt-revolt] ready in ${ready.channels.length} channels`); - console.log(`[bolt-revolt] and ${ready.servers.length} servers`); + console.log( + `[bolt-revolt] ready in ${ready.channels.length} channels and ${ready.servers.length} servers`, + ); }); this.bot.bonfire.on('Message', async (msg) => { @@ -46,9 +47,24 @@ export class revolt_plugin extends plugin { this.bot.bonfire.on('MessageUpdate', async (msg) => { if (!msg.channel || msg.channel === 'undefined') return; + let old_msg: Message; + + try { + old_msg = await this.bot.request( + 'get', + `/channels/${msg.channel}/messages/${msg.id}`, + undefined, + ) as Message; + } catch { + return; + } + this.emit( 'edit_message', - await to_lightning(this.bot, msg.data as Message), + await to_lightning(this.bot, { + ...old_msg, + ...msg.data, + }), ); }); diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index cfa4e828..bcd9829e 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -1,7 +1,8 @@ import type { Client } from '@jersey/rvapi'; -import type { Channel, Member, Role, Server } from '@jersey/revolt-api-types'; +import type { Channel, Role, Server } from '@jersey/revolt-api-types'; import { LightningError, log_error } from '@jersey/lightning'; import { handle_error } from './error_handler.ts'; +import { fetch_member } from './fetch_member.ts'; const permissions_to_check = [ 1 << 23, // ManageMessages @@ -24,12 +25,10 @@ export async function check_permissions( if (channel.channel_type === 'Group') { if (channel.permissions && (channel.permissions & permissions)) { - return channel; + return channel._id; } - log_error( - 'insufficient group permissions: missing ManageMessages and/or Masquerade', - ); + log_error('missing ManageMessages and/or Masquerade permission'); } else if (channel.channel_type === 'TextChannel') { return await server_permissions(channel, client, bot_id); } else { @@ -53,11 +52,7 @@ async function server_permissions( undefined, ) as Server; - const member = await client.request( - 'get', - `/servers/${channel.server}/members/${bot_id}`, - undefined, - ) as Member; + const member = await fetch_member(client, channel, bot_id); // check server permissions let total_permissions = server.default_permissions; @@ -87,9 +82,7 @@ async function server_permissions( } } - if (total_permissions & permissions) return channel; + if (total_permissions & permissions) return channel._id; - log_error( - 'insufficient group permissions: missing ManageMessages and/or Masquerade', - ); + log_error('missing ManageMessages and/or Masquerade permission'); } diff --git a/packages/revolt/src/to_lightning.ts b/packages/revolt/src/to_lightning.ts index a027ffb2..e3accd90 100644 --- a/packages/revolt/src/to_lightning.ts +++ b/packages/revolt/src/to_lightning.ts @@ -1,14 +1,9 @@ -import type { - Channel, - Embed, - Member, - Message, - User, -} from '@jersey/revolt-api-types'; +import type { Channel, Embed, Message, User } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; import type { embed, message, message_author } from '@jersey/lightning'; import { decodeTime } from '@std/ulid'; import { to_revolt } from './to_revolt.ts'; +import { fetch_member } from './fetch_member.ts'; export async function to_lightning( api: Client, @@ -27,7 +22,9 @@ export async function to_lightning( content: message.content ?? undefined, embeds: (message.embeds as Embed[] | undefined)?.map((i) => { return { - color: "colour" in i && i.colour ? parseInt(i.colour.replace('#', ''), 16) : undefined, + color: 'colour' in i && i.colour + ? parseInt(i.colour.replace('#', ''), 16) + : undefined, ...i, } as embed; }), @@ -85,11 +82,7 @@ async function get_author( return author_data; } else { try { - const member = await api.request( - 'get', - `/servers/${channel.server}/members/${author_id}`, - undefined, - ) as Member; + const member = await fetch_member(api, channel, author_id); return { ...author_data, diff --git a/packages/revolt/src/to_revolt.ts b/packages/revolt/src/to_revolt.ts index 493a4fcf..89cd709b 100644 --- a/packages/revolt/src/to_revolt.ts +++ b/packages/revolt/src/to_revolt.ts @@ -47,12 +47,12 @@ function map_embeds(embeds?: embed[]): SendableEmbed[] | undefined { return embeds.map((embed) => { const data: SendableEmbed = { - colour: `#${embed.color?.toString(16)}`, - description: embed.description, - icon_url: embed.author?.icon_url, - media: embed.image?.url, - title: embed.title, - url: embed.url, + icon_url: embed.author?.icon_url ?? null, + url: embed.url ?? null, + title: embed.title ?? null, + description: embed.description ?? '', + media: embed.image?.url ?? null, + colour: embed.color ? `#${embed.color.toString(16)}` : null, }; if (embed.fields) { @@ -61,6 +61,10 @@ function map_embeds(embeds?: embed[]): SendableEmbed[] | undefined { } } + if (data.description?.length === 0) { + data.description = null; + } + return data; }); } From e8ce00089f8fd3ec7d3026a1e7f24bfa47fcea71 Mon Sep 17 00:00:00 2001 From: Jersey Date: Tue, 11 Mar 2025 20:39:45 -0400 Subject: [PATCH 41/97] fix file size handling and clean up guilded attachments --- packages/guilded/src/guilded_message/mod.ts | 93 ++++++++++----------- packages/revolt/src/to_lightning.ts | 2 +- packages/telegram/src/messages.ts | 3 +- 3 files changed, 48 insertions(+), 50 deletions(-) diff --git a/packages/guilded/src/guilded_message/mod.ts b/packages/guilded/src/guilded_message/mod.ts index 173ea616..e4339205 100644 --- a/packages/guilded/src/guilded_message/mod.ts +++ b/packages/guilded/src/guilded_message/mod.ts @@ -1,4 +1,4 @@ -import type { message } from '@jersey/lightning'; +import type { attachment, message } from '@jersey/lightning'; import type { Client, Message } from 'guilded.js'; import { convert_msg } from '../guilded.ts'; import { get_author } from './author.ts'; @@ -10,68 +10,28 @@ export async function guilded_to_message( ): Promise { if (msg.serverId === null) return; - const author = await get_author(msg, bot); - - const timestamp = Temporal.Instant.fromEpochMilliseconds( - msg.createdAt.valueOf(), - ); - let content = msg.content.replaceAll('\n```\n```\n', '\n'); - // /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm const urls = content.match( - /\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, + /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, ) || []; content = content.replaceAll( - /\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, + /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, '', ); - const attachments_urls = [] as [string, number][]; - - try { - const signed = await (await fetch("https://www.guilded.gg/api/v1/url-signatures", { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': `Bearer ${bot.token}`, - }, - body: JSON.stringify(urls.map((url) => ({ url }))), - })).json(); - - for (const url of signed) { - if (url.signature) { - // TODO(jersey): store the signed url somewhere and have our own proxy, like telegram - const resp = await fetch(url.signature, { - method: "HEAD" - }); - - const size = parseInt(resp.headers.get('Content-Length') || '0'); - - attachments_urls.push([url.url, size]); - } - } - } catch { - // ignore - } - return { author: { - ...author, + ...await get_author(msg, bot), color: '#F5C400', }, - attachments: attachments_urls.map(([url, size]) => { - return { - name: url.split('/').pop()?.split('?')[0] || 'unknown', - file: url, - size, - }; - }), + attachments: await get_attachments(bot, urls), channel: msg.channelId, id: msg.id, - timestamp, + timestamp: Temporal.Instant.fromEpochMilliseconds( + msg.createdAt.valueOf(), + ), embeds: msg.embeds?.map(map_embed), plugin: 'bolt-guilded', reply: async (reply: message) => { @@ -81,3 +41,40 @@ export async function guilded_to_message( reply_id: msg.isReply ? msg.replyMessageIds[0] : undefined, }; } + +async function get_attachments(bot: Client, urls: string[]) { + const attachments = [] as attachment[]; + + try { + const signed = + await (await fetch('https://www.guilded.gg/api/v1/url-signatures', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${bot.token}`, + }, + body: JSON.stringify({ + urls: urls.map((url) => (url.split('(').pop()?.split(')')[0])), + }), + })).json(); + + for (const url of signed.urlSignatures || []) { + if (url.signature) { + const resp = await fetch(url.signature, { + method: 'HEAD', + }); + + attachments.push({ + name: url.signature.split('/').pop()?.split('?')[0] || 'unknown', + file: url.signature, + size: parseInt(resp.headers.get('Content-Length') || '0') / 1048576, + }); + } + } + } catch { + // ignore + } + + return attachments; +} diff --git a/packages/revolt/src/to_lightning.ts b/packages/revolt/src/to_lightning.ts index e3accd90..3474e9a5 100644 --- a/packages/revolt/src/to_lightning.ts +++ b/packages/revolt/src/to_lightning.ts @@ -14,7 +14,7 @@ export async function to_lightning( return { file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, name: i.filename, - size: i.size, + size: i.size / 1048576, }; }), author: await get_author(api, message.author, message.channel), diff --git a/packages/telegram/src/messages.ts b/packages/telegram/src/messages.ts index 6e6cd568..07180c65 100644 --- a/packages/telegram/src/messages.ts +++ b/packages/telegram/src/messages.ts @@ -56,7 +56,8 @@ export async function from_telegram( ...base, attachments: [{ file: `${cfg.plugin_url}/${file.file_path}`, - size: (file.file_size ?? 0) / 1000000, + name: file.file_path, + size: (file.file_size ?? 0) / 1048576, }], }; } From 5337794a4dd71d1053b777d525ebb07dd43ffa5a Mon Sep 17 00:00:00 2001 From: Jersey Date: Tue, 11 Mar 2025 22:11:16 -0400 Subject: [PATCH 42/97] add quick little warning for mongodb --- packages/lightning/src/database/mongo.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/lightning/src/database/mongo.ts b/packages/lightning/src/database/mongo.ts index 94b76eb2..b7ce9a8f 100644 --- a/packages/lightning/src/database/mongo.ts +++ b/packages/lightning/src/database/mongo.ts @@ -14,6 +14,15 @@ export type mongo_config = { export class mongo extends redis_messages implements bridge_data { static async create(opts: mongo_config) { const client = new MongoClient(); + + if ( + typeof opts.database === 'string' && opts.database.includes('localhost') + ) { + console.warn( + "[lightning-mongo] if MongoDB doesn't connect, please replace localhost with 127.0.0.1 and try again", + ); + } + await client.connect(opts.database); const database = client.database(); From 426216d192e10a210e58977805b105e2ec2fb5f6 Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 12 Mar 2025 20:27:38 -0400 Subject: [PATCH 43/97] clean up code, rename interfaces, remove extra licenses - `plugin_port` -> `proxy_port` on telegram - `plugin_url` -> `proxy_url` on telegram - remove `lightning` field from `create_command` - remove `banner` field from `message_author` --- .github/workflows/publish.yml | 2 +- .gitignore | 1 - packages/discord/deno.json | 1 + packages/discord/license | 18 --- packages/discord/src/authors.ts | 40 +++++ packages/discord/src/bridge_to_discord.ts | 84 ----------- packages/discord/src/commands.ts | 103 +++++++++++++ packages/discord/src/discord_message/files.ts | 33 ----- .../discord/src/discord_message/get_author.ts | 40 ----- packages/discord/src/discord_message/mod.ts | 59 -------- .../src/discord_message/reply_embed.ts | 24 --- packages/discord/src/error_handler.ts | 36 ----- packages/discord/src/errors.ts | 40 +++++ packages/discord/src/events.ts | 39 +++++ packages/discord/src/files.ts | 33 +++++ packages/discord/src/messages.ts | 113 ++++++++++++++ packages/discord/src/mod.ts | 104 ++++++++----- packages/discord/src/replies.ts | 31 ++++ packages/discord/src/slash_commands.ts | 58 -------- packages/discord/src/stickers.ts | 33 +++++ packages/discord/src/to_lightning/command.ts | 44 ------ packages/discord/src/to_lightning/message.ts | 90 ------------ packages/guilded/deno.json | 1 + packages/guilded/license | 18 --- packages/guilded/src/attachments.ts | 42 ++++++ packages/guilded/src/authors.ts | 71 +++++++++ .../map_embed.ts => embeds.ts} | 30 +++- packages/guilded/src/error_handler.ts | 34 ----- packages/guilded/src/errors.ts | 34 +++++ packages/guilded/src/events.ts | 33 +++++ packages/guilded/src/guilded.ts | 114 --------------- .../guilded/src/guilded_message/author.ts | 56 ------- packages/guilded/src/guilded_message/mod.ts | 80 ---------- packages/guilded/src/messages.ts | 92 ++++++++++++ packages/guilded/src/mod.ts | 47 ++---- packages/guilded/src/replies.ts | 31 ++++ packages/lightning/deno.jsonc | 5 +- packages/lightning/license | 18 --- packages/lightning/src/commands/runners.ts | 10 +- packages/lightning/src/database/redis.ts | 2 +- .../lightning/src/database/redis_message.ts | 2 +- packages/lightning/src/lightning.ts | 2 +- packages/lightning/src/structures/events.ts | 2 +- packages/lightning/src/structures/messages.ts | 2 - packages/revolt/deno.json | 1 + packages/revolt/license | 18 --- packages/revolt/src/attachments.ts | 27 ++++ packages/revolt/src/author.ts | 57 ++++++++ packages/revolt/src/embeds.ts | 31 ++++ .../src/{error_handler.ts => errors.ts} | 22 +-- packages/revolt/src/events.ts | 61 ++++++++ .../revolt/src/{fetch_member.ts => member.ts} | 22 ++- packages/revolt/src/messages.ts | 87 +++++++++++ packages/revolt/src/mod.ts | 65 +-------- packages/revolt/src/permissions.ts | 14 +- packages/revolt/src/to_lightning.ts | 106 -------------- packages/revolt/src/to_revolt.ts | 91 ------------ packages/telegram/deno.json | 1 + packages/telegram/license | 18 --- packages/telegram/src/file_proxy.ts | 6 +- packages/telegram/src/messages.ts | 138 +++++++++--------- packages/telegram/src/mod.ts | 25 ++-- 62 files changed, 1240 insertions(+), 1302 deletions(-) delete mode 100644 packages/discord/license create mode 100644 packages/discord/src/authors.ts delete mode 100644 packages/discord/src/bridge_to_discord.ts create mode 100644 packages/discord/src/commands.ts delete mode 100644 packages/discord/src/discord_message/files.ts delete mode 100644 packages/discord/src/discord_message/get_author.ts delete mode 100644 packages/discord/src/discord_message/mod.ts delete mode 100644 packages/discord/src/discord_message/reply_embed.ts delete mode 100644 packages/discord/src/error_handler.ts create mode 100644 packages/discord/src/errors.ts create mode 100644 packages/discord/src/events.ts create mode 100644 packages/discord/src/files.ts create mode 100644 packages/discord/src/messages.ts create mode 100644 packages/discord/src/replies.ts delete mode 100644 packages/discord/src/slash_commands.ts create mode 100644 packages/discord/src/stickers.ts delete mode 100644 packages/discord/src/to_lightning/command.ts delete mode 100644 packages/discord/src/to_lightning/message.ts delete mode 100644 packages/guilded/license create mode 100644 packages/guilded/src/attachments.ts create mode 100644 packages/guilded/src/authors.ts rename packages/guilded/src/{guilded_message/map_embed.ts => embeds.ts} (52%) delete mode 100644 packages/guilded/src/error_handler.ts create mode 100644 packages/guilded/src/errors.ts create mode 100644 packages/guilded/src/events.ts delete mode 100644 packages/guilded/src/guilded.ts delete mode 100644 packages/guilded/src/guilded_message/author.ts delete mode 100644 packages/guilded/src/guilded_message/mod.ts create mode 100644 packages/guilded/src/messages.ts create mode 100644 packages/guilded/src/replies.ts delete mode 100644 packages/lightning/license delete mode 100644 packages/revolt/license create mode 100644 packages/revolt/src/attachments.ts create mode 100644 packages/revolt/src/author.ts create mode 100644 packages/revolt/src/embeds.ts rename packages/revolt/src/{error_handler.ts => errors.ts} (52%) create mode 100644 packages/revolt/src/events.ts rename packages/revolt/src/{fetch_member.ts => member.ts} (58%) create mode 100644 packages/revolt/src/messages.ts delete mode 100644 packages/revolt/src/to_lightning.ts delete mode 100644 packages/revolt/src/to_revolt.ts delete mode 100644 packages/telegram/license diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b7d638c2..839dfcee 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: - name: setup deno uses: denoland/setup-deno@v1 with: - deno-version: v2.1.4 + deno-version: v2.2.3 - name: setup qemu uses: docker/setup-qemu-action@v3 - name: setup buildx diff --git a/.gitignore b/.gitignore index 031f26e3..20944bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ /.env /config /config.ts -packages/postgres \ No newline at end of file diff --git a/packages/discord/deno.json b/packages/discord/deno.json index f0256496..c815e1ba 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,6 +1,7 @@ { "name": "@jersey/lightning-plugin-discord", "version": "0.8.0", + "license": "MIT", "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", diff --git a/packages/discord/license b/packages/discord/license deleted file mode 100644 index d366acad..00000000 --- a/packages/discord/license +++ /dev/null @@ -1,18 +0,0 @@ -Copyright (c) William Horning and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the โ€œSoftwareโ€), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/discord/src/authors.ts b/packages/discord/src/authors.ts new file mode 100644 index 00000000..82a55c06 --- /dev/null +++ b/packages/discord/src/authors.ts @@ -0,0 +1,40 @@ +import type { API } from '@discordjs/core'; +import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; +import type { + APIGuildMember, + GatewayMessageUpdateDispatchData, +} from 'discord-api-types'; + +export async function fetch_author( + api: API, + message: GatewayMessageUpdateDispatchData, +): Promise<{ profile: string; username: string }> { + let profile = message.author.avatar !== null + ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png` + : `https://cdn.discordapp.com/embed/avatars/${ + calculateUserDefaultAvatarIndex(message.author.id) + }.png`; + + let username = message.author.global_name ?? message.author.username; + + if (message.guild_id) { + try { + // remove type assertion once deno resolves the return type for getMember properly + const member = message.member ?? await api.guilds.getMember( + message.guild_id, + message.author.id, + ) as APIGuildMember; + + if (member.avatar) { + profile = + `https://cdn.discordapp.com/guilds/${message.guild_id}/users/${message.author.id}/avatars/${member.avatar}.png`; + } + + if (member.nick) username = member.nick; + } catch { + // safe to ignore, we already have a name and avatar + } + } + + return { profile, username }; +} diff --git a/packages/discord/src/bridge_to_discord.ts b/packages/discord/src/bridge_to_discord.ts deleted file mode 100644 index fd243818..00000000 --- a/packages/discord/src/bridge_to_discord.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { create_opts, delete_opts, edit_opts } from '@jersey/lightning'; -import { message_to_discord } from './discord_message/mod.ts'; -import { error_handler } from './error_handler.ts'; -import type { API } from '@discordjs/core'; - -type data = { id: string; token: string }; - -export async function setup_bridge(api: API, channel: string) { - try { - const { id, token } = await api.channels.createWebhook( - channel, - { - name: 'lightning bridge', - }, - ); - - return { id, token }; - } catch (e) { - return error_handler(e, channel, 'setting up channel'); - } -} - -export async function create_message(api: API, opts: create_opts) { - const data = opts.channel.data as data; - const transformed = await message_to_discord( - opts.msg, - api, - opts.channel.id, - opts.reply_id, - opts.settings.allow_everyone, - ); - - try { - const res = await api.webhooks.execute( - data.id, - data.token, - transformed, - ); - - return [res.id]; - } catch (e) { - return error_handler(e, opts.channel.id, 'creating message'); - } -} - -export async function edit_message(api: API, opts: edit_opts) { - const data = opts.channel.data as data; - const transformed = await message_to_discord( - opts.msg, - api, - opts.channel.id, - opts.reply_id, - opts.settings.allow_everyone, - ); - - try { - await api.webhooks.editMessage( - data.id, - data.token, - opts.edit_ids[0], - transformed, - ); - - return opts.edit_ids; - } catch (e) { - return error_handler(e, opts.channel.id, 'editing message'); - } -} - -export async function delete_message(api: API, opts: delete_opts) { - const data = opts.channel.data as data; - - try { - await api.webhooks.deleteMessage( - data.id, - data.token, - opts.edit_ids[0], - ); - - return opts.edit_ids; - } catch (e) { - return error_handler(e, opts.channel.id, 'editing message'); - } -} diff --git a/packages/discord/src/commands.ts b/packages/discord/src/commands.ts new file mode 100644 index 00000000..24af7a72 --- /dev/null +++ b/packages/discord/src/commands.ts @@ -0,0 +1,103 @@ +import type { API, ToEventProps } from '@discordjs/core'; +import type { command, create_command, lightning } from '@jersey/lightning'; +import type { + APIInteraction, + RESTPutAPIApplicationCommandsJSONBody, +} from 'discord-api-types'; +import { get_discord_message } from './messages.ts'; +import type { discord_config } from './mod.ts'; + +export async function set_slash_commands( + api: API, + config: discord_config, + lightning: lightning, +): Promise { + if (!config.slash_commands) return; + + await api.applicationCommands.bulkOverwriteGlobalCommands( + config.application_id, + get_slash_commands(lightning.commands.values().toArray()), + ); +} + +function get_slash_commands( + commands: command[], +): RESTPutAPIApplicationCommandsJSONBody { + return commands.map((command) => { + const opts = []; + + if (command.arguments) { + for (const argument of command.arguments) { + opts.push({ + name: argument.name, + description: argument.description, + type: 3, + required: argument.required, + }); + } + } + + if (command.subcommands) { + for (const subcommand of command.subcommands) { + opts.push({ + name: subcommand.name, + description: subcommand.description, + type: 1, + options: subcommand.arguments?.map((opt) => ({ + name: opt.name, + description: opt.description, + type: 3, + required: opt.required, + })), + }); + } + } + + return { + name: command.name, + type: 1, + description: command.description, + options: opts, + }; + }); +} + +export function get_lightning_command( + interaction: ToEventProps, +): create_command | undefined { + if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; + + const args: Record = {}; + let subcommand: string | undefined; + + for (const option of interaction.data.data.options || []) { + if (option.type === 1) { + subcommand = option.name; + for (const suboption of option.options ?? []) { + if (suboption.type === 3) { + args[suboption.name] = suboption.value; + } + } + } else if (option.type === 3) { + args[option.name] = option.value; + } + } + + return { + args, + channel: interaction.data.channel.id, + command: interaction.data.data.name, + id: interaction.data.id, + plugin: 'bolt-discord', + reply: async (msg) => + await interaction.api.interactions.reply( + interaction.data.id, + interaction.data.token, + await get_discord_message(msg), + ), + subcommand, + timestamp: Temporal.Instant.fromEpochMilliseconds( + Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, + ), + }; +} diff --git a/packages/discord/src/discord_message/files.ts b/packages/discord/src/discord_message/files.ts deleted file mode 100644 index 67c56528..00000000 --- a/packages/discord/src/discord_message/files.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { attachment } from '@jersey/lightning'; -import type { RawFile } from '@discordjs/rest'; - -export async function files_up_to_25MiB(attachments: attachment[] | undefined) { - if (!attachments) return; - - const files: RawFile[] = []; - let total_size = 0; - - for (const attachment of attachments) { - if (attachment.size >= 25) continue; - if (total_size + attachment.size >= 25) break; - - try { - const data = new Uint8Array( - await (await fetch(attachment.file, { - signal: AbortSignal.timeout(5000), - })).arrayBuffer(), - ); - - files.push({ - name: attachment.name ?? attachment.file.split('/').pop()!, - data, - }); - - total_size += attachment.size; - } catch { - continue; - } - } - - return files; -} diff --git a/packages/discord/src/discord_message/get_author.ts b/packages/discord/src/discord_message/get_author.ts deleted file mode 100644 index fbc262c9..00000000 --- a/packages/discord/src/discord_message/get_author.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { GatewayMessageUpdateDispatchData } from 'discord-api-types'; -import type { API } from '@discordjs/core'; -import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; - -export async function get_author( - api: API, - message: GatewayMessageUpdateDispatchData, -) { - let name = message.author?.global_name || message.author?.username || - 'discord user'; - let avatar = message.author?.avatar - ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png` - : `https://cdn.discordapp.com/embed/avatars/${ - calculateUserDefaultAvatarIndex( - message.author?.id || '360005875697582081', - ) - }.png`; - - const channel = await api.channels.get(message.channel_id); - - if ('guild_id' in channel && channel.guild_id && message.author) { - try { - const member = await api.guilds.getMember( - channel.guild_id, - message.author.id, - ); - - if (member.nick !== null && member.nick !== undefined) { - name = member.nick; - } - avatar = member.avatar - ? `https://cdn.discordapp.com/guilds/${channel.guild_id}/users/${message.author.id}/avatars/${member.avatar}.png` - : avatar; - } catch { - // safe to ignore - } - } - - return { name, avatar }; -} diff --git a/packages/discord/src/discord_message/mod.ts b/packages/discord/src/discord_message/mod.ts deleted file mode 100644 index d09397e0..00000000 --- a/packages/discord/src/discord_message/mod.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { message } from '@jersey/lightning'; -import type { API } from '@discordjs/core'; -import { - AllowedMentionsTypes, - type RESTPostAPIWebhookWithTokenJSONBody, - type RESTPostAPIWebhookWithTokenQuery, -} from 'discord-api-types'; -import type { RawFile } from '@discordjs/rest'; -import { reply_embed } from './reply_embed.ts'; -import { files_up_to_25MiB } from './files.ts'; - -export interface discord_message_send - extends - RESTPostAPIWebhookWithTokenJSONBody, - RESTPostAPIWebhookWithTokenQuery { - files?: RawFile[]; - wait: true; -} - -export async function message_to_discord( - msg: message, - api?: API, - channel?: string, - reply_id?: string, - suppress_everyone?: boolean, -): Promise { - const discord: discord_message_send = { - avatar_url: msg.author.profile, - content: (msg.content?.length || 0) > 2000 - ? `${msg.content?.substring(0, 1997)}...` - : msg.content, - embeds: msg.embeds?.map((e) => { - return { ...e, timestamp: e.timestamp?.toString() }; - }), - username: msg.author.username, - wait: true, - allowed_mentions: suppress_everyone - ? { - parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User], - } - : undefined, - }; - - if (api && channel && reply_id) { - const embed = await reply_embed(api, channel, reply_id); - if (embed) { - if (!discord.embeds) discord.embeds = []; - discord.embeds.push(embed); - } - } - - discord.files = await files_up_to_25MiB(msg.attachments); - - if (!discord.content && (!discord.embeds || discord.embeds.length === 0)) { - discord.content = '*empty message*'; - } - - return discord; -} diff --git a/packages/discord/src/discord_message/reply_embed.ts b/packages/discord/src/discord_message/reply_embed.ts deleted file mode 100644 index debe8249..00000000 --- a/packages/discord/src/discord_message/reply_embed.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { APIMessage } from 'discord-api-types'; -import { get_author } from './get_author.ts'; - -export async function reply_embed(api: API, channel: string, id: string) { - try { - const message = await api.channels.getMessage( - channel, - id, - ) as APIMessage; - - const { name, avatar } = await get_author(api, message); - - return { - author: { - name: `replying to ${name}`, - icon_url: avatar, - }, - description: message.content, - }; - } catch { - return; - } -} diff --git a/packages/discord/src/error_handler.ts b/packages/discord/src/error_handler.ts deleted file mode 100644 index 6f07d129..00000000 --- a/packages/discord/src/error_handler.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { DiscordAPIError } from '@discordjs/rest'; -import { log_error } from '@jersey/lightning'; - -export function error_handler(e: unknown, channel_id: string, action: string) { - if (e instanceof DiscordAPIError) { - if (e.code === 30007 || e.code === 30058) { - log_error(e, { - message: 'too many webhooks in channel/guild. try deleting some', - extra: { channel_id }, - }); - } else if (e.code === 50013) { - log_error(e, { - message: 'missing permissions to create webhook. check bot permissions', - extra: { channel_id }, - }); - } else if (e.code === 10003 || e.code === 10015 || e.code === 50027) { - log_error(e, { - disable: true, - message: `disabling channel due to error code ${e.code}`, - extra: { channel_id }, - }); - } else if (action === 'editing message' && e.code === 10008) { - return []; // message already deleted or non-existent - } else { - log_error(e, { - message: `unknown error ${action}`, - extra: { channel_id, code: e.code }, - }); - } - } else { - log_error(e, { - message: `unknown error ${action}`, - extra: { channel_id }, - }); - } -} diff --git a/packages/discord/src/errors.ts b/packages/discord/src/errors.ts new file mode 100644 index 00000000..52661c7c --- /dev/null +++ b/packages/discord/src/errors.ts @@ -0,0 +1,40 @@ +import { DiscordAPIError } from '@discordjs/rest'; +import { log_error } from '@jersey/lightning'; + +export function handle_error( + err: unknown, + channel: string, + edit?: boolean, +): never[] { + if (err instanceof DiscordAPIError) { + if (err.code === 30007 || err.code === 30058) { + log_error(err, { + message: 'too many webhooks in channel/guild. try deleting some', + extra: { channel }, + }); + } else if (err.code === 50013) { + log_error(err, { + message: 'missing permissions to create webhook. check bot permissions', + extra: { channel }, + }); + } else if (err.code === 10003 || err.code === 10015 || err.code === 50027) { + log_error(err, { + disable: true, + message: `disabling channel due to error code ${err.code}`, + extra: { channel }, + }); + } else if (edit && err.code === 10008) { + return []; // message already deleted or non-existent + } else { + log_error(err, { + message: `unknown discord api error`, + extra: { channel, code: err.code }, + }); + } + } else { + log_error(err, { + message: `unknown discord plugin error`, + extra: { channel }, + }); + } +} diff --git a/packages/discord/src/events.ts b/packages/discord/src/events.ts new file mode 100644 index 00000000..d9a8e5ad --- /dev/null +++ b/packages/discord/src/events.ts @@ -0,0 +1,39 @@ +import type { Client } from '@discordjs/core'; +import { GatewayDispatchEvents } from 'discord-api-types'; +import { get_lightning_command } from './commands.ts'; +import { get_lightning_message } from './messages.ts'; +import type { discord_plugin } from './mod.ts'; + +export function setup_events( + client: Client, + emit: discord_plugin['emit'], +): void { + // @ts-ignore deno isn't properly handling the eventemitter code + client.once(GatewayDispatchEvents.Ready, ({ data }) => { + console.log( + `[bolt-discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, + ); + }); + // @ts-ignore deno isn't properly handling the eventemitter code + client.on(GatewayDispatchEvents.MessageCreate, async (msg) => { + emit('create_message', await get_lightning_message(msg.api, msg.data)); + }); + // @ts-ignore deno isn't properly handling the eventemitter code + client.on(GatewayDispatchEvents.MessageUpdate, async (msg) => { + emit('edit_message', await get_lightning_message(msg.api, msg.data)); + }); + // @ts-ignore deno isn't properly handling the eventemitter code + client.on(GatewayDispatchEvents.MessageDelete, (msg) => { + emit('delete_message', { + channel: msg.data.channel_id, + id: msg.data.id, + plugin: 'bolt-discord', + timestamp: Temporal.Now.instant(), + }); + }); + // @ts-ignore deno isn't properly handling the eventemitter code + client.on(GatewayDispatchEvents.InteractionCreate, (cmd) => { + const command = get_lightning_command(cmd); + if (command) emit('create_command', command); + }); +} diff --git a/packages/discord/src/files.ts b/packages/discord/src/files.ts new file mode 100644 index 00000000..7e316777 --- /dev/null +++ b/packages/discord/src/files.ts @@ -0,0 +1,33 @@ +import type { RawFile } from '@discordjs/rest'; +import type { attachment } from '@jersey/lightning'; + +export async function fetch_files( + attachments: attachment[] | undefined, +): Promise { + if (!attachments) return; + + let total_size = 0; + + return (await Promise.all( + attachments.map(async (attachment) => { + try { + if (attachment.size >= 25) return; + if (total_size + attachment.size >= 25) return; + + const data = new Uint8Array( + await (await fetch(attachment.file, { + signal: AbortSignal.timeout(5000), + })).arrayBuffer(), + ); + + const name = attachment.name ?? attachment.file?.split('/').pop()!; + + total_size += attachment.size; + + return { data, name }; + } catch { + return; + } + }), + )).filter((i) => i !== undefined); +} diff --git a/packages/discord/src/messages.ts b/packages/discord/src/messages.ts new file mode 100644 index 00000000..8c6242f3 --- /dev/null +++ b/packages/discord/src/messages.ts @@ -0,0 +1,113 @@ +import type { API } from '@discordjs/core'; +import type { RawFile } from '@discordjs/rest'; +import type { message } from '@jersey/lightning'; +import { + AllowedMentionsTypes, + type APIEmbed, + type GatewayMessageUpdateDispatchData, + type RESTPostAPIWebhookWithTokenJSONBody, + type RESTPostAPIWebhookWithTokenQuery, +} from 'discord-api-types'; +import { fetch_author } from './authors.ts'; +import { fetch_files } from './files.ts'; +import { fetch_reply_embed, type reply_options } from './replies.ts'; +import { fetch_sticker_attachments } from './stickers.ts'; + +export interface discord_webhook_payload + extends + RESTPostAPIWebhookWithTokenJSONBody, + RESTPostAPIWebhookWithTokenQuery { + embeds: APIEmbed[]; + files?: RawFile[]; + wait: true; +} + +export async function get_discord_message( + msg: message, + reply?: reply_options, + limit_mentions?: boolean, +): Promise { + const payload: discord_webhook_payload = { + allowed_mentions: limit_mentions + ? { parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User] } + : undefined, + avatar_url: msg.author.profile, + // TODO(jersey): since telegram forced multiple message support, split the message into two? + content: (msg.content?.length || 0) > 2000 + ? `${msg.content?.substring(0, 1997)}...` + : msg.content, + embeds: (msg.embeds ?? []).map((e) => ({ + ...e, + timestamp: e.timestamp?.toString(), + })), + files: await fetch_files(msg.attachments), + username: msg.author.username, + wait: true, + }; + + if (reply) { + const embed = await fetch_reply_embed(reply); + + if (embed) payload.embeds.push(embed); + } + + if (!payload.content && (!payload.embeds || payload.embeds.length === 0)) { + // this acts like a blank message and renders nothing + payload.content = '_ _'; + } + + return payload; +} + +export async function get_lightning_message( + api: API, + message: GatewayMessageUpdateDispatchData, +): Promise { + if (message.flags && message.flags & 128) message.content = '*loading...*'; + + if (message.type === 7) message.content = '*joined on discord*'; + + if (message.sticker_items) { + if (!message.attachments) message.attachments = []; + const stickers = await fetch_sticker_attachments(message.sticker_items); + if (stickers) message.attachments.push(...stickers); + } + + return { + attachments: message.attachments?.map( + (i: typeof message['attachments'][0]) => { + return { + file: i.url, + alt: i.description, + name: i.filename, + size: i.size / 1048576, // bytes -> MiB + }; + }, + ), + author: { + rawname: message.author.username, + id: message.author.id, + color: '#5865F2', + ...await fetch_author(api, message), + }, + channel: message.channel_id, + content: message.content, + embeds: message.embeds.map((i) => ({ + ...i, + timestamp: i.timestamp ? Number(i.timestamp) : undefined, + video: i.video ? { ...i.video, url: i.video.url ?? '' } : undefined, + })), + id: message.id, + plugin: 'bolt-discord', + reply_id: message.referenced_message?.id, + reply: async (msg: message) => { + await api.channels.createMessage(message.channel_id, { + ...(await get_discord_message(msg)), + message_reference: { message_id: message.id }, + }); + }, + timestamp: Temporal.Instant.fromEpochMilliseconds( + Number(BigInt(message.id) >> 22n) + 1420070400000, + ), + }; +} diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 6a736c18..868fa3ee 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -8,11 +8,10 @@ import { type lightning, plugin, } from '@jersey/lightning'; -import { GatewayDispatchEvents } from 'discord-api-types'; -import * as bridge from './bridge_to_discord.ts'; -import { setup_slash_commands } from './slash_commands.ts'; -import { command_to } from './to_lightning/command.ts'; -import { message } from './to_lightning/message.ts'; +import { set_slash_commands } from './commands.ts'; +import { handle_error } from './errors.ts'; +import { setup_events } from './events.ts'; +import { get_discord_message } from './messages.ts'; /** configuration for the discord plugin */ export interface discord_config { @@ -32,63 +31,94 @@ export class discord_plugin extends plugin { constructor(l: lightning, config: discord_config) { super(l, config); - // @ts-ignore their type for makeRequest is funky + + // @ts-ignore the Undici type for fetch differs from Deno, but it works the same const rest = new REST({ version: '10', makeRequest: fetch }).setToken( config.token, ); + const gateway = new WebSocketManager({ token: config.token, intents: 0 | 33281, rest, }); + // @ts-ignore Deno doesn't properly handle the AsyncEventEmitter class types, but this works this.client = new Client({ rest, gateway }); this.api = this.client.api; - setup_slash_commands(this.api, config, l); - this.setup_events(); + set_slash_commands(this.api, config, l); + setup_events(this.client, this.emit); gateway.connect(); } - private setup_events() { - this.client.once(GatewayDispatchEvents.Ready, ({ data }) => { - console.log( - `[bolt-discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, + async setup_channel(channel: string): Promise { + try { + const { id, token } = await this.api.channels.createWebhook( + channel, + { name: 'lightning bridge' }, ); - }); - this.client.on(GatewayDispatchEvents.MessageCreate, async (msg) => { - this.emit('create_message', await message(msg.api, msg.data)); - }); - this.client.on(GatewayDispatchEvents.MessageUpdate, async (msg) => { - this.emit('edit_message', await message(msg.api, msg.data)); - }); - this.client.on(GatewayDispatchEvents.MessageDelete, (msg) => { - this.emit('delete_message', { - channel: msg.data.channel_id, - id: msg.data.id, - plugin: 'bolt-discord', - timestamp: Temporal.Now.instant(), - }); - }); - this.client.on(GatewayDispatchEvents.InteractionCreate, (cmd) => { - const command = command_to(cmd, this.lightning); - if (command) this.emit('create_command', command); - }); - } - async setup_channel(channel: string): Promise { - return await bridge.setup_bridge(this.api, channel); + return { id, token }; + } catch (e) { + return handle_error(e, channel); + } } async create_message(opts: create_opts): Promise { - return await bridge.create_message(this.api, opts); + const data = opts.channel.data as { id: string; token: string }; + + try { + const res = await this.api.webhooks.execute( + data.id, + data.token, + await get_discord_message( + opts.msg, + { api: this.api, channel: opts.channel.id, reply_id: opts.reply_id }, + opts.settings.allow_everyone, + ), + ); + + return [res.id]; + } catch (e) { + return handle_error(e, opts.channel.id); + } } async edit_message(opts: edit_opts): Promise { - return await bridge.edit_message(this.api, opts); + const data = opts.channel.data as { id: string; token: string }; + + try { + await this.api.webhooks.editMessage( + data.id, + data.token, + opts.edit_ids[0], + await get_discord_message( + opts.msg, + { api: this.api, channel: opts.channel.id, reply_id: opts.reply_id }, + opts.settings.allow_everyone, + ), + ); + + return opts.edit_ids; + } catch (e) { + return handle_error(e, opts.channel.id, true); + } } async delete_message(opts: delete_opts): Promise { - return await bridge.delete_message(this.api, opts); + const data = opts.channel.data as { id: string; token: string }; + + try { + await this.api.webhooks.deleteMessage( + data.id, + data.token, + opts.edit_ids[0], + ); + + return opts.edit_ids; + } catch (e) { + return handle_error(e, opts.channel.id, true); + } } } diff --git a/packages/discord/src/replies.ts b/packages/discord/src/replies.ts new file mode 100644 index 00000000..f710e5da --- /dev/null +++ b/packages/discord/src/replies.ts @@ -0,0 +1,31 @@ +import type { API } from '@discordjs/core'; +import type { APIEmbed } from 'discord-api-types'; +import { fetch_author } from './authors.ts'; + +export interface reply_options { + api?: API; + channel?: string; + reply_id?: string; +} + +export async function fetch_reply_embed( + { api, channel, reply_id }: reply_options, +): Promise { + if (!api || !channel || !reply_id) return; + + try { + const message = await api.channels.getMessage(channel, reply_id); + + const { profile, username } = await fetch_author(api, message); + + return { + author: { + name: `replying to ${username}`, + icon_url: profile, + }, + description: message.content, + }; + } catch { + return; + } +} diff --git a/packages/discord/src/slash_commands.ts b/packages/discord/src/slash_commands.ts deleted file mode 100644 index caf52c40..00000000 --- a/packages/discord/src/slash_commands.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { command, lightning } from '@jersey/lightning'; -import type { API } from '@discordjs/core'; -import type { discord_config } from './mod.ts'; - -export async function setup_slash_commands( - api: API, - config: discord_config, - lightning: lightning, -) { - if (!config.slash_commands) return; - - const commands = lightning.commands.values().toArray(); - - await api.applicationCommands.bulkOverwriteGlobalCommands( - config.application_id, - commands_to_discord(commands), - ); -} - -function commands_to_discord(commands: command[]) { - return commands.map((command) => { - const opts = []; - - if (command.arguments) { - for (const argument of command.arguments) { - opts.push({ - name: argument.name, - description: argument.description, - type: 3, - required: argument.required, - }); - } - } - - if (command.subcommands) { - for (const subcommand of command.subcommands) { - opts.push({ - name: subcommand.name, - description: subcommand.description, - type: 1, - options: subcommand.arguments?.map((opt) => ({ - name: opt.name, - description: opt.description, - type: 3, - required: opt.required, - })), - }); - } - } - - return { - name: command.name, - type: 1, - description: command.description, - options: opts, - }; - }); -} diff --git a/packages/discord/src/stickers.ts b/packages/discord/src/stickers.ts new file mode 100644 index 00000000..5c694517 --- /dev/null +++ b/packages/discord/src/stickers.ts @@ -0,0 +1,33 @@ +import type { APIAttachment, APIStickerItem } from 'discord-api-types'; + +export async function fetch_sticker_attachments( + stickers?: APIStickerItem[], +): Promise { + if (!stickers) return; + + return (await Promise.all(stickers.map(async (sticker) => { + let type; + + if (sticker.format_type === 1) type = 'png'; + if (sticker.format_type === 2) type = 'apng'; + if (sticker.format_type === 3) type = 'lottie'; + if (sticker.format_type === 4) type = 'gif'; + + const url = `https://media.discordapp.net/stickers/${sticker.id}.${type}`; + + const request = await fetch(url, { method: 'HEAD' }); + + if (request.ok) { + return { + url, + description: sticker.name, + filename: `${sticker.name}.${type}`, + size: parseInt(request.headers.get('Content-Length') ?? '0') / 1048576, + id: sticker.id, + proxy_url: url, + }; + } else { + return; + } + }))).filter((i) => i !== undefined); +} diff --git a/packages/discord/src/to_lightning/command.ts b/packages/discord/src/to_lightning/command.ts deleted file mode 100644 index 76fb86c9..00000000 --- a/packages/discord/src/to_lightning/command.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { APIInteraction } from 'discord-api-types'; -import type { create_command, lightning } from '@jersey/lightning'; -import { message_to_discord } from '../discord_message/mod.ts'; - -export function command_to( - interaction: { api: API; data: APIInteraction }, - lightning: lightning, -) { - if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; - const opts = {} as Record; - let subcmd; - - for (const opt of interaction.data.data.options || []) { - if (opt.type === 1) { - subcmd = opt.name; - for (const subopt of opt.options || []) { - if (subopt.type === 3) opts[subopt.name] = subopt.value; - } - } else if (opt.type === 3) { - opts[opt.name] = opt.value; - } - } - - return { - command: interaction.data.data.name, - subcommand: subcmd, - channel: interaction.data.channel.id, - id: interaction.data.id, - timestamp: Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, - ), - lightning, - plugin: 'bolt-discord', - reply: async (msg) => { - await interaction.api.interactions.reply( - interaction.data.id, - interaction.data.token, - await message_to_discord(msg), - ); - }, - args: opts, - } as create_command; -} diff --git a/packages/discord/src/to_lightning/message.ts b/packages/discord/src/to_lightning/message.ts deleted file mode 100644 index b038058c..00000000 --- a/packages/discord/src/to_lightning/message.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { GatewayMessageUpdateDispatchData } from 'discord-api-types'; -import { get_author } from '../discord_message/get_author.ts'; -import { message_to_discord } from '../discord_message/mod.ts'; -import type { message } from '@jersey/lightning'; - -export async function message( - api: API, - message: GatewayMessageUpdateDispatchData, -): Promise { - if (message.flags && message.flags & 128) message.content = 'Loading...'; - - if (message.type === 7) message.content = '*joined on discord*'; - - if (message.sticker_items) { - if (!message.attachments) message.attachments = []; - for (const sticker of message.sticker_items) { - let type; - if (sticker.format_type === 1) type = 'png'; - if (sticker.format_type === 2) type = 'apng'; - if (sticker.format_type === 3) type = 'lottie'; - if (sticker.format_type === 4) type = 'gif'; - const url = `https://media.discordapp.net/stickers/${sticker.id}.${type}`; - const req = await fetch(url, { method: 'HEAD' }); - if (req.ok) { - message.attachments.push({ - url, - description: sticker.name, - filename: `${sticker.name}.${type}`, - size: 0, - id: sticker.id, - proxy_url: url, - }); - } else { - message.content = '*used sticker*'; - } - } - } - - const { name, avatar } = await get_author(api, message); - - const data = { - author: { - profile: avatar, - username: name, - rawname: message.author?.username || 'discord user', - id: message.author?.id || message.webhook_id || '', - color: '#5865F2', - }, - channel: message.channel_id, - content: (message.content?.length || 0) > 2000 - ? `${message.content?.substring(0, 1997)}...` - : message.content, - id: message.id, - timestamp: Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(message.id) >> 22n) + 1420070400000, - ), - embeds: message.embeds?.map( - (i: Exclude[0]) => { - return { - ...i, - timestamp: i.timestamp ? Number(i.timestamp) : undefined, - }; - }, - ), - reply: async (msg: message) => { - if (!data.author.id || data.author.id === '') return; - await api.channels.createMessage(message.channel_id, { - ...(await message_to_discord(msg)), - message_reference: { - message_id: message.id, - }, - }); - }, - plugin: 'bolt-discord', - attachments: message.attachments?.map( - (i: Exclude[0]) => { - return { - file: i.url, - alt: i.description, - name: i.filename, - size: i.size / 1048576, // bytes -> MiB - }; - }, - ), - reply_id: message.referenced_message?.id, - }; - - return data as message; -} diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 622bc6ba..6a556aba 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,6 +1,7 @@ { "name": "@jersey/lightning-plugin-guilded", "version": "0.8.0", + "license": "MIT", "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", diff --git a/packages/guilded/license b/packages/guilded/license deleted file mode 100644 index d366acad..00000000 --- a/packages/guilded/license +++ /dev/null @@ -1,18 +0,0 @@ -Copyright (c) William Horning and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the โ€œSoftwareโ€), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/guilded/src/attachments.ts b/packages/guilded/src/attachments.ts new file mode 100644 index 00000000..9db8f9de --- /dev/null +++ b/packages/guilded/src/attachments.ts @@ -0,0 +1,42 @@ +import type { attachment } from '@jersey/lightning'; +import type { Client } from 'guilded.js'; + +export async function fetch_attachments( + bot: Client, + urls: string[], +): Promise { + const attachments: attachment[] = []; + + try { + const signed = + await (await fetch('https://www.guilded.gg/api/v1/url-signatures', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Bearer ${bot.token}`, + }, + body: JSON.stringify({ + urls: urls.map((url) => (url.split('(').pop()?.split(')')[0])), + }), + })).json(); + + for (const url of signed.urlSignatures || []) { + if (url.signature) { + const resp = await fetch(url.signature, { + method: 'HEAD', + }); + + attachments.push({ + name: url.signature.split('/').pop()?.split('?')[0] || 'unknown', + file: url.signature, + size: parseInt(resp.headers.get('Content-Length') || '0') / 1048576, + }); + } + } + } catch { + // ignore + } + + return attachments; +} diff --git a/packages/guilded/src/authors.ts b/packages/guilded/src/authors.ts new file mode 100644 index 00000000..21fac56f --- /dev/null +++ b/packages/guilded/src/authors.ts @@ -0,0 +1,71 @@ +import type { message, message_author } from '@jersey/lightning'; +import type { Client, Message } from 'guilded.js'; + +export async function fetch_author( + msg: Message, + bot: Client, +): Promise { + if (!msg.createdByWebhookId && msg.authorId !== 'Ann6LewA') { + try { + const author = await bot.members.fetch( + msg.serverId!, + msg.authorId, + ); + + return { + username: author.nickname || author.username || author.user?.name || + 'Guilded User', + rawname: author.username || author.user?.name || 'Guilded User', + id: msg.authorId, + profile: author.user?.avatar || undefined, + }; + } catch { + return { + username: 'Guilded User', + rawname: 'GuildedUser', + id: msg.authorId, + }; + } + } else if (msg.createdByWebhookId) { + try { + const webhook = await bot.webhooks.fetch( + msg.serverId!, + msg.createdByWebhookId, + ); + + return { + username: webhook.name, + rawname: webhook.name, + id: webhook.id, + profile: webhook.raw.avatar, + }; + } catch { + return { + username: 'Guilded Webhook', + rawname: 'GuildedWebhook', + id: msg.createdByWebhookId, + }; + } + } else { + return { + username: 'Guilded User', + rawname: 'GuildedUser', + id: msg.authorId, + }; + } +} + +function is_valid_username(e: string): boolean { + if (!e || e.length === 0 || e.length > 25) return false; + return /^[a-zA-Z0-9_ ()-]*$/gms.test(e); +} + +export function get_valid_username(msg: message): string { + if (is_valid_username(msg.author.username)) { + return msg.author.username; + } else if (is_valid_username(msg.author.rawname)) { + return msg.author.rawname; + } else { + return `${msg.author.id}`; + } +} diff --git a/packages/guilded/src/guilded_message/map_embed.ts b/packages/guilded/src/embeds.ts similarity index 52% rename from packages/guilded/src/guilded_message/map_embed.ts rename to packages/guilded/src/embeds.ts index cdce8281..4ad1c5d6 100644 --- a/packages/guilded/src/guilded_message/map_embed.ts +++ b/packages/guilded/src/embeds.ts @@ -1,8 +1,11 @@ +import type { EmbedPayload } from '@guildedjs/api'; import type { embed } from '@jersey/lightning'; import type { Embed } from 'guilded.js'; -export function map_embed(embed: Embed): embed { - return { +export function get_lightning_embeds(embeds?: Embed[]): embed[] | undefined { + if (!embeds) return; + + return embeds.map((embed) => ({ ...embed, author: embed.author ? { @@ -26,5 +29,26 @@ export function map_embed(embed: Embed): embed { title: embed.title || undefined, url: embed.url || undefined, video: embed.video || undefined, - }; + })); +} + +export function get_guilded_embeds( + embeds?: embed[], +): EmbedPayload[] | undefined { + if (!embeds) return; + + return embeds.flatMap((embed) => { + Object.keys(embed).forEach((key) => { + embed[key as keyof embed] === null + ? (embed[key as keyof embed] = undefined) + : embed[key as keyof embed]; + }); + if (!embed.description || embed.description === '') return []; + return [ + { + ...embed, + timestamp: embed.timestamp ? String(embed.timestamp) : undefined, + }, + ]; + }); } diff --git a/packages/guilded/src/error_handler.ts b/packages/guilded/src/error_handler.ts deleted file mode 100644 index 6fde0bdd..00000000 --- a/packages/guilded/src/error_handler.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { log_error } from '@jersey/lightning'; -import { GuildedAPIError } from 'guilded.js'; - -export function error_handler(e: unknown, channel_id: string, action: string) { - if (e instanceof GuildedAPIError) { - if (e.response.status === 404) { - if (action === 'deleting message') return []; - - log_error(e, { - message: - "resource not found! if you're trying to make a bridge, this is likely an issue with Guilded", - extra: { channel_id, response: e.response }, - disable: true, - }); - } else if (e.response.status === 403) { - log_error(e, { - message: 'no permission to send/delete messages! check bot permissions', - extra: { channel_id, response: e.response }, - disable: true, - }); - } else { - log_error(e, { - message: - `unknown guilded error ${action} with status code ${e.response.status}`, - extra: { channel_id, response: e.response }, - }); - } - } else { - log_error(e, { - message: `unknown error ${action}`, - extra: { channel_id }, - }); - } -} diff --git a/packages/guilded/src/errors.ts b/packages/guilded/src/errors.ts new file mode 100644 index 00000000..5ceb15f4 --- /dev/null +++ b/packages/guilded/src/errors.ts @@ -0,0 +1,34 @@ +import { GuildedAPIError } from '@guildedjs/api'; +import { log_error } from '@jersey/lightning'; + +export function handle_error(err: unknown, channel: string, edit?: boolean) { + if (err instanceof GuildedAPIError) { + if (err.response.status === 404) { + if (edit) return []; + + log_error(err, { + message: + "resource not found! if you're trying to make a bridge, this is likely an issue with Guilded", + extra: { channel_id: channel, response: err.response }, + disable: true, + }); + } else if (err.response.status === 403) { + log_error(err, { + message: 'no permission to send/delete messages! check bot permissions', + extra: { channel_id: channel, response: err.response }, + disable: true, + }); + } else { + log_error(err, { + message: + `unknown guilded error with status code ${err.response.status}`, + extra: { channel_id: channel, response: err.response }, + }); + } + } else { + log_error(err, { + message: `unknown error`, + extra: { channel_id: channel }, + }); + } +} diff --git a/packages/guilded/src/events.ts b/packages/guilded/src/events.ts new file mode 100644 index 00000000..4312ff2f --- /dev/null +++ b/packages/guilded/src/events.ts @@ -0,0 +1,33 @@ +import type { Client } from 'guilded.js'; +import { get_lightning_message } from './messages.ts'; +import type { guilded_plugin } from './mod.ts'; + +export function setup_events(bot: Client, emit: guilded_plugin['emit']) { + // @ts-ignore deno isn't properly handling the eventemitter code + bot.on('ready', () => { + console.log(`[bolt-guilded] ready as ${bot.user?.name}`); + }); + // @ts-ignore deno isn't properly handling the eventemitter code + bot.on('messageCreated', async (message) => { + const msg = await get_lightning_message(message, bot); + if (msg) emit('create_message', msg); + }); + // @ts-ignore deno isn't properly handling the eventemitter code + bot.on('messageUpdated', async (message) => { + const msg = await get_lightning_message(message, bot); + if (msg) emit('edit_message', msg); + }); + // @ts-ignore deno isn't properly handling the eventemitter code + bot.on('messageDeleted', (del) => { + emit('delete_message', { + channel: del.channelId, + id: del.id, + plugin: 'bolt-guilded', + timestamp: Temporal.Instant.from(del.deletedAt), + }); + }); + // @ts-ignore deno isn't dealing with the import + bot.ws.emitter.on('exit', () => { + bot.ws.connect(); + }); +} diff --git a/packages/guilded/src/guilded.ts b/packages/guilded/src/guilded.ts deleted file mode 100644 index 956e95a0..00000000 --- a/packages/guilded/src/guilded.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { embed, message } from '@jersey/lightning'; -import type { Client, EmbedPayload, WebhookMessageContent } from 'guilded.js'; - -type guilded_msg = Exclude & { - replyMessageIds?: string[]; -}; - -export async function convert_msg( - msg: message, - channel?: string, - bot?: Client, - allow_everyone = true, -): Promise { - const message = { - content: msg.content, - avatar_url: msg.author.profile, - username: get_valid_username(msg), - embeds: [ - ...fix_embed(msg.embeds), - ...(await get_reply_embeds(msg, channel, bot)), - ], - } as guilded_msg; - - if (msg.reply_id) message.replyMessageIds = [msg.reply_id]; - - if (msg.attachments?.length) { - if (!message.embeds) message.embeds = []; - message.embeds.push({ - title: 'attachments', - description: msg.attachments - .slice(0, 5) - .map((a) => { - return `![${a.alt || a.name}](${a.file})`; - }) - .join('\n'), - }); - } - - if (message.embeds?.length === 0 || !message.embeds) delete message.embeds; - - if (!message.content && !message.embeds) message.content = '*empty message*'; - - if (!allow_everyone && message.content) { - message.content = message.content.replace(/@everyone/g, '(a)everyone'); - message.content = message.content.replace(/@here/g, '(a)here'); - } - - return message; -} - -function get_valid_username(msg: message) { - function valid(e: string) { - if (!e || e.length === 0 || e.length > 25) return false; - return /^[a-zA-Z0-9_ ()-]*$/gms.test(e); - } - - if (valid(msg.author.username)) { - return msg.author.username; - } else if (valid(msg.author.rawname)) { - return msg.author.rawname; - } else { - return `${msg.author.id}`; - } -} - -async function get_reply_embeds( - msg: message, - channel?: string, - bot?: Client, -) { - if (!msg.reply_id || !channel || !bot) return []; - try { - const msg_replied_to = await bot.messages.fetch( - channel, - msg.reply_id, - ); - let author; - if (!msg_replied_to.createdByWebhookId) { - author = await bot.members.fetch( - msg_replied_to.serverId!, - msg_replied_to.authorId, - ); - } - return [ - { - author: { - name: `reply to ${author?.nickname || author?.username || 'a user'}`, - icon_url: author?.user?.avatar || undefined, - }, - description: msg_replied_to.content, - }, - ...(msg_replied_to.embeds || []), - ] as EmbedPayload[]; - } catch { - return []; - } -} - -function fix_embed(embeds: embed[] = []) { - return embeds.flatMap((embed) => { - Object.keys(embed).forEach((key) => { - embed[key as keyof embed] === null - ? (embed[key as keyof embed] = undefined) - : embed[key as keyof embed]; - }); - if (!embed.description || embed.description === '') return []; - return [ - { - ...embed, - timestamp: embed.timestamp ? String(embed.timestamp) : undefined, - }, - ]; - }) as (EmbedPayload & { timestamp: string })[]; -} diff --git a/packages/guilded/src/guilded_message/author.ts b/packages/guilded/src/guilded_message/author.ts deleted file mode 100644 index 634b6a60..00000000 --- a/packages/guilded/src/guilded_message/author.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { message_author } from '@jersey/lightning'; -import type { Client, Message } from 'guilded.js'; - -export async function get_author( - msg: Message, - bot: Client, -): Promise { - if (!msg.createdByWebhookId && msg.authorId !== 'Ann6LewA') { - try { - const au = await bot.members.fetch( - msg.serverId!, - msg.authorId, - ); - - return { - username: au.nickname || au.username || au.user?.name || 'Guilded User', - rawname: au.username || au.user?.name || 'Guilded User', - id: msg.authorId, - profile: au.user?.avatar || undefined, - }; - } catch { - return { - username: 'Guilded User', - rawname: 'GuildedUser', - id: msg.authorId, - }; - } - } else if (msg.createdByWebhookId) { - // try to fetch webhook? - try { - const wh = await bot.webhooks.fetch( - msg.serverId!, - msg.createdByWebhookId, - ); - - return { - username: wh.name, - rawname: wh.name, - id: wh.id, - profile: wh.raw.avatar, - }; - } catch { - return { - username: 'Guilded Webhook', - rawname: 'GuildedWebhook', - id: msg.createdByWebhookId, - }; - } - } else { - return { - username: 'Guilded User', - rawname: 'GuildedUser', - id: msg.authorId, - }; - } -} diff --git a/packages/guilded/src/guilded_message/mod.ts b/packages/guilded/src/guilded_message/mod.ts deleted file mode 100644 index e4339205..00000000 --- a/packages/guilded/src/guilded_message/mod.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { attachment, message } from '@jersey/lightning'; -import type { Client, Message } from 'guilded.js'; -import { convert_msg } from '../guilded.ts'; -import { get_author } from './author.ts'; -import { map_embed } from './map_embed.ts'; - -export async function guilded_to_message( - msg: Message, - bot: Client, -): Promise { - if (msg.serverId === null) return; - - let content = msg.content.replaceAll('\n```\n```\n', '\n'); - - const urls = content.match( - /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, - ) || []; - - content = content.replaceAll( - /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, - '', - ); - - return { - author: { - ...await get_author(msg, bot), - color: '#F5C400', - }, - attachments: await get_attachments(bot, urls), - channel: msg.channelId, - id: msg.id, - timestamp: Temporal.Instant.fromEpochMilliseconds( - msg.createdAt.valueOf(), - ), - embeds: msg.embeds?.map(map_embed), - plugin: 'bolt-guilded', - reply: async (reply: message) => { - await msg.reply(await convert_msg(reply)); - }, - content, - reply_id: msg.isReply ? msg.replyMessageIds[0] : undefined, - }; -} - -async function get_attachments(bot: Client, urls: string[]) { - const attachments = [] as attachment[]; - - try { - const signed = - await (await fetch('https://www.guilded.gg/api/v1/url-signatures', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': `Bearer ${bot.token}`, - }, - body: JSON.stringify({ - urls: urls.map((url) => (url.split('(').pop()?.split(')')[0])), - }), - })).json(); - - for (const url of signed.urlSignatures || []) { - if (url.signature) { - const resp = await fetch(url.signature, { - method: 'HEAD', - }); - - attachments.push({ - name: url.signature.split('/').pop()?.split('?')[0] || 'unknown', - file: url.signature, - size: parseInt(resp.headers.get('Content-Length') || '0') / 1048576, - }); - } - } - } catch { - // ignore - } - - return attachments; -} diff --git a/packages/guilded/src/messages.ts b/packages/guilded/src/messages.ts new file mode 100644 index 00000000..54e8d949 --- /dev/null +++ b/packages/guilded/src/messages.ts @@ -0,0 +1,92 @@ +import type { WebhookMessageContent } from '@guildedjs/api'; +import type { message } from '@jersey/lightning'; +import type { Client, Message } from 'guilded.js'; +import { fetch_attachments } from './attachments.ts'; +import { fetch_author, get_valid_username } from './authors.ts'; +import { get_guilded_embeds, get_lightning_embeds } from './embeds.ts'; +import { fetch_reply_embed } from './replies.ts'; + +type guilded_webhook_payload = Exclude; + +export async function get_lightning_message( + msg: Message, + bot: Client, +): Promise { + if (msg.serverId === null) return; + + let content = msg.content.replaceAll('\n```\n```\n', '\n'); + + const urls = content.match( + /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, + ) || []; + + content = content.replaceAll( + /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, + '', + ); + + return { + author: { + ...await fetch_author(msg, bot), + color: '#F5C400', + }, + attachments: await fetch_attachments(bot, urls), + channel: msg.channelId, + id: msg.id, + timestamp: Temporal.Instant.fromEpochMilliseconds( + msg.createdAt.valueOf(), + ), + embeds: get_lightning_embeds(msg.embeds), + plugin: 'bolt-guilded', + reply: async (reply: message) => { + await msg.reply(await get_guilded_message(reply)); + }, + content, + reply_id: msg.isReply ? msg.replyMessageIds[0] : undefined, + }; +} + +export async function get_guilded_message( + msg: message, + channel?: string, + bot?: Client, + everyone = true, +): Promise { + const message: guilded_webhook_payload = { + content: msg.content, + avatar_url: msg.author.profile, + username: get_valid_username(msg), + embeds: get_guilded_embeds(msg.embeds), + }; + + if (msg.reply_id) { + const embed = await fetch_reply_embed(msg, channel, bot); + + if (embed) { + if (!message.embeds) message.embeds = []; + message.embeds.push(embed); + } + } + + if (msg.attachments?.length) { + if (!message.embeds) message.embeds = []; + message.embeds.push({ + title: 'attachments', + description: msg.attachments + .slice(0, 5) + .map((a) => { + return `![${a.alt || a.name}](${a.file})`; + }) + .join('\n'), + }); + } + + if (!message.content && !message.embeds) message.content = '\u2800'; + + if (!everyone && message.content) { + message.content = message.content.replace(/@everyone/g, '(a)everyone'); + message.content = message.content.replace(/@here/g, '(a)here'); + } + + return message; +} diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index e6f9164a..ecbbd943 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -1,3 +1,4 @@ +import { WebhookClient } from '@guildedjs/api'; import { type create_opts, type delete_opts, @@ -6,10 +7,10 @@ import { log_error, plugin, } from '@jersey/lightning'; -import { Client, WebhookClient } from 'guilded.js'; -import { error_handler } from './error_handler.ts'; -import { convert_msg } from './guilded.ts'; -import { guilded_to_message } from './guilded_message/mod.ts'; +import { Client } from 'guilded.js'; +import { handle_error } from './errors.ts'; +import { setup_events } from './events.ts'; +import { get_guilded_message } from './messages.ts'; /** options for the guilded plugin */ export interface guilded_config { @@ -32,33 +33,9 @@ export class guilded_plugin extends plugin { }; this.bot = new Client({ token: c.token, ws: opts, rest: opts }); - this.setup_events(); - this.bot.login(); - } - private setup_events() { - this.bot.on('ready', () => { - console.log(`[bolt-guilded] ready as ${this.bot.user?.name}`); - }); - this.bot.on('messageCreated', async (message) => { - const msg = await guilded_to_message(message, this.bot); - if (msg) this.emit('create_message', msg); - }); - this.bot.on('messageUpdated', async (message) => { - const msg = await guilded_to_message(message, this.bot); - if (msg) this.emit('edit_message', msg); - }); - this.bot.on('messageDeleted', (del) => { - this.emit('delete_message', { - channel: del.channelId, - id: del.id, - plugin: 'bolt-guilded', - timestamp: Temporal.Instant.from(del.deletedAt), - }); - }); - this.bot.ws.emitter.on('exit', () => { - this.bot.ws.connect(); - }); + setup_events(this.bot, this.emit); + this.bot.login(); } async setup_channel(channel: string): Promise { @@ -76,7 +53,7 @@ export class guilded_plugin extends plugin { return { id: webhook.id, token: webhook.token }; } catch (e) { - return error_handler(e, channel, 'creating webhook'); + return handle_error(e, channel); } } @@ -87,7 +64,7 @@ export class guilded_plugin extends plugin { ); const res = await webhook.send( - await convert_msg( + await get_guilded_message( opts.msg, opts.channel.id, this.bot, @@ -97,13 +74,13 @@ export class guilded_plugin extends plugin { return [res.id]; } catch (e) { - return error_handler(e, opts.channel.id, 'creating message'); + return handle_error(e, opts.channel.id); } } + // guilded doesn't support editing messages // deno-lint-ignore require-await async edit_message(opts: edit_opts): Promise { - // guilded does not support editing messages return opts.edit_ids; } @@ -113,7 +90,7 @@ export class guilded_plugin extends plugin { return opts.edit_ids; } catch (e) { - return error_handler(e, opts.channel.id, 'deleting message'); + return handle_error(e, opts.channel.id, true); } } } diff --git a/packages/guilded/src/replies.ts b/packages/guilded/src/replies.ts new file mode 100644 index 00000000..f37c0239 --- /dev/null +++ b/packages/guilded/src/replies.ts @@ -0,0 +1,31 @@ +import type { EmbedPayload } from '@guildedjs/api'; +import type { message } from '@jersey/lightning'; +import type { Client } from 'guilded.js'; +import { fetch_author } from './authors.ts'; + +export async function fetch_reply_embed( + msg: message, + channel?: string, + bot?: Client, +): Promise { + if (!msg.reply_id || !channel || !bot) return; + + try { + const replied_to_message = await bot.messages.fetch( + channel, + msg.reply_id, + ); + + const author = await fetch_author(replied_to_message, bot); + + return { + author: { + name: `reply to ${author.username}`, + icon_url: author.profile, + }, + description: replied_to_message.content, + }; + } catch { + return; + } +} diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index b3d9404f..a51ce5c6 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -1,6 +1,7 @@ { "name": "@jersey/lightning", "version": "0.8.0", + "license": "MIT", "exports": "./src/mod.ts", "imports": { // TODO(jersey): get @db/mongo@^0.34.0 on JSR @@ -9,8 +10,8 @@ "@db/postgres": "jsr:@jersey/test@0.8.6", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", "@iuioiua/redis": "jsr:@iuioiua/redis@^1.0.1", - "@std/cli/parse-args": "jsr:@std/cli@^1.0.3/parse-args", - "@std/path": "jsr:@std/path@^1.0.0", + "@std/cli/parse-args": "jsr:@std/cli@^1.0.14/parse-args", + "@std/path": "jsr:@std/path@^1.0.8", "@std/ulid": "jsr:@std/ulid@^1.0.0" } } diff --git a/packages/lightning/license b/packages/lightning/license deleted file mode 100644 index d366acad..00000000 --- a/packages/lightning/license +++ /dev/null @@ -1,18 +0,0 @@ -Copyright (c) William Horning and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the โ€œSoftwareโ€), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/lightning/src/commands/runners.ts b/packages/lightning/src/commands/runners.ts index d37290ae..e64eb8c0 100644 --- a/packages/lightning/src/commands/runners.ts +++ b/packages/lightning/src/commands/runners.ts @@ -14,17 +14,16 @@ export async function execute_text_command(msg: message, lightning: lightning) { return await run_command({ ...msg, - lightning, command: cmd as string, rest: rest as string[], - }); + }, lightning); } export async function run_command( opts: create_command, + lightning: lightning ) { - let command = opts.lightning.commands.get(opts.command) ?? - opts.lightning.commands.get('help')!; + let command = lightning.commands.get(opts.command) ?? lightning.commands.get('help')!; const subcommand_name = opts.subcommand ?? opts.rest?.shift(); @@ -46,7 +45,7 @@ export async function run_command( if (!opts.args[arg.name]) { return opts.reply( create_message( - `Please provide the \`${arg.name}\` argument. Try using the \`${opts.lightning.config.prefix}help\` command.`, + `Please provide the \`${arg.name}\` argument. Try using the \`${lightning.config.prefix}help\` command.`, ), false, ); @@ -59,6 +58,7 @@ export async function run_command( resp = await command.execute({ ...opts, args: opts.args, + lightning }); } catch (e) { if (e instanceof LightningError) resp = e; diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index 0eddc38d..ad0e6e0e 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -99,7 +99,7 @@ export class redis extends redis_messages implements bridge_data { `lightning-bchannel-${channel.id}`, bridge.id, ]); - }; + } } } diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index d4f5d605..c8b2522d 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -10,7 +10,7 @@ export class redis_messages { ]); if (db_data_version === null) { - const number_keys = await rd.sendCommand(["DBSIZE"]) as number; + const number_keys = await rd.sendCommand(['DBSIZE']) as number; if (number_keys === 0) db_data_version = '0.8.0'; } diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index 03f84590..7f50162e 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -63,7 +63,7 @@ export class lightning { } if (name === 'create_command') { - run_command(value[0] as create_command); + run_command(value[0] as create_command, this); continue; } diff --git a/packages/lightning/src/structures/events.ts b/packages/lightning/src/structures/events.ts index 6f433eb5..d14cd478 100644 --- a/packages/lightning/src/structures/events.ts +++ b/packages/lightning/src/structures/events.ts @@ -2,7 +2,7 @@ import type { command_opts } from './commands.ts'; import type { deleted_message, message } from './messages.ts'; /** command execution event */ -export interface create_command extends Omit { +export interface create_command extends Omit, 'lightning'> { /** the command to run */ command: string; /** the subcommand, if any, to use */ diff --git a/packages/lightning/src/structures/messages.ts b/packages/lightning/src/structures/messages.ts index c6e23b53..c85a3cd6 100644 --- a/packages/lightning/src/structures/messages.ts +++ b/packages/lightning/src/structures/messages.ts @@ -57,8 +57,6 @@ export interface message_author { 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 */ id: string; /** the color of an author */ diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index 6c1b533d..a96d9299 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,6 +1,7 @@ { "name": "@jersey/lightning-plugin-revolt", "version": "0.8.0", + "license": "MIT", "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", diff --git a/packages/revolt/license b/packages/revolt/license deleted file mode 100644 index d366acad..00000000 --- a/packages/revolt/license +++ /dev/null @@ -1,18 +0,0 @@ -Copyright (c) William Horning and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the โ€œSoftwareโ€), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/revolt/src/attachments.ts b/packages/revolt/src/attachments.ts new file mode 100644 index 00000000..567e884c --- /dev/null +++ b/packages/revolt/src/attachments.ts @@ -0,0 +1,27 @@ +import { type attachment, LightningError } from '@jersey/lightning'; +import type { Client } from '@jersey/rvapi'; + +export async function upload_attachments( + api: Client, + attachments?: attachment[], +): Promise { + if (!attachments) return undefined; + + return (await Promise.all( + attachments.map(async (attachment) => { + try { + return await api.media.upload_file( + 'attachments', + await (await fetch(attachment.file)).blob(), + ); + } catch (e) { + new LightningError(e, { + message: 'Failed to upload attachment', + extra: { original: e }, + }); + + return; + } + }), + )).filter((i) => i !== undefined); +} diff --git a/packages/revolt/src/author.ts b/packages/revolt/src/author.ts new file mode 100644 index 00000000..928a74a8 --- /dev/null +++ b/packages/revolt/src/author.ts @@ -0,0 +1,57 @@ +import type { Channel, User } from '@jersey/revolt-api-types'; +import type { Client } from '@jersey/rvapi'; +import type { message_author } from '@jersey/lightning'; +import { fetch_member } from './member.ts'; + +export async function get_author( + api: Client, + author_id: string, + channel_id: string, +): Promise { + try { + const channel = await api.request( + 'get', + `/channels/${channel_id}`, + undefined, + ) as Channel; + + const author = await api.request( + 'get', + `/users/${author_id}`, + undefined, + ) as User; + + const author_data = { + id: author_id, + rawname: author.username, + username: author.username, + color: '#FF4654', + profile: author.avatar + ? `https://autumn.revolt.chat/avatars/${author.avatar._id}` + : undefined, + }; + + if (channel.channel_type !== 'TextChannel') return author_data; + + try { + const member = await fetch_member(api, channel, author_id); + + return { + ...author_data, + username: member.nickname ?? author_data.username, + profile: member.avatar + ? `https://autumn.revolt.chat/avatars/${member.avatar._id}` + : author_data.profile, + }; + } catch { + return author_data; + } + } catch { + return { + id: author_id, + rawname: 'RevoltUser', + username: 'Revolt User', + color: '#FF4654', + }; + } +} diff --git a/packages/revolt/src/embeds.ts b/packages/revolt/src/embeds.ts new file mode 100644 index 00000000..a3bdfb53 --- /dev/null +++ b/packages/revolt/src/embeds.ts @@ -0,0 +1,31 @@ +import type { SendableEmbed } from '@jersey/revolt-api-types'; +import type { embed } from '@jersey/lightning'; + +export function get_revolt_embeds( + embeds?: embed[], +): SendableEmbed[] | undefined { + if (!embeds) return undefined; + + return embeds.map((embed) => { + const data: SendableEmbed = { + icon_url: embed.author?.icon_url ?? null, + url: embed.url ?? null, + title: embed.title ?? null, + description: embed.description ?? '', + media: embed.image?.url ?? null, + colour: embed.color ? `#${embed.color.toString(16)}` : null, + }; + + if (embed.fields) { + for (const field of embed.fields) { + data.description += `\n\n**${field.name}**\n${field.value}`; + } + } + + if (data.description?.length === 0) { + data.description = null; + } + + return data; + }); +} diff --git a/packages/revolt/src/error_handler.ts b/packages/revolt/src/errors.ts similarity index 52% rename from packages/revolt/src/error_handler.ts rename to packages/revolt/src/errors.ts index 576f8ef0..be34ed94 100644 --- a/packages/revolt/src/error_handler.ts +++ b/packages/revolt/src/errors.ts @@ -1,31 +1,31 @@ import { MediaError, RequestError } from '@jersey/rvapi'; import { log_error } from '@jersey/lightning'; -export function handle_error(e: unknown, edit?: boolean) { - if (e instanceof MediaError) { - log_error(e, { - message: e.message, +export function handle_error(err: unknown, edit?: boolean) { + if (err instanceof MediaError) { + log_error(err, { + message: err.message, }); - } else if (e instanceof RequestError) { - if (e.cause.status === 403) { - log_error(e, { + } else if (err instanceof RequestError) { + if (err.cause.status === 403) { + log_error(err, { message: 'Insufficient permissions', disable: true, }); - } else if (e.cause.status === 404) { + } else if (err.cause.status === 404) { if (edit) return []; - log_error(e, { + log_error(err, { message: 'Resource not found', disable: true, }); } else { - log_error(e, { + log_error(err, { message: 'unknown error', }); } } else { - log_error(e, { + log_error(err, { message: 'unknown error', }); } diff --git a/packages/revolt/src/events.ts b/packages/revolt/src/events.ts new file mode 100644 index 00000000..501c6072 --- /dev/null +++ b/packages/revolt/src/events.ts @@ -0,0 +1,61 @@ +import { type Client, createClient } from '@jersey/rvapi'; +import type { Message } from '@jersey/revolt-api-types'; +import { get_lightning_message } from './messages.ts'; +import type { revolt_config, revolt_plugin } from './mod.ts'; + +export function setup_events( + bot: Client, + config: revolt_config, + emit: revolt_plugin['emit'], +) { + bot.bonfire.on('Ready', (ready) => { + console.log( + `[bolt-revolt] ready in ${ready.channels.length} channels and ${ready.servers.length} servers`, + ); + }); + + bot.bonfire.on('Message', async (msg) => { + if (!msg.channel || msg.channel === 'undefined') return; + + emit('create_message', await get_lightning_message(bot, msg)); + }); + + bot.bonfire.on('MessageUpdate', async (msg) => { + if (!msg.channel || msg.channel === 'undefined') return; + + let oldMessage: Message; + + try { + oldMessage = await bot.request( + 'get', + `/channels/${msg.channel}/messages/${msg.id}`, + undefined, + ) as Message; + } catch { + return; + } + + emit( + 'edit_message', + await get_lightning_message(bot, { + ...oldMessage, + ...msg.data, + }), + ); + }); + + bot.bonfire.on('MessageDelete', (msg) => { + emit('delete_message', { + channel: msg.channel, + id: msg.id, + timestamp: Temporal.Now.instant(), + plugin: 'bolt-revolt', + }); + }); + + bot.bonfire.on('socket_close', (info) => { + console.warn('[bolt-revolt] socket closed', info); + bot = createClient(config); + setup_events(bot, config, emit); + }); +} diff --git a/packages/revolt/src/fetch_member.ts b/packages/revolt/src/member.ts similarity index 58% rename from packages/revolt/src/fetch_member.ts rename to packages/revolt/src/member.ts index 20bcdd5c..f31db9c8 100644 --- a/packages/revolt/src/fetch_member.ts +++ b/packages/revolt/src/member.ts @@ -1,36 +1,34 @@ import type { Channel, Member } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; -interface member_map_value { +const member_cache = new Map<`${string}/${string}`, { value: Member; expiry: number; -} - -const member_map = new Map(); +}>(); export async function fetch_member( client: Client, channel: Channel & { channel_type: 'TextChannel' }, - user_id: string, + user: string, ): Promise { const time_now = Temporal.Now.instant().epochMilliseconds; - const member = member_map.get(user_id); + const member = member_cache.get(`${channel.server}/${user}`); if (member && member.expiry > time_now) { return member.value; } - const member_resp = await client.request( + const response = await client.request( 'get', - `/servers/${channel.server}/members/${user_id}`, + `/servers/${channel.server}/members/${user}`, undefined, - ); + ) as Member; - member_map.set(user_id, { - value: member_resp as Member, + member_cache.set(`${channel.server}/${user}`, { + value: response, expiry: time_now + 300000, }); - return member_resp as Member; + return response; } diff --git a/packages/revolt/src/messages.ts b/packages/revolt/src/messages.ts new file mode 100644 index 00000000..ca47355c --- /dev/null +++ b/packages/revolt/src/messages.ts @@ -0,0 +1,87 @@ +import type { DataMessageSend, Message } from '@jersey/revolt-api-types'; +import type { Client } from '@jersey/rvapi'; +import type { embed, message } from '@jersey/lightning'; +import { decodeTime } from '@std/ulid'; +import { get_author } from './author.ts'; +import { upload_attachments } from './attachments.ts'; +import { get_revolt_embeds } from './embeds.ts'; + +export async function get_lightning_message( + api: Client, + message: Message, +): Promise { + return { + attachments: message.attachments?.map((i) => { + return { + file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, + name: i.filename, + size: i.size / 1048576, + }; + }), + author: await get_author(api, message.author, message.channel), + channel: message.channel, + content: message.content ?? undefined, + embeds: message.embeds?.map((i) => { + return { + color: 'colour' in i && i.colour + ? parseInt(i.colour.replace('#', ''), 16) + : undefined, + ...i, + } as embed; + }), + id: message._id, + plugin: 'bolt-revolt', + reply: async (msg: message, masquerade = true) => { + await api.request( + 'post', + `/channels/${message.channel}/messages`, + { + ...(await get_revolt_message( + api, + { ...msg, reply_id: message._id }, + masquerade as boolean, + )), + }, + ); + }, + timestamp: message.edited + ? Temporal.Instant.from(message.edited) + : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), + reply_id: message.replies?.[0] ?? undefined, + }; +} + +export async function get_revolt_message( + api: Client, + message: message, + masquerade = true, +): Promise { + const attachments = await upload_attachments(api, message.attachments); + const embeds = get_revolt_embeds(message.embeds); + + if ( + (!message.content || message.content.length < 1) && + (!embeds || embeds.length < 1) && + (!attachments || attachments.length < 1) + ) { + message.content = '*empty message*'; + } + + return { + attachments, + content: (message.content?.length || 0) > 2000 + ? `${message.content?.substring(0, 1997)}...` + : message.content, + embeds, + replies: message.reply_id + ? [{ id: message.reply_id, mention: true }] + : undefined, + masquerade: masquerade + ? { + name: message.author.username, + avatar: message.author.profile, + colour: message.author.color, + } + : undefined, + }; +} diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index b777d54e..b11447d4 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -7,10 +7,10 @@ import { } from '@jersey/lightning'; import { type Client, createClient } from '@jersey/rvapi'; import type { Message } from '@jersey/revolt-api-types'; -import { handle_error } from './error_handler.ts'; +import { handle_error } from './errors.ts'; import { check_permissions } from './permissions.ts'; -import { to_revolt } from './to_revolt.ts'; -import { to_lightning } from './to_lightning.ts'; +import { get_revolt_message } from './messages.ts'; +import { setup_events } from './events.ts'; /** the config for the revolt plugin */ export interface revolt_config { @@ -28,60 +28,7 @@ export class revolt_plugin extends plugin { constructor(l: lightning, config: revolt_config) { super(l, config); this.bot = createClient(config); - this.setup_events(); - } - - private setup_events() { - this.bot.bonfire.on('Ready', (ready) => { - console.log( - `[bolt-revolt] ready in ${ready.channels.length} channels and ${ready.servers.length} servers`, - ); - }); - - this.bot.bonfire.on('Message', async (msg) => { - if (!msg.channel || msg.channel === 'undefined') return; - - this.emit('create_message', await to_lightning(this.bot, msg)); - }); - - this.bot.bonfire.on('MessageUpdate', async (msg) => { - if (!msg.channel || msg.channel === 'undefined') return; - - let old_msg: Message; - - try { - old_msg = await this.bot.request( - 'get', - `/channels/${msg.channel}/messages/${msg.id}`, - undefined, - ) as Message; - } catch { - return; - } - - this.emit( - 'edit_message', - await to_lightning(this.bot, { - ...old_msg, - ...msg.data, - }), - ); - }); - - this.bot.bonfire.on('MessageDelete', (msg) => { - this.emit('delete_message', { - channel: msg.channel, - id: msg.id, - timestamp: Temporal.Now.instant(), - plugin: 'bolt-revolt', - }); - }); - - this.bot.bonfire.on('socket_close', (info) => { - console.warn('[bolt-revolt] socket closed', info); - this.bot = createClient(this.config); - this.setup_events(); - }); + setup_events(this.bot, config, this.emit); } async setup_channel(channel: string): Promise { @@ -93,7 +40,7 @@ export class revolt_plugin extends plugin { const { _id } = (await this.bot.request( 'post', `/channels/${opts.channel.id}/messages`, - await to_revolt(this.bot, opts.msg, true), + await get_revolt_message(this.bot, opts.msg, true), )) as Message; return [_id]; @@ -107,7 +54,7 @@ export class revolt_plugin extends plugin { await this.bot.request( 'patch', `/channels/${opts.channel.id}/messages/${opts.edit_ids[0]}`, - await to_revolt(this.bot, opts.msg, true), + await get_revolt_message(this.bot, opts.msg, true), ); return opts.edit_ids; diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index bcd9829e..dfc3041e 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -1,15 +1,15 @@ import type { Client } from '@jersey/rvapi'; import type { Channel, Role, Server } from '@jersey/revolt-api-types'; import { LightningError, log_error } from '@jersey/lightning'; -import { handle_error } from './error_handler.ts'; -import { fetch_member } from './fetch_member.ts'; +import { handle_error } from './errors.ts'; +import { fetch_member } from './member.ts'; -const permissions_to_check = [ +const permissions_bits = [ 1 << 23, // ManageMessages 1 << 28, // Masquerade ]; -const permissions = permissions_to_check.reduce((a, b) => a | b, 0); +const permissions = permissions_bits.reduce((a, b) => a | b, 0); export async function check_permissions( channel_id: string, @@ -58,14 +58,14 @@ async function server_permissions( let total_permissions = server.default_permissions; for (const role of (member.roles || [])) { - const { permissions: role_perms } = await client.request( + const { permissions: role_permissions } = await client.request( 'get', `/servers/${channel.server}/roles/${role}`, undefined, ) as Role; - total_permissions |= role_perms.a || 0; - total_permissions &= ~role_perms.d || 0; + total_permissions |= role_permissions.a || 0; + total_permissions &= ~role_permissions.d || 0; } // apply default allow/denies diff --git a/packages/revolt/src/to_lightning.ts b/packages/revolt/src/to_lightning.ts deleted file mode 100644 index 3474e9a5..00000000 --- a/packages/revolt/src/to_lightning.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Channel, Embed, Message, User } from '@jersey/revolt-api-types'; -import type { Client } from '@jersey/rvapi'; -import type { embed, message, message_author } from '@jersey/lightning'; -import { decodeTime } from '@std/ulid'; -import { to_revolt } from './to_revolt.ts'; -import { fetch_member } from './fetch_member.ts'; - -export async function to_lightning( - api: Client, - message: Message, -): Promise { - return { - attachments: message.attachments?.map((i) => { - return { - file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, - name: i.filename, - size: i.size / 1048576, - }; - }), - author: await get_author(api, message.author, message.channel), - channel: message.channel, - content: message.content ?? undefined, - embeds: (message.embeds as Embed[] | undefined)?.map((i) => { - return { - color: 'colour' in i && i.colour - ? parseInt(i.colour.replace('#', ''), 16) - : undefined, - ...i, - } as embed; - }), - id: message._id, - plugin: 'bolt-revolt', - reply: async (msg: message, masquerade = true) => { - await api.request( - 'post', - `/channels/${message.channel}/messages`, - { - ...(await to_revolt( - api, - { ...msg, reply_id: message._id }, - masquerade as boolean, - )), - }, - ); - }, - timestamp: message.edited - ? Temporal.Instant.from(message.edited) - : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), - reply_id: message.replies?.[0] ?? undefined, - }; -} - -async function get_author( - api: Client, - author_id: string, - channel_id: string, -): Promise { - try { - const channel = await api.request( - 'get', - `/channels/${channel_id}`, - undefined, - ) as Channel; - - const author = await api.request( - 'get', - `/users/${author_id}`, - undefined, - ) as User; - - const author_data = { - id: author_id, - rawname: author.username, - username: author.username, - color: '#FF4654', - profile: author.avatar - ? `https://autumn.revolt.chat/avatars/${author.avatar._id}` - : undefined, - }; - - if (channel.channel_type !== 'TextChannel') { - return author_data; - } else { - try { - const member = await fetch_member(api, channel, author_id); - - return { - ...author_data, - username: member.nickname ?? author_data.username, - profile: member.avatar - ? `https://autumn.revolt.chat/avatars/${member.avatar._id}` - : author_data.profile, - }; - } catch { - return author_data; - } - } - } catch { - return { - id: author_id, - rawname: 'RevoltUser', - username: 'Revolt User', - color: '#FF4654', - }; - } -} diff --git a/packages/revolt/src/to_revolt.ts b/packages/revolt/src/to_revolt.ts deleted file mode 100644 index 89cd709b..00000000 --- a/packages/revolt/src/to_revolt.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; -import { - type attachment, - type embed, - LightningError, - type message, -} from '@jersey/lightning'; -import type { Client } from '@jersey/rvapi'; - -export async function to_revolt( - api: Client, - message: message, - masquerade = true, -): Promise { - const attachments = await upload_attachments(api, message.attachments); - const embeds = map_embeds(message.embeds); - - if ( - (!message.content || message.content.length < 1) && - (!embeds || embeds.length < 1) && - (!attachments || attachments.length < 1) - ) { - message.content = '*empty message*'; - } - - return { - attachments, - content: (message.content?.length || 0) > 2000 - ? `${message.content?.substring(0, 1997)}...` - : message.content, - embeds, - replies: message.reply_id - ? [{ id: message.reply_id, mention: true }] - : undefined, - masquerade: masquerade - ? { - name: message.author.username, - avatar: message.author.profile, - colour: message.author.color, - } - : undefined, - }; -} - -function map_embeds(embeds?: embed[]): SendableEmbed[] | undefined { - if (!embeds) return undefined; - - return embeds.map((embed) => { - const data: SendableEmbed = { - icon_url: embed.author?.icon_url ?? null, - url: embed.url ?? null, - title: embed.title ?? null, - description: embed.description ?? '', - media: embed.image?.url ?? null, - colour: embed.color ? `#${embed.color.toString(16)}` : null, - }; - - if (embed.fields) { - for (const field of embed.fields) { - data.description += `\n\n**${field.name}**\n${field.value}`; - } - } - - if (data.description?.length === 0) { - data.description = null; - } - - return data; - }); -} - -async function upload_attachments(api: Client, attachments?: attachment[]) { - if (!attachments) return undefined; - - return (await Promise.all( - attachments.map(async (attachment) => - api.media.upload_file( - 'attachments', - await (await fetch(attachment.file)).blob(), - ) - .then((id) => [id]) - .catch((e) => { - new LightningError(e, { - message: 'Failed to upload attachment', - extra: { original: e }, - }); - return [] as string[]; - }) - ), - )).flat(); -} diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index 5a9cc2e9..a32ac7a5 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,6 +1,7 @@ { "name": "@jersey/lightning-plugin-telegram", "version": "0.8.0", + "license": "MIT", "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", diff --git a/packages/telegram/license b/packages/telegram/license deleted file mode 100644 index d366acad..00000000 --- a/packages/telegram/license +++ /dev/null @@ -1,18 +0,0 @@ -Copyright (c) William Horning and contributors - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the โ€œSoftwareโ€), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED โ€œAS ISโ€, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/telegram/src/file_proxy.ts b/packages/telegram/src/file_proxy.ts index 64fe3a78..6346ce62 100644 --- a/packages/telegram/src/file_proxy.ts +++ b/packages/telegram/src/file_proxy.ts @@ -1,11 +1,11 @@ import type { telegram_config } from './mod.ts'; -export function file_proxy(config: telegram_config) { +export function setup_file_proxy(config: telegram_config) { Deno.serve({ - port: config.plugin_port, + port: config.proxy_port, onListen: ({ port }) => { console.log(`[bolt-telegram] file proxy listening on localhost:${port}`); - console.log(`[bolt-telegram] also available at: ${config.plugin_url}`); + console.log(`[bolt-telegram] also available at: ${config.proxy_url}`); }, }, (req: Request) => { const { pathname } = new URL(req.url); diff --git a/packages/telegram/src/messages.ts b/packages/telegram/src/messages.ts index 07180c65..8a6ea9ec 100644 --- a/packages/telegram/src/messages.ts +++ b/packages/telegram/src/messages.ts @@ -1,20 +1,39 @@ import type { message } from '@jersey/lightning'; import type { Context } from 'grammy'; import type { Message } from 'grammy/types'; -import { default as convert_md } from 'telegramify-markdown'; +import convert_markdown from 'telegramify-markdown'; import type { telegram_config } from './mod.ts'; -export function from_lightning(msg: message) { +type message_type = + | 'text' + | 'dice' + | 'location' + | 'document' + | 'animation' + | 'audio' + | 'photo' + | 'sticker' + | 'video' + | 'video_note' + | 'voice' + | 'unsupported'; + +export function get_lightning_message( + msg: message, +): { function: 'sendMessage' | 'sendDocument'; value: string }[] { let content = `${msg.author.username} ยป ${msg.content || '_no content_'}`; if ((msg.embeds?.length ?? 0) > 0) { content = `${content}\n_this message has embeds_`; } - const messages = [{ + const messages: { + function: 'sendMessage' | 'sendDocument'; + value: string; + }[] = [{ function: 'sendMessage', - value: convert_md(content, 'escape'), - }] as { function: 'sendMessage' | 'sendDocument'; value: string }[]; + value: convert_markdown(content, 'escape'), + }]; for (const attachment of (msg.attachments ?? [])) { messages.push({ @@ -26,60 +45,10 @@ export function from_lightning(msg: message) { return messages; } -export async function from_telegram( - ctx: Context, - cfg: telegram_config, -): Promise { - const msg = (ctx.editedMessage || ctx.msg) as Message | undefined; - if (!msg) return; - const type = get_message_type(msg); - const base = await get_base_msg(ctx, msg, cfg); - - switch (type) { - case 'text': - return { - ...base, - content: msg.text, - }; - case 'document': - case 'animation': - case 'audio': - case 'photo': - case 'sticker': - case 'video': - case 'video_note': - case 'voice': { - const file_obj = type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!; - const file = await ctx.api.getFile(file_obj.file_id); - if (!file.file_path) return; - return { - ...base, - attachments: [{ - file: `${cfg.plugin_url}/${file.file_path}`, - name: file.file_path, - size: (file.file_size ?? 0) / 1048576, - }], - }; - } - case 'dice': - return { - ...base, - content: `${msg.dice!.emoji} ${msg.dice!.value}`, - }; - case 'location': - return { - ...base, - content: `https://www.google.com/maps/search/?api=1&query=${ - msg.location!.latitude - }%2C${msg.location!.longitude}`, - }; - case 'unsupported': - return; - } -} - -function get_message_type(msg: Message) { +function get_message_type(msg: Message): message_type { if ('text' in msg) return 'text'; + if ('dice' in msg) return 'dice'; + if ('location' in msg) return 'location'; if ('document' in msg) return 'document'; if ('animation' in msg) return 'animation'; if ('audio' in msg) return 'audio'; @@ -88,19 +57,19 @@ function get_message_type(msg: Message) { if ('video' in msg) return 'video'; if ('video_note' in msg) return 'video_note'; if ('voice' in msg) return 'voice'; - if ('dice' in msg) return 'dice'; - if ('location' in msg) return 'location'; return 'unsupported'; } -async function get_base_msg( +export async function get_telegram_message( ctx: Context, - msg: Message, cfg: telegram_config, -): Promise { +): Promise { + const msg = ctx.editedMessage || ctx.msg; + if (!msg) return; const author = await ctx.getAuthor(); const pfps = await ctx.getUserProfilePhotos({ limit: 1 }); - return { + const type = get_message_type(msg); + const base = { author: { username: author.user.last_name ? `${author.user.first_name} ${author.user.last_name}` @@ -108,7 +77,7 @@ async function get_base_msg( rawname: author.user.username || author.user.first_name, color: '#24A1DE', profile: pfps.total_count - ? `${cfg.plugin_url}/${ + ? `${cfg.proxy_url}/${ (await ctx.api.getFile(pfps.photos[0][0].file_id)).file_path }` : undefined, @@ -120,8 +89,8 @@ async function get_base_msg( (msg.edit_date || msg.date) * 1000, ), plugin: 'bolt-telegram', - reply: async (lmsg) => { - for (const m of from_lightning(lmsg)) { + reply: async (reply: message) => { + for (const m of get_lightning_message(reply)) { await ctx.api[m.function](msg.chat.id.toString(), m.value, { reply_parameters: { message_id: msg.message_id, @@ -134,4 +103,39 @@ async function get_base_msg( ? msg.reply_to_message.message_id.toString() : undefined, }; + + switch (type) { + case 'text': + return { + ...base, + content: msg.text, + }; + case 'dice': + return { + ...base, + content: `${msg.dice!.emoji} ${msg.dice!.value}`, + }; + case 'location': + return { + ...base, + content: `https://www.google.com/maps/search/?api=1&query=${ + msg.location!.latitude + }%2C${msg.location!.longitude}`, + }; + case 'unsupported': + return; + default: { + const fileObj = type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!; + const file = await ctx.api.getFile(fileObj.file_id); + if (!file.file_path) return; + return { + ...base, + attachments: [{ + file: `${cfg.proxy_url}/${file.file_path}`, + name: file.file_path, + size: (file.file_size ?? 0) / 1048576, + }], + }; + } + } } diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 6a185d88..cb14964d 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -6,18 +6,18 @@ import { plugin, } from '@jersey/lightning'; import { Bot } from 'grammy'; -import { from_lightning, from_telegram } from './messages.ts'; -import { file_proxy } from './file_proxy.ts'; +import { get_lightning_message, get_telegram_message } from './messages.ts'; +import { setup_file_proxy } from './file_proxy.ts'; /** options for the telegram plugin */ -export type telegram_config = { +export interface telegram_config { /** the token for the bot */ bot_token: string; /** the port the plugins proxy will run on */ - plugin_port: number; + proxy_port: number; /** the publically accessible url of the plugin */ - plugin_url: string; -}; + proxy_url: string; +} /** the plugin to use */ export class telegram_plugin extends plugin { @@ -28,17 +28,17 @@ export class telegram_plugin extends plugin { super(l, cfg); this.bot = new Bot(cfg.bot_token); this.bot.on('message', async (ctx) => { - const msg = await from_telegram(ctx, cfg); + const msg = await get_telegram_message(ctx, cfg); if (!msg) return; this.emit('create_message', msg); }); this.bot.on('edited_message', async (ctx) => { - const msg = await from_telegram(ctx, cfg); + const msg = await get_telegram_message(ctx, cfg); if (!msg) return; this.emit('edit_message', msg); }); // turns out it's impossible to deal with messages being deleted due to tdlib/telegram-bot-api#286 - file_proxy(cfg); + setup_file_proxy(cfg); this.bot.start(); } @@ -48,10 +48,9 @@ export class telegram_plugin extends plugin { } async create_message(opts: create_opts): Promise { - const content = from_lightning(opts.msg); const messages = []; - for (const msg of content) { + for (const msg of get_lightning_message(opts.msg)) { const result = await this.bot.api[msg.function]( opts.channel.id, msg.value, @@ -72,12 +71,10 @@ export class telegram_plugin extends plugin { } async edit_message(opts: edit_opts): Promise { - const content = from_lightning(opts.msg)[0]; - await this.bot.api.editMessageText( opts.channel.id, Number(opts.edit_ids[0]), - content.value, + get_lightning_message(opts.msg)[0].value, { parse_mode: 'MarkdownV2', }, From 0b8ed6ea37042a70cf5f0328b24bd075d6699081 Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 12 Mar 2025 22:43:12 -0400 Subject: [PATCH 44/97] enable editing on database migration --- packages/lightning/src/database/redis_message.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index c8b2522d..f5b06119 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -42,7 +42,7 @@ export class redis_messages { messages: parsed.messages, name: `migrated bridge ${parsed.id}`, settings: { - allow_editing: parsed.allow_editing, + allow_editing: true, use_rawname: parsed.use_rawname, allow_everyone: true, }, From d71188d40b3b5a198d347a6193b6d9f4d4965061 Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 12 Mar 2025 22:59:51 -0400 Subject: [PATCH 45/97] remove user facing bolt references --- packages/discord/src/events.ts | 2 +- packages/guilded/src/events.ts | 2 +- packages/revolt/src/events.ts | 4 ++-- packages/telegram/src/file_proxy.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/discord/src/events.ts b/packages/discord/src/events.ts index d9a8e5ad..5cac44f7 100644 --- a/packages/discord/src/events.ts +++ b/packages/discord/src/events.ts @@ -11,7 +11,7 @@ export function setup_events( // @ts-ignore deno isn't properly handling the eventemitter code client.once(GatewayDispatchEvents.Ready, ({ data }) => { console.log( - `[bolt-discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, + `[discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, ); }); // @ts-ignore deno isn't properly handling the eventemitter code diff --git a/packages/guilded/src/events.ts b/packages/guilded/src/events.ts index 4312ff2f..6ae6c8fd 100644 --- a/packages/guilded/src/events.ts +++ b/packages/guilded/src/events.ts @@ -5,7 +5,7 @@ import type { guilded_plugin } from './mod.ts'; export function setup_events(bot: Client, emit: guilded_plugin['emit']) { // @ts-ignore deno isn't properly handling the eventemitter code bot.on('ready', () => { - console.log(`[bolt-guilded] ready as ${bot.user?.name}`); + console.log(`[guilded] ready as ${bot.user?.name}`); }); // @ts-ignore deno isn't properly handling the eventemitter code bot.on('messageCreated', async (message) => { diff --git a/packages/revolt/src/events.ts b/packages/revolt/src/events.ts index 501c6072..6cf0cb73 100644 --- a/packages/revolt/src/events.ts +++ b/packages/revolt/src/events.ts @@ -10,7 +10,7 @@ export function setup_events( ) { bot.bonfire.on('Ready', (ready) => { console.log( - `[bolt-revolt] ready in ${ready.channels.length} channels and ${ready.servers.length} servers`, + `[revolt] ready in ${ready.channels.length} channels and ${ready.servers.length} servers`, ); }); @@ -54,7 +54,7 @@ export function setup_events( }); bot.bonfire.on('socket_close', (info) => { - console.warn('[bolt-revolt] socket closed', info); + console.warn('[revolt] socket closed', info); bot = createClient(config); setup_events(bot, config, emit); }); diff --git a/packages/telegram/src/file_proxy.ts b/packages/telegram/src/file_proxy.ts index 6346ce62..89144b5c 100644 --- a/packages/telegram/src/file_proxy.ts +++ b/packages/telegram/src/file_proxy.ts @@ -4,8 +4,8 @@ export function setup_file_proxy(config: telegram_config) { Deno.serve({ port: config.proxy_port, onListen: ({ port }) => { - console.log(`[bolt-telegram] file proxy listening on localhost:${port}`); - console.log(`[bolt-telegram] also available at: ${config.proxy_url}`); + console.log(`[telegram] file proxy listening on localhost:${port}`); + console.log(`[telegram] also available at: ${config.proxy_url}`); }, }, (req: Request) => { const { pathname } = new URL(req.url); From 1d97540e04eaad8f63817d655cdb26d8eafba898 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 13 Mar 2025 00:08:48 -0400 Subject: [PATCH 46/97] typo --- packages/discord/src/authors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/discord/src/authors.ts b/packages/discord/src/authors.ts index 82a55c06..b4b4a4af 100644 --- a/packages/discord/src/authors.ts +++ b/packages/discord/src/authors.ts @@ -15,12 +15,12 @@ export async function fetch_author( calculateUserDefaultAvatarIndex(message.author.id) }.png`; - let username = message.author.global_name ?? message.author.username; + let username = message.author.global_name || message.author.username; if (message.guild_id) { try { // remove type assertion once deno resolves the return type for getMember properly - const member = message.member ?? await api.guilds.getMember( + const member = message.member || await api.guilds.getMember( message.guild_id, message.author.id, ) as APIGuildMember; From 58fc162866dc43c2e3df018965f8266eb396f863 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 13 Mar 2025 12:17:16 -0400 Subject: [PATCH 47/97] update mongo and remove some assertations --- packages/lightning/deno.jsonc | 3 +-- packages/lightning/src/commands/runners.ts | 12 ++++++------ packages/lightning/src/structures/events.ts | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index a51ce5c6..17c8ac61 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -4,9 +4,8 @@ "license": "MIT", "exports": "./src/mod.ts", "imports": { - // TODO(jersey): get @db/mongo@^0.34.0 on JSR + "@db/mongo": "jsr:@db/mongo@^0.34.0", // TODO(jersey): get updated @db/postgres on JSR - "@db/mongo": "jsr:@db/mongo@^0.33.0", "@db/postgres": "jsr:@jersey/test@0.8.6", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", "@iuioiua/redis": "jsr:@iuioiua/redis@^1.0.1", diff --git a/packages/lightning/src/commands/runners.ts b/packages/lightning/src/commands/runners.ts index e64eb8c0..245c51e4 100644 --- a/packages/lightning/src/commands/runners.ts +++ b/packages/lightning/src/commands/runners.ts @@ -39,7 +39,7 @@ export async function run_command( for (const arg of command.arguments || []) { if (!opts.args[arg.name]) { - opts.args[arg.name] = opts.rest?.shift() as string; + opts.args[arg.name] = opts.rest?.shift(); } if (!opts.args[arg.name]) { @@ -57,15 +57,15 @@ export async function run_command( try { resp = await command.execute({ ...opts, - args: opts.args, + args: opts.args as Record, lightning }); } catch (e) { if (e instanceof LightningError) resp = e; - else {resp = new LightningError(e, { - message: 'An error occurred while executing the command', - extra: { command: command.name }, - });} + else resp = new LightningError(e, { + message: 'An error occurred while executing the command', + extra: { command: command.name }, + }); } try { diff --git a/packages/lightning/src/structures/events.ts b/packages/lightning/src/structures/events.ts index d14cd478..8c987de1 100644 --- a/packages/lightning/src/structures/events.ts +++ b/packages/lightning/src/structures/events.ts @@ -8,7 +8,7 @@ export interface create_command extends Omit, 'lightn /** the subcommand, if any, to use */ subcommand?: string; /** arguments, if any, to use */ - args?: Record; + args?: Record; /** extra string options */ rest?: string[]; /** event reply function */ From b1ebbacd1c35a7fdfb9f2d0c49a4647c13ca67f9 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 30 Mar 2025 18:05:47 -0400 Subject: [PATCH 48/97] use guildapi (not yet published) --- packages/guilded/README.md | 7 +- packages/guilded/deno.json | 4 +- packages/guilded/src/attachments.ts | 21 ++-- packages/guilded/src/authors.ts | 56 +++++------ packages/guilded/src/embeds.ts | 66 +++++++------ packages/guilded/src/errors.ts | 17 ++-- packages/guilded/src/events.ts | 33 ------- packages/guilded/src/messages.ts | 40 +++++--- packages/guilded/src/mod.ts | 95 +++++++++++++------ packages/guilded/src/replies.ts | 17 ++-- .../lightning/src/database/redis_message.ts | 10 ++ 11 files changed, 183 insertions(+), 183 deletions(-) delete mode 100644 packages/guilded/src/events.ts diff --git a/packages/guilded/README.md b/packages/guilded/README.md index bb95016b..19bb38ee 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -2,13 +2,12 @@ lightning-plugin-guilded is a plugin for [lightning](https://williamhorning.eu.org/lightning) that adds support for -telegram +guilded ## example config ```ts -import type { config } from 'jsr:@jersey/lightning@0.7.4'; -import { guilded_plugin } from 'jsr:@jersey/lightning-plugin-guilded@0.7.4'; +import { guilded_plugin } from 'jsr:@jersey/lightning-plugin-guilded@0.8.0'; export default { plugins: [ @@ -16,5 +15,5 @@ export default { token: 'your_token', }), ], -} as config; +}; ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 6a556aba..f500e371 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -5,7 +5,7 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "guilded.js": "npm:guilded.js@^0.25.0", - "@guildedjs/api": "npm:@guildedjs/api@^0.4.0" + "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.1", + "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@0.0.1" } } diff --git a/packages/guilded/src/attachments.ts b/packages/guilded/src/attachments.ts index 9db8f9de..62b52040 100644 --- a/packages/guilded/src/attachments.ts +++ b/packages/guilded/src/attachments.ts @@ -1,5 +1,5 @@ import type { attachment } from '@jersey/lightning'; -import type { Client } from 'guilded.js'; +import type { Client } from '@jersey/guildapi'; export async function fetch_attachments( bot: Client, @@ -8,20 +8,13 @@ export async function fetch_attachments( const attachments: attachment[] = []; try { - const signed = - await (await fetch('https://www.guilded.gg/api/v1/url-signatures', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': `Bearer ${bot.token}`, - }, - body: JSON.stringify({ - urls: urls.map((url) => (url.split('(').pop()?.split(')')[0])), - }), - })).json(); + const signed = await bot.request('post', '/url-signatures', { + urls: urls.map( + (url) => (url.split('(').pop())?.split(')')[0], + ).filter((i) => i !== undefined), + }); - for (const url of signed.urlSignatures || []) { + for (const url of signed.urlSignatures) { if (url.signature) { const resp = await fetch(url.signature, { method: 'HEAD', diff --git a/packages/guilded/src/authors.ts b/packages/guilded/src/authors.ts index 21fac56f..6e909c4f 100644 --- a/packages/guilded/src/authors.ts +++ b/packages/guilded/src/authors.ts @@ -1,56 +1,44 @@ import type { message, message_author } from '@jersey/lightning'; -import type { Client, Message } from 'guilded.js'; +import type { Client } from '@jersey/guildapi'; +import type { ChatMessage, ServerMember } from '@jersey/guilded-api-types'; export async function fetch_author( - msg: Message, + msg: ChatMessage, bot: Client, ): Promise { - if (!msg.createdByWebhookId && msg.authorId !== 'Ann6LewA') { - try { - const author = await bot.members.fetch( - msg.serverId!, - msg.authorId, - ); + try { + if (!msg.createdByWebhookId) { + const author = await bot.request( + 'get', + `/servers/${msg.serverId}/members/${msg.createdBy}`, + undefined, + ) as ServerMember; return { - username: author.nickname || author.username || author.user?.name || - 'Guilded User', - rawname: author.username || author.user?.name || 'Guilded User', - id: msg.authorId, - profile: author.user?.avatar || undefined, - }; - } catch { - return { - username: 'Guilded User', - rawname: 'GuildedUser', - id: msg.authorId, + username: author.nickname || author.user.name, + rawname: author.user.name, + id: msg.createdBy, + profile: author.user.avatar || undefined, }; - } - } else if (msg.createdByWebhookId) { - try { - const webhook = await bot.webhooks.fetch( - msg.serverId!, - msg.createdByWebhookId, + } else { + const { webhook } = await bot.request( + 'get', + `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, + undefined, ); return { username: webhook.name, rawname: webhook.name, id: webhook.id, - profile: webhook.raw.avatar, - }; - } catch { - return { - username: 'Guilded Webhook', - rawname: 'GuildedWebhook', - id: msg.createdByWebhookId, + profile: webhook.avatar, }; } - } else { + } catch { return { username: 'Guilded User', rawname: 'GuildedUser', - id: msg.authorId, + id: msg.createdByWebhookId ?? msg.createdBy, }; } } diff --git a/packages/guilded/src/embeds.ts b/packages/guilded/src/embeds.ts index 4ad1c5d6..4ff3d790 100644 --- a/packages/guilded/src/embeds.ts +++ b/packages/guilded/src/embeds.ts @@ -1,54 +1,52 @@ -import type { EmbedPayload } from '@guildedjs/api'; +import type { ChatEmbed } from '@jersey/guilded-api-types'; import type { embed } from '@jersey/lightning'; -import type { Embed } from 'guilded.js'; -export function get_lightning_embeds(embeds?: Embed[]): embed[] | undefined { +export function get_lightning_embeds( + embeds?: ChatEmbed[], +): embed[] | undefined { if (!embeds) return; return embeds.map((embed) => ({ ...embed, author: embed.author ? { - name: embed.author.name || 'embed author', - icon_url: embed.author.iconURL || undefined, - url: embed.author.url || undefined, + ...embed.author, + name: embed.author.name || '', + } + : undefined, + image: embed.image + ? { + ...embed.image, + url: embed.image.url || '', + } + : undefined, + thumbnail: embed.thumbnail + ? { + ...embed.thumbnail, + url: embed.thumbnail.url || '', } : undefined, - image: embed.image || undefined, - thumbnail: embed.thumbnail || undefined, timestamp: embed.timestamp ? Number(embed.timestamp) : undefined, - color: embed.color || undefined, - description: embed.description || undefined, - fields: embed.fields.map((i) => { - return { - ...i, - inline: i.inline || undefined, - }; - }), - footer: embed.footer || undefined, - title: embed.title || undefined, - url: embed.url || undefined, - video: embed.video || undefined, })); } export function get_guilded_embeds( embeds?: embed[], -): EmbedPayload[] | undefined { +): ChatEmbed[] | undefined { if (!embeds) return; - return embeds.flatMap((embed) => { - Object.keys(embed).forEach((key) => { - embed[key as keyof embed] === null - ? (embed[key as keyof embed] = undefined) - : embed[key as keyof embed]; - }); - if (!embed.description || embed.description === '') return []; - return [ - { - ...embed, - timestamp: embed.timestamp ? String(embed.timestamp) : undefined, - }, - ]; + return embeds.map((i) => { + return { + ...i, + fields: i.fields + ? i.fields.map((j) => { + return { + ...j, + inline: j.inline ?? false, + }; + }) + : undefined, + timestamp: i.timestamp ? String(i.timestamp) : undefined, + }; }); } diff --git a/packages/guilded/src/errors.ts b/packages/guilded/src/errors.ts index 5ceb15f4..c05faf1b 100644 --- a/packages/guilded/src/errors.ts +++ b/packages/guilded/src/errors.ts @@ -1,28 +1,27 @@ -import { GuildedAPIError } from '@guildedjs/api'; +import { RequestError } from '@jersey/guilded-api-types'; import { log_error } from '@jersey/lightning'; export function handle_error(err: unknown, channel: string, edit?: boolean) { - if (err instanceof GuildedAPIError) { - if (err.response.status === 404) { + if (err instanceof RequestError) { + if (err.cause.status === 404) { if (edit) return []; log_error(err, { message: "resource not found! if you're trying to make a bridge, this is likely an issue with Guilded", - extra: { channel_id: channel, response: err.response }, + extra: { channel_id: channel, response: err.cause }, disable: true, }); - } else if (err.response.status === 403) { + } else if (err.cause.status === 403) { log_error(err, { message: 'no permission to send/delete messages! check bot permissions', - extra: { channel_id: channel, response: err.response }, + extra: { channel_id: channel, response: err.cause }, disable: true, }); } else { log_error(err, { - message: - `unknown guilded error with status code ${err.response.status}`, - extra: { channel_id: channel, response: err.response }, + message: `unknown guilded error with status code ${err.cause.status}`, + extra: { channel_id: channel, response: err.cause }, }); } } else { diff --git a/packages/guilded/src/events.ts b/packages/guilded/src/events.ts deleted file mode 100644 index 6ae6c8fd..00000000 --- a/packages/guilded/src/events.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Client } from 'guilded.js'; -import { get_lightning_message } from './messages.ts'; -import type { guilded_plugin } from './mod.ts'; - -export function setup_events(bot: Client, emit: guilded_plugin['emit']) { - // @ts-ignore deno isn't properly handling the eventemitter code - bot.on('ready', () => { - console.log(`[guilded] ready as ${bot.user?.name}`); - }); - // @ts-ignore deno isn't properly handling the eventemitter code - bot.on('messageCreated', async (message) => { - const msg = await get_lightning_message(message, bot); - if (msg) emit('create_message', msg); - }); - // @ts-ignore deno isn't properly handling the eventemitter code - bot.on('messageUpdated', async (message) => { - const msg = await get_lightning_message(message, bot); - if (msg) emit('edit_message', msg); - }); - // @ts-ignore deno isn't properly handling the eventemitter code - bot.on('messageDeleted', (del) => { - emit('delete_message', { - channel: del.channelId, - id: del.id, - plugin: 'bolt-guilded', - timestamp: Temporal.Instant.from(del.deletedAt), - }); - }); - // @ts-ignore deno isn't dealing with the import - bot.ws.emitter.on('exit', () => { - bot.ws.connect(); - }); -} diff --git a/packages/guilded/src/messages.ts b/packages/guilded/src/messages.ts index 54e8d949..5585e424 100644 --- a/packages/guilded/src/messages.ts +++ b/packages/guilded/src/messages.ts @@ -1,26 +1,32 @@ -import type { WebhookMessageContent } from '@guildedjs/api'; import type { message } from '@jersey/lightning'; -import type { Client, Message } from 'guilded.js'; +import type { Client } from '@jersey/guildapi'; import { fetch_attachments } from './attachments.ts'; import { fetch_author, get_valid_username } from './authors.ts'; import { get_guilded_embeds, get_lightning_embeds } from './embeds.ts'; import { fetch_reply_embed } from './replies.ts'; +import type { ChatEmbed, ChatMessage } from '@jersey/guilded-api-types'; -type guilded_webhook_payload = Exclude; +type webhook_payload = { + content?: string; + embeds?: ChatEmbed[]; + replyMessageIds?: string[]; + avatar_url?: string; + username?: string; +}; export async function get_lightning_message( - msg: Message, + msg: ChatMessage, bot: Client, ): Promise { - if (msg.serverId === null) return; + if (!msg.serverId) return; - let content = msg.content.replaceAll('\n```\n```\n', '\n'); + let content = msg.content?.replaceAll('\n```\n```\n', '\n'); - const urls = content.match( + const urls = content?.match( /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, ) || []; - content = content.replaceAll( + content = content?.replaceAll( /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, '', ); @@ -33,16 +39,22 @@ export async function get_lightning_message( attachments: await fetch_attachments(bot, urls), channel: msg.channelId, id: msg.id, - timestamp: Temporal.Instant.fromEpochMilliseconds( - msg.createdAt.valueOf(), + timestamp: Temporal.Instant.from( + msg.createdAt, ), embeds: get_lightning_embeds(msg.embeds), plugin: 'bolt-guilded', reply: async (reply: message) => { - await msg.reply(await get_guilded_message(reply)); + await bot.request( + 'post', + `/channels/${msg.channelId}/messages`, + await get_guilded_message(reply), + ); }, content, - reply_id: msg.isReply ? msg.replyMessageIds[0] : undefined, + reply_id: msg.replyMessageIds && msg.replyMessageIds.length > 0 + ? msg.replyMessageIds[0] + : undefined, }; } @@ -51,8 +63,8 @@ export async function get_guilded_message( channel?: string, bot?: Client, everyone = true, -): Promise { - const message: guilded_webhook_payload = { +): Promise { + const message: webhook_payload = { content: msg.content, avatar_url: msg.author.profile, username: get_valid_username(msg), diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index ecbbd943..8ff53cb8 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -1,4 +1,3 @@ -import { WebhookClient } from '@guildedjs/api'; import { type create_opts, type delete_opts, @@ -7,10 +6,10 @@ import { log_error, plugin, } from '@jersey/lightning'; -import { Client } from 'guilded.js'; import { handle_error } from './errors.ts'; -import { setup_events } from './events.ts'; -import { get_guilded_message } from './messages.ts'; +import { get_guilded_message, get_lightning_message } from './messages.ts'; +import { type Client, createClient } from '@jersey/guildapi'; +import type { ServerChannel } from '@jersey/guilded-api-types'; /** options for the guilded plugin */ export interface guilded_config { @@ -26,28 +25,54 @@ export class guilded_plugin extends plugin { constructor(l: lightning, c: guilded_config) { super(l, c); - const opts = { - headers: { - 'x-guilded-bot-api-use-official-markdown': 'true', - }, - }; + this.bot = createClient(c.token); - this.bot = new Client({ token: c.token, ws: opts, rest: opts }); + this.bot.socket.on('ready', (user) => { + console.log(`[guilded] ready as ${user.name}`); + }); - setup_events(this.bot, this.emit); - this.bot.login(); + this.bot.socket.on('ChatMessageCreated', async ({ d: { message } }) => { + const msg = await get_lightning_message(message, this.bot); + if (msg) this.emit('create_message', msg); + }); + + this.bot.socket.on('ChatMessageUpdated', async ({ d: { message } }) => { + const msg = await get_lightning_message(message, this.bot); + if (msg) this.emit('edit_message', msg); + }); + + this.bot.socket.on('ChatMessageDeleted', ({ d: { message } }) => { + this.emit('delete_message', { + channel: message.channelId, + id: message.id, + plugin: 'bolt-guilded', + timestamp: Temporal.Instant.from(message.deletedAt), + }); + }); + + this.bot.socket.connect(); } async setup_channel(channel: string): Promise { try { - const { serverId } = await this.bot.channels.fetch(channel); - const webhook = await this.bot.webhooks.create(serverId, { - channelId: channel, - name: 'Lightning Bridges', - }); + const { channel: { serverId } } = await this.bot.request( + 'get', + `/channels/${channel}`, + undefined, + ) as { channel: ServerChannel }; + + const { webhook } = await this.bot.request( + 'post', + `/servers/${serverId}/webhooks`, + { + channelId: channel, + name: 'Lightning Bridges', + }, + ); + if (!webhook.id || !webhook.token) { log_error('failed to create webhook: missing id or token', { - extra: { webhook: webhook.raw }, + extra: { webhook: webhook }, }); } @@ -59,18 +84,21 @@ export class guilded_plugin extends plugin { async create_message(opts: create_opts): Promise { try { - const webhook = new WebhookClient( - opts.channel.data as { id: string; token: string }, - ); - - const res = await webhook.send( - await get_guilded_message( - opts.msg, - opts.channel.id, - this.bot, - opts.settings.allow_everyone, - ), - ); + const data = opts.channel.data as { id: string; token: string }; + const res = await (await fetch( + `https://media.guilded.gg/webhooks/${data.id}/${data.token}`, + { + method: 'POST', + body: JSON.stringify( + await get_guilded_message( + opts.msg, + opts.channel.id, + this.bot, + opts.settings.allow_everyone, + ), + ), + }, + )).json(); return [res.id]; } catch (e) { @@ -86,7 +114,12 @@ export class guilded_plugin extends plugin { async delete_message(opts: delete_opts): Promise { try { - await this.bot.messages.delete(opts.channel.id, opts.edit_ids[0]); + await this.bot.request( + 'delete', + // @ts-expect-error: guilded's openapi spec is really bad + `/channels/${opts.channel}/messages/${opts.edit_ids[0]}`, + undefined, + ); return opts.edit_ids; } catch (e) { diff --git a/packages/guilded/src/replies.ts b/packages/guilded/src/replies.ts index f37c0239..75152b95 100644 --- a/packages/guilded/src/replies.ts +++ b/packages/guilded/src/replies.ts @@ -1,29 +1,30 @@ -import type { EmbedPayload } from '@guildedjs/api'; import type { message } from '@jersey/lightning'; -import type { Client } from 'guilded.js'; +import type { Client } from '@jersey/guildapi'; import { fetch_author } from './authors.ts'; +import type { ChatEmbed } from '@jersey/guilded-api-types'; export async function fetch_reply_embed( msg: message, channel?: string, bot?: Client, -): Promise { +): Promise { if (!msg.reply_id || !channel || !bot) return; try { - const replied_to_message = await bot.messages.fetch( - channel, - msg.reply_id, + const replied_to = await bot.request( + 'get', + `/channels/${channel}/messages/${msg.reply_id}`, + undefined, ); - const author = await fetch_author(replied_to_message, bot); + const author = await fetch_author(replied_to.message, bot); return { author: { name: `reply to ${author.username}`, icon_url: author.profile, }, - description: replied_to_message.content, + description: replied_to.message.content, }; } catch { return; diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index f5b06119..53b6f227 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -20,14 +20,19 @@ export class redis_messages { `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, ); + console.log('[lightning-redis] getting keys'); + const all_keys = await rd.sendCommand([ 'KEYS', 'lightning-*', ]) as string[]; + console.log('[lightning-redis] got keys'); + const new_data = [] as [string, bridge | bridge_message | string][]; for (const key of all_keys) { + console.log(`[lightning-redis] migrating key ${key}`); const type = await rd.sendCommand(['TYPE', key]) as string; const action = type === 'string' ? 'GET' : 'JSON.GET'; const value = await rd.sendCommand([action, key]) as string; @@ -52,6 +57,11 @@ export class redis_messages { } } + Deno.writeTextFileSync( + 'lightning-redis-migration.json', + JSON.stringify(new_data, null, 2), + ); + console.warn('[lightning-redis] do you want to continue?'); const write = confirm('write the data to the database?'); From 591eaa0a106b0753e479d0d2c1484b1207ff76d1 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 30 Mar 2025 23:09:32 -0400 Subject: [PATCH 49/97] aaaa bugs --- deno.jsonc | 2 +- packages/discord/src/mod.ts | 2 +- packages/guilded/deno.json | 4 ++-- packages/guilded/src/authors.ts | 4 ++-- packages/guilded/src/messages.ts | 4 ++-- packages/guilded/src/mod.ts | 11 +++++++++-- packages/lightning/src/database/postgres.ts | 10 ++++------ 7 files changed, 21 insertions(+), 16 deletions(-) diff --git a/deno.jsonc b/deno.jsonc index 2ca80d8e..427b8cf4 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -27,5 +27,5 @@ "./packages/discord" ], "lock": false, - "unstable": ["temporal"] + "unstable": ["net", "temporal"] } diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 868fa3ee..7a58babf 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -48,7 +48,7 @@ export class discord_plugin extends plugin { this.api = this.client.api; set_slash_commands(this.api, config, l); - setup_events(this.client, this.emit); + setup_events(this.client, this.emit.bind(this)); gateway.connect(); } diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index f500e371..01448d82 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -5,7 +5,7 @@ "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.1", - "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@0.0.1" + "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.4", + "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@0.0.2" } } diff --git a/packages/guilded/src/authors.ts b/packages/guilded/src/authors.ts index 6e909c4f..e2d4d1fb 100644 --- a/packages/guilded/src/authors.ts +++ b/packages/guilded/src/authors.ts @@ -8,11 +8,11 @@ export async function fetch_author( ): Promise { try { if (!msg.createdByWebhookId) { - const author = await bot.request( + const { member: author } = await bot.request( 'get', `/servers/${msg.serverId}/members/${msg.createdBy}`, undefined, - ) as ServerMember; + ) as { member: ServerMember }; return { username: author.nickname || author.user.name, diff --git a/packages/guilded/src/messages.ts b/packages/guilded/src/messages.ts index 5585e424..d7fca2e0 100644 --- a/packages/guilded/src/messages.ts +++ b/packages/guilded/src/messages.ts @@ -48,8 +48,8 @@ export async function get_lightning_message( await bot.request( 'post', `/channels/${msg.channelId}/messages`, - await get_guilded_message(reply), - ); + { content: reply.content! }, + ).catch(async e=>console.log(await e.cause.text())); }, content, reply_id: msg.replyMessageIds && msg.replyMessageIds.length > 0 diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 8ff53cb8..7ac5563c 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -8,7 +8,7 @@ import { } from '@jersey/lightning'; import { handle_error } from './errors.ts'; import { get_guilded_message, get_lightning_message } from './messages.ts'; -import { type Client, createClient } from '@jersey/guildapi'; +import { type Client, createClient, SocketManager } from '@jersey/guildapi'; import type { ServerChannel } from '@jersey/guilded-api-types'; /** options for the guilded plugin */ @@ -27,6 +27,11 @@ export class guilded_plugin extends plugin { this.bot = createClient(c.token); + this.bot.socket = new SocketManager({ + token: c.token, + replayMissedEvents: false, + }); + this.bot.socket.on('ready', (user) => { console.log(`[guilded] ready as ${user.name}`); }); @@ -89,6 +94,7 @@ export class guilded_plugin extends plugin { `https://media.guilded.gg/webhooks/${data.id}/${data.token}`, { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( await get_guilded_message( opts.msg, @@ -102,6 +108,7 @@ export class guilded_plugin extends plugin { return [res.id]; } catch (e) { + console.log(e); return handle_error(e, opts.channel.id); } } @@ -116,7 +123,7 @@ export class guilded_plugin extends plugin { try { await this.bot.request( 'delete', - // @ts-expect-error: guilded's openapi spec is really bad + // @ts-expect-error: this is typed wrong `/channels/${opts.channel}/messages/${opts.edit_ids[0]}`, undefined, ); diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index 30ebb96f..d3f765c4 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -92,8 +92,7 @@ export class postgres implements bridge_data { (id, bridge_id, channels, messages, settings) VALUES (${msg.id}, ${msg.bridge_id}, ${JSON.stringify(msg.channels)}, ${ JSON.stringify(msg.messages) - }, ${JSON.stringify(msg.settings)}) - `; + }, ${JSON.stringify(msg.settings)})`; } async edit_message(msg: bridge_message): Promise { @@ -112,15 +111,14 @@ export class postgres implements bridge_data { `; } + // FIXME(jersey): this is horendously wrong somewhere async get_message(id: string): Promise { const res = await this.pg.queryObject(` SELECT * FROM bridge_messages WHERE id = '${id}' OR EXISTS ( SELECT 1 FROM jsonb_array_elements(messages) AS msg - WHERE EXISTS ( - SELECT 1 FROM jsonb_array_elements(msg->'id') AS id - WHERE id = '${id}' - ) + CROSS JOIN jsonb_array_elements_text(msg->'id') AS id_element + WHERE id_element = '${id}' ) `); From 3f5ca2bdb92cbcc3fdbd219e48d893bf7be451d6 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 6 Apr 2025 23:09:55 -0400 Subject: [PATCH 50/97] rewrite plugins and move toward 0.8.0-alpha.1 release --- .mailmap | 2 + deno.jsonc | 8 +- packages/discord/deno.json | 2 +- packages/discord/src/authors.ts | 40 ---- packages/discord/src/commands.ts | 129 ++++-------- packages/discord/src/events.ts | 39 ---- packages/discord/src/files.ts | 33 --- packages/discord/src/incoming.ts | 176 ++++++++++++++++ packages/discord/src/messages.ts | 113 ---------- packages/discord/src/mod.ts | 198 +++++++++++------- packages/discord/src/outgoing.ts | 116 ++++++++++ packages/discord/src/replies.ts | 31 --- packages/discord/src/stickers.ts | 33 --- packages/guilded/deno.json | 2 +- packages/guilded/src/attachments.ts | 35 ---- packages/guilded/src/authors.ts | 59 ------ packages/guilded/src/embeds.ts | 52 ----- packages/guilded/src/incoming.ts | 129 ++++++++++++ packages/guilded/src/messages.ts | 104 --------- packages/guilded/src/mod.ts | 178 +++++++++------- packages/guilded/src/outgoing.ts | 108 ++++++++++ packages/guilded/src/replies.ts | 32 --- packages/lightning/src/bridge.ts | 59 +++--- .../src/commands/bridge/_internal.ts | 14 +- .../lightning/src/commands/bridge/create.ts | 4 +- .../lightning/src/commands/bridge/join.ts | 4 +- .../lightning/src/commands/bridge/leave.ts | 8 +- .../lightning/src/commands/bridge/status.ts | 11 +- .../lightning/src/commands/bridge/toggle.ts | 6 +- packages/lightning/src/commands/runners.ts | 37 ++-- .../lightning/src/database/redis_message.ts | 56 ++--- packages/lightning/src/lightning.ts | 32 +-- packages/lightning/src/structures/bridge.ts | 44 +--- packages/lightning/src/structures/commands.ts | 33 ++- packages/lightning/src/structures/events.ts | 30 --- packages/lightning/src/structures/media.ts | 68 ------ packages/lightning/src/structures/messages.ts | 82 +++++++- packages/lightning/src/structures/mod.ts | 2 - packages/lightning/src/structures/plugins.ts | 56 +++-- packages/revolt/deno.json | 2 +- packages/revolt/src/attachments.ts | 27 --- packages/revolt/src/author.ts | 57 ----- packages/revolt/src/cache.ts | 194 +++++++++++++++++ packages/revolt/src/embeds.ts | 31 --- packages/revolt/src/events.ts | 61 ------ packages/revolt/src/incoming.ts | 37 ++++ packages/revolt/src/member.ts | 34 --- packages/revolt/src/messages.ts | 87 -------- packages/revolt/src/mod.ts | 146 ++++++++----- packages/revolt/src/outgoing.ts | 87 ++++++++ packages/revolt/src/permissions.ts | 98 ++++----- packages/telegram/deno.json | 8 +- packages/telegram/src/file_proxy.ts | 18 -- packages/telegram/src/incoming.ts | 87 ++++++++ packages/telegram/src/messages.ts | 141 ------------- packages/telegram/src/mod.ts | 92 ++++---- packages/telegram/src/outgoing.ts | 32 +++ readme.md | 5 + todo.md | 4 + 59 files changed, 1704 insertions(+), 1709 deletions(-) create mode 100644 .mailmap delete mode 100644 packages/discord/src/authors.ts delete mode 100644 packages/discord/src/events.ts delete mode 100644 packages/discord/src/files.ts create mode 100644 packages/discord/src/incoming.ts delete mode 100644 packages/discord/src/messages.ts create mode 100644 packages/discord/src/outgoing.ts delete mode 100644 packages/discord/src/replies.ts delete mode 100644 packages/discord/src/stickers.ts delete mode 100644 packages/guilded/src/attachments.ts delete mode 100644 packages/guilded/src/authors.ts delete mode 100644 packages/guilded/src/embeds.ts create mode 100644 packages/guilded/src/incoming.ts delete mode 100644 packages/guilded/src/messages.ts create mode 100644 packages/guilded/src/outgoing.ts delete mode 100644 packages/guilded/src/replies.ts delete mode 100644 packages/lightning/src/structures/events.ts delete mode 100644 packages/lightning/src/structures/media.ts delete mode 100644 packages/revolt/src/attachments.ts delete mode 100644 packages/revolt/src/author.ts create mode 100644 packages/revolt/src/cache.ts delete mode 100644 packages/revolt/src/embeds.ts delete mode 100644 packages/revolt/src/events.ts create mode 100644 packages/revolt/src/incoming.ts delete mode 100644 packages/revolt/src/member.ts delete mode 100644 packages/revolt/src/messages.ts create mode 100644 packages/revolt/src/outgoing.ts delete mode 100644 packages/telegram/src/file_proxy.ts create mode 100644 packages/telegram/src/incoming.ts delete mode 100644 packages/telegram/src/messages.ts create mode 100644 packages/telegram/src/outgoing.ts create mode 100644 todo.md diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..42411243 --- /dev/null +++ b/.mailmap @@ -0,0 +1,2 @@ +Jersey William Horning +Jersey William Horning diff --git a/deno.jsonc b/deno.jsonc index 427b8cf4..90cd4170 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -19,13 +19,7 @@ ] } }, - "workspace": [ - "./packages/lightning", - "./packages/telegram", - "./packages/revolt", - "./packages/guilded", - "./packages/discord" - ], + "workspace": ["./packages/*"], "lock": false, "unstable": ["net", "temporal"] } diff --git a/packages/discord/deno.json b/packages/discord/deno.json index c815e1ba..182d4d09 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,6 +1,6 @@ { "name": "@jersey/lightning-plugin-discord", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": "./src/mod.ts", "imports": { diff --git a/packages/discord/src/authors.ts b/packages/discord/src/authors.ts deleted file mode 100644 index b4b4a4af..00000000 --- a/packages/discord/src/authors.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { API } from '@discordjs/core'; -import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; -import type { - APIGuildMember, - GatewayMessageUpdateDispatchData, -} from 'discord-api-types'; - -export async function fetch_author( - api: API, - message: GatewayMessageUpdateDispatchData, -): Promise<{ profile: string; username: string }> { - let profile = message.author.avatar !== null - ? `https://cdn.discordapp.com/avatars/${message.author.id}/${message.author.avatar}.png` - : `https://cdn.discordapp.com/embed/avatars/${ - calculateUserDefaultAvatarIndex(message.author.id) - }.png`; - - let username = message.author.global_name || message.author.username; - - if (message.guild_id) { - try { - // remove type assertion once deno resolves the return type for getMember properly - const member = message.member || await api.guilds.getMember( - message.guild_id, - message.author.id, - ) as APIGuildMember; - - if (member.avatar) { - profile = - `https://cdn.discordapp.com/guilds/${message.guild_id}/users/${message.author.id}/avatars/${member.avatar}.png`; - } - - if (member.nick) username = member.nick; - } catch { - // safe to ignore, we already have a name and avatar - } - } - - return { profile, username }; -} diff --git a/packages/discord/src/commands.ts b/packages/discord/src/commands.ts index 24af7a72..427bc6ac 100644 --- a/packages/discord/src/commands.ts +++ b/packages/discord/src/commands.ts @@ -1,103 +1,48 @@ -import type { API, ToEventProps } from '@discordjs/core'; -import type { command, create_command, lightning } from '@jersey/lightning'; -import type { - APIInteraction, - RESTPutAPIApplicationCommandsJSONBody, -} from 'discord-api-types'; -import { get_discord_message } from './messages.ts'; -import type { discord_config } from './mod.ts'; +import type { API } from '@discordjs/core'; +import type { command } from '@jersey/lightning'; -export async function set_slash_commands( +export async function setup_commands( api: API, - config: discord_config, - lightning: lightning, + commands: command[], ): Promise { - if (!config.slash_commands) return; - await api.applicationCommands.bulkOverwriteGlobalCommands( - config.application_id, - get_slash_commands(lightning.commands.values().toArray()), - ); -} - -function get_slash_commands( - commands: command[], -): RESTPutAPIApplicationCommandsJSONBody { - return commands.map((command) => { - const opts = []; - - if (command.arguments) { - for (const argument of command.arguments) { - opts.push({ - name: argument.name, - description: argument.description, - type: 3, - required: argument.required, - }); - } - } - - if (command.subcommands) { - for (const subcommand of command.subcommands) { - opts.push({ - name: subcommand.name, - description: subcommand.description, - type: 1, - options: subcommand.arguments?.map((opt) => ({ - name: opt.name, - description: opt.description, + (await api.applications.getCurrent()).id, + commands.map((command) => { + const opts = []; + + if (command.arguments) { + for (const argument of command.arguments) { + opts.push({ + name: argument.name, + description: argument.description, type: 3, - required: opt.required, - })), - }); + required: argument.required, + }); + } } - } - - return { - name: command.name, - type: 1, - description: command.description, - options: opts, - }; - }); -} -export function get_lightning_command( - interaction: ToEventProps, -): create_command | undefined { - if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; - - const args: Record = {}; - let subcommand: string | undefined; - - for (const option of interaction.data.data.options || []) { - if (option.type === 1) { - subcommand = option.name; - for (const suboption of option.options ?? []) { - if (suboption.type === 3) { - args[suboption.name] = suboption.value; + if (command.subcommands) { + for (const subcommand of command.subcommands) { + opts.push({ + name: subcommand.name, + description: subcommand.description, + type: 1, + options: subcommand.arguments?.map((opt) => ({ + name: opt.name, + description: opt.description, + type: 3, + required: opt.required, + })), + }); } } - } else if (option.type === 3) { - args[option.name] = option.value; - } - } - return { - args, - channel: interaction.data.channel.id, - command: interaction.data.data.name, - id: interaction.data.id, - plugin: 'bolt-discord', - reply: async (msg) => - await interaction.api.interactions.reply( - interaction.data.id, - interaction.data.token, - await get_discord_message(msg), - ), - subcommand, - timestamp: Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, - ), - }; + return { + name: command.name, + type: 1, + description: command.description, + options: opts, + }; + }), + ); } diff --git a/packages/discord/src/events.ts b/packages/discord/src/events.ts deleted file mode 100644 index 5cac44f7..00000000 --- a/packages/discord/src/events.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Client } from '@discordjs/core'; -import { GatewayDispatchEvents } from 'discord-api-types'; -import { get_lightning_command } from './commands.ts'; -import { get_lightning_message } from './messages.ts'; -import type { discord_plugin } from './mod.ts'; - -export function setup_events( - client: Client, - emit: discord_plugin['emit'], -): void { - // @ts-ignore deno isn't properly handling the eventemitter code - client.once(GatewayDispatchEvents.Ready, ({ data }) => { - console.log( - `[discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, - ); - }); - // @ts-ignore deno isn't properly handling the eventemitter code - client.on(GatewayDispatchEvents.MessageCreate, async (msg) => { - emit('create_message', await get_lightning_message(msg.api, msg.data)); - }); - // @ts-ignore deno isn't properly handling the eventemitter code - client.on(GatewayDispatchEvents.MessageUpdate, async (msg) => { - emit('edit_message', await get_lightning_message(msg.api, msg.data)); - }); - // @ts-ignore deno isn't properly handling the eventemitter code - client.on(GatewayDispatchEvents.MessageDelete, (msg) => { - emit('delete_message', { - channel: msg.data.channel_id, - id: msg.data.id, - plugin: 'bolt-discord', - timestamp: Temporal.Now.instant(), - }); - }); - // @ts-ignore deno isn't properly handling the eventemitter code - client.on(GatewayDispatchEvents.InteractionCreate, (cmd) => { - const command = get_lightning_command(cmd); - if (command) emit('create_command', command); - }); -} diff --git a/packages/discord/src/files.ts b/packages/discord/src/files.ts deleted file mode 100644 index 7e316777..00000000 --- a/packages/discord/src/files.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { RawFile } from '@discordjs/rest'; -import type { attachment } from '@jersey/lightning'; - -export async function fetch_files( - attachments: attachment[] | undefined, -): Promise { - if (!attachments) return; - - let total_size = 0; - - return (await Promise.all( - attachments.map(async (attachment) => { - try { - if (attachment.size >= 25) return; - if (total_size + attachment.size >= 25) return; - - const data = new Uint8Array( - await (await fetch(attachment.file, { - signal: AbortSignal.timeout(5000), - })).arrayBuffer(), - ); - - const name = attachment.name ?? attachment.file?.split('/').pop()!; - - total_size += attachment.size; - - return { data, name }; - } catch { - return; - } - }), - )).filter((i) => i !== undefined); -} diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts new file mode 100644 index 00000000..8296bc43 --- /dev/null +++ b/packages/discord/src/incoming.ts @@ -0,0 +1,176 @@ +import { + type GatewayMessageDeleteDispatchData, + type GatewayMessageUpdateDispatchData, + MessageFlags, + MessageReferenceType, + MessageType, +} from 'discord-api-types'; +import type { attachment, create_command, deleted_message, message } from '@jersey/lightning'; +import type { API, APIInteraction, APIStickerItem, ToEventProps } from '@discordjs/core'; +import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; +import { getOutgoingMessage } from './outgoing.ts'; + +export function getDeletedMessage( + data: GatewayMessageDeleteDispatchData, +): deleted_message { + return { + message_id: data.id, + channel_id: data.channel_id, + plugin: 'bolt-discord', + timestamp: Temporal.Now.instant(), + }; +} + +async function fetchAuthor(api: API, data: GatewayMessageUpdateDispatchData) { + let profile = data.author.avatar !== null + ? `https://cdn.discordapp.com/avatars/${data.author.id}/${data.author.avatar}.png` + : `https://cdn.discordapp.com/embed/avatars/${ + calculateUserDefaultAvatarIndex(data.author.id) + }.png`; + + let username = data.author.global_name || data.author.username; + + if (data.guild_id) { + try { + const member = data.member || await api.guilds.getMember( + data.guild_id, + data.author.id, + ); + + if (member.avatar) { + profile = + `https://cdn.discordapp.com/guilds/${data.guild_id}/users/${data.author.id}/avatars/${member.avatar}.png`; + } + + if (member.nick) username = member.nick; + } catch { + // safe to ignore, we already have a name and avatar + } + } + + return { profile, username }; +} + +async function fetchStickers( + stickers: APIStickerItem[], +): Promise { + return (await Promise.allSettled(stickers.map(async (sticker) => { + let type; + + if (sticker.format_type === 1) type = 'png'; + if (sticker.format_type === 2) type = 'apng'; + if (sticker.format_type === 3) type = 'lottie'; + if (sticker.format_type === 4) type = 'gif'; + + const url = `https://media.discordapp.net/stickers/${sticker.id}.${type}`; + + const request = await fetch(url, { method: 'HEAD' }); + + return { + file: url, + alt: sticker.name, + name: `${sticker.name}.${type}`, + size: parseInt(request.headers.get('Content-Length') ?? '0', 10) / + 1048576, + }; + }))).flatMap((i) => i.status === 'fulfilled' ? i.value : []); +} + +export async function getIncomingMessage( + { api, data }: { api: API; data: GatewayMessageUpdateDispatchData }, +): Promise { + // normal messages, replies, and user joins + if ( + data.type !== MessageType.Default && + data.type !== MessageType.Reply && + data.type !== MessageType.UserJoin && + data.type !== MessageType.ChatInputCommand && + data.type !== MessageType.ContextMenuCommand + ) { + return; + } + + const message: message = { + attachments: [ + ...data.attachments?.map( + (i: typeof data['attachments'][0]) => { + return { + file: i.url, + alt: i.description, + name: i.filename, + size: i.size / 1048576, // bytes -> MiB + }; + }, + ), + ...data.sticker_items ? await fetchStickers(data.sticker_items) : [], + ], + author: { + rawname: data.author.username, + id: data.author.id, + color: '#5865F2', + ...await fetchAuthor(api, data), + }, + channel_id: data.channel_id, + content: data.type === MessageType.UserJoin + ? '*joined on discord*' + : (data.flags || 0) & MessageFlags.Loading + ? '*loading...*' + : data.content, + embeds: data.embeds.map((i) => ({ + ...i, + timestamp: i.timestamp ? Number(i.timestamp) : undefined, + video: i.video ? { ...i.video, url: i.video.url ?? '' } : undefined, + })), + message_id: data.id, + plugin: 'bolt-discord', + reply_id: data.message_reference && + data.message_reference.type === MessageReferenceType.Default + ? data.message_reference.message_id + : undefined, + timestamp: Temporal.Instant.fromEpochMilliseconds( + Number(BigInt(data.id) >> 22n) + 1420070400000, + ), + }; + + return message; +} + +export function getIncomingCommand( + interaction: ToEventProps, +): create_command | undefined { + if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; + + const args: Record = {}; + let subcommand: string | undefined; + + for (const option of interaction.data.data.options || []) { + if (option.type === 1) { + subcommand = option.name; + for (const suboption of option.options ?? []) { + if (suboption.type === 3) { + args[suboption.name] = suboption.value; + } + } + } else if (option.type === 3) { + args[option.name] = option.value; + } + } + + return { + args, + channel_id: interaction.data.channel.id, + command: interaction.data.data.name, + message_id: interaction.data.id, + plugin: 'bolt-discord', + reply: async (msg) => + await interaction.api.interactions.reply( + interaction.data.id, + interaction.data.token, + await getOutgoingMessage(msg, interaction.api, false, false), + ), + subcommand, + timestamp: Temporal.Instant.fromEpochMilliseconds( + Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, + ), + }; +} diff --git a/packages/discord/src/messages.ts b/packages/discord/src/messages.ts deleted file mode 100644 index 8c6242f3..00000000 --- a/packages/discord/src/messages.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { RawFile } from '@discordjs/rest'; -import type { message } from '@jersey/lightning'; -import { - AllowedMentionsTypes, - type APIEmbed, - type GatewayMessageUpdateDispatchData, - type RESTPostAPIWebhookWithTokenJSONBody, - type RESTPostAPIWebhookWithTokenQuery, -} from 'discord-api-types'; -import { fetch_author } from './authors.ts'; -import { fetch_files } from './files.ts'; -import { fetch_reply_embed, type reply_options } from './replies.ts'; -import { fetch_sticker_attachments } from './stickers.ts'; - -export interface discord_webhook_payload - extends - RESTPostAPIWebhookWithTokenJSONBody, - RESTPostAPIWebhookWithTokenQuery { - embeds: APIEmbed[]; - files?: RawFile[]; - wait: true; -} - -export async function get_discord_message( - msg: message, - reply?: reply_options, - limit_mentions?: boolean, -): Promise { - const payload: discord_webhook_payload = { - allowed_mentions: limit_mentions - ? { parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User] } - : undefined, - avatar_url: msg.author.profile, - // TODO(jersey): since telegram forced multiple message support, split the message into two? - content: (msg.content?.length || 0) > 2000 - ? `${msg.content?.substring(0, 1997)}...` - : msg.content, - embeds: (msg.embeds ?? []).map((e) => ({ - ...e, - timestamp: e.timestamp?.toString(), - })), - files: await fetch_files(msg.attachments), - username: msg.author.username, - wait: true, - }; - - if (reply) { - const embed = await fetch_reply_embed(reply); - - if (embed) payload.embeds.push(embed); - } - - if (!payload.content && (!payload.embeds || payload.embeds.length === 0)) { - // this acts like a blank message and renders nothing - payload.content = '_ _'; - } - - return payload; -} - -export async function get_lightning_message( - api: API, - message: GatewayMessageUpdateDispatchData, -): Promise { - if (message.flags && message.flags & 128) message.content = '*loading...*'; - - if (message.type === 7) message.content = '*joined on discord*'; - - if (message.sticker_items) { - if (!message.attachments) message.attachments = []; - const stickers = await fetch_sticker_attachments(message.sticker_items); - if (stickers) message.attachments.push(...stickers); - } - - return { - attachments: message.attachments?.map( - (i: typeof message['attachments'][0]) => { - return { - file: i.url, - alt: i.description, - name: i.filename, - size: i.size / 1048576, // bytes -> MiB - }; - }, - ), - author: { - rawname: message.author.username, - id: message.author.id, - color: '#5865F2', - ...await fetch_author(api, message), - }, - channel: message.channel_id, - content: message.content, - embeds: message.embeds.map((i) => ({ - ...i, - timestamp: i.timestamp ? Number(i.timestamp) : undefined, - video: i.video ? { ...i.video, url: i.video.url ?? '' } : undefined, - })), - id: message.id, - plugin: 'bolt-discord', - reply_id: message.referenced_message?.id, - reply: async (msg: message) => { - await api.channels.createMessage(message.channel_id, { - ...(await get_discord_message(msg)), - message_reference: { message_id: message.id }, - }); - }, - timestamp: Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(message.id) >> 22n) + 1420070400000, - ), - }; -} diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 7a58babf..edb0c587 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -1,124 +1,168 @@ -import { Client } from '@discordjs/core'; -import { REST } from '@discordjs/rest'; +import { REST, type RESTOptions } from '@discordjs/rest'; import { WebSocketManager } from '@discordjs/ws'; +import { Client } from '@discordjs/core'; +import { GatewayDispatchEvents } from 'discord-api-types'; +import { getDeletedMessage, getIncomingCommand, getIncomingMessage } from './incoming.ts'; +import { handle_error } from './errors.ts'; +import { getOutgoingMessage } from './outgoing.ts'; import { - type create_opts, - type delete_opts, - type edit_opts, - type lightning, + type bridge_message_opts, + type deleted_message, + type message, + type command, plugin, } from '@jersey/lightning'; -import { set_slash_commands } from './commands.ts'; -import { handle_error } from './errors.ts'; -import { setup_events } from './events.ts'; -import { get_discord_message } from './messages.ts'; +import { setup_commands } from './commands.ts'; -/** configuration for the discord plugin */ -export interface discord_config { - /** the discord bot token */ +/** Options to use for the Discord plugin */ +export interface DiscordOptions { + /** The token to use for the bot */ token: string; - /** whether to enable slash commands */ - slash_commands: boolean; - /** discord application id */ - application_id: string; } -/** the plugin to use */ -export class discord_plugin extends plugin { +export default class DiscordPlugin extends plugin { name = 'bolt-discord'; - private api: Client['api']; private client: Client; - constructor(l: lightning, config: discord_config) { - super(l, config); + constructor(config: DiscordOptions) { + super(config); - // @ts-ignore the Undici type for fetch differs from Deno, but it works the same - const rest = new REST({ version: '10', makeRequest: fetch }).setToken( - config.token, - ); + const rest = new REST({ + makeRequest: fetch as RESTOptions['makeRequest'], + userAgentAppendix: `${navigator.userAgent} lightningplugindiscord/0.8.0`, + version: '10', + }).setToken(config.token); const gateway = new WebSocketManager({ token: config.token, - intents: 0 | 33281, + intents: 0 | 16813601, rest, }); - // @ts-ignore Deno doesn't properly handle the AsyncEventEmitter class types, but this works - this.client = new Client({ rest, gateway }); - this.api = this.client.api; - - set_slash_commands(this.api, config, l); - setup_events(this.client, this.emit.bind(this)); + this.client = new Client({ gateway, rest }); + this.setup_events(); gateway.connect(); } - async setup_channel(channel: string): Promise { + private setup_events() { + this.client.on(GatewayDispatchEvents.MessageCreate, async (data) => { + const msg = await getIncomingMessage(data); + if (msg) this.emit('create_message', msg); + }).on(GatewayDispatchEvents.MessageDelete, ({ data }) => { + this.emit('delete_message', getDeletedMessage(data)); + }).on(GatewayDispatchEvents.MessageDeleteBulk, ({ data }) => { + for (const id of data.ids) { + this.emit('delete_message', getDeletedMessage({ id, ...data })); + } + }).on(GatewayDispatchEvents.MessageUpdate, async (data) => { + const msg = await getIncomingMessage(data); + if (msg) this.emit('edit_message', msg); + }).on(GatewayDispatchEvents.InteractionCreate, (data) => { + const cmd = getIncomingCommand(data) + if (cmd) this.emit('create_command', cmd); + }).on(GatewayDispatchEvents.Ready, async ({ data }) => { + this.log( + 'info', + `ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, + `invite me at https://discord.com/oauth2/authorize?client_id=${ + (await this.client.api.applications.getCurrent()).id + }&scope=bot&permissions=8`, + ); + }); + } + + override async set_commands(commands: command[]): Promise { + await setup_commands(this.client.api, commands); + } + + async setup_channel(channelID: string): Promise { try { - const { id, token } = await this.api.channels.createWebhook( - channel, + const { id, token } = await this.client.api.channels.createWebhook( + channelID, { name: 'lightning bridge' }, ); return { id, token }; } catch (e) { - return handle_error(e, channel); + return handle_error(e, channelID); } } - async create_message(opts: create_opts): Promise { - const data = opts.channel.data as { id: string; token: string }; - + async send_message( + message: message, + data?: bridge_message_opts, + ): Promise { try { - const res = await this.api.webhooks.execute( - data.id, - data.token, - await get_discord_message( - opts.msg, - { api: this.api, channel: opts.channel.id, reply_id: opts.reply_id }, - opts.settings.allow_everyone, - ), + const msg = await getOutgoingMessage( + message, + this.client.api, + data !== undefined, + data?.settings?.allow_everyone ?? false, ); - return [res.id]; + if (data) { + const webhook = data.channel.data as { id: string; token: string }; + return [ + (await this.client.api.webhooks.execute( + webhook.id, + webhook.token, + msg, + )).id, + ]; + } else { + return [ + (await this.client.api.channels.createMessage( + message.channel_id, + msg, + )) + .id, + ]; + } } catch (e) { - return handle_error(e, opts.channel.id); + return handle_error(e, message.channel_id); } } - async edit_message(opts: edit_opts): Promise { - const data = opts.channel.data as { id: string; token: string }; - + async edit_message( + message: message, + data: bridge_message_opts & { edit_ids: string[] }, + ): Promise { try { - await this.api.webhooks.editMessage( - data.id, - data.token, - opts.edit_ids[0], - await get_discord_message( - opts.msg, - { api: this.api, channel: opts.channel.id, reply_id: opts.reply_id }, - opts.settings.allow_everyone, + const webhook = data.channel.data as { id: string; token: string }; + + await this.client.api.webhooks.editMessage( + webhook.id, + webhook.token, + data.edit_ids[0], + await getOutgoingMessage( + message, + this.client.api, + data !== undefined, + data?.settings?.allow_everyone ?? false, ), ); - - return opts.edit_ids; + return data.edit_ids; } catch (e) { - return handle_error(e, opts.channel.id, true); + return handle_error(e, data.channel.id, true); } } - async delete_message(opts: delete_opts): Promise { - const data = opts.channel.data as { id: string; token: string }; - - try { - await this.api.webhooks.deleteMessage( - data.id, - data.token, - opts.edit_ids[0], - ); - - return opts.edit_ids; - } catch (e) { - return handle_error(e, opts.channel.id, true); + async delete_messages(msgs: deleted_message[]): Promise { + const successful = []; + + for (const msg of msgs) { + try { + await this.client.api.channels.deleteMessage( + msg.channel_id, + msg.message_id, + ); + successful.push(msg.message_id); + } catch (e) { + // if this doesn't throw, it's fine + handle_error(e, msg.channel_id, true); + } } + + return successful; } } diff --git a/packages/discord/src/outgoing.ts b/packages/discord/src/outgoing.ts new file mode 100644 index 00000000..7f644220 --- /dev/null +++ b/packages/discord/src/outgoing.ts @@ -0,0 +1,116 @@ +import { + AllowedMentionsTypes, + type APIEmbed, + type APIMessageReference, + ButtonStyle, + ComponentType, + type RESTPostAPIWebhookWithTokenJSONBody, + type RESTPostAPIWebhookWithTokenQuery, +} from 'discord-api-types'; +import type { attachment, message } from '@jersey/lightning'; +import type { RawFile } from '@discordjs/rest'; +import type { API } from '@discordjs/core'; + +export interface DiscordPayload + extends + RESTPostAPIWebhookWithTokenJSONBody, + RESTPostAPIWebhookWithTokenQuery { + embeds: APIEmbed[]; + files?: RawFile[]; + message_reference?: APIMessageReference & { message_id: string }, + wait: true; +} + +async function fetchReplyComponent( + channelID: string, + replyID?: string, + api?: API, +) { + try { + if (!replyID || !api) return; + + const channel = await api.channels.get(channelID); + const channelPath = 'guild_id' in channel + ? `${channel.guild_id}/${channelID}` + : `@me/${channelID}`; + const msg = await api.channels.getMessage(channelID, replyID); + + return [{ + type: ComponentType.ActionRow as const, + components: [{ + type: ComponentType.Button as const, + style: ButtonStyle.Link as const, + label: `reply to ${msg.author.username}`, + url: `https://discord.com/channels/${channelPath}/${replyID}`, + }], + }]; + } catch { + // TODO(jersey): maybe log this? + return; + } +} + +async function fetchFiles( + attachments: attachment[] | undefined, +): Promise { + if (!attachments) return; + + let totalSize = 0; + + return (await Promise.all( + attachments.map(async (attachment) => { + try { + if (attachment.size >= 25) return; + if (totalSize + attachment.size >= 25) return; + + const data = new Uint8Array( + await (await fetch(attachment.file, { + signal: AbortSignal.timeout(5000), + })).arrayBuffer(), + ); + + const name = attachment.name ?? attachment.file?.split('/').pop()!; + + totalSize += attachment.size; + + return { data, name }; + } catch { + return; + } + }), + )).filter((i) => i !== undefined); +} + +export async function getOutgoingMessage( + msg: message, + api: API, + button_reply: boolean, + limit_mentions: boolean, +): Promise { + const payload: DiscordPayload = { + allowed_mentions: limit_mentions + ? { parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User] } + : undefined, + avatar_url: msg.author.profile, + // TODO(jersey): since telegram forced multiple message support, split the message into two? + content: (msg.content?.length || 0) > 2000 + ? `${msg.content?.substring(0, 1997)}...` + : msg.content, + components: button_reply ? await fetchReplyComponent(msg.channel_id, msg.reply_id, api) : undefined, + embeds: (msg.embeds ?? []).map((e) => ({ + ...e, + timestamp: e.timestamp?.toString(), + })), + files: await fetchFiles(msg.attachments), + message_reference: !button_reply && msg.reply_id ? { type: 0, channel_id: msg.channel_id, message_id: msg.reply_id } : undefined, + username: msg.author.username, + wait: true, + }; + + if (!payload.content && (!payload.embeds || payload.embeds.length === 0)) { + // this acts like a blank message and renders nothing + payload.content = '_ _'; + } + + return payload; +} diff --git a/packages/discord/src/replies.ts b/packages/discord/src/replies.ts deleted file mode 100644 index f710e5da..00000000 --- a/packages/discord/src/replies.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { APIEmbed } from 'discord-api-types'; -import { fetch_author } from './authors.ts'; - -export interface reply_options { - api?: API; - channel?: string; - reply_id?: string; -} - -export async function fetch_reply_embed( - { api, channel, reply_id }: reply_options, -): Promise { - if (!api || !channel || !reply_id) return; - - try { - const message = await api.channels.getMessage(channel, reply_id); - - const { profile, username } = await fetch_author(api, message); - - return { - author: { - name: `replying to ${username}`, - icon_url: profile, - }, - description: message.content, - }; - } catch { - return; - } -} diff --git a/packages/discord/src/stickers.ts b/packages/discord/src/stickers.ts deleted file mode 100644 index 5c694517..00000000 --- a/packages/discord/src/stickers.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { APIAttachment, APIStickerItem } from 'discord-api-types'; - -export async function fetch_sticker_attachments( - stickers?: APIStickerItem[], -): Promise { - if (!stickers) return; - - return (await Promise.all(stickers.map(async (sticker) => { - let type; - - if (sticker.format_type === 1) type = 'png'; - if (sticker.format_type === 2) type = 'apng'; - if (sticker.format_type === 3) type = 'lottie'; - if (sticker.format_type === 4) type = 'gif'; - - const url = `https://media.discordapp.net/stickers/${sticker.id}.${type}`; - - const request = await fetch(url, { method: 'HEAD' }); - - if (request.ok) { - return { - url, - description: sticker.name, - filename: `${sticker.name}.${type}`, - size: parseInt(request.headers.get('Content-Length') ?? '0') / 1048576, - id: sticker.id, - proxy_url: url, - }; - } else { - return; - } - }))).filter((i) => i !== undefined); -} diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 01448d82..56acc8ca 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,6 +1,6 @@ { "name": "@jersey/lightning-plugin-guilded", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": "./src/mod.ts", "imports": { diff --git a/packages/guilded/src/attachments.ts b/packages/guilded/src/attachments.ts deleted file mode 100644 index 62b52040..00000000 --- a/packages/guilded/src/attachments.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { attachment } from '@jersey/lightning'; -import type { Client } from '@jersey/guildapi'; - -export async function fetch_attachments( - bot: Client, - urls: string[], -): Promise { - const attachments: attachment[] = []; - - try { - const signed = await bot.request('post', '/url-signatures', { - urls: urls.map( - (url) => (url.split('(').pop())?.split(')')[0], - ).filter((i) => i !== undefined), - }); - - for (const url of signed.urlSignatures) { - if (url.signature) { - const resp = await fetch(url.signature, { - method: 'HEAD', - }); - - attachments.push({ - name: url.signature.split('/').pop()?.split('?')[0] || 'unknown', - file: url.signature, - size: parseInt(resp.headers.get('Content-Length') || '0') / 1048576, - }); - } - } - } catch { - // ignore - } - - return attachments; -} diff --git a/packages/guilded/src/authors.ts b/packages/guilded/src/authors.ts deleted file mode 100644 index e2d4d1fb..00000000 --- a/packages/guilded/src/authors.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { message, message_author } from '@jersey/lightning'; -import type { Client } from '@jersey/guildapi'; -import type { ChatMessage, ServerMember } from '@jersey/guilded-api-types'; - -export async function fetch_author( - msg: ChatMessage, - bot: Client, -): Promise { - try { - if (!msg.createdByWebhookId) { - const { member: author } = await bot.request( - 'get', - `/servers/${msg.serverId}/members/${msg.createdBy}`, - undefined, - ) as { member: ServerMember }; - - return { - username: author.nickname || author.user.name, - rawname: author.user.name, - id: msg.createdBy, - profile: author.user.avatar || undefined, - }; - } else { - const { webhook } = await bot.request( - 'get', - `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, - undefined, - ); - - return { - username: webhook.name, - rawname: webhook.name, - id: webhook.id, - profile: webhook.avatar, - }; - } - } catch { - return { - username: 'Guilded User', - rawname: 'GuildedUser', - id: msg.createdByWebhookId ?? msg.createdBy, - }; - } -} - -function is_valid_username(e: string): boolean { - if (!e || e.length === 0 || e.length > 25) return false; - return /^[a-zA-Z0-9_ ()-]*$/gms.test(e); -} - -export function get_valid_username(msg: message): string { - if (is_valid_username(msg.author.username)) { - return msg.author.username; - } else if (is_valid_username(msg.author.rawname)) { - return msg.author.rawname; - } else { - return `${msg.author.id}`; - } -} diff --git a/packages/guilded/src/embeds.ts b/packages/guilded/src/embeds.ts deleted file mode 100644 index 4ff3d790..00000000 --- a/packages/guilded/src/embeds.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { ChatEmbed } from '@jersey/guilded-api-types'; -import type { embed } from '@jersey/lightning'; - -export function get_lightning_embeds( - embeds?: ChatEmbed[], -): embed[] | undefined { - if (!embeds) return; - - return embeds.map((embed) => ({ - ...embed, - author: embed.author - ? { - ...embed.author, - name: embed.author.name || '', - } - : undefined, - image: embed.image - ? { - ...embed.image, - url: embed.image.url || '', - } - : undefined, - thumbnail: embed.thumbnail - ? { - ...embed.thumbnail, - url: embed.thumbnail.url || '', - } - : undefined, - timestamp: embed.timestamp ? Number(embed.timestamp) : undefined, - })); -} - -export function get_guilded_embeds( - embeds?: embed[], -): ChatEmbed[] | undefined { - if (!embeds) return; - - return embeds.map((i) => { - return { - ...i, - fields: i.fields - ? i.fields.map((j) => { - return { - ...j, - inline: j.inline ?? false, - }; - }) - : undefined, - timestamp: i.timestamp ? String(i.timestamp) : undefined, - }; - }); -} diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts new file mode 100644 index 00000000..3755f107 --- /dev/null +++ b/packages/guilded/src/incoming.ts @@ -0,0 +1,129 @@ +import type { ChatMessage, ServerMember } from '@jersey/guilded-api-types'; +import type { Client } from '@jersey/guildapi'; +import type { attachment, message } from '@jersey/lightning'; + +export async function fetchAuthor(msg: ChatMessage, client: Client) { + try { + if (!msg.createdByWebhookId) { + const { member: author } = await client.request( + 'get', + `/servers/${msg.serverId}/members/${msg.createdBy}`, + undefined, + ) as { member: ServerMember }; + + return { + username: author.nickname || author.user.name, + rawname: author.user.name, + id: msg.createdBy, + profile: author.user.avatar || undefined, + }; + } else { + const { webhook } = await client.request( + 'get', + `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, + undefined, + ); + + return { + username: webhook.name, + rawname: webhook.name, + id: webhook.id, + profile: webhook.avatar, + }; + } + } catch { + return { + username: 'Guilded User', + rawname: 'GuildedUser', + id: msg.createdByWebhookId ?? msg.createdBy, + }; + } +} + +async function fetchAttachments(urls: string[], client: Client) { + const attachments: attachment[] = []; + + try { + const signed = await client.request('post', '/url-signatures', { + urls: urls.map( + (url) => (url.split('(').pop())?.split(')')[0], + ).filter((i) => i !== undefined), + }); + + for (const url of signed.urlSignatures) { + if (url.signature) { + const resp = await fetch(url.signature, { + method: 'HEAD', + }); + + attachments.push({ + name: url.signature.split('/').pop()?.split('?')[0] || 'unknown', + file: url.signature, + size: parseInt(resp.headers.get('Content-Length') || '0') / 1048576, + }); + } + } + } catch { + // ignore + } + + return attachments; +} + +export async function getIncomingMessage( + msg: ChatMessage, + client: Client, +): Promise { + if (!msg.serverId) return; + + let content = msg.content?.replaceAll('\n```\n```\n', '\n'); + + const urls = content?.match( + /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, + ) || []; + + content = content?.replaceAll( + /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, + '', + ); + + return { + attachments: await fetchAttachments(urls, client), + author: { + ...await fetchAuthor(msg, client), + color: '#F5C400', + }, + channel_id: msg.channelId, + content, + embeds: msg.embeds?.map((embed) => ({ + ...embed, + author: embed.author + ? { + ...embed.author, + name: embed.author.name || '', + } + : undefined, + image: embed.image + ? { + ...embed.image, + url: embed.image.url || '', + } + : undefined, + thumbnail: embed.thumbnail + ? { + ...embed.thumbnail, + url: embed.thumbnail.url || '', + } + : undefined, + timestamp: embed.timestamp ? Number(embed.timestamp) : undefined, + })), + message_id: msg.id, + plugin: 'bolt-guilded', + reply_id: msg.replyMessageIds && msg.replyMessageIds.length > 0 + ? msg.replyMessageIds[0] + : undefined, + timestamp: Temporal.Instant.from( + msg.createdAt, + ), + }; +} diff --git a/packages/guilded/src/messages.ts b/packages/guilded/src/messages.ts deleted file mode 100644 index d7fca2e0..00000000 --- a/packages/guilded/src/messages.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { message } from '@jersey/lightning'; -import type { Client } from '@jersey/guildapi'; -import { fetch_attachments } from './attachments.ts'; -import { fetch_author, get_valid_username } from './authors.ts'; -import { get_guilded_embeds, get_lightning_embeds } from './embeds.ts'; -import { fetch_reply_embed } from './replies.ts'; -import type { ChatEmbed, ChatMessage } from '@jersey/guilded-api-types'; - -type webhook_payload = { - content?: string; - embeds?: ChatEmbed[]; - replyMessageIds?: string[]; - avatar_url?: string; - username?: string; -}; - -export async function get_lightning_message( - msg: ChatMessage, - bot: Client, -): Promise { - if (!msg.serverId) return; - - let content = msg.content?.replaceAll('\n```\n```\n', '\n'); - - const urls = content?.match( - /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, - ) || []; - - content = content?.replaceAll( - /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, - '', - ); - - return { - author: { - ...await fetch_author(msg, bot), - color: '#F5C400', - }, - attachments: await fetch_attachments(bot, urls), - channel: msg.channelId, - id: msg.id, - timestamp: Temporal.Instant.from( - msg.createdAt, - ), - embeds: get_lightning_embeds(msg.embeds), - plugin: 'bolt-guilded', - reply: async (reply: message) => { - await bot.request( - 'post', - `/channels/${msg.channelId}/messages`, - { content: reply.content! }, - ).catch(async e=>console.log(await e.cause.text())); - }, - content, - reply_id: msg.replyMessageIds && msg.replyMessageIds.length > 0 - ? msg.replyMessageIds[0] - : undefined, - }; -} - -export async function get_guilded_message( - msg: message, - channel?: string, - bot?: Client, - everyone = true, -): Promise { - const message: webhook_payload = { - content: msg.content, - avatar_url: msg.author.profile, - username: get_valid_username(msg), - embeds: get_guilded_embeds(msg.embeds), - }; - - if (msg.reply_id) { - const embed = await fetch_reply_embed(msg, channel, bot); - - if (embed) { - if (!message.embeds) message.embeds = []; - message.embeds.push(embed); - } - } - - if (msg.attachments?.length) { - if (!message.embeds) message.embeds = []; - message.embeds.push({ - title: 'attachments', - description: msg.attachments - .slice(0, 5) - .map((a) => { - return `![${a.alt || a.name}](${a.file})`; - }) - .join('\n'), - }); - } - - if (!message.content && !message.embeds) message.content = '\u2800'; - - if (!everyone && message.content) { - message.content = message.content.replace(/@everyone/g, '(a)everyone'); - message.content = message.content.replace(/@here/g, '(a)here'); - } - - return message; -} diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 7ac5563c..a2cde890 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -1,136 +1,152 @@ +import { type Client, createClient } from '@jersey/guildapi'; import { - type create_opts, - type delete_opts, - type edit_opts, - type lightning, - log_error, + type bridge_message_opts, + type deleted_message, + type message, plugin, } from '@jersey/lightning'; +import { getIncomingMessage } from './incoming.ts'; import { handle_error } from './errors.ts'; -import { get_guilded_message, get_lightning_message } from './messages.ts'; -import { type Client, createClient, SocketManager } from '@jersey/guildapi'; import type { ServerChannel } from '@jersey/guilded-api-types'; +import { getOutgoingMessage } from './outgoing.ts'; /** options for the guilded plugin */ -export interface guilded_config { +export interface GuildedOptions { /** the token to use */ token: string; } -/** the plugin to use */ -export class guilded_plugin extends plugin { +export default class GuildedPlugin extends plugin { name = 'bolt-guilded'; - bot: Client; + private client: Client; - constructor(l: lightning, c: guilded_config) { - super(l, c); - - this.bot = createClient(c.token); - - this.bot.socket = new SocketManager({ - token: c.token, - replayMissedEvents: false, - }); - - this.bot.socket.on('ready', (user) => { - console.log(`[guilded] ready as ${user.name}`); - }); + constructor(opts: GuildedOptions) { + super(opts); + this.client = createClient(opts.token); + this.setup_events(); + this.client.socket.connect(); + } - this.bot.socket.on('ChatMessageCreated', async ({ d: { message } }) => { - const msg = await get_lightning_message(message, this.bot); + private setup_events() { + this.client.socket.on('ChatMessageCreated', async (data) => { + const msg = await getIncomingMessage(data.d.message, this.client); if (msg) this.emit('create_message', msg); - }); - - this.bot.socket.on('ChatMessageUpdated', async ({ d: { message } }) => { - const msg = await get_lightning_message(message, this.bot); - if (msg) this.emit('edit_message', msg); - }); - - this.bot.socket.on('ChatMessageDeleted', ({ d: { message } }) => { + }).on('ChatMessageDeleted', ({ d }) => { this.emit('delete_message', { - channel: message.channelId, - id: message.id, + channel_id: d.message.channelId, + message_id: d.message.id, plugin: 'bolt-guilded', - timestamp: Temporal.Instant.from(message.deletedAt), + timestamp: Temporal.Instant.from(d.deletedAt), }); + }).on('ChatMessageUpdated', async (data) => { + const msg = await getIncomingMessage(data.d.message, this.client); + if (msg) this.emit('edit_message', msg); + }).on('ready', (data) => { + this.log('info', `Ready as ${data.name} (${data.id})`); }); - - this.bot.socket.connect(); } - async setup_channel(channel: string): Promise { + async setup_channel(channelID: string): Promise { try { - const { channel: { serverId } } = await this.bot.request( + const { channel: { serverId } } = await this.client.request( 'get', - `/channels/${channel}`, + `/channels/${channelID}`, undefined, ) as { channel: ServerChannel }; - const { webhook } = await this.bot.request( + const { webhook } = await this.client.request( 'post', `/servers/${serverId}/webhooks`, { - channelId: channel, + channelId: channelID, name: 'Lightning Bridges', }, ); if (!webhook.id || !webhook.token) { - log_error('failed to create webhook: missing id or token', { - extra: { webhook: webhook }, - }); + throw 'failed to create webhook: missing id or token'; } return { id: webhook.id, token: webhook.token }; } catch (e) { - return handle_error(e, channel); + return handle_error(e, channelID); } } - async create_message(opts: create_opts): Promise { + async send_message( + message: message, + data?: bridge_message_opts, + ): Promise { try { - const data = opts.channel.data as { id: string; token: string }; - const res = await (await fetch( - `https://media.guilded.gg/webhooks/${data.id}/${data.token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify( - await get_guilded_message( - opts.msg, - opts.channel.id, - this.bot, - opts.settings.allow_everyone, - ), - ), - }, - )).json(); + const msg = await getOutgoingMessage( + message, + this.client, + data?.settings?.allow_everyone ?? false, + ); - return [res.id]; + if (data) { + const webhook = data.channel.data as { id: string; token: string }; + + const res = await (await fetch( + `https://media.guilded.gg/webhooks/${webhook.id}/${webhook.token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(msg), + }, + )).json(); + + return [res.id]; + } else { + const resp = await this.client.request( + 'post', + `/channels/${message.channel_id}/messages`, + msg, + ); + + return [resp.message.id]; + } } catch (e) { - console.log(e); - return handle_error(e, opts.channel.id); + return handle_error(e, message.channel_id); } } - // guilded doesn't support editing messages - // deno-lint-ignore require-await - async edit_message(opts: edit_opts): Promise { - return opts.edit_ids; - } + async edit_message( + message: message, + data?: bridge_message_opts & { edit_ids: string[] }, + ): Promise { + // guilded webhooks don't support editing + if (data) return data.edit_ids; - async delete_message(opts: delete_opts): Promise { try { - await this.bot.request( - 'delete', - // @ts-expect-error: this is typed wrong - `/channels/${opts.channel}/messages/${opts.edit_ids[0]}`, - undefined, + const resp = await this.client.request( + 'put', + `/channels/${message.channel_id}/messages/${message.message_id}`, + await getOutgoingMessage(message, this.client, false), ); - return opts.edit_ids; + return [resp.message.id]; } catch (e) { - return handle_error(e, opts.channel.id, true); + return handle_error(e, message.channel_id, true); + } + } + + async delete_messages(messages: deleted_message[]): Promise { + const successful = []; + + for (const msg of messages) { + try { + await this.client.request( + 'delete', // @ts-expect-error: this is typed wrong + `/channels/${opts.channel}/messages/${msg.message_id[0]}`, + undefined, + ); + successful.push(msg.message_id); + } catch (e) { + handle_error(e, msg.channel_id, true); + } } + + return successful; } } diff --git a/packages/guilded/src/outgoing.ts b/packages/guilded/src/outgoing.ts new file mode 100644 index 00000000..9ee1b8b1 --- /dev/null +++ b/packages/guilded/src/outgoing.ts @@ -0,0 +1,108 @@ +import type { Client } from '@jersey/guildapi'; +import type { message } from '@jersey/lightning'; +import type { ChatEmbed } from '@jersey/guilded-api-types'; +import { fetchAuthor } from './incoming.ts'; + +type GuildedPayload = { + content?: string; + embeds?: ChatEmbed[]; + replyMessageIds?: string[]; + avatar_url?: string; + username?: string; +}; + +const usernameRegex = /^[a-zA-Z0-9_ ()-]{1,25}$/ms; + +function getUsername(msg: message): string { + if (usernameRegex.test(msg.author.username)) { + return msg.author.username; + } else if (usernameRegex.test(msg.author.rawname)) { + return msg.author.rawname; + } else { + return `${msg.author.id}`; + } +} + +async function fetchReplyEmbed( + msg: message, + client: Client, +): Promise { + if (!msg.reply_id) return; + + try { + const replied_to = await client.request( + 'get', + `/channels/${msg.channel_id}/messages/${msg.reply_id}`, + undefined, + ); + + const author = await fetchAuthor(replied_to.message, client); + + return { + author: { + name: `reply to ${author.username}`, + icon_url: author.profile, + }, + description: replied_to.message.content, + }; + } catch { + return; + } +} + +export async function getOutgoingMessage( + msg: message, + client: Client, + limitMentions?: boolean, +): Promise { + const message: GuildedPayload = { + content: msg.content, + avatar_url: msg.author.profile, + username: getUsername(msg), + embeds: msg.embeds?.map((i) => { + return { + ...i, + fields: i.fields + ? i.fields.map((j) => { + return { + ...j, + inline: j.inline ?? false, + }; + }) + : undefined, + timestamp: i.timestamp ? String(i.timestamp) : undefined, + }; + }), + }; + + if (msg.reply_id) { + const embed = await fetchReplyEmbed(msg, client); + + if (embed) { + if (!message.embeds) message.embeds = []; + message.embeds.push(embed); + } + } + + if (msg.attachments?.length) { + if (!message.embeds) message.embeds = []; + message.embeds.push({ + title: 'attachments', + description: msg.attachments + .slice(0, 5) + .map((a) => { + return `![${a.alt || a.name}](${a.file})`; + }) + .join('\n'), + }); + } + + if (!message.content && !message.embeds) message.content = '\u2800'; + + if (limitMentions && message.content) { + message.content = message.content.replace(/@everyone/g, '(a)everyone'); + message.content = message.content.replace(/@here/g, '(a)here'); + } + + return message; +} diff --git a/packages/guilded/src/replies.ts b/packages/guilded/src/replies.ts deleted file mode 100644 index 75152b95..00000000 --- a/packages/guilded/src/replies.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { message } from '@jersey/lightning'; -import type { Client } from '@jersey/guildapi'; -import { fetch_author } from './authors.ts'; -import type { ChatEmbed } from '@jersey/guilded-api-types'; - -export async function fetch_reply_embed( - msg: message, - channel?: string, - bot?: Client, -): Promise { - if (!msg.reply_id || !channel || !bot) return; - - try { - const replied_to = await bot.request( - 'get', - `/channels/${channel}/messages/${msg.reply_id}`, - undefined, - ); - - const author = await fetch_author(replied_to.message, bot); - - return { - author: { - name: `reply to ${author.username}`, - icon_url: author.profile, - }, - description: replied_to.message.content, - }; - } catch { - return; - } -} diff --git a/packages/lightning/src/bridge.ts b/packages/lightning/src/bridge.ts index 398ff5c8..c42d24b0 100644 --- a/packages/lightning/src/bridge.ts +++ b/packages/lightning/src/bridge.ts @@ -18,33 +18,24 @@ export async function bridge_message( let bridge; if (event === 'create_message') { - bridge = await lightning.data.get_bridge_by_channel(data.channel); + bridge = await lightning.data.get_bridge_by_channel(data.channel_id); } else { - bridge = await lightning.data.get_message(data.id); + bridge = await lightning.data.get_message(data.message_id); } if (!bridge) return; - // handle bridge settings - if (event !== 'create_message' && bridge.settings.allow_editing !== true) { - return; - } - - if (bridge.settings.use_rawname && 'author' in data) { - data.author.username = data.author.rawname; - } - // if the channel this event is from is disabled, return if ( bridge.channels.find((channel) => - channel.id === data.channel && channel.plugin === data.plugin && + channel.id === data.channel_id && channel.plugin === data.plugin && channel.disabled ) ) return; // filter out the channel this event is from and any disabled channels const channels = bridge.channels.filter( - (i) => i.id !== data.channel || i.plugin !== data.plugin, + (i) => i.id !== data.channel_id || i.plugin !== data.plugin, ).filter((i) => !i.disabled || !i.data); // if there are no more channels, return @@ -80,13 +71,31 @@ export async function bridge_message( let result_ids: string[]; try { - result_ids = await plugin[event]({ - channel, - settings: bridge.settings, - reply_id, - edit_ids: prior_bridged_ids?.id as string[], - msg: data as message, - }); + switch (event) { + case 'create_message': + case 'edit_message': + result_ids = await plugin.send_message({ + ...(data as message), + reply_id, + channel_id: channel.id, + message_id: prior_bridged_ids?.id[0] ?? '', + }, { + channel, + settings: bridge.settings, + edit_ids: prior_bridged_ids?.id, + }); + break; + case 'delete_message': + result_ids = await plugin.delete_messages( + prior_bridged_ids!.id.map((i) => { + return { + ...(data as deleted_message), + message_id: i, + channel_id: channel.id, + }; + }), + ); + } } catch (e) { if (e instanceof LightningError && e.disable_channel) { await disable_channel(channel, bridge, e, lightning); @@ -100,13 +109,7 @@ export async function bridge_message( }); try { - result_ids = await plugin[event]({ - channel, - settings: bridge.settings, - reply_id, - edit_ids: prior_bridged_ids?.id as string[], - msg: err.msg, - }); + result_ids = await plugin.send_message(err.msg); } catch (e) { new LightningError(e, { message: `Failed to log error message in bridge`, @@ -130,7 +133,7 @@ export async function bridge_message( await lightning.data[event]({ ...bridge, - id: data.id, + id: data.message_id, messages, bridge_id: bridge.id, }); diff --git a/packages/lightning/src/commands/bridge/_internal.ts b/packages/lightning/src/commands/bridge/_internal.ts index b2f1d4e7..9dd2c289 100644 --- a/packages/lightning/src/commands/bridge/_internal.ts +++ b/packages/lightning/src/commands/bridge/_internal.ts @@ -4,15 +4,15 @@ import type { bridge_channel, command_opts } from '../../structures/mod.ts'; export async function bridge_add_common( opts: command_opts, ): Promise { - const existing_bridge = await opts.lightning.data.get_bridge_by_channel( - opts.channel, + const existing_bridge = await opts.bridge_data.get_bridge_by_channel( + opts.channel_id, ); if (existing_bridge) { - return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.lightning.config.prefix}bridge leave\` or \`${opts.lightning.config.prefix}help\` commands.`; + return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.prefix}bridge leave\` or \`${opts.prefix}help\` commands.`; } - const plugin = opts.lightning.plugins.get(opts.plugin); + const plugin = opts.plugins.get(opts.plugin); if (!plugin) { log_error('Internal error: platform support not found', { @@ -23,16 +23,16 @@ export async function bridge_add_common( let bridge_data; try { - bridge_data = await plugin.setup_channel(opts.channel); + bridge_data = await plugin.setup_channel(opts.channel_id); } catch (e) { log_error(e, { message: 'Failed to create bridge using plugin', - extra: { channel: opts.channel, plugin_name: opts.plugin }, + extra: { channel: opts.channel_id, plugin_name: opts.plugin }, }); } return { - id: opts.channel, + id: opts.channel_id, data: bridge_data, disabled: false, plugin: opts.plugin, diff --git a/packages/lightning/src/commands/bridge/create.ts b/packages/lightning/src/commands/bridge/create.ts index b16482de..c7645e6d 100644 --- a/packages/lightning/src/commands/bridge/create.ts +++ b/packages/lightning/src/commands/bridge/create.ts @@ -20,8 +20,8 @@ export async function create( }; try { - const { id } = await opts.lightning.data.create_bridge(bridge_data); - return `Bridge created successfully!\nYou can now join it using \`${opts.lightning.config.prefix}bridge join ${id}\`.\nKeep this ID safe, don't share it with anyone, and delete this message.`; + const { id } = await opts.bridge_data.create_bridge(bridge_data); + return `Bridge created successfully!\nYou can now join it using \`${opts.prefix}bridge join ${id}\`.\nKeep this ID safe, don't share it with anyone, and delete this message.`; } catch (e) { log_error(e, { message: 'Failed to insert bridge into database', diff --git a/packages/lightning/src/commands/bridge/join.ts b/packages/lightning/src/commands/bridge/join.ts index 9cf02bb9..1627c208 100644 --- a/packages/lightning/src/commands/bridge/join.ts +++ b/packages/lightning/src/commands/bridge/join.ts @@ -9,7 +9,7 @@ export async function join( if (typeof result === 'string') return result; - const target_bridge = await opts.lightning.data.get_bridge_by_id( + const target_bridge = await opts.bridge_data.get_bridge_by_id( opts.args.id, ); @@ -20,7 +20,7 @@ export async function join( target_bridge.channels.push(result); try { - await opts.lightning.data.edit_bridge(target_bridge); + await opts.bridge_data.edit_bridge(target_bridge); return `Bridge joined successfully!`; } catch (e) { diff --git a/packages/lightning/src/commands/bridge/leave.ts b/packages/lightning/src/commands/bridge/leave.ts index 327c069e..f086a22f 100644 --- a/packages/lightning/src/commands/bridge/leave.ts +++ b/packages/lightning/src/commands/bridge/leave.ts @@ -2,18 +2,18 @@ import type { command_opts } from '../../structures/commands.ts'; import { log_error } from '../../structures/errors.ts'; export async function leave(opts: command_opts): Promise { - const bridge = await opts.lightning.data.get_bridge_by_channel( - opts.channel, + const bridge = await opts.bridge_data.get_bridge_by_channel( + opts.channel_id, ); if (!bridge) return `You are not in a bridge`; bridge.channels = bridge.channels.filter(( ch, - ) => ch.id !== opts.channel); + ) => ch.id !== opts.channel_id); try { - await opts.lightning.data.edit_bridge( + await opts.bridge_data.edit_bridge( bridge, ); return `Bridge left successfully`; diff --git a/packages/lightning/src/commands/bridge/status.ts b/packages/lightning/src/commands/bridge/status.ts index 9db79f8f..d29ddc72 100644 --- a/packages/lightning/src/commands/bridge/status.ts +++ b/packages/lightning/src/commands/bridge/status.ts @@ -1,8 +1,9 @@ +import { bridge_settings_list } from '../../structures/bridge.ts'; import type { command_opts } from '../../structures/commands.ts'; export async function status(opts: command_opts): Promise { - const bridge = await opts.lightning.data.get_bridge_by_channel( - opts.channel, + const bridge = await opts.bridge_data.get_bridge_by_channel( + opts.channel_id, ); if (!bridge) return `You are not in a bridge`; @@ -15,7 +16,11 @@ export async function status(opts: command_opts): Promise { str += `\nSettings:\n`; - for (const [key, value] of Object.entries(bridge.settings)) { + for ( + const [key, value] of Object.entries(bridge.settings).filter(([key]) => + bridge_settings_list.includes(key) + ) + ) { str += `- \`${key}\` ${value ? 'โœ”' : 'โŒ'}\n`; } diff --git a/packages/lightning/src/commands/bridge/toggle.ts b/packages/lightning/src/commands/bridge/toggle.ts index 5c8944ba..6a73b646 100644 --- a/packages/lightning/src/commands/bridge/toggle.ts +++ b/packages/lightning/src/commands/bridge/toggle.ts @@ -3,8 +3,8 @@ import { log_error } from '../../structures/errors.ts'; import { bridge_settings_list } from '../../structures/bridge.ts'; export async function toggle(opts: command_opts): Promise { - const bridge = await opts.lightning.data.get_bridge_by_channel( - opts.channel, + const bridge = await opts.bridge_data.get_bridge_by_channel( + opts.channel_id, ); if (!bridge) return `You are not in a bridge`; @@ -18,7 +18,7 @@ export async function toggle(opts: command_opts): Promise { bridge.settings[key] = !bridge.settings[key]; try { - await opts.lightning.data.edit_bridge( + await opts.bridge_data.edit_bridge( bridge, ); return `Bridge settings updated successfully`; diff --git a/packages/lightning/src/commands/runners.ts b/packages/lightning/src/commands/runners.ts index 245c51e4..0572aafa 100644 --- a/packages/lightning/src/commands/runners.ts +++ b/packages/lightning/src/commands/runners.ts @@ -9,21 +9,29 @@ import { export async function execute_text_command(msg: message, lightning: lightning) { if (!msg.content?.startsWith(lightning.config.prefix)) return; - const [cmd, ...rest] = msg.content.replace(lightning.config.prefix, '') + const [command, ...rest] = msg.content.replace(lightning.config.prefix, '') .split(' '); return await run_command({ ...msg, - command: cmd as string, - rest: rest as string[], + command, + rest, + reply: async (message: message) => { + await lightning.plugins.get(msg.plugin)?.send_message({ + ...message, + channel_id: msg.channel_id, + reply_id: msg.message_id, + }); + }, }, lightning); } export async function run_command( opts: create_command, - lightning: lightning + lightning: lightning, ) { - let command = lightning.commands.get(opts.command) ?? lightning.commands.get('help')!; + let command = lightning.commands.get(opts.command) ?? + lightning.commands.get('help')!; const subcommand_name = opts.subcommand ?? opts.rest?.shift(); @@ -47,7 +55,6 @@ export async function run_command( create_message( `Please provide the \`${arg.name}\` argument. Try using the \`${lightning.config.prefix}help\` command.`, ), - false, ); } } @@ -56,22 +63,26 @@ export async function run_command( try { resp = await command.execute({ + prefix: lightning.config.prefix, ...opts, args: opts.args as Record, - lightning + bridge_data: lightning.data, + plugins: lightning.plugins, }); } catch (e) { if (e instanceof LightningError) resp = e; - else resp = new LightningError(e, { - message: 'An error occurred while executing the command', - extra: { command: command.name }, - }); + else { + resp = new LightningError(e, { + message: 'An error occurred while executing the command', + extra: { command: command.name }, + }); + } } try { if (typeof resp === 'string') { - await opts.reply(create_message(resp), false); - } else await opts.reply(resp.msg, false); + await opts.reply(create_message(resp)); + } else await opts.reply(resp.msg); } catch (e) { new LightningError(e, { message: 'An error occurred while sending the command response', diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts index 53b6f227..b08718f7 100644 --- a/packages/lightning/src/database/redis_message.ts +++ b/packages/lightning/src/database/redis_message.ts @@ -29,33 +29,35 @@ export class redis_messages { console.log('[lightning-redis] got keys'); - const new_data = [] as [string, bridge | bridge_message | string][]; - - for (const key of all_keys) { + const new_data = await Promise.all(all_keys.map(async (key: string) => { console.log(`[lightning-redis] migrating key ${key}`); const type = await rd.sendCommand(['TYPE', key]) as string; - const action = type === 'string' ? 'GET' : 'JSON.GET'; - const value = await rd.sendCommand([action, key]) as string; + const value = await rd.sendCommand([ + type === 'string' ? 'GET' : 'JSON.GET', + key, + ]) as string; try { const parsed = JSON.parse(value); - - new_data.push([key, { - id: key.split('-')[2], - bridge_id: parsed.id, - channels: parsed.channels, - messages: parsed.messages, - name: `migrated bridge ${parsed.id}`, - settings: { - allow_editing: true, - use_rawname: parsed.use_rawname, - allow_everyone: true, - }, - }]); + return [ + key, + JSON.stringify( + { + id: key.split('-')[2], + bridge_id: parsed.id, + channels: parsed.channels, + messages: parsed.messages, + name: parsed.id, + settings: { + allow_everyone: false, + }, + } as bridge | bridge_message, + ), + ]; } catch { - new_data.push([key, value]); + return [key, value]; } - } + })); Deno.writeTextFileSync( 'lightning-redis-migration.json', @@ -69,16 +71,14 @@ export class redis_messages { if (write || env_confirm === 'true') { await rd.sendCommand(['DEL', ...all_keys]); - - const data = new_data.map(( - [key, value], - ) => [key, JSON.stringify(value)]); - - await rd.sendCommand(['MSET', ...data.flat()]); - await rd.sendCommand(['SET', 'lightning-db-version', '0.8.0']); + await rd.sendCommand([ + 'MSET', + 'lightning-db-version', + '0.8.0', + ...new_data.flat(1), + ]); console.warn('[lightning-redis] data written to database'); - return; } else { console.warn('[lightning-redis] data not written to database'); log_error('migration cancelled'); diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index 7f50162e..ae1cbdc5 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -45,9 +45,12 @@ export class lightning { this.plugins = new Map>(); for (const plugin of this.config.plugins || []) { - if (plugin.support.includes('0.8.0')) { - const plugin_instance = new plugin.type(this, plugin.config); + if (plugin.support.includes('0.8.0-alpha.1')) { + const plugin_instance: plugin = new plugin.type(plugin.config); this.plugins.set(plugin_instance.name, plugin_instance); + if (plugin_instance.set_commands) { + plugin_instance.set_commands(this.commands.values().toArray()); + } this.handle_events(plugin_instance); } } @@ -58,20 +61,25 @@ export class lightning { for await (const { name, value } of plugin) { await new Promise((res) => setTimeout(res, 150)); - if (sessionStorage.getItem(`${value[0].plugin}-${value[0].id}`)) { + if (sessionStorage.getItem(`${value[0].plugin}-${value[0].message_id}`)) { continue; } - if (name === 'create_command') { - run_command(value[0] as create_command, this); - continue; - } - - if (name === 'create_message') { - execute_text_command(value[0] as message, this); + switch (name) { + case 'create_command': + run_command(value[0] as create_command, this); + break; + case 'create_message': + execute_text_command(value[0] as message, this); + bridge_message(this, name, value[0]); + break; + case 'edit_message': + bridge_message(this, name, value[0]); + break; + case 'delete_message': + bridge_message(this, name, value[0]); + break; } - - bridge_message(this, name, value[0]); } } diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index 1c535059..a50193c0 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -1,5 +1,3 @@ -import type { deleted_message, message } from './messages.ts'; - /** representation of a bridge */ export interface bridge { /** ulid secret used as primary key */ @@ -26,19 +24,13 @@ export interface bridge_channel { /** possible settings for a bridge */ export interface bridge_settings { - /** allow editing/deletion */ - allow_editing: boolean; /** `@everyone/@here/@room` */ allow_everyone: boolean; - /** rawname = username */ - use_rawname: boolean; } /** list of settings for a bridge */ export const bridge_settings_list = [ - 'allow_editing', 'allow_everyone', - 'use_rawname', ]; /** representation of a bridged message collection */ @@ -65,40 +57,12 @@ export interface bridged_message { plugin: string; } -/** a message to be bridged */ -export interface create_opts { - /** the actual message */ - msg: message; - /** the channel to use */ - channel: bridge_channel; - /** the settings to use */ - settings: bridge_settings; - /** message to reply to, if any */ - reply_id?: string; -} - -/** a message to be edited */ -export interface edit_opts { - /** the actual message */ - msg: message; - /** the channel to use */ - channel: bridge_channel; - /** the settings to use */ - settings: bridge_settings; - /** message to reply to, if any */ - reply_id?: string; - /** ids of messages to edit */ - edit_ids: string[]; -} - -/** a message to be deleted */ -export interface delete_opts { - /** the actual deleted message */ - msg: deleted_message; +/** options for a message to be bridged */ +export interface bridge_message_opts { /** the channel to use */ channel: bridge_channel; + /** ids of messages to edit, if any */ + edit_ids?: string[]; /** the settings to use */ settings: bridge_settings; - /** ids of messages to delete */ - edit_ids: string[]; } diff --git a/packages/lightning/src/structures/commands.ts b/packages/lightning/src/structures/commands.ts index f6258b3d..a5fd7d18 100644 --- a/packages/lightning/src/structures/commands.ts +++ b/packages/lightning/src/structures/commands.ts @@ -1,4 +1,6 @@ -import type { lightning } from '../lightning.ts'; +import type { bridge_data } from '../database/mod.ts'; +import type { plugin } from './plugins.ts'; +import type { message } from './messages.ts'; /** representation of a command */ export interface command { @@ -29,13 +31,36 @@ export interface command_argument { /** options passed to command#execute */ export interface command_opts { /** the channel the command was run in */ - channel: string; + channel_id: string; /** the plugin the command was run with */ plugin: string; /** the time the command was sent */ timestamp: Temporal.Instant; /** arguments for the command */ args: Record; - /** a lightning instance */ - lightning: lightning; + /** the command prefix used */ + prefix: string; + /** bridge data (for bridge commands) */ + bridge_data: bridge_data; + /** plugin data */ + plugins: Map>; +} + +/** command execution event */ +export interface create_command + extends Pick { + /** the command to run */ + command: string; + /** the subcommand, if any, to use */ + subcommand?: string; + /** arguments, if any, to use */ + args?: Record; + /** the command prefix used */ + prefix?: string; + /** extra string options */ + rest?: string[]; + /** event reply function */ + reply: (message: message) => Promise; + /** id of the associated event */ + message_id: string; } diff --git a/packages/lightning/src/structures/events.ts b/packages/lightning/src/structures/events.ts deleted file mode 100644 index 8c987de1..00000000 --- a/packages/lightning/src/structures/events.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { command_opts } from './commands.ts'; -import type { deleted_message, message } from './messages.ts'; - -/** command execution event */ -export interface create_command extends Omit, 'lightning'> { - /** the command to run */ - command: string; - /** the subcommand, if any, to use */ - subcommand?: string; - /** arguments, if any, to use */ - args?: Record; - /** extra string options */ - rest?: string[]; - /** event reply function */ - reply: message['reply']; - /** id of the associated event */ - id: string; -} - -/** the events emitted by a plugin */ -export type plugin_events = { - /** when a message is created */ - create_message: [message]; - /** when a message is edited */ - edit_message: [message]; - /** when a message is deleted */ - delete_message: [deleted_message]; - /** when a command is run */ - create_command: [create_command]; -}; diff --git a/packages/lightning/src/structures/media.ts b/packages/lightning/src/structures/media.ts deleted file mode 100644 index f3564d4a..00000000 --- a/packages/lightning/src/structures/media.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** attachments within a message */ -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 in MiB */ - size: number; -} - -/** a discord-style embed */ -export interface embed { - /** the author of the embed */ - author?: { - /** the name of the author */ - name: string; - /** the url of the author */ - url?: string; - /** the icon of the author */ - icon_url?: string; - }; - /** the color of the embed */ - color?: number; - /** the text in an embed */ - description?: string; - /** fields within the embed */ - fields?: { - /** the name of the field */ - name: string; - /** the value of the field */ - value: string; - /** whether or not the field is inline */ - inline?: boolean; - }[]; - /** a footer shown in the embed */ - footer?: { - /** the footer text */ - text: string; - /** the icon of the footer */ - icon_url?: string; - }; - /** an image shown in the embed */ - image?: media; - /** a thumbnail shown in the embed */ - thumbnail?: media; - /** the time (in epoch ms) shown in the embed */ - timestamp?: number; - /** the title of the embed */ - title?: string; - /** a site linked to by the embed */ - url?: string; - /** a video inside of the embed */ - video?: media; -} - -/** media inside of an embed */ -export interface media { - /** the height of the media */ - height?: number; - /** the url of the media */ - url: string; - /** the width of the media */ - width?: number; -} diff --git a/packages/lightning/src/structures/messages.ts b/packages/lightning/src/structures/messages.ts index c85a3cd6..594f8a6d 100644 --- a/packages/lightning/src/structures/messages.ts +++ b/packages/lightning/src/structures/messages.ts @@ -1,5 +1,3 @@ -import type { attachment, embed } from './media.ts'; - /** * creates a message that can be sent using lightning * @param text the text of the message (can be markdown) @@ -13,26 +11,94 @@ export function create_message(text: string): message { id: 'lightning', }, content: text, - channel: '', - id: '', - reply: async () => {}, + channel_id: '', + message_id: '', timestamp: Temporal.Now.instant(), plugin: 'lightning', }; } +/** attachments within a message */ +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 in MiB */ + size: number; +} + /** a representation of a message that has been deleted */ export interface deleted_message { /** the message's id */ - id: string; + message_id: string; /** the channel the message was sent in */ - channel: string; + channel_id: string; /** the plugin that recieved the message */ plugin: string; /** the time the message was sent/edited as a temporal instant */ timestamp: Temporal.Instant; } +/** a discord-style embed */ +export interface embed { + /** the author of the embed */ + author?: { + /** the name of the author */ + name: string; + /** the url of the author */ + url?: string; + /** the icon of the author */ + icon_url?: string; + }; + /** the color of the embed */ + color?: number; + /** the text in an embed */ + description?: string; + /** fields within the embed */ + fields?: { + /** the name of the field */ + name: string; + /** the value of the field */ + value: string; + /** whether or not the field is inline */ + inline?: boolean; + }[]; + /** a footer shown in the embed */ + footer?: { + /** the footer text */ + text: string; + /** the icon of the footer */ + icon_url?: string; + }; + /** an image shown in the embed */ + image?: media; + /** a thumbnail shown in the embed */ + thumbnail?: media; + /** the time (in epoch ms) shown in the embed */ + timestamp?: number; + /** the title of the embed */ + title?: string; + /** a site linked to by the embed */ + url?: string; + /** a video inside of the embed */ + video?: media; +} + +/** media inside of an embed */ +export interface media { + /** the height of the media */ + height?: number; + /** the url of the media */ + url: string; + /** the width of the media */ + width?: number; +} + /** a message recieved by a plugin */ export interface message extends deleted_message { /** the attachments sent with the message */ @@ -43,8 +109,6 @@ export interface message extends deleted_message { content?: string; /** discord-style embeds */ embeds?: embed[]; - /** a function to reply to a message */ - reply: (message: message, optional?: unknown) => Promise; /** the id of the message replied to */ reply_id?: string; } diff --git a/packages/lightning/src/structures/mod.ts b/packages/lightning/src/structures/mod.ts index 89145a13..7f5b03fd 100644 --- a/packages/lightning/src/structures/mod.ts +++ b/packages/lightning/src/structures/mod.ts @@ -1,7 +1,5 @@ export * from './bridge.ts'; export * from './commands.ts'; export * from './errors.ts'; -export * from './events.ts'; -export * from './media.ts'; export * from './messages.ts'; export * from './plugins.ts'; diff --git a/packages/lightning/src/structures/plugins.ts b/packages/lightning/src/structures/plugins.ts index 61557d07..c052985d 100644 --- a/packages/lightning/src/structures/plugins.ts +++ b/packages/lightning/src/structures/plugins.ts @@ -1,53 +1,77 @@ import { EventEmitter } from '@denosaurs/event'; -import type { lightning } from '../lightning.ts'; -import type { create_opts, delete_opts, edit_opts } from './bridge.ts'; -import type { plugin_events } from './events.ts'; +import type { bridge_message_opts } from './bridge.ts'; +import type { deleted_message, message } from './messages.ts'; +import type { command, create_command } from './commands.ts'; + +/** the events emitted by a plugin */ +export type plugin_events = { + /** when a message is created */ + create_message: [message]; + /** when a message is edited */ + edit_message: [message]; + /** when a message is deleted */ + delete_message: [deleted_message]; + /** when a command is run */ + create_command: [create_command]; +}; /** the way to make a plugin */ export interface create_plugin< plugin_type extends plugin, > { /** the actual constructor of the plugin */ - type: new (l: lightning, config: plugin_type['config']) => plugin_type; + type: new (config: plugin_type['config']) => plugin_type; /** the configuration options for the plugin */ config: plugin_type['config']; /** version(s) the plugin supports */ support: string[]; } +/** a plugin for lightning */ +export interface plugin { + /** set commands on the platform, if available */ + set_commands?(commands: command[]): Promise | void; +} + /** a plugin for lightning */ export abstract class plugin extends EventEmitter { - /** 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; /** create a new plugin instance */ static new>( - this: new (l: lightning, config: T['config']) => T, + this: new (config: T['config']) => T, config: T['config'], ): create_plugin { - return { type: this, config, support: ['0.8.0'] }; + return { type: this, config, support: ['0.8.0-alpha.1'] }; } /** initialize a plugin with the given lightning instance and config */ - constructor(l: lightning, config: cfg) { + constructor(config: cfg) { super(); - this.lightning = l; this.config = config; } + + /** log something to the console */ + log(type: 'info' | 'warn' | 'error', ...args: unknown[]) { + for (const arg of args) { + console[type](`[${this.name}]`, arg); + } + } /** setup a channel to be used in a bridge */ abstract setup_channel(channel: string): Promise | unknown; /** send a message to a given channel */ - abstract create_message( - opts: create_opts, + abstract send_message( + message: message, + opts?: bridge_message_opts, ): Promise; /** edit a message in a given channel */ abstract edit_message( - opts: edit_opts, + message: message, + opts?: bridge_message_opts & { edit_ids: string[] }, ): Promise; - /** delete a message in a given channel */ - abstract delete_message( - opts: delete_opts, + /** delete messages in a given channel */ + abstract delete_messages( + messages: deleted_message[], ): Promise; } diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index a96d9299..a462bb0c 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,6 +1,6 @@ { "name": "@jersey/lightning-plugin-revolt", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": "./src/mod.ts", "imports": { diff --git a/packages/revolt/src/attachments.ts b/packages/revolt/src/attachments.ts deleted file mode 100644 index 567e884c..00000000 --- a/packages/revolt/src/attachments.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { type attachment, LightningError } from '@jersey/lightning'; -import type { Client } from '@jersey/rvapi'; - -export async function upload_attachments( - api: Client, - attachments?: attachment[], -): Promise { - if (!attachments) return undefined; - - return (await Promise.all( - attachments.map(async (attachment) => { - try { - return await api.media.upload_file( - 'attachments', - await (await fetch(attachment.file)).blob(), - ); - } catch (e) { - new LightningError(e, { - message: 'Failed to upload attachment', - extra: { original: e }, - }); - - return; - } - }), - )).filter((i) => i !== undefined); -} diff --git a/packages/revolt/src/author.ts b/packages/revolt/src/author.ts deleted file mode 100644 index 928a74a8..00000000 --- a/packages/revolt/src/author.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { Channel, User } from '@jersey/revolt-api-types'; -import type { Client } from '@jersey/rvapi'; -import type { message_author } from '@jersey/lightning'; -import { fetch_member } from './member.ts'; - -export async function get_author( - api: Client, - author_id: string, - channel_id: string, -): Promise { - try { - const channel = await api.request( - 'get', - `/channels/${channel_id}`, - undefined, - ) as Channel; - - const author = await api.request( - 'get', - `/users/${author_id}`, - undefined, - ) as User; - - const author_data = { - id: author_id, - rawname: author.username, - username: author.username, - color: '#FF4654', - profile: author.avatar - ? `https://autumn.revolt.chat/avatars/${author.avatar._id}` - : undefined, - }; - - if (channel.channel_type !== 'TextChannel') return author_data; - - try { - const member = await fetch_member(api, channel, author_id); - - return { - ...author_data, - username: member.nickname ?? author_data.username, - profile: member.avatar - ? `https://autumn.revolt.chat/avatars/${member.avatar._id}` - : author_data.profile, - }; - } catch { - return author_data; - } - } catch { - return { - id: author_id, - rawname: 'RevoltUser', - username: 'Revolt User', - color: '#FF4654', - }; - } -} diff --git a/packages/revolt/src/cache.ts b/packages/revolt/src/cache.ts new file mode 100644 index 00000000..37318b5f --- /dev/null +++ b/packages/revolt/src/cache.ts @@ -0,0 +1,194 @@ +import type { + Channel, + Masquerade, + Member, + Message, + Role, + Server, + User, +} from '@jersey/revolt-api-types'; +import type { Client } from '@jersey/rvapi'; +import type { message_author } from '@jersey/lightning'; + +class RevoltCacher { + private map = new Map(); + get(key: K): V | undefined { + const time = Temporal.Now.instant().epochMilliseconds; + const v = this.map.get(key); + + if (v && v.expiry >= time) return v.value; + } + set(key: K, val: V): V { + const time = Temporal.Now.instant().epochMilliseconds; + this.map.set(key, { value: val, expiry: time + 30000 }); + return val; + } +} + +const authorCache = new RevoltCacher<`${string}/${string}`, message_author>(); +const channelCache = new RevoltCacher(); +const memberCache = new RevoltCacher<`${string}/${string}`, Member>(); +const messageCache = new RevoltCacher<`${string}/${string}`, Message>(); +const roleCache = new RevoltCacher<`${string}/${string}`, Role>(); +const serverCache = new RevoltCacher(); +const userCache = new RevoltCacher(); + +export async function fetchAuthor( + api: Client, + authorID: string, + channelID: string, + masquerade?: Masquerade, +): Promise { + try { + const cached = authorCache.get(`${authorID}/${channelID}`); + + if (cached) return cached; + + const channel = await fetchChannel(api, channelID); + const author = await fetchUser(api, authorID); + + const data = { + id: authorID, + rawname: author.username, + username: masquerade?.name ?? author.username, + color: masquerade?.colour ?? '#FF4654', + profile: masquerade?.avatar ?? + (author.avatar + ? `https://autumn.revolt.chat/avatars/${author.avatar._id}` + : undefined), + }; + + if (channel.channel_type !== 'TextChannel') return data; + + try { + const member = await fetchMember(api, channel.server, authorID); + + return authorCache.set(`${authorID}/${channelID}`, { + ...data, + username: masquerade?.name ?? member.nickname ?? data.username, + profile: masquerade?.avatar ?? + (member.avatar + ? `https://autumn.revolt.chat/avatars/${member.avatar._id}` + : data.profile), + }); + } catch { + return authorCache.set(`${authorID}/${channelID}`, data); + } + } catch { + return { + id: authorID, + rawname: masquerade?.name ?? 'RevoltUser', + username: masquerade?.name ?? 'Revolt User', + profile: masquerade?.avatar ?? undefined, + color: masquerade?.colour ?? '#FF4654', + }; + } +} + +export async function fetchChannel( + api: Client, + channelID: string, +): Promise { + const cached = channelCache.get(channelID); + + if (cached) return cached; + + const channel = await api.request( + 'get', + `/channels/${channelID}`, + undefined, + ) as Channel; + + return channelCache.set(channelID, channel); +} + +export async function fetchMember( + client: Client, + serverID: string, + userID: string, +): Promise { + const member = memberCache.get(`${serverID}/${userID}`); + + if (member) return member; + + const response = await client.request( + 'get', + `/servers/${serverID}/members/${userID}`, + undefined, + ) as Member; + + return memberCache.set(`${serverID}/${userID}`, response); +} + +export async function fetchMessage( + client: Client, + channelID: string, + messageID: string, +): Promise { + const message = messageCache.get(`${channelID}/${messageID}`); + + if (message) return message; + + const response = await client.request( + 'get', + `/channels/${channelID}/messages/${messageID}`, + undefined, + ) as Message; + + return messageCache.set(`${channelID}/${messageID}`, response); +} + +export async function fetchRole( + client: Client, + serverID: string, + roleID: string, +): Promise { + const role = roleCache.get(`${serverID}/${roleID}`); + + if (role) return role; + + const response = await client.request( + 'get', + `/servers/${serverID}/roles/${roleID}`, + undefined, + ) as Role; + + return roleCache.set(`${serverID}/${roleID}`, response); +} + +export async function fetchServer( + client: Client, + serverID: string, +): Promise { + const server = serverCache.get(serverID); + + if (server) return server; + + const response = await client.request( + 'get', + `/servers/${serverID}`, + undefined, + ) as Server; + + return serverCache.set(serverID, response); +} + +export async function fetchUser( + api: Client, + userID: string, +): Promise { + const cached = userCache.get(userID); + + if (cached) return cached; + + const user = await api.request( + 'get', + `/users/${userID}`, + undefined, + ) as User; + + return userCache.set(userID, user); +} diff --git a/packages/revolt/src/embeds.ts b/packages/revolt/src/embeds.ts deleted file mode 100644 index a3bdfb53..00000000 --- a/packages/revolt/src/embeds.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { SendableEmbed } from '@jersey/revolt-api-types'; -import type { embed } from '@jersey/lightning'; - -export function get_revolt_embeds( - embeds?: embed[], -): SendableEmbed[] | undefined { - if (!embeds) return undefined; - - return embeds.map((embed) => { - const data: SendableEmbed = { - icon_url: embed.author?.icon_url ?? null, - url: embed.url ?? null, - title: embed.title ?? null, - description: embed.description ?? '', - media: embed.image?.url ?? null, - colour: embed.color ? `#${embed.color.toString(16)}` : null, - }; - - if (embed.fields) { - for (const field of embed.fields) { - data.description += `\n\n**${field.name}**\n${field.value}`; - } - } - - if (data.description?.length === 0) { - data.description = null; - } - - return data; - }); -} diff --git a/packages/revolt/src/events.ts b/packages/revolt/src/events.ts deleted file mode 100644 index 6cf0cb73..00000000 --- a/packages/revolt/src/events.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { type Client, createClient } from '@jersey/rvapi'; -import type { Message } from '@jersey/revolt-api-types'; -import { get_lightning_message } from './messages.ts'; -import type { revolt_config, revolt_plugin } from './mod.ts'; - -export function setup_events( - bot: Client, - config: revolt_config, - emit: revolt_plugin['emit'], -) { - bot.bonfire.on('Ready', (ready) => { - console.log( - `[revolt] ready in ${ready.channels.length} channels and ${ready.servers.length} servers`, - ); - }); - - bot.bonfire.on('Message', async (msg) => { - if (!msg.channel || msg.channel === 'undefined') return; - - emit('create_message', await get_lightning_message(bot, msg)); - }); - - bot.bonfire.on('MessageUpdate', async (msg) => { - if (!msg.channel || msg.channel === 'undefined') return; - - let oldMessage: Message; - - try { - oldMessage = await bot.request( - 'get', - `/channels/${msg.channel}/messages/${msg.id}`, - undefined, - ) as Message; - } catch { - return; - } - - emit( - 'edit_message', - await get_lightning_message(bot, { - ...oldMessage, - ...msg.data, - }), - ); - }); - - bot.bonfire.on('MessageDelete', (msg) => { - emit('delete_message', { - channel: msg.channel, - id: msg.id, - timestamp: Temporal.Now.instant(), - plugin: 'bolt-revolt', - }); - }); - - bot.bonfire.on('socket_close', (info) => { - console.warn('[revolt] socket closed', info); - bot = createClient(config); - setup_events(bot, config, emit); - }); -} diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts new file mode 100644 index 00000000..b9bb1757 --- /dev/null +++ b/packages/revolt/src/incoming.ts @@ -0,0 +1,37 @@ +import type { Client } from '@jersey/rvapi'; +import type { Message as APIMessage } from '@jersey/revolt-api-types'; +import { decodeTime } from '@std/ulid'; +import type { embed, message } from '@jersey/lightning'; +import { fetchAuthor } from './cache.ts'; + +export async function getIncomingMessage( + message: APIMessage, + api: Client, +): Promise { + return { + attachments: message.attachments?.map((i) => { + return { + file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, + name: i.filename, + size: i.size / 1048576, + }; + }), + author: await fetchAuthor(api, message.author, message.channel), + channel_id: message.channel, + content: message.content ?? undefined, + embeds: message.embeds?.map((i) => { + return { + color: 'colour' in i && i.colour + ? parseInt(i.colour.replace('#', ''), 16) + : undefined, + ...i, + } as embed; + }), + message_id: message._id, + plugin: 'bolt-revolt', + timestamp: message.edited + ? Temporal.Instant.from(message.edited) + : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), + reply_id: message.replies?.[0] ?? undefined, + }; +} diff --git a/packages/revolt/src/member.ts b/packages/revolt/src/member.ts deleted file mode 100644 index f31db9c8..00000000 --- a/packages/revolt/src/member.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Channel, Member } from '@jersey/revolt-api-types'; -import type { Client } from '@jersey/rvapi'; - -const member_cache = new Map<`${string}/${string}`, { - value: Member; - expiry: number; -}>(); - -export async function fetch_member( - client: Client, - channel: Channel & { channel_type: 'TextChannel' }, - user: string, -): Promise { - const time_now = Temporal.Now.instant().epochMilliseconds; - - const member = member_cache.get(`${channel.server}/${user}`); - - if (member && member.expiry > time_now) { - return member.value; - } - - const response = await client.request( - 'get', - `/servers/${channel.server}/members/${user}`, - undefined, - ) as Member; - - member_cache.set(`${channel.server}/${user}`, { - value: response, - expiry: time_now + 300000, - }); - - return response; -} diff --git a/packages/revolt/src/messages.ts b/packages/revolt/src/messages.ts deleted file mode 100644 index ca47355c..00000000 --- a/packages/revolt/src/messages.ts +++ /dev/null @@ -1,87 +0,0 @@ -import type { DataMessageSend, Message } from '@jersey/revolt-api-types'; -import type { Client } from '@jersey/rvapi'; -import type { embed, message } from '@jersey/lightning'; -import { decodeTime } from '@std/ulid'; -import { get_author } from './author.ts'; -import { upload_attachments } from './attachments.ts'; -import { get_revolt_embeds } from './embeds.ts'; - -export async function get_lightning_message( - api: Client, - message: Message, -): Promise { - return { - attachments: message.attachments?.map((i) => { - return { - file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, - name: i.filename, - size: i.size / 1048576, - }; - }), - author: await get_author(api, message.author, message.channel), - channel: message.channel, - content: message.content ?? undefined, - embeds: message.embeds?.map((i) => { - return { - color: 'colour' in i && i.colour - ? parseInt(i.colour.replace('#', ''), 16) - : undefined, - ...i, - } as embed; - }), - id: message._id, - plugin: 'bolt-revolt', - reply: async (msg: message, masquerade = true) => { - await api.request( - 'post', - `/channels/${message.channel}/messages`, - { - ...(await get_revolt_message( - api, - { ...msg, reply_id: message._id }, - masquerade as boolean, - )), - }, - ); - }, - timestamp: message.edited - ? Temporal.Instant.from(message.edited) - : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), - reply_id: message.replies?.[0] ?? undefined, - }; -} - -export async function get_revolt_message( - api: Client, - message: message, - masquerade = true, -): Promise { - const attachments = await upload_attachments(api, message.attachments); - const embeds = get_revolt_embeds(message.embeds); - - if ( - (!message.content || message.content.length < 1) && - (!embeds || embeds.length < 1) && - (!attachments || attachments.length < 1) - ) { - message.content = '*empty message*'; - } - - return { - attachments, - content: (message.content?.length || 0) > 2000 - ? `${message.content?.substring(0, 1997)}...` - : message.content, - embeds, - replies: message.reply_id - ? [{ id: message.reply_id, mention: true }] - : undefined, - masquerade: masquerade - ? { - name: message.author.username, - avatar: message.author.profile, - colour: message.author.color, - } - : undefined, - }; -} diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index b11447d4..8c485ff8 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -1,79 +1,125 @@ -import { - type create_opts, - type delete_opts, - type edit_opts, - type lightning, - plugin, -} from '@jersey/lightning'; import { type Client, createClient } from '@jersey/rvapi'; -import type { Message } from '@jersey/revolt-api-types'; +import { getIncomingMessage } from './incoming.ts'; +import type { Message as APIMessage } from '@jersey/revolt-api-types'; import { handle_error } from './errors.ts'; +import { getOutgoingMessage } from './outgoing.ts'; +import { fetchMessage } from './cache.ts'; import { check_permissions } from './permissions.ts'; -import { get_revolt_message } from './messages.ts'; -import { setup_events } from './events.ts'; +import { + type bridge_message_opts, + type deleted_message, + type message, + plugin, +} from '@jersey/lightning'; -/** the config for the revolt plugin */ -export interface revolt_config { - /** the token for the revolt bot */ +export interface RevoltOptions { token: string; - /** the user id for the bot */ user_id: string; } -/** the plugin to use */ -export class revolt_plugin extends plugin { - bot: Client; +export default class RevoltPlugin extends plugin { name = 'bolt-revolt'; + private client: Client; + + constructor(opts: RevoltOptions) { + super(opts); + this.client = createClient({ token: opts.token }); + this.setupEvents(); + } + + private setupEvents() { + this.client.bonfire.on('Message', async (data) => { + const msg = await getIncomingMessage(data, this.client); + if (msg) this.emit('create_message', msg); + }).on('MessageDelete', (data) => { + this.emit('delete_message', { + channel_id: data.channel, + message_id: data.id, + plugin: this.name, + timestamp: Temporal.Now.instant(), + }); + }).on('MessageUpdate', async (data) => { + let oldMessage: APIMessage; + + try { + oldMessage = await fetchMessage(this.client, data.channel, data.id); + } catch { + return; + } - constructor(l: lightning, config: revolt_config) { - super(l, config); - this.bot = createClient(config); - setup_events(this.bot, config, this.emit); + const msg = await getIncomingMessage({ + ...oldMessage, + ...data, + }, this.client); + + if (msg) this.emit('edit_message', msg); + }).on('Ready', (data) => { + this.log( + 'info', + `ready in ${data.servers.length} servers as ${ + data.users.find((i) => i._id === this.config.user_id)?.username + }`, + `invite me at https://app.revolt.chat/bot/${this.config.user_id}`, + ); + }); } - async setup_channel(channel: string): Promise { - return await check_permissions(channel, this.bot, this.config.user_id); + async setup_channel(channelID: string): Promise { + return await check_permissions(channelID, this.config.user_id, this.client); } - async create_message(opts: create_opts): Promise { + async send_message( + message: message, + data?: bridge_message_opts, + ): Promise { try { - const { _id } = (await this.bot.request( - 'post', - `/channels/${opts.channel.id}/messages`, - await get_revolt_message(this.bot, opts.msg, true), - )) as Message; - - return [_id]; + return [ + (await this.client.request( + 'post', + `/channels/${message.channel_id}/messages`, + await getOutgoingMessage(this.client, message, data !== undefined), + ) as APIMessage)._id, + ]; } catch (e) { return handle_error(e); } } - async edit_message(opts: edit_opts): Promise { + async edit_message( + message: message, + data?: bridge_message_opts & { edit_ids: string[] }, + ): Promise { try { - await this.bot.request( - 'patch', - `/channels/${opts.channel.id}/messages/${opts.edit_ids[0]}`, - await get_revolt_message(this.bot, opts.msg, true), - ); - - return opts.edit_ids; + return [ + (await this.client.request( + 'patch', + `/channels/${message.channel_id}/messages/${ + data?.edit_ids[0] ?? message.message_id + }`, + await getOutgoingMessage(this.client, message, data !== undefined), + ) as APIMessage)._id, + ]; } catch (e) { - return handle_error(e, true); + return handle_error(e); } } - async delete_message(opts: delete_opts): Promise { - try { - await this.bot.request( - 'delete', - `/channels/${opts.channel.id}/messages/${opts.edit_ids[0]}`, - undefined, - ); + async delete_messages(messages: deleted_message[]): Promise { + const successful = []; - return opts.edit_ids; - } catch (e) { - return handle_error(e, true); + for (const msg of messages) { + try { + await this.client.request( + 'delete', + `/channels/${msg.channel_id}/messages/${msg.message_id}`, + undefined, + ); + successful.push(msg.message_id); + } catch (e) { + handle_error(e); + } } + + return successful; } } diff --git a/packages/revolt/src/outgoing.ts b/packages/revolt/src/outgoing.ts new file mode 100644 index 00000000..63ae0754 --- /dev/null +++ b/packages/revolt/src/outgoing.ts @@ -0,0 +1,87 @@ +import type { Client } from '@jersey/rvapi'; +import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; +import { + type attachment, + LightningError, + type message, +} from '@jersey/lightning'; + +async function uploadAttachments( + api: Client, + attachments?: attachment[], +): Promise { + if (!attachments) return undefined; + + return (await Promise.all( + attachments.map(async (attachment) => { + try { + return await api.media.upload_file( + 'attachments', + await (await fetch(attachment.file)).blob(), + ); + } catch (e) { + new LightningError(e, { + message: 'Failed to upload attachment', + extra: { original: e }, + }); + + return; + } + }), + )).filter((i) => i !== undefined); +} + +export async function getOutgoingMessage( + api: Client, + message: message, + masquerade = true, +): Promise { + const attachments = await uploadAttachments(api, message.attachments); + + if ( + (!message.content || message.content.length < 1) && + (!message.embeds || message.embeds.length < 1) && + (!attachments || attachments.length < 1) + ) { + message.content = '*empty message*'; + } + + return { + attachments, + content: (message.content?.length || 0) > 2000 + ? `${message.content?.substring(0, 1997)}...` + : message.content, + embeds: message.embeds?.map((embed) => { + const data: SendableEmbed = { + icon_url: embed.author?.icon_url, + url: embed.url, + title: embed.title, + description: embed.description ?? '', + media: embed.image?.url, + colour: embed.color ? `#${embed.color.toString(16)}` : null, + }; + + if (embed.fields) { + for (const field of embed.fields) { + data.description += `\n\n**${field.name}**\n${field.value}`; + } + } + + if (data.description?.length === 0) { + data.description = null; + } + + return data; + }), + replies: message.reply_id + ? [{ id: message.reply_id, mention: true }] + : undefined, + masquerade: masquerade + ? { + name: message.author.username, + avatar: message.author.profile, + colour: message.author.color, + } + : undefined, + }; +} diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index dfc3041e..781296a3 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -1,88 +1,70 @@ import type { Client } from '@jersey/rvapi'; -import type { Channel, Role, Server } from '@jersey/revolt-api-types'; import { LightningError, log_error } from '@jersey/lightning'; import { handle_error } from './errors.ts'; -import { fetch_member } from './member.ts'; +import { fetchChannel, fetchMember, fetchRole, fetchServer } from './cache.ts'; -const permissions_bits = [ +const permission_bits = [ 1 << 23, // ManageMessages 1 << 28, // Masquerade ]; -const permissions = permissions_bits.reduce((a, b) => a | b, 0); +const needed_permissions = permission_bits.reduce((a, b) => a | b, 0); export async function check_permissions( - channel_id: string, + channelID: string, + botID: string, client: Client, - bot_id: string, ) { try { - const channel = await client.request( - 'get', - `/channels/${channel_id}`, - undefined, - ) as Channel; + const channel = await fetchChannel(client, channelID); if (channel.channel_type === 'Group') { - if (channel.permissions && (channel.permissions & permissions)) { + if (channel.permissions && (channel.permissions & needed_permissions)) { return channel._id; } log_error('missing ManageMessages and/or Masquerade permission'); } else if (channel.channel_type === 'TextChannel') { - return await server_permissions(channel, client, bot_id); - } else { - log_error(`unsupported channel type: ${channel.channel_type}`); - } - } catch (e) { - if (e instanceof LightningError) throw e; + const server = await fetchServer(client, channel.server); + const member = await fetchMember(client, channel.server, botID); - handle_error(e); - } -} - -async function server_permissions( - channel: Channel & { channel_type: 'TextChannel' }, - client: Client, - bot_id: string, -) { - const server = await client.request( - 'get', - `/servers/${channel.server}`, - undefined, - ) as Server; + // check server permissions + let currentPermissions = server.default_permissions; - const member = await fetch_member(client, channel, bot_id); + for (const role of (member.roles || [])) { + const { permissions: role_permissions } = await fetchRole( + client, + server._id, + role, + ); - // check server permissions - let total_permissions = server.default_permissions; + currentPermissions |= role_permissions.a || 0; + currentPermissions &= ~role_permissions.d || 0; + } - for (const role of (member.roles || [])) { - const { permissions: role_permissions } = await client.request( - 'get', - `/servers/${channel.server}/roles/${role}`, - undefined, - ) as Role; + // apply default allow/denies + if (channel.default_permissions) { + currentPermissions |= channel.default_permissions.a; + currentPermissions &= ~channel.default_permissions.d; + } - total_permissions |= role_permissions.a || 0; - total_permissions &= ~role_permissions.d || 0; - } + // apply role permissions + if (channel.role_permissions) { + for (const role of (member.roles || [])) { + currentPermissions |= channel.role_permissions[role]?.a || 0; + currentPermissions &= ~channel.role_permissions[role]?.d || 0; + } + } - // apply default allow/denies - if (channel.default_permissions) { - total_permissions |= channel.default_permissions.a; - total_permissions &= ~channel.default_permissions.d; - } + if (currentPermissions & needed_permissions) return channel._id; - // apply role permissions - if (channel.role_permissions) { - for (const role of (member.roles || [])) { - total_permissions |= channel.role_permissions[role]?.a || 0; - total_permissions &= ~channel.role_permissions[role]?.d || 0; + log_error('missing ManageMessages and/or Masquerade permission'); + } else { + log_error(`unsupported channel type: ${channel.channel_type}`); } - } - - if (total_permissions & permissions) return channel._id; + } catch (e) { + if (e instanceof LightningError) throw e; - log_error('missing ManageMessages and/or Masquerade permission'); + handle_error(e); + } } diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index a32ac7a5..dbca0b46 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,11 +1,11 @@ { "name": "@jersey/lightning-plugin-telegram", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", - "telegramify-markdown": "npm:telegramify-markdown@^1.2.4", - "grammy": "npm:grammy@^1.35.0" + "@jersey/lightning": "jsr:@jersey/lightning@0.8.0", + "telegramify-markdown": "npm:telegramify-markdown@^1.3.0", + "grammy": "npm:grammy@^1.35.1" } } diff --git a/packages/telegram/src/file_proxy.ts b/packages/telegram/src/file_proxy.ts deleted file mode 100644 index 89144b5c..00000000 --- a/packages/telegram/src/file_proxy.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { telegram_config } from './mod.ts'; - -export function setup_file_proxy(config: telegram_config) { - Deno.serve({ - port: config.proxy_port, - onListen: ({ port }) => { - console.log(`[telegram] file proxy listening on localhost:${port}`); - console.log(`[telegram] also available at: ${config.proxy_url}`); - }, - }, (req: Request) => { - const { pathname } = new URL(req.url); - return fetch( - `https://api.telegram.org/file/bot${config.bot_token}/${ - pathname.replace('/telegram/', '') - }`, - ); - }); -} diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts new file mode 100644 index 00000000..7e639fdb --- /dev/null +++ b/packages/telegram/src/incoming.ts @@ -0,0 +1,87 @@ +import type { Context } from 'grammy'; +import type { message } from '@jersey/lightning'; + +const types = [ + 'text', + 'dice', + 'location', + 'document', + 'animation', + 'audio', + 'photo', + 'sticker', + 'video', + 'video_note', + 'voice', + 'unsupported', +] as const; + +export async function getIncomingMessage( + ctx: Context, + proxy: string, +): Promise { + const msg = ctx.editedMessage || ctx.msg; + if (!msg) return; + const author = await ctx.getAuthor(); + const pfps = await ctx.getUserProfilePhotos({ limit: 1 }); + const type = types.find((type) => type in msg) ?? 'unsupported'; + const base: message = { + author: { + username: author.user.last_name + ? `${author.user.first_name} ${author.user.last_name}` + : author.user.first_name, + rawname: author.user.username || author.user.first_name, + color: '#24A1DE', + profile: pfps.total_count + ? `${proxy}/${ + (await ctx.api.getFile(pfps.photos[0][0].file_id)).file_path + }` + : undefined, + id: author.user.id.toString(), + }, + channel_id: msg.chat.id.toString(), + message_id: msg.message_id.toString(), + timestamp: Temporal.Instant.fromEpochMilliseconds( + (msg.edit_date || msg.date) * 1000, + ), + plugin: 'bolt-telegram', + reply_id: msg.reply_to_message + ? msg.reply_to_message.message_id.toString() + : undefined, + }; + + switch (type) { + case 'text': + return { + ...base, + content: msg.text, + }; + case 'dice': + return { + ...base, + content: `${msg.dice!.emoji} ${msg.dice!.value}`, + }; + case 'location': + return { + ...base, + content: `https://www.openstreetmap.com/#map=18/${ + msg.location!.latitude + }/${msg.location!.longitude}`, + }; + case 'unsupported': + return; + default: { + const fileObj = type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!; + const file = await ctx.api.getFile(fileObj.file_id); + if (!file.file_path) return; + return { + ...base, + attachments: [{ + file: `${proxy}/${file.file_path}`, + name: file.file_path, + size: (file.file_size ?? 0) / 1048576, + }], + }; + } + } +} diff --git a/packages/telegram/src/messages.ts b/packages/telegram/src/messages.ts deleted file mode 100644 index 8a6ea9ec..00000000 --- a/packages/telegram/src/messages.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { message } from '@jersey/lightning'; -import type { Context } from 'grammy'; -import type { Message } from 'grammy/types'; -import convert_markdown from 'telegramify-markdown'; -import type { telegram_config } from './mod.ts'; - -type message_type = - | 'text' - | 'dice' - | 'location' - | 'document' - | 'animation' - | 'audio' - | 'photo' - | 'sticker' - | 'video' - | 'video_note' - | 'voice' - | 'unsupported'; - -export function get_lightning_message( - msg: message, -): { function: 'sendMessage' | 'sendDocument'; value: string }[] { - let content = `${msg.author.username} ยป ${msg.content || '_no content_'}`; - - if ((msg.embeds?.length ?? 0) > 0) { - content = `${content}\n_this message has embeds_`; - } - - const messages: { - function: 'sendMessage' | 'sendDocument'; - value: string; - }[] = [{ - function: 'sendMessage', - value: convert_markdown(content, 'escape'), - }]; - - for (const attachment of (msg.attachments ?? [])) { - messages.push({ - function: 'sendDocument', - value: attachment.file, - }); - } - - return messages; -} - -function get_message_type(msg: Message): message_type { - if ('text' in msg) return 'text'; - if ('dice' in msg) return 'dice'; - if ('location' in msg) return 'location'; - if ('document' in msg) return 'document'; - if ('animation' in msg) return 'animation'; - if ('audio' in msg) return 'audio'; - if ('photo' in msg) return 'photo'; - if ('sticker' in msg) return 'sticker'; - if ('video' in msg) return 'video'; - if ('video_note' in msg) return 'video_note'; - if ('voice' in msg) return 'voice'; - return 'unsupported'; -} - -export async function get_telegram_message( - ctx: Context, - cfg: telegram_config, -): Promise { - const msg = ctx.editedMessage || ctx.msg; - if (!msg) return; - const author = await ctx.getAuthor(); - const pfps = await ctx.getUserProfilePhotos({ limit: 1 }); - const type = get_message_type(msg); - const base = { - author: { - username: author.user.last_name - ? `${author.user.first_name} ${author.user.last_name}` - : author.user.first_name, - rawname: author.user.username || author.user.first_name, - color: '#24A1DE', - profile: pfps.total_count - ? `${cfg.proxy_url}/${ - (await ctx.api.getFile(pfps.photos[0][0].file_id)).file_path - }` - : undefined, - id: author.user.id.toString(), - }, - channel: msg.chat.id.toString(), - id: msg.message_id.toString(), - timestamp: Temporal.Instant.fromEpochMilliseconds( - (msg.edit_date || msg.date) * 1000, - ), - plugin: 'bolt-telegram', - reply: async (reply: message) => { - for (const m of get_lightning_message(reply)) { - await ctx.api[m.function](msg.chat.id.toString(), m.value, { - reply_parameters: { - message_id: msg.message_id, - }, - parse_mode: 'MarkdownV2', - }); - } - }, - reply_id: msg.reply_to_message - ? msg.reply_to_message.message_id.toString() - : undefined, - }; - - switch (type) { - case 'text': - return { - ...base, - content: msg.text, - }; - case 'dice': - return { - ...base, - content: `${msg.dice!.emoji} ${msg.dice!.value}`, - }; - case 'location': - return { - ...base, - content: `https://www.google.com/maps/search/?api=1&query=${ - msg.location!.latitude - }%2C${msg.location!.longitude}`, - }; - case 'unsupported': - return; - default: { - const fileObj = type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!; - const file = await ctx.api.getFile(fileObj.file_id); - if (!file.file_path) return; - return { - ...base, - attachments: [{ - file: `${cfg.proxy_url}/${file.file_path}`, - name: file.file_path, - size: (file.file_size ?? 0) / 1048576, - }], - }; - } - } -} diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index cb14964d..bcbab65e 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -1,63 +1,74 @@ import { - type create_opts, - type delete_opts, - type edit_opts, - type lightning, + type bridge_message_opts, + type deleted_message, + type message, plugin, } from '@jersey/lightning'; import { Bot } from 'grammy'; -import { get_lightning_message, get_telegram_message } from './messages.ts'; -import { setup_file_proxy } from './file_proxy.ts'; +import { getIncomingMessage } from './incoming.ts'; +import { getOutgoingMessage } from './outgoing.ts'; /** options for the telegram plugin */ export interface telegram_config { /** the token for the bot */ - bot_token: string; + token: string; /** the port the plugins proxy will run on */ proxy_port: number; /** the publically accessible url of the plugin */ proxy_url: string; } -/** the plugin to use */ -export class telegram_plugin extends plugin { +export default class TelegramPlugin extends plugin { name = 'bolt-telegram'; - bot: Bot; + private bot: Bot; - constructor(l: lightning, cfg: telegram_config) { - super(l, cfg); - this.bot = new Bot(cfg.bot_token); - this.bot.on('message', async (ctx) => { - const msg = await get_telegram_message(ctx, cfg); - if (!msg) return; - this.emit('create_message', msg); + constructor(opts: telegram_config) { + super(opts); + this.bot = new Bot(opts.token); + this.bot.start(); + + this.bot.on(['message', 'edited_message'], async (ctx) => { + const msg = await getIncomingMessage(ctx, this.config.proxy_url); + if (msg) this.emit('create_message', msg); }); - this.bot.on('edited_message', async (ctx) => { - const msg = await get_telegram_message(ctx, cfg); - if (!msg) return; - this.emit('edit_message', msg); + + Deno.serve({ + port: this.config.proxy_port, + onListen: ({ port }) => { + this.log( + 'info', + `file proxy listening on localhost:${port}`, + `also available at: ${this.config.proxy_url}`, + ); + }, + }, (req: Request) => { + const { pathname } = new URL(req.url); + return fetch( + `https://api.telegram.org/file/bot${this.config.token}/${ + pathname.replace('/telegram/', '') + }`, + ); }); - // turns out it's impossible to deal with messages being deleted due to tdlib/telegram-bot-api#286 - setup_file_proxy(cfg); - this.bot.start(); } - /** create a bridge */ setup_channel(channel: string): unknown { return channel; } - async create_message(opts: create_opts): Promise { + async send_message( + message: message, + data?: bridge_message_opts, + ): Promise { const messages = []; - for (const msg of get_lightning_message(opts.msg)) { + for (const msg of getOutgoingMessage(message, data !== undefined)) { const result = await this.bot.api[msg.function]( - opts.channel.id, + message.channel_id, msg.value, { - reply_parameters: opts.reply_id + reply_parameters: message.reply_id ? { - message_id: Number(opts.reply_id), + message_id: Number(message.reply_id), } : undefined, parse_mode: 'MarkdownV2', @@ -70,11 +81,14 @@ export class telegram_plugin extends plugin { return messages; } - async edit_message(opts: edit_opts): Promise { + async edit_message( + message: message, + opts: bridge_message_opts & { edit_ids: string[] }, + ): Promise { await this.bot.api.editMessageText( opts.channel.id, Number(opts.edit_ids[0]), - get_lightning_message(opts.msg)[0].value, + getOutgoingMessage(message, true)[0].value, { parse_mode: 'MarkdownV2', }, @@ -83,14 +97,14 @@ export class telegram_plugin extends plugin { return opts.edit_ids; } - async delete_message(opts: delete_opts): Promise { - for (const id of opts.edit_ids) { - await this.bot.api.deleteMessage( - opts.channel.id, - Number(id), - ); + async delete_messages(messages: deleted_message[]): Promise { + const successful: string[] = []; + + for (const msg of messages) { + await this.bot.api.deleteMessage(msg.channel_id, Number(msg.message_id)); + successful.push(msg.message_id); } - return opts.edit_ids; + return successful; } } diff --git a/packages/telegram/src/outgoing.ts b/packages/telegram/src/outgoing.ts new file mode 100644 index 00000000..54eb4b33 --- /dev/null +++ b/packages/telegram/src/outgoing.ts @@ -0,0 +1,32 @@ +import convert_markdown from 'telegramify-markdown'; +import type { message } from '@jersey/lightning'; + +export function getOutgoingMessage( + msg: message, + bridged: boolean, +): { function: 'sendMessage' | 'sendDocument'; value: string }[] { + let content = bridged + ? `${msg.author.username} ยป ${msg.content || '_no content_'}` + : msg.content ?? '_no content_'; + + if ((msg.embeds?.length ?? 0) > 0) { + content += '\n_this message has embeds_'; + } + + const messages: { + function: 'sendMessage' | 'sendDocument'; + value: string; + }[] = [{ + function: 'sendMessage', + value: convert_markdown(content, 'escape'), + }]; + + for (const attachment of (msg.attachments ?? [])) { + messages.push({ + function: 'sendDocument', + value: attachment.file, + }); + } + + return messages; +} diff --git a/readme.md b/readme.md index 041692d4..b8a23215 100644 --- a/readme.md +++ b/readme.md @@ -2,6 +2,11 @@ # lightning - a chatbot +> [!NOTE] +> This branch contains the next version of lightning, currently `0.8.0-alpha.1`, +> and reflects active development. To see the latest stable version, go to the +> `main` branch. + - **Connecting Communities**: bridges many popular messaging apps - **Extensible**: support for messaging apps provided by plugins which can be enabled/disabled by the user diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..acc0fdc3 --- /dev/null +++ b/todo.md @@ -0,0 +1,4 @@ +# todos + +- plugin configuration shouldn't be code, use something like toml and have valibot validate it +- make things closer to ~/projects/lightning/arch.tldr \ No newline at end of file From a51c1fbcfc22b05bce6b169c3387c356a119c6ad Mon Sep 17 00:00:00 2001 From: Jersey Date: Tue, 8 Apr 2025 19:42:46 -0400 Subject: [PATCH 51/97] start to simplify the plugin api --- packages/discord/src/mod.ts | 11 +++++++--- packages/guilded/src/mod.ts | 1 + packages/lightning/src/lightning.ts | 13 +++++------- packages/lightning/src/structures/plugins.ts | 22 ++------------------ packages/revolt/src/mod.ts | 1 + packages/telegram/src/mod.ts | 1 + 6 files changed, 18 insertions(+), 31 deletions(-) diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index edb0c587..ad15973c 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -2,14 +2,18 @@ import { REST, type RESTOptions } from '@discordjs/rest'; import { WebSocketManager } from '@discordjs/ws'; import { Client } from '@discordjs/core'; import { GatewayDispatchEvents } from 'discord-api-types'; -import { getDeletedMessage, getIncomingCommand, getIncomingMessage } from './incoming.ts'; +import { + getDeletedMessage, + getIncomingCommand, + getIncomingMessage, +} from './incoming.ts'; import { handle_error } from './errors.ts'; import { getOutgoingMessage } from './outgoing.ts'; import { type bridge_message_opts, + type command, type deleted_message, type message, - type command, plugin, } from '@jersey/lightning'; import { setup_commands } from './commands.ts'; @@ -22,6 +26,7 @@ export interface DiscordOptions { export default class DiscordPlugin extends plugin { name = 'bolt-discord'; + support = ['0.8.0-alpha.1']; private client: Client; constructor(config: DiscordOptions) { @@ -58,7 +63,7 @@ export default class DiscordPlugin extends plugin { const msg = await getIncomingMessage(data); if (msg) this.emit('edit_message', msg); }).on(GatewayDispatchEvents.InteractionCreate, (data) => { - const cmd = getIncomingCommand(data) + const cmd = getIncomingCommand(data); if (cmd) this.emit('create_command', cmd); }).on(GatewayDispatchEvents.Ready, async ({ data }) => { this.log( diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index a2cde890..49e5c18b 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -18,6 +18,7 @@ export interface GuildedOptions { export default class GuildedPlugin extends plugin { name = 'bolt-guilded'; + support = ['0.8.0-alpha.1']; private client: Client; constructor(opts: GuildedOptions) { diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts index ae1cbdc5..83fa12cf 100644 --- a/packages/lightning/src/lightning.ts +++ b/packages/lightning/src/lightning.ts @@ -9,7 +9,6 @@ import { import type { command, create_command, - create_plugin, message, plugin, } from './structures/mod.ts'; @@ -21,8 +20,7 @@ export interface config { /** database options */ database: database_config; /** a list of plugins */ - // deno-lint-ignore no-explicit-any - plugins?: create_plugin[]; + plugins?: plugin[]; /** the prefix used for commands */ prefix: string; } @@ -46,12 +44,11 @@ export class lightning { for (const plugin of this.config.plugins || []) { if (plugin.support.includes('0.8.0-alpha.1')) { - const plugin_instance: plugin = new plugin.type(plugin.config); - this.plugins.set(plugin_instance.name, plugin_instance); - if (plugin_instance.set_commands) { - plugin_instance.set_commands(this.commands.values().toArray()); + this.plugins.set(plugin.name, plugin); + if (plugin.set_commands) { + plugin.set_commands(this.commands.values().toArray()); } - this.handle_events(plugin_instance); + this.handle_events(plugin); } } } diff --git a/packages/lightning/src/structures/plugins.ts b/packages/lightning/src/structures/plugins.ts index c052985d..5d1af125 100644 --- a/packages/lightning/src/structures/plugins.ts +++ b/packages/lightning/src/structures/plugins.ts @@ -15,18 +15,6 @@ export type plugin_events = { create_command: [create_command]; }; -/** the way to make a plugin */ -export interface create_plugin< - plugin_type extends plugin, -> { - /** the actual constructor of the plugin */ - type: new (config: plugin_type['config']) => plugin_type; - /** the configuration options for the plugin */ - config: plugin_type['config']; - /** version(s) the plugin supports */ - support: string[]; -} - /** a plugin for lightning */ export interface plugin { /** set commands on the platform, if available */ @@ -39,19 +27,13 @@ export abstract class plugin extends EventEmitter { config: cfg; /** the name of your plugin */ abstract name: string; - /** create a new plugin instance */ - static new>( - this: new (config: T['config']) => T, - config: T['config'], - ): create_plugin { - return { type: this, config, support: ['0.8.0-alpha.1'] }; - } + /** the versions supported by your plugin */ + abstract support: string[] /** initialize a plugin with the given lightning instance and config */ constructor(config: cfg) { super(); this.config = config; } - /** log something to the console */ log(type: 'info' | 'warn' | 'error', ...args: unknown[]) { for (const arg of args) { diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 8c485ff8..ba2cf905 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -19,6 +19,7 @@ export interface RevoltOptions { export default class RevoltPlugin extends plugin { name = 'bolt-revolt'; + support = ['0.8.0-alpha.1']; private client: Client; constructor(opts: RevoltOptions) { diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index bcbab65e..58bde456 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -20,6 +20,7 @@ export interface telegram_config { export default class TelegramPlugin extends plugin { name = 'bolt-telegram'; + support = ['0.8.0-alpha.1']; private bot: Bot; constructor(opts: telegram_config) { From ba91acc14db08a2fee4276c88c53bc3e62bc092b Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 9 Apr 2025 22:02:14 -0400 Subject: [PATCH 52/97] remove plugin log method --- packages/discord/src/mod.ts | 7 +++---- packages/guilded/src/mod.ts | 2 +- packages/lightning/src/structures/commands.ts | 2 +- packages/lightning/src/structures/plugins.ts | 8 +------- packages/revolt/src/mod.ts | 9 ++++----- packages/telegram/src/mod.ts | 6 ++---- 6 files changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index ad15973c..e0b7b447 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -66,10 +66,9 @@ export default class DiscordPlugin extends plugin { const cmd = getIncomingCommand(data); if (cmd) this.emit('create_command', cmd); }).on(GatewayDispatchEvents.Ready, async ({ data }) => { - this.log( - 'info', - `ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} guilds`, - `invite me at https://discord.com/oauth2/authorize?client_id=${ + console.log( + `[discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length}`, + `[discord] invite me at https://discord.com/oauth2/authorize?client_id=${ (await this.client.api.applications.getCurrent()).id }&scope=bot&permissions=8`, ); diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 49e5c18b..2b296bd3 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -43,7 +43,7 @@ export default class GuildedPlugin extends plugin { const msg = await getIncomingMessage(data.d.message, this.client); if (msg) this.emit('edit_message', msg); }).on('ready', (data) => { - this.log('info', `Ready as ${data.name} (${data.id})`); + console.log(`[guilded] ready as ${data.name} (${data.id})`); }); } diff --git a/packages/lightning/src/structures/commands.ts b/packages/lightning/src/structures/commands.ts index a5fd7d18..b2aaa185 100644 --- a/packages/lightning/src/structures/commands.ts +++ b/packages/lightning/src/structures/commands.ts @@ -48,7 +48,7 @@ export interface command_opts { /** command execution event */ export interface create_command - extends Pick { + extends Pick { /** the command to run */ command: string; /** the subcommand, if any, to use */ diff --git a/packages/lightning/src/structures/plugins.ts b/packages/lightning/src/structures/plugins.ts index 5d1af125..56ce878b 100644 --- a/packages/lightning/src/structures/plugins.ts +++ b/packages/lightning/src/structures/plugins.ts @@ -28,18 +28,12 @@ export abstract class plugin extends EventEmitter { /** the name of your plugin */ abstract name: string; /** the versions supported by your plugin */ - abstract support: string[] + abstract support: string[]; /** initialize a plugin with the given lightning instance and config */ constructor(config: cfg) { super(); this.config = config; } - /** log something to the console */ - log(type: 'info' | 'warn' | 'error', ...args: unknown[]) { - for (const arg of args) { - console[type](`[${this.name}]`, arg); - } - } /** setup a channel to be used in a bridge */ abstract setup_channel(channel: string): Promise | unknown; /** send a message to a given channel */ diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index ba2cf905..55797223 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -55,12 +55,11 @@ export default class RevoltPlugin extends plugin { if (msg) this.emit('edit_message', msg); }).on('Ready', (data) => { - this.log( - 'info', - `ready in ${data.servers.length} servers as ${ + console.log( + `[revolt] ready as ${ data.users.find((i) => i._id === this.config.user_id)?.username - }`, - `invite me at https://app.revolt.chat/bot/${this.config.user_id}`, + } in ${data.servers.length}`, + `[revolt] invite me at https://app.revolt.chat/bot/${this.config.user_id}` ); }); } diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 58bde456..c730026a 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -36,10 +36,8 @@ export default class TelegramPlugin extends plugin { Deno.serve({ port: this.config.proxy_port, onListen: ({ port }) => { - this.log( - 'info', - `file proxy listening on localhost:${port}`, - `also available at: ${this.config.proxy_url}`, + console.log( + `[telegram] proxy available at localhost:${port} or ${this.config.proxy_url}`, ); }, }, (req: Request) => { From fb0f9c7c80075b68a261970caf58009ac64edd15 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 12 Apr 2025 01:26:15 -0400 Subject: [PATCH 53/97] remove mongodb, use toml config, and some other changes --- .gitignore | 1 + packages/discord/README.md | 20 +- packages/discord/deno.json | 3 +- packages/discord/src/incoming.ts | 15 +- packages/discord/src/mod.ts | 26 +-- packages/discord/src/outgoing.ts | 10 +- packages/guilded/README.md | 17 +- packages/guilded/deno.json | 3 +- packages/guilded/src/mod.ts | 20 +- packages/lightning/deno.jsonc | 4 +- packages/lightning/src/bridge.ts | 185 ------------------ packages/lightning/src/bridge/commands.ts | 177 +++++++++++++++++ packages/lightning/src/bridge/handler.ts | 143 ++++++++++++++ packages/lightning/src/bridge/setup.ts | 69 +++++++ packages/lightning/src/cli.ts | 28 +-- packages/lightning/src/cli_config.ts | 63 ++++++ .../src/commands/bridge/_internal.ts | 40 ---- .../lightning/src/commands/bridge/create.ts | 31 --- .../lightning/src/commands/bridge/join.ts | 32 --- .../lightning/src/commands/bridge/leave.ts | 26 --- .../lightning/src/commands/bridge/status.ts | 28 --- .../lightning/src/commands/bridge/toggle.ts | 31 --- packages/lightning/src/commands/default.ts | 76 ------- packages/lightning/src/commands/runners.ts | 92 --------- packages/lightning/src/core.ts | 172 ++++++++++++++++ packages/lightning/src/database/mod.ts | 18 +- packages/lightning/src/database/mongo.ts | 120 ------------ packages/lightning/src/database/postgres.ts | 38 ++-- packages/lightning/src/database/redis.ts | 155 ++++++++++++++- .../lightning/src/database/redis_message.ts | 150 -------------- packages/lightning/src/lightning.ts | 89 --------- packages/lightning/src/mod.ts | 1 - packages/lightning/src/structures/bridge.ts | 10 +- packages/lightning/src/structures/commands.ts | 40 ++-- packages/lightning/src/structures/messages.ts | 3 +- packages/lightning/src/structures/plugins.ts | 27 +-- packages/revolt/README.md | 18 +- packages/revolt/deno.json | 3 +- packages/revolt/src/mod.ts | 31 +-- packages/telegram/README.md | 20 +- packages/telegram/deno.json | 3 +- packages/telegram/src/mod.ts | 36 ++-- todo.md | 6 +- 43 files changed, 958 insertions(+), 1122 deletions(-) delete mode 100644 packages/lightning/src/bridge.ts create mode 100644 packages/lightning/src/bridge/commands.ts create mode 100644 packages/lightning/src/bridge/handler.ts create mode 100644 packages/lightning/src/bridge/setup.ts create mode 100644 packages/lightning/src/cli_config.ts delete mode 100644 packages/lightning/src/commands/bridge/_internal.ts delete mode 100644 packages/lightning/src/commands/bridge/create.ts delete mode 100644 packages/lightning/src/commands/bridge/join.ts delete mode 100644 packages/lightning/src/commands/bridge/leave.ts delete mode 100644 packages/lightning/src/commands/bridge/status.ts delete mode 100644 packages/lightning/src/commands/bridge/toggle.ts delete mode 100644 packages/lightning/src/commands/default.ts delete mode 100644 packages/lightning/src/commands/runners.ts create mode 100644 packages/lightning/src/core.ts delete mode 100644 packages/lightning/src/database/mongo.ts delete mode 100644 packages/lightning/src/database/redis_message.ts delete mode 100644 packages/lightning/src/lightning.ts diff --git a/.gitignore b/.gitignore index 20944bbe..0a4c1252 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.env /config /config.ts +/lightning.toml \ No newline at end of file diff --git a/packages/discord/README.md b/packages/discord/README.md index a9828295..e5beefcd 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -6,17 +6,13 @@ discord ## example config -```ts -import type { config } from 'jsr:@jersey/lightning@0.7.4'; -import { discord_plugin } from 'jsr:@jersey/lightning-plugin-discord@0.7.4'; +```toml +# lightning.toml +# ... -export default { - plugins: [ - discord_plugin.new({ - app_id: 'your_application_id', - token: 'your_token', - slash_cmds: false, - }), - ], -} as config; +[[plugins]] +plugin = "jsr:@jersey/lightning-plugin-discord@0.8.0" +config = { token = "YOUR_DISCORD_TOKEN" } + +# ... ``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json index 182d4d09..4e9889ca 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -8,6 +8,7 @@ "@discordjs/core": "npm:@discordjs/core@^2.0.1", "@discordjs/rest": "npm:@discordjs/rest@^2.4.3", "@discordjs/ws": "npm:@discordjs/ws@^2.0.1", - "discord-api-types": "npm:discord-api-types@0.37.119/v10" + "discord-api-types": "npm:discord-api-types@0.37.119/v10", + "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" } } diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts index 8296bc43..6dbdce3d 100644 --- a/packages/discord/src/incoming.ts +++ b/packages/discord/src/incoming.ts @@ -5,8 +5,18 @@ import { MessageReferenceType, MessageType, } from 'discord-api-types'; -import type { attachment, create_command, deleted_message, message } from '@jersey/lightning'; -import type { API, APIInteraction, APIStickerItem, ToEventProps } from '@discordjs/core'; +import type { + attachment, + create_command, + deleted_message, + message, +} from '@jersey/lightning'; +import type { + API, + APIInteraction, + APIStickerItem, + ToEventProps, +} from '@discordjs/core'; import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; import { getOutgoingMessage } from './outgoing.ts'; @@ -161,6 +171,7 @@ export function getIncomingCommand( channel_id: interaction.data.channel.id, command: interaction.data.data.name, message_id: interaction.data.id, + prefix: '/', plugin: 'bolt-discord', reply: async (msg) => await interaction.api.interactions.reply( diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index e0b7b447..9cac921d 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -17,29 +17,29 @@ import { plugin, } from '@jersey/lightning'; import { setup_commands } from './commands.ts'; +import { type InferOutput, object, string } from '@valibot/valibot'; -/** Options to use for the Discord plugin */ -export interface DiscordOptions { +/** Options for the Discord plugin */ +export const config = object({ /** The token to use for the bot */ - token: string; -} + token: string(), +}); -export default class DiscordPlugin extends plugin { +export default class DiscordPlugin extends plugin { name = 'bolt-discord'; - support = ['0.8.0-alpha.1']; private client: Client; - constructor(config: DiscordOptions) { - super(config); + constructor(cfg: InferOutput) { + super(); const rest = new REST({ makeRequest: fetch as RESTOptions['makeRequest'], userAgentAppendix: `${navigator.userAgent} lightningplugindiscord/0.8.0`, version: '10', - }).setToken(config.token); + }).setToken(cfg.token); const gateway = new WebSocketManager({ - token: config.token, + token: cfg.token, intents: 0 | 16813601, rest, }); @@ -67,8 +67,8 @@ export default class DiscordPlugin extends plugin { if (cmd) this.emit('create_command', cmd); }).on(GatewayDispatchEvents.Ready, async ({ data }) => { console.log( - `[discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length}`, - `[discord] invite me at https://discord.com/oauth2/authorize?client_id=${ + `[discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} servers`, + `\n[discord] invite me at https://discord.com/oauth2/authorize?client_id=${ (await this.client.api.applications.getCurrent()).id }&scope=bot&permissions=8`, ); @@ -92,7 +92,7 @@ export default class DiscordPlugin extends plugin { } } - async send_message( + async create_message( message: message, data?: bridge_message_opts, ): Promise { diff --git a/packages/discord/src/outgoing.ts b/packages/discord/src/outgoing.ts index 7f644220..5787fe03 100644 --- a/packages/discord/src/outgoing.ts +++ b/packages/discord/src/outgoing.ts @@ -17,7 +17,7 @@ export interface DiscordPayload RESTPostAPIWebhookWithTokenQuery { embeds: APIEmbed[]; files?: RawFile[]; - message_reference?: APIMessageReference & { message_id: string }, + message_reference?: APIMessageReference & { message_id: string }; wait: true; } @@ -96,13 +96,17 @@ export async function getOutgoingMessage( content: (msg.content?.length || 0) > 2000 ? `${msg.content?.substring(0, 1997)}...` : msg.content, - components: button_reply ? await fetchReplyComponent(msg.channel_id, msg.reply_id, api) : undefined, + components: button_reply + ? await fetchReplyComponent(msg.channel_id, msg.reply_id, api) + : undefined, embeds: (msg.embeds ?? []).map((e) => ({ ...e, timestamp: e.timestamp?.toString(), })), files: await fetchFiles(msg.attachments), - message_reference: !button_reply && msg.reply_id ? { type: 0, channel_id: msg.channel_id, message_id: msg.reply_id } : undefined, + message_reference: !button_reply && msg.reply_id + ? { type: 0, channel_id: msg.channel_id, message_id: msg.reply_id } + : undefined, username: msg.author.username, wait: true, }; diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 19bb38ee..262e7d10 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -6,14 +6,13 @@ guilded ## example config -```ts -import { guilded_plugin } from 'jsr:@jersey/lightning-plugin-guilded@0.8.0'; +```toml +# lightning.toml +# ... -export default { - plugins: [ - guilded_plugin.new({ - token: 'your_token', - }), - ], -}; +[[plugins]] +plugin = "jsr:@jersey/lightning-plugin-guilded@0.8.0" +config = { token = "YOUR_GUILDED_TOKEN" } + +# ... ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 56acc8ca..48dcc9b9 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -6,6 +6,7 @@ "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.4", - "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@0.0.2" + "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@0.0.2", + "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" } } diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 2b296bd3..774e9dfa 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -9,20 +9,20 @@ import { getIncomingMessage } from './incoming.ts'; import { handle_error } from './errors.ts'; import type { ServerChannel } from '@jersey/guilded-api-types'; import { getOutgoingMessage } from './outgoing.ts'; +import { type InferOutput, object, string } from '@valibot/valibot'; -/** options for the guilded plugin */ -export interface GuildedOptions { - /** the token to use */ - token: string; -} +/** Options for the Guilded plugin */ +export const config = object({ + /** The token to use for the bot */ + token: string(), +}); -export default class GuildedPlugin extends plugin { +export default class GuildedPlugin extends plugin { name = 'bolt-guilded'; - support = ['0.8.0-alpha.1']; private client: Client; - constructor(opts: GuildedOptions) { - super(opts); + constructor(opts: InferOutput) { + super(); this.client = createClient(opts.token); this.setup_events(); this.client.socket.connect(); @@ -74,7 +74,7 @@ export default class GuildedPlugin extends plugin { } } - async send_message( + async create_message( message: message, data?: bridge_message_opts, ): Promise { diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc index 17c8ac61..a15226be 100644 --- a/packages/lightning/deno.jsonc +++ b/packages/lightning/deno.jsonc @@ -11,6 +11,8 @@ "@iuioiua/redis": "jsr:@iuioiua/redis@^1.0.1", "@std/cli/parse-args": "jsr:@std/cli@^1.0.14/parse-args", "@std/path": "jsr:@std/path@^1.0.8", - "@std/ulid": "jsr:@std/ulid@^1.0.0" + "@std/ulid": "jsr:@std/ulid@^1.0.0", + "@std/toml": "jsr:@std/toml@^1.0.4", + "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" } } diff --git a/packages/lightning/src/bridge.ts b/packages/lightning/src/bridge.ts deleted file mode 100644 index c42d24b0..00000000 --- a/packages/lightning/src/bridge.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { lightning } from './lightning.ts'; -import { LightningError } from './structures/errors.ts'; -import type { - bridge, - bridge_channel, - bridge_message, - bridged_message, - deleted_message, - message, -} from './structures/mod.ts'; - -export async function bridge_message( - lightning: lightning, - event: 'create_message' | 'edit_message' | 'delete_message', - data: message | deleted_message, -) { - // get the bridge and return if it doesn't exist - let bridge; - - if (event === 'create_message') { - bridge = await lightning.data.get_bridge_by_channel(data.channel_id); - } else { - bridge = await lightning.data.get_message(data.message_id); - } - - if (!bridge) return; - - // if the channel this event is from is disabled, return - if ( - bridge.channels.find((channel) => - channel.id === data.channel_id && channel.plugin === data.plugin && - channel.disabled - ) - ) return; - - // filter out the channel this event is from and any disabled channels - const channels = bridge.channels.filter( - (i) => i.id !== data.channel_id || i.plugin !== data.plugin, - ).filter((i) => !i.disabled || !i.data); - - // if there are no more channels, return - if (channels.length < 1) return; - - const messages = [] as bridged_message[]; - - for (const channel of channels) { - let prior_bridged_ids; - - if (event !== 'create_message') { - prior_bridged_ids = (bridge as bridge_message).messages?.find((i) => - i.channel === channel.id && i.plugin === channel.plugin - ); - - if (!prior_bridged_ids) continue; // the message wasn't bridged previously - } - - const plugin = lightning.plugins.get(channel.plugin); - - if (!plugin) { - await disable_channel( - channel, - bridge, - new LightningError(`plugin ${channel.plugin} doesn't exist`), - lightning, - ); - continue; - } - - const reply_id = await get_reply_id(lightning, data, channel); - - let result_ids: string[]; - - try { - switch (event) { - case 'create_message': - case 'edit_message': - result_ids = await plugin.send_message({ - ...(data as message), - reply_id, - channel_id: channel.id, - message_id: prior_bridged_ids?.id[0] ?? '', - }, { - channel, - settings: bridge.settings, - edit_ids: prior_bridged_ids?.id, - }); - break; - case 'delete_message': - result_ids = await plugin.delete_messages( - prior_bridged_ids!.id.map((i) => { - return { - ...(data as deleted_message), - message_id: i, - channel_id: channel.id, - }; - }), - ); - } - } catch (e) { - if (e instanceof LightningError && e.disable_channel) { - await disable_channel(channel, bridge, e, lightning); - continue; - } - - // try sending an error message - - const err = e instanceof LightningError ? e : new LightningError(e, { - message: `An error occurred while processing a message in the bridge.`, - }); - - try { - result_ids = await plugin.send_message(err.msg); - } catch (e) { - new LightningError(e, { - message: `Failed to log error message in bridge`, - extra: { channel, original_error: err.id }, - }); - - continue; - } - } - - for (const result_id of result_ids) { - sessionStorage.setItem(`${channel.plugin}-${result_id}`, '1'); - } - - messages.push({ - id: result_ids, - channel: channel.id, - plugin: channel.plugin, - }); - } - - await lightning.data[event]({ - ...bridge, - id: data.message_id, - messages, - bridge_id: bridge.id, - }); -} - -async function get_reply_id( - core: lightning, - msg: message | deleted_message, - channel: bridge_channel, -): Promise { - if ('reply_id' in msg && msg.reply_id) { - try { - const bridged = await core.data.get_message(msg.reply_id); - - const bridged_message = bridged?.messages?.find((i) => - i.channel === channel.id && i.plugin === channel.plugin - ); - - return bridged_message?.id[0]; - } catch { - return; - } - } -} - -async function disable_channel( - channel: bridge_channel, - bridge: bridge | bridge_message, - error: LightningError, - lightning: lightning, -) { - new LightningError( - `disabling channel ${channel.id} in bridge ${bridge.id}`, - { - extra: { original_error: error.id }, - }, - ); - - await lightning.data.edit_bridge({ - name: 'name' in bridge ? bridge.name : bridge.id, - id: 'bridge_id' in bridge ? bridge.bridge_id : bridge.id, - channels: bridge.channels.map((i) => - i.id === channel.id && i.plugin === channel.plugin - ? { ...i, disabled: true, data: error } - : i - ), - settings: bridge.settings, - }); -} diff --git a/packages/lightning/src/bridge/commands.ts b/packages/lightning/src/bridge/commands.ts new file mode 100644 index 00000000..c20cd338 --- /dev/null +++ b/packages/lightning/src/bridge/commands.ts @@ -0,0 +1,177 @@ +import type { bridge_data } from '../database/mod.ts'; +import { bridge_settings_list } from '../structures/bridge.ts'; +import { log_error } from '../structures/errors.ts'; +import type { bridge_channel, command_opts } from '../structures/mod.ts'; + +export async function create( + db: bridge_data, + opts: command_opts, +): Promise { + const result = await _add(db, opts); + + if (typeof result === 'string') return result; + + const data = { + name: opts.args.name!, + channels: [result], + settings: { + allow_editing: true, + allow_everyone: false, + use_rawname: false, + }, + }; + + try { + const { id } = await db.create_bridge(data); + return `Bridge created successfully!\nYou can now join it using \`${opts.prefix}bridge join ${id}\`.\nKeep this ID safe, don't share it with anyone, and delete this message.`; + } catch (e) { + log_error(e, { + message: 'Failed to insert bridge into database', + extra: data, + }); + } +} + +export async function join( + db: bridge_data, + opts: command_opts, +): Promise { + const result = await _add(db, opts); + + if (typeof result === 'string') return result; + + const target_bridge = await db.get_bridge_by_id( + opts.args.id!, + ); + + if (!target_bridge) { + return `Bridge with id \`${opts.args.id}\` not found. Make sure you have the correct id.`; + } + + target_bridge.channels.push(result); + + try { + await db.edit_bridge(target_bridge); + + return `Bridge joined successfully!`; + } catch (e) { + log_error(e, { + message: 'Failed to update bridge in database', + extra: { target_bridge }, + }); + } +} + +async function _add( + db: bridge_data, + opts: command_opts, +): Promise { + const existing_bridge = await db.get_bridge_by_channel( + opts.channel_id, + ); + + if (existing_bridge) { + return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.prefix}bridge leave\` or \`${opts.prefix}help\` commands.`; + } + + try { + return { + id: opts.channel_id, + data: await opts.plugin.setup_channel(opts.channel_id), + disabled: false, + plugin: opts.plugin.name, + }; + } catch (e) { + log_error(e, { + message: 'Failed to create bridge using plugin', + extra: { channel: opts.channel_id, plugin_name: opts.plugin }, + }); + } +} + +export async function leave( + db: bridge_data, + opts: command_opts, +): Promise { + const bridge = await db.get_bridge_by_channel( + opts.channel_id, + ); + + if (!bridge) return `You are not in a bridge`; + + bridge.channels = bridge.channels.filter(( + ch, + ) => ch.id !== opts.channel_id); + + try { + await db.edit_bridge( + bridge, + ); + return `Bridge left successfully`; + } catch (e) { + log_error(e, { + message: 'Failed to update bridge in database', + extra: { bridge }, + }); + } +} + +export async function status( + db: bridge_data, + opts: command_opts, +): Promise { + const bridge = await db.get_bridge_by_channel( + opts.channel_id, + ); + + if (!bridge) return `You are not in a bridge`; + + let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; + + for (const [i, value] of bridge.channels.entries()) { + str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`\n`; + } + + str += `\nSettings:\n`; + + for ( + const [key, value] of Object.entries(bridge.settings).filter(([key]) => + bridge_settings_list.includes(key) + ) + ) { + str += `- \`${key}\` ${value ? 'โœ”' : 'โŒ'}\n`; + } + + return str; +} + +export async function toggle( + db: bridge_data, + opts: command_opts, +): Promise { + const bridge = await db.get_bridge_by_channel( + opts.channel_id, + ); + + if (!bridge) return `You are not in a bridge`; + + if (!bridge_settings_list.includes(opts.args.setting!)) { + return `That setting does not exist`; + } + + const key = opts.args.setting as keyof typeof bridge.settings; + + bridge.settings[key] = !bridge.settings[key]; + + try { + await db.edit_bridge( + bridge, + ); + return `Bridge settings updated successfully`; + } catch (e) { + log_error(e, { + message: 'Failed to update bridge in database', + extra: { bridge }, + }); + } +} diff --git a/packages/lightning/src/bridge/handler.ts b/packages/lightning/src/bridge/handler.ts new file mode 100644 index 00000000..f9d742ea --- /dev/null +++ b/packages/lightning/src/bridge/handler.ts @@ -0,0 +1,143 @@ +import type { bridge_data } from '../database/mod.ts'; +import type { core } from '../core.ts'; +import { LightningError } from '../structures/errors.ts'; +import type { bridge_message, bridged_message } from '../structures/bridge.ts'; +import type { deleted_message, message } from '../structures/messages.ts'; + +export async function bridge_message( + core: core, + bridge_data: bridge_data, + event: 'create_message' | 'edit_message' | 'delete_message', + data: message | deleted_message, +) { + const bridge = event === 'create_message' + ? await bridge_data.get_bridge_by_channel(data.channel_id) + : await bridge_data.get_message(data.message_id); + + if (!bridge) return; + + // if the channel is disabled, return + if ( + bridge.channels.some( + (channel) => + channel.id === data.channel_id && + channel.plugin === data.plugin && + channel.disabled, + ) + ) return; + + // remove ourselves & disabled channels + const channels = bridge.channels.filter((channel) => + (channel.id !== data.channel_id || channel.plugin !== data.plugin) && + (!channel.disabled || !channel.data) + ); + + // if there aren't any left, return + if (channels.length < 1) return; + + const messages: bridged_message[] = []; + + for (const channel of channels) { + const prior_bridged_ids = event === 'create_message' + ? undefined + : (bridge as bridge_message).messages.find((i) => + i.channel === channel.id && i.plugin === channel.plugin + ); + + if (event !== 'create_message' && !prior_bridged_ids) continue; + + const plugin = core.get_plugin(channel.plugin)!; + + let reply_id: string | undefined; + + if ('reply_id' in data && data.reply_id) { + try { + const bridged = await bridge_data.get_message(data.reply_id); + + reply_id = bridged?.messages?.find((message) => + message.channel === channel.id && message.plugin === channel.plugin + )?.id[0]; + } catch { + reply_id = undefined; + } + } + + try { + let result_ids: string[]; + + switch (event) { + case 'create_message': + case 'edit_message': + result_ids = await plugin[event]( + { + ...(data as message), + reply_id, + channel_id: channel.id, + message_id: prior_bridged_ids?.id[0] ?? '', + }, + { + channel, + settings: bridge.settings, + edit_ids: prior_bridged_ids?.id as string[], + }, + ); + break; + case 'delete_message': + result_ids = await plugin.delete_messages( + prior_bridged_ids!.id.map((id) => ({ + ...(data as deleted_message), + message_id: id, + channel_id: channel.id, + })), + ); + } + + result_ids.forEach((id) => core.set_handled(channel.plugin, id)); + + messages.push({ + id: result_ids, + channel: channel.id, + plugin: channel.plugin, + }); + } catch (e) { + const err = new LightningError(e, { + message: `An error occurred while processing a message in the bridge`, + }); + + if (!err.disable_channel) { + try { + const result_ids = await plugin.create_message(err.msg); + result_ids.forEach((id) => core.set_handled(channel.plugin, id)); + } catch (e) { + new LightningError(e, { + message: `Failed to log error message in bridge`, + extra: { channel, original_error: err.id }, + }); + } + } else { + new LightningError( + `disabling channel ${channel.id} in bridge ${bridge.id}`, + { + extra: { original_error: err.id }, + }, + ); + + await bridge_data.edit_bridge({ + ...bridge, + channels: bridge.channels.map((ch) => + ch.id === channel.id && ch.plugin === channel.plugin + ? { ...ch, disabled: true } + : ch + ), + }); + } + } + } + + await bridge_data[event]({ + ...bridge, + id: data.message_id, + messages, + bridge_id: bridge.id, + }); +} diff --git a/packages/lightning/src/bridge/setup.ts b/packages/lightning/src/bridge/setup.ts new file mode 100644 index 00000000..eb034abd --- /dev/null +++ b/packages/lightning/src/bridge/setup.ts @@ -0,0 +1,69 @@ +import { create_database, type database_config } from '../database/mod.ts'; +import type { core } from '../core.ts'; +import { create, join, leave, status, toggle } from './commands.ts'; +import { bridge_message } from './handler.ts'; + +export async function setup_bridge(core: core, config: database_config) { + const database = await create_database(config); + + core.on( + 'create_message', + (msg) => bridge_message(core, database, 'create_message', msg), + ); + core.on( + 'edit_message', + (msg) => bridge_message(core, database, 'edit_message', msg), + ); + core.on( + 'delete_message', + (msg) => bridge_message(core, database, 'delete_message', msg), + ); + + core.set_command({ + name: 'bridge', + description: 'bridge commands', + execute: () => 'take a look at the subcommands of this command', + subcommands: [ + { + name: 'create', + description: 'create a new bridge', + arguments: [{ + name: 'name', + description: 'name of the bridge', + required: true, + }], + execute: (o) => create(database, o), + }, + { + name: 'join', + description: 'join an existing bridge', + arguments: [{ + name: 'id', + description: 'id of the bridge', + required: true, + }], + execute: (o) => join(database, o), + }, + { + name: 'leave', + description: 'leave the current bridge', + execute: (o) => leave(database, o), + }, + { + name: 'toggle', + description: 'toggle a setting on the current bridge', + arguments: [{ + name: 'setting', + description: 'setting to toggle', + required: true, + }], + execute: (o) => toggle(database, o), + }, + { + name: 'status', + description: 'get the status of the current bridge', + execute: (o) => status(database, o), + }, + ], + }); +} diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index bd416a88..b1e5e868 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -1,8 +1,10 @@ import { parseArgs } from '@std/cli/parse-args'; -import { join, toFileUrl } from '@std/path'; -import { type config, lightning } from './lightning.ts'; +import { join } from '@std/path'; +import { parse_config } from './cli_config.ts'; import { log_error } from './structures/errors.ts'; import { handle_migration } from './database/mod.ts'; +import { core } from './core.ts'; +import { setup_bridge } from './bridge/setup.ts'; const version = '0.8.0'; const _ = parseArgs(Deno.args); @@ -12,26 +14,12 @@ if (_.v || _.version) { } else if (_.h || _.help) { run_help(); } else if (_._[0] === 'run') { - if (!_.config) _.config = join(Deno.cwd(), 'config.ts'); - - const config_url = toFileUrl(_.config).toString(); - - const config = (await import(config_url)).default as config; - - if (config?.error_url) { - Deno.env.set('LIGHTNING_ERROR_WEBHOOK', config.error_url); - } - - addEventListener('error', (ev) => { - log_error(ev.error, { extra: { type: 'global error' } }); - }); - - addEventListener('unhandledrejection', (ev) => { - log_error(ev.reason, { extra: { type: 'global rejection' } }); - }); + if (!_.config) _.config = join(Deno.cwd(), 'lightning.toml'); try { - await lightning.create(config); + const config = await parse_config(_.config); + const lightning = new core(config); + setup_bridge(lightning, config.database); } catch (e) { log_error(e, { extra: { type: 'global class error' } }); } diff --git a/packages/lightning/src/cli_config.ts b/packages/lightning/src/cli_config.ts new file mode 100644 index 00000000..0e53d97f --- /dev/null +++ b/packages/lightning/src/cli_config.ts @@ -0,0 +1,63 @@ +import { + array, + literal, + number, + object, + optional, + parse as parse_schema, + record, + string, + union, + unknown, +} from '@valibot/valibot'; +import { parse as parse_toml } from '@std/toml'; +import type { database_config } from './database/mod.ts'; +import type { core_config } from './core.ts'; + +const cli_config = object({ + database: union([ + object({ + type: literal('postgres'), + config: string(), + }), + object({ + type: literal('redis'), + config: object({ + port: number(), + hostname: optional(string()), + }), + }), + ]), + error_url: optional(string()), + prefix: optional(string(), '!'), + plugins: array(object({ + plugin: string(), + config: record(string(), unknown()), + })), +}); + +export interface config extends core_config { + database: database_config; + error_url?: string; +} + +// TODO: error handle +export async function parse_config( + path: string, +): Promise { + const file = await Deno.readTextFile(path); + const raw = parse_toml(file); + const parsed = parse_schema(cli_config, raw); + const new_plugins = []; + + for (const plugin of parsed.plugins) { + new_plugins.push({ + module: await import(plugin.plugin), + config: plugin.config, + }); + } + + Deno.env.set('LIGHTNING_ERROR_WEBHOOK', parsed.error_url || ''); + + return { ...parsed, plugins: new_plugins }; +} diff --git a/packages/lightning/src/commands/bridge/_internal.ts b/packages/lightning/src/commands/bridge/_internal.ts deleted file mode 100644 index 9dd2c289..00000000 --- a/packages/lightning/src/commands/bridge/_internal.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { log_error } from '../../structures/errors.ts'; -import type { bridge_channel, command_opts } from '../../structures/mod.ts'; - -export async function bridge_add_common( - opts: command_opts, -): Promise { - const existing_bridge = await opts.bridge_data.get_bridge_by_channel( - opts.channel_id, - ); - - if (existing_bridge) { - return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.prefix}bridge leave\` or \`${opts.prefix}help\` commands.`; - } - - const plugin = opts.plugins.get(opts.plugin); - - if (!plugin) { - log_error('Internal error: platform support not found', { - extra: { plugin: opts.plugin }, - }); - } - - let bridge_data; - - try { - bridge_data = await plugin.setup_channel(opts.channel_id); - } catch (e) { - log_error(e, { - message: 'Failed to create bridge using plugin', - extra: { channel: opts.channel_id, plugin_name: opts.plugin }, - }); - } - - return { - id: opts.channel_id, - data: bridge_data, - disabled: false, - plugin: opts.plugin, - }; -} diff --git a/packages/lightning/src/commands/bridge/create.ts b/packages/lightning/src/commands/bridge/create.ts deleted file mode 100644 index c7645e6d..00000000 --- a/packages/lightning/src/commands/bridge/create.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { command_opts } from '../../structures/commands.ts'; -import { log_error } from '../../structures/errors.ts'; -import { bridge_add_common } from './_internal.ts'; - -export async function create( - opts: command_opts, -): Promise { - const result = await bridge_add_common(opts); - - if (typeof result === 'string') return result; - - const bridge_data = { - name: opts.args.name, - channels: [result], - settings: { - allow_editing: true, - allow_everyone: false, - use_rawname: false, - }, - }; - - try { - const { id } = await opts.bridge_data.create_bridge(bridge_data); - return `Bridge created successfully!\nYou can now join it using \`${opts.prefix}bridge join ${id}\`.\nKeep this ID safe, don't share it with anyone, and delete this message.`; - } catch (e) { - log_error(e, { - message: 'Failed to insert bridge into database', - extra: bridge_data, - }); - } -} diff --git a/packages/lightning/src/commands/bridge/join.ts b/packages/lightning/src/commands/bridge/join.ts deleted file mode 100644 index 1627c208..00000000 --- a/packages/lightning/src/commands/bridge/join.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { command_opts } from '../../structures/commands.ts'; -import { log_error } from '../../structures/errors.ts'; -import { bridge_add_common } from './_internal.ts'; - -export async function join( - opts: command_opts, -): Promise { - const result = await bridge_add_common(opts); - - if (typeof result === 'string') return result; - - const target_bridge = await opts.bridge_data.get_bridge_by_id( - opts.args.id, - ); - - if (!target_bridge) { - return `Bridge with id \`${opts.args.id}\` not found. Make sure you have the correct id.`; - } - - target_bridge.channels.push(result); - - try { - await opts.bridge_data.edit_bridge(target_bridge); - - return `Bridge joined successfully!`; - } catch (e) { - log_error(e, { - message: 'Failed to update bridge in database', - extra: { target_bridge }, - }); - } -} diff --git a/packages/lightning/src/commands/bridge/leave.ts b/packages/lightning/src/commands/bridge/leave.ts deleted file mode 100644 index f086a22f..00000000 --- a/packages/lightning/src/commands/bridge/leave.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { command_opts } from '../../structures/commands.ts'; -import { log_error } from '../../structures/errors.ts'; - -export async function leave(opts: command_opts): Promise { - const bridge = await opts.bridge_data.get_bridge_by_channel( - opts.channel_id, - ); - - if (!bridge) return `You are not in a bridge`; - - bridge.channels = bridge.channels.filter(( - ch, - ) => ch.id !== opts.channel_id); - - try { - await opts.bridge_data.edit_bridge( - bridge, - ); - return `Bridge left successfully`; - } catch (e) { - log_error(e, { - message: 'Failed to update bridge in database', - extra: { bridge }, - }); - } -} diff --git a/packages/lightning/src/commands/bridge/status.ts b/packages/lightning/src/commands/bridge/status.ts deleted file mode 100644 index d29ddc72..00000000 --- a/packages/lightning/src/commands/bridge/status.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { bridge_settings_list } from '../../structures/bridge.ts'; -import type { command_opts } from '../../structures/commands.ts'; - -export async function status(opts: command_opts): Promise { - const bridge = await opts.bridge_data.get_bridge_by_channel( - opts.channel_id, - ); - - if (!bridge) return `You are not in a bridge`; - - let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; - - for (const [i, value] of bridge.channels.entries()) { - str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`\n`; - } - - str += `\nSettings:\n`; - - for ( - const [key, value] of Object.entries(bridge.settings).filter(([key]) => - bridge_settings_list.includes(key) - ) - ) { - str += `- \`${key}\` ${value ? 'โœ”' : 'โŒ'}\n`; - } - - return str; -} diff --git a/packages/lightning/src/commands/bridge/toggle.ts b/packages/lightning/src/commands/bridge/toggle.ts deleted file mode 100644 index 6a73b646..00000000 --- a/packages/lightning/src/commands/bridge/toggle.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { command_opts } from '../../structures/commands.ts'; -import { log_error } from '../../structures/errors.ts'; -import { bridge_settings_list } from '../../structures/bridge.ts'; - -export async function toggle(opts: command_opts): Promise { - const bridge = await opts.bridge_data.get_bridge_by_channel( - opts.channel_id, - ); - - if (!bridge) return `You are not in a bridge`; - - if (!bridge_settings_list.includes(opts.args.setting)) { - return `That setting does not exist`; - } - - const key = opts.args.setting as keyof typeof bridge.settings; - - bridge.settings[key] = !bridge.settings[key]; - - try { - await opts.bridge_data.edit_bridge( - bridge, - ); - return `Bridge settings updated successfully`; - } catch (e) { - log_error(e, { - message: 'Failed to update bridge in database', - extra: { bridge }, - }); - } -} diff --git a/packages/lightning/src/commands/default.ts b/packages/lightning/src/commands/default.ts deleted file mode 100644 index 880508b2..00000000 --- a/packages/lightning/src/commands/default.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { command, command_opts } from '../structures/commands.ts'; -import { create } from './bridge/create.ts'; -import { join } from './bridge/join.ts'; -import { leave } from './bridge/leave.ts'; -import { status } from './bridge/status.ts'; -import { toggle } from './bridge/toggle.ts'; - -export const default_commands = new Map([ - ['help', { - name: 'help', - description: 'get help with the bot', - execute: () => - 'check out [the docs](https://williamhorning.eu.org/lightning/) for help.', - }], - ['ping', { - name: 'ping', - description: 'check if the bot is alive', - execute: ({ timestamp }: command_opts) => - `Pong! ๐Ÿ“ ${ - Temporal.Now.instant().since(timestamp).round('millisecond') - .total('milliseconds') - }ms`, - }], - ['version', { - name: 'version', - description: 'get the bots version', - execute: () => 'hello from v0.8.0!', - }], - ['bridge', { - name: 'bridge', - description: 'bridge commands', - execute: () => 'take a look at the subcommands of this command', - subcommands: [ - { - name: 'create', - description: 'create a new bridge', - arguments: [{ - name: 'name', - description: 'name of the bridge', - required: true, - }], - execute: create, - }, - { - name: 'join', - description: 'join an existing bridge', - arguments: [{ - name: 'id', - description: 'id of the bridge', - required: true, - }], - execute: join, - }, - { - name: 'leave', - description: 'leave the current bridge', - execute: leave, - }, - { - name: 'toggle', - description: 'toggle a setting on the current bridge', - arguments: [{ - name: 'setting', - description: 'setting to toggle', - required: true, - }], - execute: toggle, - }, - { - name: 'status', - description: 'get the status of the current bridge', - execute: status, - }, - ], - }], -]) as Map; diff --git a/packages/lightning/src/commands/runners.ts b/packages/lightning/src/commands/runners.ts deleted file mode 100644 index 0572aafa..00000000 --- a/packages/lightning/src/commands/runners.ts +++ /dev/null @@ -1,92 +0,0 @@ -import type { lightning } from '../lightning.ts'; -import { - type create_command, - create_message, - LightningError, - type message, -} from '../structures/mod.ts'; - -export async function execute_text_command(msg: message, lightning: lightning) { - if (!msg.content?.startsWith(lightning.config.prefix)) return; - - const [command, ...rest] = msg.content.replace(lightning.config.prefix, '') - .split(' '); - - return await run_command({ - ...msg, - command, - rest, - reply: async (message: message) => { - await lightning.plugins.get(msg.plugin)?.send_message({ - ...message, - channel_id: msg.channel_id, - reply_id: msg.message_id, - }); - }, - }, lightning); -} - -export async function run_command( - opts: create_command, - lightning: lightning, -) { - let command = lightning.commands.get(opts.command) ?? - lightning.commands.get('help')!; - - const subcommand_name = opts.subcommand ?? opts.rest?.shift(); - - if (command.subcommands && subcommand_name) { - const subcommand = command.subcommands.find((i) => - i.name === subcommand_name - ); - - if (subcommand) command = subcommand; - } - - if (!opts.args) opts.args = {}; - - for (const arg of command.arguments || []) { - if (!opts.args[arg.name]) { - opts.args[arg.name] = opts.rest?.shift(); - } - - if (!opts.args[arg.name]) { - return opts.reply( - create_message( - `Please provide the \`${arg.name}\` argument. Try using the \`${lightning.config.prefix}help\` command.`, - ), - ); - } - } - - let resp: string | LightningError; - - try { - resp = await command.execute({ - prefix: lightning.config.prefix, - ...opts, - args: opts.args as Record, - bridge_data: lightning.data, - plugins: lightning.plugins, - }); - } catch (e) { - if (e instanceof LightningError) resp = e; - else { - resp = new LightningError(e, { - message: 'An error occurred while executing the command', - extra: { command: command.name }, - }); - } - } - - try { - if (typeof resp === 'string') { - await opts.reply(create_message(resp)); - } else await opts.reply(resp.msg); - } catch (e) { - new LightningError(e, { - message: 'An error occurred while sending the command response', - extra: { command: command.name }, - }); - } -} diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts new file mode 100644 index 00000000..2dd1741c --- /dev/null +++ b/packages/lightning/src/core.ts @@ -0,0 +1,172 @@ +import { EventEmitter } from '@denosaurs/event'; +import type { + plugin, + plugin_events, + plugin_module, +} from './structures/plugins.ts'; +import { parse } from '@valibot/valibot'; +import { LightningError } from './structures/errors.ts'; +import type { + command, + command_opts, + create_command, +} from './structures/commands.ts'; +import { create_message, type message } from './structures/messages.ts'; + +export interface core_config { + prefix?: string; + plugins: { + module: plugin_module; + config: Record; + }[]; +} + +export class core extends EventEmitter { + private commands = new Map([ + ['help', { + name: 'help', + description: 'get help with the bot', + execute: () => + 'check out [the docs](https://williamhorning.eu.org/lightning/) for help.', + }], + ['ping', { + name: 'ping', + description: 'check if the bot is alive', + execute: ({ timestamp }: command_opts) => + `Pong! ๐Ÿ“ ${ + Temporal.Now.instant().since(timestamp).round('millisecond') + .total('milliseconds') + }ms`, + }], + ['version', { + name: 'version', + description: 'get the bots version', + execute: () => 'hello from v0.8.0!', + }], + ]); + private plugins = new Map(); + private handled = new Set(); + private prefix: string; + + constructor(cfg: core_config) { + super(); + this.prefix = cfg.prefix || '!'; + + for (const { module, config } of cfg.plugins) { + if (!module.default || !module.config) { + throw new Error(`one or more of you plugins isn't actually a plugin!`); + } + + const plugin_config = parse(module.config, config); + + const instance = new module.default(plugin_config); + + this.plugins.set(instance.name, instance); + this.handle_events(instance); + } + } + + set_handled(plugin: string, message_id: string): void { + this.handled.add(`${plugin}-${message_id}`); + } + + set_command(opts: command): void { + this.commands.set(opts.name, opts); + } + + get_plugin(name: string): plugin | undefined { + return this.plugins.get(name); + } + + private async handle_events(plugin: plugin): Promise { + for await (const { name, value } of plugin) { + await new Promise((res) => setTimeout(res, 150)); + + if (this.handled.has(`${value[0].plugin}-${value[0].message_id}`)) { + continue; + } + + if (name === 'create_command') { + this.handle_command(value[0] as create_command, plugin); + } + + if (name === 'create_message') { + const msg = value[0] as message; + + if (msg.content?.startsWith(this.prefix)) { + const [command, ...rest] = msg.content.replace(this.prefix, '').split( + ' ', + ); + + this.handle_command({ + ...msg, + args: {}, + command, + prefix: this.prefix, + reply: async (message: message) => { + await plugin.create_message({ + ...message, + channel_id: msg.channel_id, + reply_id: msg.message_id, + }); + }, + rest, + }, plugin); + } + } + + this.emit(name, ...value); + } + } + + private async handle_command( + opts: create_command, + plugin: plugin, + ): Promise { + let command = this.commands.get(opts.command) ?? this.commands.get('help')!; + + if (command.subcommands && opts.subcommand) { + const subcommand = command.subcommands.find((i) => + i.name === opts.subcommand + ); + + if (subcommand) command = subcommand; + } + + for (const arg of (command.arguments || [])) { + if (!opts.args[arg.name]) { + opts.args[arg.name] = opts.rest?.shift(); + } + + if (!opts.args[arg.name]) { + return opts.reply( + create_message( + `Please provide the \`${arg.name}\` argument. Try using the \`${opts.prefix}help\` command.`, + ), + ); + } + } + + let resp: string | LightningError; + + try { + resp = await command.execute({ ...opts, plugin }); + } catch (e) { + resp = new LightningError(e, { + message: 'An error occurred while executing the command', + extra: { command: command.name }, + }); + } + + try { + await opts.reply( + resp instanceof LightningError ? resp.msg : create_message(resp), + ); + } catch (e) { + new LightningError(e, { + message: 'An error occurred while sending the command response', + extra: { command: command.name }, + }); + } + } +} diff --git a/packages/lightning/src/database/mod.ts b/packages/lightning/src/database/mod.ts index 63652ec5..0a48ab22 100644 --- a/packages/lightning/src/database/mod.ts +++ b/packages/lightning/src/database/mod.ts @@ -1,6 +1,5 @@ import type { bridge, bridge_message } from '../structures/bridge.ts'; -import { mongo, type mongo_config } from './mongo.ts'; -import { postgres, type postgres_config } from './postgres.ts'; +import { postgres } from './postgres.ts'; import { redis, type redis_config } from './redis.ts'; export interface bridge_data { @@ -20,13 +19,10 @@ export interface bridge_data { export type database_config = { type: 'postgres'; - config: postgres_config; + config: string; } | { type: 'redis'; config: redis_config; -} | { - type: 'mongo'; - config: mongo_config; }; export async function create_database( @@ -37,8 +33,6 @@ export async function create_database( return await postgres.create(config.config); case 'redis': return await redis.create(config.config); - case 'mongo': - return await mongo.create(config.config); default: throw new Error('invalid database type'); } @@ -46,14 +40,12 @@ export async function create_database( function get_database( type: string, -): typeof postgres | typeof redis | typeof mongo { +): typeof postgres | typeof redis { switch (type) { case 'postgres': return postgres; case 'redis': return redis; - case 'mongo': - return mongo; default: throw new Error('invalid database type'); } @@ -61,12 +53,12 @@ function get_database( export async function handle_migration() { const start_type = prompt( - 'Please enter your starting database type (postgres, redis, mongo):', + 'Please enter your starting database type (postgres, redis):', ) ?? ''; const start = await get_database(start_type).migration_get_instance(); const end_type = prompt( - 'Please enter your ending database type (postgres, redis, mongo):', + 'Please enter your ending database type (postgres, redis):', ) ?? ''; const end = await get_database(end_type).migration_get_instance(); diff --git a/packages/lightning/src/database/mongo.ts b/packages/lightning/src/database/mongo.ts deleted file mode 100644 index b7ce9a8f..00000000 --- a/packages/lightning/src/database/mongo.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { type Collection, type ConnectOptions, MongoClient } from '@db/mongo'; -import { RedisClient } from '@iuioiua/redis'; -import { ulid } from '@std/ulid'; -import type { bridge } from '../structures/bridge.ts'; -import { log_error } from '../structures/errors.ts'; -import type { bridge_data } from './mod.ts'; -import { redis_messages } from './redis_message.ts'; - -export type mongo_config = { - database: ConnectOptions | string; - redis: Deno.ConnectOptions; -}; - -export class mongo extends redis_messages implements bridge_data { - static async create(opts: mongo_config) { - const client = new MongoClient(); - - if ( - typeof opts.database === 'string' && opts.database.includes('localhost') - ) { - console.warn( - "[lightning-mongo] if MongoDB doesn't connect, please replace localhost with 127.0.0.1 and try again", - ); - } - - await client.connect(opts.database); - - const database = client.database(); - const db_data_version = await database.collection('lightning').findOne({ - _id: 'db_data_version', - }); - const bridge_collection_exists = (await database.listCollectionNames()) - .includes('bridges'); - - if (db_data_version?.version !== '0.8.0' && bridge_collection_exists) { - log_error( - 'Please delete the bridge collection or follow the migrations process in the documentation', - { - extra: { - see: - 'https://williamhorning.eu.org/lightning/hosting/legacy-migrations', - }, - }, - ); - } else if (!db_data_version && !bridge_collection_exists) { - await database.collection('lightning').insertOne({ - _id: 'db_data_version', - version: '0.8.0', - }); - await database.createCollection('bridges'); - } - - const redis = new RedisClient(await Deno.connect(opts.redis)); - - await redis_messages.migrate(redis); - - return new this(database.collection('bridges'), redis); - } - - private constructor( - private bridges: Collection, - redis: RedisClient, - ) { - super(redis); - } - - async create_bridge(br: Omit): Promise { - const id = ulid(); - await this.bridges.insertOne({ _id: id, id, ...br }); - return { id, ...br }; - } - - async edit_bridge(br: bridge): Promise { - await this.bridges.replaceOne({ _id: br.id }, br); - } - - async get_bridge_by_id(id: string): Promise { - return await this.bridges.findOne({ _id: id }); - } - - async get_bridge_by_channel(ch: string): Promise { - return await this.bridges.findOne({ - channels: { - $all: [{ - $elemMatch: { id: ch }, - }], - }, - }); - } - - async migration_get_bridges(): Promise { - return await this.bridges.find().toArray(); - } - - async migration_set_bridges(bridges: bridge[]): Promise { - await this.bridges.insertMany(bridges.map((b) => ({ _id: b.id, ...b }))); - } - - static async migration_get_instance(): Promise { - const redis_hostname = - prompt('Please enter your Redis hostname (localhost):') || 'localhost'; - const redis_port = prompt('Please enter your Redis port (6379):') || - '6379'; - const mongo_str = prompt( - 'Please enter your MongoDB connection string (mongodb://localhost:27017):', - ) || - 'mongodb://localhost:27017'; - - const redis = new RedisClient( - await Deno.connect({ - hostname: redis_hostname, - port: parseInt(redis_port), - }), - ); - const client = new MongoClient(); - await client.connect(mongo_str); - - return new mongo(client.database('lightning').collection('bridges'), redis); - } -} diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index d3f765c4..213975c9 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -6,8 +6,8 @@ import type { bridge_data } from './mod.ts'; export type { ClientOptions as postgres_config }; export class postgres implements bridge_data { - static async create(pg_options: ClientOptions): Promise { - const pg = new Client(pg_options); + static async create(pg_url: string): Promise { + const pg = new Client(pg_url); await pg.connect(); await postgres.setup_schema(pg); @@ -36,6 +36,7 @@ export class postgres implements bridge_data { CREATE TABLE IF NOT EXISTS bridge_messages ( id TEXT PRIMARY KEY, + name TEXT NOT NULL, bridge_id TEXT NOT NULL, channels JSONB NOT NULL, messages JSONB NOT NULL, @@ -89,10 +90,10 @@ export class postgres implements bridge_data { async create_message(msg: bridge_message): Promise { await this.pg.queryArray`INSERT INTO bridge_messages - (id, bridge_id, channels, messages, settings) VALUES - (${msg.id}, ${msg.bridge_id}, ${JSON.stringify(msg.channels)}, ${ - JSON.stringify(msg.messages) - }, ${JSON.stringify(msg.settings)})`; + (id, name, bridge_id, channels, messages, settings) VALUES + (${msg.id}, ${msg.name}, ${msg.bridge_id}, ${ + JSON.stringify(msg.channels) + }, ${JSON.stringify(msg.messages)}, ${JSON.stringify(msg.settings)})`; } async edit_message(msg: bridge_message): Promise { @@ -111,7 +112,6 @@ export class postgres implements bridge_data { `; } - // FIXME(jersey): this is horendously wrong somewhere async get_message(id: string): Promise { const res = await this.pg.queryObject(` SELECT * FROM bridge_messages @@ -163,23 +163,11 @@ export class postgres implements bridge_data { } static async migration_get_instance(): Promise { - const pg_user = prompt('Please enter your Postgres username (server):') || - 'server'; - const pg_password = - prompt('Please enter your Postgres password (password):') || 'password'; - const pg_host = prompt('Please enter your Postgres host (localhost):') || - 'localhost'; - const pg_port = prompt('Please enter your Postgres port (5432):') || - '5432'; - const pg_db = prompt('Please enter your Postgres database (lightning):') || - 'lightning'; - - return await postgres.create({ - user: pg_user, - password: pg_password, - hostname: pg_host, - port: parseInt(pg_port), - database: pg_db, - }); + const pg_url = prompt( + 'Please enter your Postgres connection string (postgres://localhost):', + ) || + 'postgres://localhost'; + + return await postgres.create(pg_url); } } diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index ad0e6e0e..caa686b5 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -1,23 +1,112 @@ import { RedisClient } from '@iuioiua/redis'; import { ulid } from '@std/ulid'; -import type { bridge } from '../structures/bridge.ts'; +import type { bridge, bridge_message } from '../structures/bridge.ts'; import type { bridge_data } from './mod.ts'; -import { redis_messages } from './redis_message.ts'; +import { log_error } from '../structures/errors.ts'; export type redis_config = Deno.ConnectOptions; -export class redis extends redis_messages implements bridge_data { +export class redis implements bridge_data { static async create(rd_options: Deno.ConnectOptions): Promise { const conn = await Deno.connect(rd_options); const client = new RedisClient(conn); - await redis_messages.migrate(client); + await this.migrate(client); return new this(client); } - private constructor(redis: RedisClient) { - super(redis); + static async migrate(rd: RedisClient): Promise { + let db_data_version = await rd.sendCommand([ + 'GET', + 'lightning-db-version', + ]); + + if (db_data_version === null) { + const number_keys = await rd.sendCommand(['DBSIZE']) as number; + + if (number_keys === 0) db_data_version = '0.8.0'; + } + + if (db_data_version !== '0.8.0') { + console.warn( + `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, + ); + + console.log('[lightning-redis] getting keys'); + + const all_keys = await rd.sendCommand([ + 'KEYS', + 'lightning-*', + ]) as string[]; + + console.log('[lightning-redis] got keys'); + + const new_data = await Promise.all(all_keys.map(async (key: string) => { + console.log(`[lightning-redis] migrating key ${key}`); + const type = await rd.sendCommand(['TYPE', key]) as string; + const value = await rd.sendCommand([ + type === 'string' ? 'GET' : 'JSON.GET', + key, + ]) as string; + + try { + const parsed = JSON.parse(value); + return [ + key, + JSON.stringify( + { + id: key.split('-')[2], + bridge_id: parsed.id, + channels: parsed.channels, + messages: parsed.messages, + name: parsed.id, + settings: { + allow_everyone: false, + }, + } as bridge | bridge_message, + ), + ]; + } catch { + return [key, value]; + } + })); + + Deno.writeTextFileSync( + 'lightning-redis-migration.json', + JSON.stringify(new_data, null, 2), + ); + + console.warn('[lightning-redis] do you want to continue?'); + + const write = confirm('write the data to the database?'); + const env_confirm = Deno.env.get('LIGHTNING_MIGRATE_CONFIRM'); + + if (write || env_confirm === 'true') { + await rd.sendCommand(['DEL', ...all_keys]); + await rd.sendCommand([ + 'MSET', + 'lightning-db-version', + '0.8.0', + ...new_data.flat(1), + ]); + + console.warn('[lightning-redis] data written to database'); + } else { + console.warn('[lightning-redis] data not written to database'); + log_error('migration cancelled'); + } + } + } + + private constructor(public redis: RedisClient) { + this.redis = redis; + } + + async get_json(key: string): Promise { + const reply = await this.redis.sendCommand(['GET', key]); + if (!reply || reply === 'OK') return; + return JSON.parse(reply as string) as T; } async create_bridge(br: Omit): Promise { @@ -66,6 +155,36 @@ export class redis extends redis_messages implements bridge_data { return await this.get_json(`lightning-bridge-${channel}`); } + async create_message(msg: bridge_message): Promise { + await this.redis.sendCommand([ + 'SET', + 'lightning-message-${msg.id}', + JSON.stringify(msg), + ]); + + for (const message of msg.messages) { + await this.redis.sendCommand([ + 'SET', + `lightning-message-${message.id}`, + JSON.stringify(msg), + ]); + } + } + + async edit_message(msg: bridge_message): Promise { + await this.create_message(msg); + } + + async delete_message(msg: bridge_message): Promise { + await this.redis.sendCommand(['DEL', `lightning-message-${msg.id}`]); + } + + async get_message(id: string): Promise { + return await this.get_json( + `lightning-message-${id}`, + ); + } + async migration_get_bridges(): Promise { const keys = await this.redis.sendCommand([ 'KEYS', @@ -103,6 +222,30 @@ export class redis extends redis_messages implements bridge_data { } } + async migration_get_messages(): Promise { + const keys = await this.redis.sendCommand([ + 'KEYS', + 'lightning-message-*', + ]) as string[]; + + const messages = [] as bridge_message[]; + + for (const key of keys) { + const message = await this.get_json(key); + if (message) messages.push(message); + } + + return messages; + } + + async migration_set_messages(messages: bridge_message[]): Promise { + for (const message of messages) { + await this.create_message(message); + } + + await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); + } + static async migration_get_instance(): Promise { const hostname = prompt('Please enter your Redis hostname (localhost):') || 'localhost'; diff --git a/packages/lightning/src/database/redis_message.ts b/packages/lightning/src/database/redis_message.ts deleted file mode 100644 index b08718f7..00000000 --- a/packages/lightning/src/database/redis_message.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { RedisClient } from '@iuioiua/redis'; -import type { bridge, bridge_message } from '../structures/bridge.ts'; -import { log_error } from '../structures/errors.ts'; - -export class redis_messages { - static async migrate(rd: RedisClient): Promise { - let db_data_version = await rd.sendCommand([ - 'GET', - 'lightning-db-version', - ]); - - if (db_data_version === null) { - const number_keys = await rd.sendCommand(['DBSIZE']) as number; - - if (number_keys === 0) db_data_version = '0.8.0'; - } - - if (db_data_version !== '0.8.0') { - console.warn( - `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, - ); - - console.log('[lightning-redis] getting keys'); - - const all_keys = await rd.sendCommand([ - 'KEYS', - 'lightning-*', - ]) as string[]; - - console.log('[lightning-redis] got keys'); - - const new_data = await Promise.all(all_keys.map(async (key: string) => { - console.log(`[lightning-redis] migrating key ${key}`); - const type = await rd.sendCommand(['TYPE', key]) as string; - const value = await rd.sendCommand([ - type === 'string' ? 'GET' : 'JSON.GET', - key, - ]) as string; - - try { - const parsed = JSON.parse(value); - return [ - key, - JSON.stringify( - { - id: key.split('-')[2], - bridge_id: parsed.id, - channels: parsed.channels, - messages: parsed.messages, - name: parsed.id, - settings: { - allow_everyone: false, - }, - } as bridge | bridge_message, - ), - ]; - } catch { - return [key, value]; - } - })); - - Deno.writeTextFileSync( - 'lightning-redis-migration.json', - JSON.stringify(new_data, null, 2), - ); - - console.warn('[lightning-redis] do you want to continue?'); - - const write = confirm('write the data to the database?'); - const env_confirm = Deno.env.get('LIGHTNING_MIGRATE_CONFIRM'); - - if (write || env_confirm === 'true') { - await rd.sendCommand(['DEL', ...all_keys]); - await rd.sendCommand([ - 'MSET', - 'lightning-db-version', - '0.8.0', - ...new_data.flat(1), - ]); - - console.warn('[lightning-redis] data written to database'); - } else { - console.warn('[lightning-redis] data not written to database'); - log_error('migration cancelled'); - } - } - } - - constructor(public redis: RedisClient) {} - - async get_json(key: string): Promise { - const reply = await this.redis.sendCommand(['GET', key]); - if (!reply || reply === 'OK') return; - return JSON.parse(reply as string) as T; - } - - async create_message(msg: bridge_message): Promise { - await this.redis.sendCommand([ - 'SET', - 'lightning-message-${msg.id}', - JSON.stringify(msg), - ]); - - for (const message of msg.messages) { - await this.redis.sendCommand([ - 'SET', - `lightning-message-${message.id}`, - JSON.stringify(msg), - ]); - } - } - - async edit_message(msg: bridge_message): Promise { - await this.create_message(msg); - } - - async delete_message(msg: bridge_message): Promise { - await this.redis.sendCommand(['DEL', `lightning-message-${msg.id}`]); - } - - async get_message(id: string): Promise { - return await this.get_json( - `lightning-message-${id}`, - ); - } - - async migration_get_messages(): Promise { - const keys = await this.redis.sendCommand([ - 'KEYS', - 'lightning-message-*', - ]) as string[]; - - const messages = [] as bridge_message[]; - - for (const key of keys) { - const message = await this.get_json(key); - if (message) messages.push(message); - } - - return messages; - } - - async migration_set_messages(messages: bridge_message[]): Promise { - for (const message of messages) { - await this.create_message(message); - } - - await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); - } -} diff --git a/packages/lightning/src/lightning.ts b/packages/lightning/src/lightning.ts deleted file mode 100644 index 83fa12cf..00000000 --- a/packages/lightning/src/lightning.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { bridge_message } from './bridge.ts'; -import { default_commands } from './commands/default.ts'; -import { execute_text_command, run_command } from './commands/runners.ts'; -import { - type bridge_data, - create_database, - type database_config, -} from './database/mod.ts'; -import type { - command, - create_command, - message, - plugin, -} from './structures/mod.ts'; - -/** configuration options for lightning */ -export interface config { - /** error URL */ - error_url?: string; - /** database options */ - database: database_config; - /** a list of plugins */ - plugins?: plugin[]; - /** the prefix used for commands */ - prefix: string; -} - -/** an instance of lightning */ -export class lightning { - /** bridge data handling */ - data: bridge_data; - /** the commands registered */ - commands: Map = default_commands; - /** the config used */ - config: config; - /** the plugins loaded */ - plugins: Map>; - - /** setup an instance with the given config and bridge data */ - constructor(bridge_data: bridge_data, config: config) { - this.data = bridge_data; - this.config = config; - this.plugins = new Map>(); - - for (const plugin of this.config.plugins || []) { - if (plugin.support.includes('0.8.0-alpha.1')) { - this.plugins.set(plugin.name, plugin); - if (plugin.set_commands) { - plugin.set_commands(this.commands.values().toArray()); - } - this.handle_events(plugin); - } - } - } - - /** event handler */ - private async handle_events(plugin: plugin) { - for await (const { name, value } of plugin) { - await new Promise((res) => setTimeout(res, 150)); - - if (sessionStorage.getItem(`${value[0].plugin}-${value[0].message_id}`)) { - continue; - } - - switch (name) { - case 'create_command': - run_command(value[0] as create_command, this); - break; - case 'create_message': - execute_text_command(value[0] as message, this); - bridge_message(this, name, value[0]); - break; - case 'edit_message': - bridge_message(this, name, value[0]); - break; - case 'delete_message': - bridge_message(this, name, value[0]); - break; - } - } - } - - /** create a new instance of lightning */ - static async create(config: config): Promise { - const data = await create_database(config.database); - - return new lightning(data, config); - } -} diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index e95da6ec..e29ef416 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -2,5 +2,4 @@ if (import.meta.main) { await import('./cli.ts'); } -export * from './lightning.ts'; export * from './structures/mod.ts'; diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index a50193c0..4a508158 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -1,6 +1,6 @@ /** representation of a bridge */ export interface bridge { - /** ulid secret used as primary key */ + /** primary key */ id: string; /** user-facing name of the bridge */ name: string; @@ -34,17 +34,11 @@ export const bridge_settings_list = [ ]; /** representation of a bridged message collection */ -export interface bridge_message { - /** original message id */ - id: string; +export interface bridge_message extends bridge { /** original bridge id */ bridge_id: string; - /** channels in the bridge */ - channels: bridge_channel[]; /** messages bridged */ messages: bridged_message[]; - /** settings for the bridge */ - settings: bridge_settings; } /** representation of an individual bridged message */ diff --git a/packages/lightning/src/structures/commands.ts b/packages/lightning/src/structures/commands.ts index b2aaa185..d5e48817 100644 --- a/packages/lightning/src/structures/commands.ts +++ b/packages/lightning/src/structures/commands.ts @@ -1,6 +1,5 @@ -import type { bridge_data } from '../database/mod.ts'; -import type { plugin } from './plugins.ts'; import type { message } from './messages.ts'; +import type { plugin } from './plugins.ts'; /** representation of a command */ export interface command { @@ -28,39 +27,32 @@ export interface command_argument { required: boolean; } -/** options passed to command#execute */ +/** options given to a command */ export interface command_opts { + /** arguments to use */ + args: Record; /** the channel the command was run in */ channel_id: string; /** the plugin the command was run with */ - plugin: string; - /** the time the command was sent */ - timestamp: Temporal.Instant; - /** arguments for the command */ - args: Record; + plugin: plugin; /** the command prefix used */ prefix: string; - /** bridge data (for bridge commands) */ - bridge_data: bridge_data; - /** plugin data */ - plugins: Map>; + /** the time the command was sent */ + timestamp: Temporal.Instant; } -/** command execution event */ -export interface create_command - extends Pick { +/** options used for a command event */ +export interface create_command extends Omit { /** the command to run */ command: string; - /** the subcommand, if any, to use */ - subcommand?: string; - /** arguments, if any, to use */ - args?: Record; - /** the command prefix used */ - prefix?: string; - /** extra string options */ + /** id of the associated event */ + message_id: string; + /** the plugin id used to run this with */ + plugin: string; + /** other, additional, options */ rest?: string[]; /** event reply function */ reply: (message: message) => Promise; - /** id of the associated event */ - message_id: string; + /** the subcommand, if any, to use */ + subcommand?: string; } diff --git a/packages/lightning/src/structures/messages.ts b/packages/lightning/src/structures/messages.ts index 594f8a6d..82b64dfd 100644 --- a/packages/lightning/src/structures/messages.ts +++ b/packages/lightning/src/structures/messages.ts @@ -6,7 +6,8 @@ export function create_message(text: string): message { return { author: { username: 'lightning', - profile: 'https://williamhorning.eu.org/assets/lightning.png', + profile: + 'https://williamhorning.eu.org/assets/lightning/logo_monocolor_dark.svg', rawname: 'lightning', id: 'lightning', }, diff --git a/packages/lightning/src/structures/plugins.ts b/packages/lightning/src/structures/plugins.ts index 56ce878b..924afdae 100644 --- a/packages/lightning/src/structures/plugins.ts +++ b/packages/lightning/src/structures/plugins.ts @@ -1,7 +1,7 @@ import { EventEmitter } from '@denosaurs/event'; import type { bridge_message_opts } from './bridge.ts'; -import type { deleted_message, message } from './messages.ts'; import type { command, create_command } from './commands.ts'; +import type { deleted_message, message } from './messages.ts'; /** the events emitted by a plugin */ export type plugin_events = { @@ -16,28 +16,19 @@ export type plugin_events = { }; /** a plugin for lightning */ -export interface plugin { +export interface plugin { /** set commands on the platform, if available */ set_commands?(commands: command[]): Promise | void; } /** a plugin for lightning */ -export abstract class plugin extends EventEmitter { - /** access the config passed to you by lightning */ - config: cfg; +export abstract class plugin extends EventEmitter { /** the name of your plugin */ abstract name: string; - /** the versions supported by your plugin */ - abstract support: string[]; - /** initialize a plugin with the given lightning instance and config */ - constructor(config: cfg) { - super(); - this.config = config; - } /** setup a channel to be used in a bridge */ abstract setup_channel(channel: string): Promise | unknown; /** send a message to a given channel */ - abstract send_message( + abstract create_message( message: message, opts?: bridge_message_opts, ): Promise; @@ -51,3 +42,13 @@ export abstract class plugin extends EventEmitter { messages: deleted_message[], ): Promise; } + +/** the type core uses to load a module */ +export interface plugin_module { + /** the plugin constructor */ + // deno-lint-ignore no-explicit-any + default?: { new (cfg: any): plugin }; + /** the config to validate use */ + // deno-lint-ignore no-explicit-any + config?: any; +} diff --git a/packages/revolt/README.md b/packages/revolt/README.md index 3e422d91..181532a2 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -6,15 +6,13 @@ telegram ## example config -```ts -import type { config } from 'jsr:@jersey/lightning@0.7.4'; -import { revolt_plugin } from 'jsr:@jersey/lightning-plugin-revolt@0.7.4'; +```toml +# lightning.toml +# ... -export default { - plugins: [ - revolt_plugin.new({ - token: 'your_token', - }), - ], -} as config; +[[plugins]] +plugin = "jsr:@jersey/lightning-plugin-revolt@0.8.0" +config = { token = "YOUR_REVOLT_TOKEN", user_id = "YOUR_BOT_USER_ID" } + +# ... ``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index a462bb0c..d72fd8ec 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -7,6 +7,7 @@ "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.7", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.3", - "@std/ulid": "jsr:@std/ulid@^1.0.0" + "@std/ulid": "jsr:@std/ulid@^1.0.0", + "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" } } diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 55797223..1511ec76 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -11,20 +11,25 @@ import { type message, plugin, } from '@jersey/lightning'; +import { type InferOutput, object, string } from '@valibot/valibot'; -export interface RevoltOptions { - token: string; - user_id: string; -} +/** Options for the Revolt plugin */ +export const config = object({ + /** The token to use for the bot plugin */ + token: string(), + /** The bot's user ID */ + user_id: string(), +}); -export default class RevoltPlugin extends plugin { +export default class RevoltPlugin extends plugin { name = 'bolt-revolt'; - support = ['0.8.0-alpha.1']; private client: Client; + private user_id: string; - constructor(opts: RevoltOptions) { - super(opts); + constructor(opts: InferOutput) { + super(); this.client = createClient({ token: opts.token }); + this.user_id = opts.user_id; this.setupEvents(); } @@ -57,18 +62,18 @@ export default class RevoltPlugin extends plugin { }).on('Ready', (data) => { console.log( `[revolt] ready as ${ - data.users.find((i) => i._id === this.config.user_id)?.username - } in ${data.servers.length}`, - `[revolt] invite me at https://app.revolt.chat/bot/${this.config.user_id}` + data.users.find((i) => i._id === this.user_id)?.username + } in ${data.servers.length} servers`, + `\n[revolt] invite me at https://app.revolt.chat/bot/${this.user_id}`, ); }); } async setup_channel(channelID: string): Promise { - return await check_permissions(channelID, this.config.user_id, this.client); + return await check_permissions(channelID, this.user_id, this.client); } - async send_message( + async create_message( message: message, data?: bridge_message_opts, ): Promise { diff --git a/packages/telegram/README.md b/packages/telegram/README.md index db2c1399..b81f79fc 100644 --- a/packages/telegram/README.md +++ b/packages/telegram/README.md @@ -6,17 +6,13 @@ telegram (including attachments via the included file proxy) ## example config -```ts -import type { config } from 'jsr:@jersey/lightning@0.8.0'; -import { telegram_plugin } from 'jsr:@jersey/lightning-plugin-telegram@0.8.0'; +```toml +# lightning.toml +# ... -export default { - plugins: [ - telegram_plugin.new({ - bot_token: 'your_token', - plugin_port: 8080, - plugin_url: 'https://your.domain/telegram/', - }), - ], -} as config; +[[plugins]] +plugin = "jsr:@jersey/lightning-plugin-telegram@0.8.0" +config = { token = "YOUR_TELEGRAM_TOKEN", proxy_port = 9090, proxy_url = "http://localhost:9090" } + +# ... ``` diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index dbca0b46..1696bf85 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -6,6 +6,7 @@ "imports": { "@jersey/lightning": "jsr:@jersey/lightning@0.8.0", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0", - "grammy": "npm:grammy@^1.35.1" + "grammy": "npm:grammy@^1.35.1", + "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" } } diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index c730026a..3bd68697 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -7,43 +7,43 @@ import { import { Bot } from 'grammy'; import { getIncomingMessage } from './incoming.ts'; import { getOutgoingMessage } from './outgoing.ts'; +import { type InferOutput, number, object, string } from '@valibot/valibot'; -/** options for the telegram plugin */ -export interface telegram_config { - /** the token for the bot */ - token: string; - /** the port the plugins proxy will run on */ - proxy_port: number; - /** the publically accessible url of the plugin */ - proxy_url: string; -} +/** Options for the Revolt plugin */ +export const config = object({ + /** The token to use for the bot */ + token: string(), + /** The port the file proxy should run on */ + proxy_port: number(), + /** The publically accessible url of the plugin */ + proxy_url: string(), +}); -export default class TelegramPlugin extends plugin { +export default class TelegramPlugin extends plugin { name = 'bolt-telegram'; - support = ['0.8.0-alpha.1']; private bot: Bot; - constructor(opts: telegram_config) { - super(opts); + constructor(opts: InferOutput) { + super(); this.bot = new Bot(opts.token); this.bot.start(); this.bot.on(['message', 'edited_message'], async (ctx) => { - const msg = await getIncomingMessage(ctx, this.config.proxy_url); + const msg = await getIncomingMessage(ctx, opts.proxy_url); if (msg) this.emit('create_message', msg); }); Deno.serve({ - port: this.config.proxy_port, + port: opts.proxy_port, onListen: ({ port }) => { console.log( - `[telegram] proxy available at localhost:${port} or ${this.config.proxy_url}`, + `[telegram] proxy available at localhost:${port} or ${opts.proxy_url}`, ); }, }, (req: Request) => { const { pathname } = new URL(req.url); return fetch( - `https://api.telegram.org/file/bot${this.config.token}/${ + `https://api.telegram.org/file/bot${opts.token}/${ pathname.replace('/telegram/', '') }`, ); @@ -54,7 +54,7 @@ export default class TelegramPlugin extends plugin { return channel; } - async send_message( + async create_message( message: message, data?: bridge_message_opts, ): Promise { diff --git a/todo.md b/todo.md index acc0fdc3..0bc7e23d 100644 --- a/todo.md +++ b/todo.md @@ -1,4 +1,2 @@ -# todos - -- plugin configuration shouldn't be code, use something like toml and have valibot validate it -- make things closer to ~/projects/lightning/arch.tldr \ No newline at end of file +- potentially replace valibot with our own validator +- sort all imports \ No newline at end of file From f262e59589b9b553c83aa759e4a129a0100c8bc0 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 12 Apr 2025 23:17:13 -0400 Subject: [PATCH 54/97] overall cleanup and prep for 0.8.0 --- .github/workflows/publish.yml | 67 ++++----- .gitignore | 2 - containerfile | 25 ++++ packages/discord/README.md | 2 +- packages/discord/deno.json | 6 +- packages/discord/src/errors.ts | 4 +- packages/discord/src/incoming.ts | 58 ++++---- packages/discord/src/mod.ts | 101 +++++++------ packages/discord/src/outgoing.ts | 38 +++-- packages/guilded/README.md | 2 +- packages/guilded/deno.json | 5 +- packages/guilded/src/incoming.ts | 12 +- packages/guilded/src/mod.ts | 85 +++++------ packages/guilded/src/outgoing.ts | 34 ++--- packages/lightning/deno.json | 12 ++ packages/lightning/deno.jsonc | 18 --- packages/lightning/dockerfile | 10 -- packages/lightning/src/bridge/commands.ts | 4 +- packages/lightning/src/cli.ts | 47 +++--- packages/lightning/src/cli_config.ts | 143 ++++++++++++------- packages/lightning/src/core.ts | 24 ++-- packages/lightning/src/database/postgres.ts | 3 +- packages/lightning/src/database/redis.ts | 3 +- packages/lightning/src/mod.ts | 2 +- packages/lightning/src/structures/bridge.ts | 2 +- packages/lightning/src/structures/errors.ts | 32 +++-- packages/lightning/src/structures/plugins.ts | 13 +- packages/revolt/README.md | 3 +- packages/revolt/deno.json | 5 +- packages/revolt/src/cache.ts | 68 ++++----- packages/revolt/src/errors.ts | 6 +- packages/revolt/src/incoming.ts | 10 +- packages/revolt/src/mod.ts | 123 +++++++++------- packages/revolt/src/outgoing.ts | 10 +- packages/revolt/src/permissions.ts | 21 +-- packages/telegram/README.md | 4 +- packages/telegram/deno.json | 7 +- packages/telegram/src/incoming.ts | 11 +- packages/telegram/src/mod.ts | 90 ++++++++---- packages/telegram/src/outgoing.ts | 4 +- todo.md | 2 - 41 files changed, 616 insertions(+), 502 deletions(-) create mode 100644 containerfile create mode 100644 packages/lightning/deno.json delete mode 100644 packages/lightning/deno.jsonc delete mode 100644 packages/lightning/dockerfile delete mode 100644 todo.md diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 839dfcee..a7e2b7fa 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,48 +6,51 @@ on: types: [published] permissions: + contents: read packages: write + id-token: write jobs: - publish: + publish_jsr: + name: publish to jsr runs-on: ubuntu-latest - permissions: - contents: read - id-token: write # auth w/JSR steps: - name: checkout uses: actions/checkout@v4 - name: setup deno uses: denoland/setup-deno@v1 with: - deno-version: v2.2.3 - - name: setup qemu - uses: docker/setup-qemu-action@v3 - - name: setup buildx - uses: docker/setup-buildx-action@v3 - - name: login to docker hub - uses: docker/login-action@v3 - with: - username: williamfromnj - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: go to the directory - run: cd packages/lightning + deno-version: v2.2.9 - name: publish to jsr - run: | - deno publish - cd packages/lightning - - name: setup docker metadata - id: metadata - uses: docker/metadata-action@v5 + run: deno publish + publish_docker: + name: publish to ghcr + strategy: + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm] + runs-on: ${{ matrix.os }} + needs: publish_jsr + steps: + - name: checkout + uses: actions/checkout@v4 + - name: login to ghcr + uses: redhat-actions/podman-login@v1 + with: + registry: ghcr.io + username: ${{github.actor}} + password: ${{secrets.GITHUB_TOKEN}} + - name: build image with podman + id: build-image + uses: redhat-actions/buildah-build@v2 with: - images: williamfromnj/bolt - tags: type=ref,event=tag - - name: build and push - uses: docker/build-push-action@v6 + image: ghcr.io/williamhorning/lightning + tags: latest ${{github.ref_name}} + containerfiles: ./containerfile + - name: push to ghcr.io + uses: redhat-actions/push-to-registry@v2 with: - context: . - file: ./packages/lightning/Dockerfile - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} + image: ${{ steps.build-image.outputs.image }} + tags: ${{ steps.build-image.outputs.tags }} + registry: ghcr.io + username: ${{github.actor}} + password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 0a4c1252..84d2fc03 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,2 @@ /.env -/config -/config.ts /lightning.toml \ No newline at end of file diff --git a/containerfile b/containerfile new file mode 100644 index 00000000..32da3b71 --- /dev/null +++ b/containerfile @@ -0,0 +1,25 @@ +FROM denoland/deno:alpine-2.2.9@sha256:40a7d91338f861e308c3dfe1346f5487d83097cdb898a2e125c05df3cd4841a8 + +# metadata +LABEL org.opencontainers.image.authors Jersey +LABEL org.opencontainers.image.url https://github.com/williamhorning/bolt +LABEL org.opencontainers.image.source https://github.com/williamhorning/bolt +LABEL org.opencontainers.image.documentation https://williamhorning.eu.org/lightning +LABEL org.opencontainers.image.version 0.8.0 +LABEL org.opencontainers.image.licenses MIT +LABEL org.opencontainers.image.title lightning + +# install lightning +RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@jersey/test@0.9.0"] + +# run as nobody instead of root +USER nobody + +# the volume containing your lightning.toml file +VOLUME [ "/data" ] + +# this is the lightning command line +ENTRYPOINT [ "lightning" ] + +# run the bot using the user-provided lightning.toml file +CMD [ "run", "/data/lightning.toml" ] diff --git a/packages/discord/README.md b/packages/discord/README.md index e5beefcd..8b6d278b 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -12,7 +12,7 @@ discord [[plugins]] plugin = "jsr:@jersey/lightning-plugin-discord@0.8.0" -config = { token = "YOUR_DISCORD_TOKEN" } +config.token = "YOUR_DISCORD_TOKEN" # ... ``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json index 4e9889ca..7e93a22f 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,14 +1,12 @@ { "name": "@jersey/lightning-plugin-discord", - "version": "0.8.0-alpha.1", + "version": "0.8.0", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "@discordjs/core": "npm:@discordjs/core@^2.0.1", "@discordjs/rest": "npm:@discordjs/rest@^2.4.3", "@discordjs/ws": "npm:@discordjs/ws@^2.0.1", - "discord-api-types": "npm:discord-api-types@0.37.119/v10", - "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" + "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0" } } diff --git a/packages/discord/src/errors.ts b/packages/discord/src/errors.ts index 52661c7c..e804ca89 100644 --- a/packages/discord/src/errors.ts +++ b/packages/discord/src/errors.ts @@ -5,7 +5,7 @@ export function handle_error( err: unknown, channel: string, edit?: boolean, -): never[] { +) { if (err instanceof DiscordAPIError) { if (err.code === 30007 || err.code === 30058) { log_error(err, { @@ -33,7 +33,7 @@ export function handle_error( } } else { log_error(err, { - message: `unknown discord plugin error`, + message: `unknown discord error`, extra: { channel }, }); } diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts index 6dbdce3d..329a4a9c 100644 --- a/packages/discord/src/incoming.ts +++ b/packages/discord/src/incoming.ts @@ -1,26 +1,20 @@ -import { - type GatewayMessageDeleteDispatchData, - type GatewayMessageUpdateDispatchData, - MessageFlags, - MessageReferenceType, - MessageType, -} from 'discord-api-types'; -import type { - attachment, - create_command, - deleted_message, - message, -} from '@jersey/lightning'; import type { API, APIInteraction, APIStickerItem, + GatewayMessageDeleteDispatchData, + GatewayMessageUpdateDispatchData, ToEventProps, } from '@discordjs/core'; -import { calculateUserDefaultAvatarIndex } from '@discordjs/rest'; -import { getOutgoingMessage } from './outgoing.ts'; +import type { + attachment, + create_command, + deleted_message, + message, +} from '@jersey/lightning'; +import { get_outgoing_message } from './outgoing.ts'; -export function getDeletedMessage( +export function get_deleted_message( data: GatewayMessageDeleteDispatchData, ): deleted_message { return { @@ -31,11 +25,11 @@ export function getDeletedMessage( }; } -async function fetchAuthor(api: API, data: GatewayMessageUpdateDispatchData) { +async function fetch_author(api: API, data: GatewayMessageUpdateDispatchData) { let profile = data.author.avatar !== null ? `https://cdn.discordapp.com/avatars/${data.author.id}/${data.author.avatar}.png` : `https://cdn.discordapp.com/embed/avatars/${ - calculateUserDefaultAvatarIndex(data.author.id) + Number(BigInt(data.author.id) >> 22n) % 6 }.png`; let username = data.author.global_name || data.author.username; @@ -61,7 +55,7 @@ async function fetchAuthor(api: API, data: GatewayMessageUpdateDispatchData) { return { profile, username }; } -async function fetchStickers( +async function fetch_stickers( stickers: APIStickerItem[], ): Promise { return (await Promise.allSettled(stickers.map(async (sticker) => { @@ -86,16 +80,16 @@ async function fetchStickers( }))).flatMap((i) => i.status === 'fulfilled' ? i.value : []); } -export async function getIncomingMessage( +export async function get_incoming_message( { api, data }: { api: API; data: GatewayMessageUpdateDispatchData }, ): Promise { // normal messages, replies, and user joins if ( - data.type !== MessageType.Default && - data.type !== MessageType.Reply && - data.type !== MessageType.UserJoin && - data.type !== MessageType.ChatInputCommand && - data.type !== MessageType.ContextMenuCommand + data.type !== 0 && + data.type !== 7 && + data.type !== 19 && + data.type !== 20 && + data.type !== 23 ) { return; } @@ -112,18 +106,18 @@ export async function getIncomingMessage( }; }, ), - ...data.sticker_items ? await fetchStickers(data.sticker_items) : [], + ...data.sticker_items ? await fetch_stickers(data.sticker_items) : [], ], author: { rawname: data.author.username, id: data.author.id, color: '#5865F2', - ...await fetchAuthor(api, data), + ...await fetch_author(api, data), }, channel_id: data.channel_id, - content: data.type === MessageType.UserJoin + content: data.type === 7 ? '*joined on discord*' - : (data.flags || 0) & MessageFlags.Loading + : (data.flags || 0) & 128 ? '*loading...*' : data.content, embeds: data.embeds.map((i) => ({ @@ -134,7 +128,7 @@ export async function getIncomingMessage( message_id: data.id, plugin: 'bolt-discord', reply_id: data.message_reference && - data.message_reference.type === MessageReferenceType.Default + data.message_reference.type === 0 ? data.message_reference.message_id : undefined, timestamp: Temporal.Instant.fromEpochMilliseconds( @@ -145,7 +139,7 @@ export async function getIncomingMessage( return message; } -export function getIncomingCommand( +export function get_incoming_command( interaction: ToEventProps, ): create_command | undefined { if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; @@ -177,7 +171,7 @@ export function getIncomingCommand( await interaction.api.interactions.reply( interaction.data.id, interaction.data.token, - await getOutgoingMessage(msg, interaction.api, false, false), + await get_outgoing_message(msg, interaction.api, false, false), ), subcommand, timestamp: Temporal.Instant.fromEpochMilliseconds( diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 9cac921d..1ca422de 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -1,40 +1,51 @@ +import { Client, GatewayDispatchEvents } from '@discordjs/core'; import { REST, type RESTOptions } from '@discordjs/rest'; import { WebSocketManager } from '@discordjs/ws'; -import { Client } from '@discordjs/core'; -import { GatewayDispatchEvents } from 'discord-api-types'; -import { - getDeletedMessage, - getIncomingCommand, - getIncomingMessage, -} from './incoming.ts'; -import { handle_error } from './errors.ts'; -import { getOutgoingMessage } from './outgoing.ts'; import { type bridge_message_opts, type command, type deleted_message, + log_error, type message, plugin, } from '@jersey/lightning'; import { setup_commands } from './commands.ts'; -import { type InferOutput, object, string } from '@valibot/valibot'; - -/** Options for the Discord plugin */ -export const config = object({ - /** The token to use for the bot */ - token: string(), -}); +import { handle_error } from './errors.ts'; +import { + get_deleted_message, + get_incoming_command, + get_incoming_message, +} from './incoming.ts'; +import { get_outgoing_message } from './outgoing.ts'; + +/** options for the discord bot */ +export type discord_config = { + /** the token for your bot */ + token: string; +}; + +/** check if something is actually a config object, return if it is */ +export function parse_config(v: unknown): discord_config { + if (typeof v !== 'object' || v === null) { + log_error("discord config isn't an object!", { without_cause: true }); + } + if (!('token' in v) || typeof v.token !== 'string') { + log_error("discord token isn't a string", { without_cause: true }); + } + return { token: v.token }; +} -export default class DiscordPlugin extends plugin { +/** discord support for lightning */ +export default class discord extends plugin { name = 'bolt-discord'; private client: Client; - constructor(cfg: InferOutput) { + /** create the plugin */ + constructor(cfg: discord_config) { super(); const rest = new REST({ makeRequest: fetch as RESTOptions['makeRequest'], - userAgentAppendix: `${navigator.userAgent} lightningplugindiscord/0.8.0`, version: '10', }).setToken(cfg.token); @@ -51,19 +62,19 @@ export default class DiscordPlugin extends plugin { private setup_events() { this.client.on(GatewayDispatchEvents.MessageCreate, async (data) => { - const msg = await getIncomingMessage(data); + const msg = await get_incoming_message(data); if (msg) this.emit('create_message', msg); }).on(GatewayDispatchEvents.MessageDelete, ({ data }) => { - this.emit('delete_message', getDeletedMessage(data)); + this.emit('delete_message', get_deleted_message(data)); }).on(GatewayDispatchEvents.MessageDeleteBulk, ({ data }) => { for (const id of data.ids) { - this.emit('delete_message', getDeletedMessage({ id, ...data })); + this.emit('delete_message', get_deleted_message({ id, ...data })); } }).on(GatewayDispatchEvents.MessageUpdate, async (data) => { - const msg = await getIncomingMessage(data); + const msg = await get_incoming_message(data); if (msg) this.emit('edit_message', msg); }).on(GatewayDispatchEvents.InteractionCreate, (data) => { - const cmd = getIncomingCommand(data); + const cmd = get_incoming_command(data); if (cmd) this.emit('create_command', cmd); }).on(GatewayDispatchEvents.Ready, async ({ data }) => { console.log( @@ -75,10 +86,12 @@ export default class DiscordPlugin extends plugin { }); } + /** setup slash commands */ override async set_commands(commands: command[]): Promise { await setup_commands(this.client.api, commands); } + /** create a webhook */ async setup_channel(channelID: string): Promise { try { const { id, token } = await this.client.api.channels.createWebhook( @@ -92,12 +105,13 @@ export default class DiscordPlugin extends plugin { } } + /** send a message using the bot itself or a webhook */ async create_message( message: message, data?: bridge_message_opts, ): Promise { try { - const msg = await getOutgoingMessage( + const msg = await get_outgoing_message( message, this.client.api, data !== undefined, @@ -127,6 +141,7 @@ export default class DiscordPlugin extends plugin { } } + /** edut a message sent by webhook */ async edit_message( message: message, data: bridge_message_opts & { edit_ids: string[] }, @@ -138,10 +153,10 @@ export default class DiscordPlugin extends plugin { webhook.id, webhook.token, data.edit_ids[0], - await getOutgoingMessage( + await get_outgoing_message( message, this.client.api, - data !== undefined, + true, data?.settings?.allow_everyone ?? false, ), ); @@ -151,22 +166,22 @@ export default class DiscordPlugin extends plugin { } } + /** delete messages */ async delete_messages(msgs: deleted_message[]): Promise { - const successful = []; - - for (const msg of msgs) { - try { - await this.client.api.channels.deleteMessage( - msg.channel_id, - msg.message_id, - ); - successful.push(msg.message_id); - } catch (e) { - // if this doesn't throw, it's fine - handle_error(e, msg.channel_id, true); - } - } - - return successful; + return await Promise.all( + msgs.map(async (msg) => { + try { + await this.client.api.channels.deleteMessage( + msg.channel_id, + msg.message_id, + ); + return msg.message_id; + } catch (e) { + // if this doesn't throw, it's fine + handle_error(e, msg.channel_id, true); + return msg.message_id; + } + }), + ); } } diff --git a/packages/discord/src/outgoing.ts b/packages/discord/src/outgoing.ts index 5787fe03..4b59f75a 100644 --- a/packages/discord/src/outgoing.ts +++ b/packages/discord/src/outgoing.ts @@ -1,27 +1,24 @@ import { AllowedMentionsTypes, + type API, type APIEmbed, - type APIMessageReference, - ButtonStyle, - ComponentType, + type DescriptiveRawFile, type RESTPostAPIWebhookWithTokenJSONBody, type RESTPostAPIWebhookWithTokenQuery, -} from 'discord-api-types'; +} from '@discordjs/core'; import type { attachment, message } from '@jersey/lightning'; -import type { RawFile } from '@discordjs/rest'; -import type { API } from '@discordjs/core'; -export interface DiscordPayload +export interface discord_payload extends RESTPostAPIWebhookWithTokenJSONBody, RESTPostAPIWebhookWithTokenQuery { embeds: APIEmbed[]; - files?: RawFile[]; - message_reference?: APIMessageReference & { message_id: string }; + files?: DescriptiveRawFile[]; + message_reference?: { type: number; channel_id: string; message_id: string }; wait: true; } -async function fetchReplyComponent( +async function fetch_reply( channelID: string, replyID?: string, api?: API, @@ -36,23 +33,22 @@ async function fetchReplyComponent( const msg = await api.channels.getMessage(channelID, replyID); return [{ - type: ComponentType.ActionRow as const, + type: 1 as const, components: [{ - type: ComponentType.Button as const, - style: ButtonStyle.Link as const, + type: 2 as const, + style: 5 as const, label: `reply to ${msg.author.username}`, url: `https://discord.com/channels/${channelPath}/${replyID}`, }], }]; } catch { - // TODO(jersey): maybe log this? return; } } -async function fetchFiles( +async function fetch_files( attachments: attachment[] | undefined, -): Promise { +): Promise { if (!attachments) return; let totalSize = 0; @@ -81,13 +77,13 @@ async function fetchFiles( )).filter((i) => i !== undefined); } -export async function getOutgoingMessage( +export async function get_outgoing_message( msg: message, api: API, button_reply: boolean, limit_mentions: boolean, -): Promise { - const payload: DiscordPayload = { +): Promise { + const payload: discord_payload = { allowed_mentions: limit_mentions ? { parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User] } : undefined, @@ -97,13 +93,13 @@ export async function getOutgoingMessage( ? `${msg.content?.substring(0, 1997)}...` : msg.content, components: button_reply - ? await fetchReplyComponent(msg.channel_id, msg.reply_id, api) + ? await fetch_reply(msg.channel_id, msg.reply_id, api) : undefined, embeds: (msg.embeds ?? []).map((e) => ({ ...e, timestamp: e.timestamp?.toString(), })), - files: await fetchFiles(msg.attachments), + files: await fetch_files(msg.attachments), message_reference: !button_reply && msg.reply_id ? { type: 0, channel_id: msg.channel_id, message_id: msg.reply_id } : undefined, diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 262e7d10..8b967b1f 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -12,7 +12,7 @@ guilded [[plugins]] plugin = "jsr:@jersey/lightning-plugin-guilded@0.8.0" -config = { token = "YOUR_GUILDED_TOKEN" } +config.token = "YOUR_GUILDED_TOKEN" # ... ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 48dcc9b9..8b4e0211 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,12 +1,11 @@ { "name": "@jersey/lightning-plugin-guilded", - "version": "0.8.0-alpha.1", + "version": "0.8.0", "license": "MIT", "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.4", - "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@0.0.2", - "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" + "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.2" } } diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts index 3755f107..e6909e83 100644 --- a/packages/guilded/src/incoming.ts +++ b/packages/guilded/src/incoming.ts @@ -1,8 +1,8 @@ -import type { ChatMessage, ServerMember } from '@jersey/guilded-api-types'; import type { Client } from '@jersey/guildapi'; +import type { ChatMessage, ServerMember } from '@jersey/guilded-api-types'; import type { attachment, message } from '@jersey/lightning'; -export async function fetchAuthor(msg: ChatMessage, client: Client) { +export async function fetch_author(msg: ChatMessage, client: Client) { try { if (!msg.createdByWebhookId) { const { member: author } = await client.request( @@ -40,7 +40,7 @@ export async function fetchAuthor(msg: ChatMessage, client: Client) { } } -async function fetchAttachments(urls: string[], client: Client) { +async function fetch_attachments(urls: string[], client: Client) { const attachments: attachment[] = []; try { @@ -70,7 +70,7 @@ async function fetchAttachments(urls: string[], client: Client) { return attachments; } -export async function getIncomingMessage( +export async function get_incoming( msg: ChatMessage, client: Client, ): Promise { @@ -88,9 +88,9 @@ export async function getIncomingMessage( ); return { - attachments: await fetchAttachments(urls, client), + attachments: await fetch_attachments(urls, client), author: { - ...await fetchAuthor(msg, client), + ...await fetch_author(msg, client), color: '#F5C400', }, channel_id: msg.channelId, diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 774e9dfa..711fe098 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -1,27 +1,39 @@ import { type Client, createClient } from '@jersey/guildapi'; +import type { ServerChannel } from '@jersey/guilded-api-types'; import { type bridge_message_opts, type deleted_message, + log_error, type message, plugin, } from '@jersey/lightning'; -import { getIncomingMessage } from './incoming.ts'; import { handle_error } from './errors.ts'; -import type { ServerChannel } from '@jersey/guilded-api-types'; -import { getOutgoingMessage } from './outgoing.ts'; -import { type InferOutput, object, string } from '@valibot/valibot'; +import { get_incoming } from './incoming.ts'; +import { get_outgoing } from './outgoing.ts'; -/** Options for the Guilded plugin */ -export const config = object({ - /** The token to use for the bot */ - token: string(), -}); +/** options for the guilded bot */ +export interface guilded_config { + /** the token to use */ + token: string; +} -export default class GuildedPlugin extends plugin { +/** check if something is actually a config object, return if it is */ +export function parse_config(v: unknown): guilded_config { + if (typeof v !== 'object' || v === null) { + log_error("guilded config isn't an object!", { without_cause: true }); + } + if (!('token' in v) || typeof v.token !== 'string') { + log_error("guilded token isn't a string", { without_cause: true }); + } + return { token: v.token }; +} + +/** guilded support for lightning */ +export default class guilded extends plugin { name = 'bolt-guilded'; private client: Client; - constructor(opts: InferOutput) { + constructor(opts: guilded_config) { super(); this.client = createClient(opts.token); this.setup_events(); @@ -30,7 +42,7 @@ export default class GuildedPlugin extends plugin { private setup_events() { this.client.socket.on('ChatMessageCreated', async (data) => { - const msg = await getIncomingMessage(data.d.message, this.client); + const msg = await get_incoming(data.d.message, this.client); if (msg) this.emit('create_message', msg); }).on('ChatMessageDeleted', ({ d }) => { this.emit('delete_message', { @@ -40,18 +52,19 @@ export default class GuildedPlugin extends plugin { timestamp: Temporal.Instant.from(d.deletedAt), }); }).on('ChatMessageUpdated', async (data) => { - const msg = await getIncomingMessage(data.d.message, this.client); + const msg = await get_incoming(data.d.message, this.client); if (msg) this.emit('edit_message', msg); }).on('ready', (data) => { console.log(`[guilded] ready as ${data.name} (${data.id})`); }); } - async setup_channel(channelID: string): Promise { + /** create a webhook in a channel */ + async setup_channel(channel_id: string): Promise { try { const { channel: { serverId } } = await this.client.request( 'get', - `/channels/${channelID}`, + `/channels/${channel_id}`, undefined, ) as { channel: ServerChannel }; @@ -59,7 +72,7 @@ export default class GuildedPlugin extends plugin { 'post', `/servers/${serverId}/webhooks`, { - channelId: channelID, + channelId: channel_id, name: 'Lightning Bridges', }, ); @@ -70,16 +83,17 @@ export default class GuildedPlugin extends plugin { return { id: webhook.id, token: webhook.token }; } catch (e) { - return handle_error(e, channelID); + return handle_error(e, channel_id); } } + /** send a message either as the bot or using a webhook */ async create_message( message: message, data?: bridge_message_opts, ): Promise { try { - const msg = await getOutgoingMessage( + const msg = await get_outgoing( message, this.client, data?.settings?.allow_everyone ?? false, @@ -112,42 +126,29 @@ export default class GuildedPlugin extends plugin { } } + /** edit stub function */ + // deno-lint-ignore require-await async edit_message( - message: message, - data?: bridge_message_opts & { edit_ids: string[] }, + _message: message, + data: bridge_message_opts & { edit_ids: string[] }, ): Promise { - // guilded webhooks don't support editing - if (data) return data.edit_ids; - - try { - const resp = await this.client.request( - 'put', - `/channels/${message.channel_id}/messages/${message.message_id}`, - await getOutgoingMessage(message, this.client, false), - ); - - return [resp.message.id]; - } catch (e) { - return handle_error(e, message.channel_id, true); - } + return data.edit_ids; } + /** delete messages from guilded */ async delete_messages(messages: deleted_message[]): Promise { - const successful = []; - - for (const msg of messages) { + return await Promise.all(messages.map(async (msg) => { try { await this.client.request( 'delete', // @ts-expect-error: this is typed wrong - `/channels/${opts.channel}/messages/${msg.message_id[0]}`, + `/channels/${msg.channel_id}/messages/${msg.message_id}`, undefined, ); - successful.push(msg.message_id); + return msg.message_id; } catch (e) { handle_error(e, msg.channel_id, true); + return msg.message_id; } - } - - return successful; + })) } } diff --git a/packages/guilded/src/outgoing.ts b/packages/guilded/src/outgoing.ts index 9ee1b8b1..2fb0b12e 100644 --- a/packages/guilded/src/outgoing.ts +++ b/packages/guilded/src/outgoing.ts @@ -1,9 +1,9 @@ import type { Client } from '@jersey/guildapi'; -import type { message } from '@jersey/lightning'; import type { ChatEmbed } from '@jersey/guilded-api-types'; -import { fetchAuthor } from './incoming.ts'; +import type { message } from '@jersey/lightning'; +import { fetch_author } from './incoming.ts'; -type GuildedPayload = { +type guilded_payload = { content?: string; embeds?: ChatEmbed[]; replyMessageIds?: string[]; @@ -11,19 +11,19 @@ type GuildedPayload = { username?: string; }; -const usernameRegex = /^[a-zA-Z0-9_ ()-]{1,25}$/ms; +const username = /^[a-zA-Z0-9_ ()-]{1,25}$/ms; -function getUsername(msg: message): string { - if (usernameRegex.test(msg.author.username)) { +function get_name(msg: message): string { + if (username.test(msg.author.username)) { return msg.author.username; - } else if (usernameRegex.test(msg.author.rawname)) { + } else if (username.test(msg.author.rawname)) { return msg.author.rawname; } else { return `${msg.author.id}`; } } -async function fetchReplyEmbed( +async function fetch_reply( msg: message, client: Client, ): Promise { @@ -36,7 +36,7 @@ async function fetchReplyEmbed( undefined, ); - const author = await fetchAuthor(replied_to.message, client); + const author = await fetch_author(replied_to.message, client); return { author: { @@ -50,15 +50,15 @@ async function fetchReplyEmbed( } } -export async function getOutgoingMessage( +export async function get_outgoing( msg: message, client: Client, limitMentions?: boolean, -): Promise { - const message: GuildedPayload = { +): Promise { + const message: guilded_payload = { content: msg.content, avatar_url: msg.author.profile, - username: getUsername(msg), + username: get_name(msg), embeds: msg.embeds?.map((i) => { return { ...i, @@ -70,13 +70,13 @@ export async function getOutgoingMessage( }; }) : undefined, - timestamp: i.timestamp ? String(i.timestamp) : undefined, + timestamp: i.timestamp ? i.timestamp.toString() : undefined, }; }), }; if (msg.reply_id) { - const embed = await fetchReplyEmbed(msg, client); + const embed = await fetch_reply(msg, client); if (embed) { if (!message.embeds) message.embeds = []; @@ -100,8 +100,8 @@ export async function getOutgoingMessage( if (!message.content && !message.embeds) message.content = '\u2800'; if (limitMentions && message.content) { - message.content = message.content.replace(/@everyone/g, '(a)everyone'); - message.content = message.content.replace(/@here/g, '(a)here'); + message.content = message.content.replace(/@everyone/gi, '(a)everyone'); + message.content = message.content.replace(/@here/gi, '(a)here'); } return message; diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json new file mode 100644 index 00000000..7ddf06fd --- /dev/null +++ b/packages/lightning/deno.json @@ -0,0 +1,12 @@ +{ + "name": "@jersey/lightning", + "version": "0.8.0", + "license": "MIT", + "exports": "./src/mod.ts", + "imports": { + "@db/postgres": "jsr:@jersey/test@0.8.6", + "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", + "@iuioiua/redis": "jsr:@iuioiua/redis@^1.0.1", + "@std/toml": "jsr:@std/toml@^1.0.4" + } +} diff --git a/packages/lightning/deno.jsonc b/packages/lightning/deno.jsonc deleted file mode 100644 index a15226be..00000000 --- a/packages/lightning/deno.jsonc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@jersey/lightning", - "version": "0.8.0", - "license": "MIT", - "exports": "./src/mod.ts", - "imports": { - "@db/mongo": "jsr:@db/mongo@^0.34.0", - // TODO(jersey): get updated @db/postgres on JSR - "@db/postgres": "jsr:@jersey/test@0.8.6", - "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", - "@iuioiua/redis": "jsr:@iuioiua/redis@^1.0.1", - "@std/cli/parse-args": "jsr:@std/cli@^1.0.14/parse-args", - "@std/path": "jsr:@std/path@^1.0.8", - "@std/ulid": "jsr:@std/ulid@^1.0.0", - "@std/toml": "jsr:@std/toml@^1.0.4", - "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" - } -} diff --git a/packages/lightning/dockerfile b/packages/lightning/dockerfile deleted file mode 100644 index 9a88c1be..00000000 --- a/packages/lightning/dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM docker.io/denoland/deno:2.1.4 - -# add lightning to the image -RUN deno install -g -A --unstable-temporal ./cli/mod.ts -RUN mkdir -p /app/data -WORKDIR /app/data - -# set lightning as the entrypoint and use the run command by default -ENTRYPOINT [ "lightning" ] -CMD [ "run"] diff --git a/packages/lightning/src/bridge/commands.ts b/packages/lightning/src/bridge/commands.ts index c20cd338..3b50636a 100644 --- a/packages/lightning/src/bridge/commands.ts +++ b/packages/lightning/src/bridge/commands.ts @@ -129,7 +129,9 @@ export async function status( let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; for (const [i, value] of bridge.channels.entries()) { - str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`\n`; + str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`${ + value.disabled ? ' (disabled)' : '' + }\n`; } str += `\nSettings:\n`; diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index b1e5e868..3d4c93c9 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -1,47 +1,36 @@ -import { parseArgs } from '@std/cli/parse-args'; -import { join } from '@std/path'; import { parse_config } from './cli_config.ts'; import { log_error } from './structures/errors.ts'; import { handle_migration } from './database/mod.ts'; import { core } from './core.ts'; import { setup_bridge } from './bridge/setup.ts'; -const version = '0.8.0'; -const _ = parseArgs(Deno.args); - -if (_.v || _.version) { - console.log(version); -} else if (_.h || _.help) { - run_help(); -} else if (_._[0] === 'run') { - if (!_.config) _.config = join(Deno.cwd(), 'lightning.toml'); - +if (Deno.args[0] === 'migrate') { + handle_migration(); +} else if (Deno.args[0] === 'run') { try { - const config = await parse_config(_.config); + const config = await parse_config( + new URL(Deno.args[1] ?? 'lightning.toml', `file://${Deno.cwd()}/`), + ); const lightning = new core(config); - setup_bridge(lightning, config.database); + await setup_bridge(lightning, config.database); } catch (e) { - log_error(e, { extra: { type: 'global class error' } }); + log_error(e, { + extra: { type: 'global class error' }, + without_cause: true, + }); } -} else if (_._[0] === 'migrate') { - handle_migration(); +} else if (Deno.args[0] === 'version') { + console.log('0.8.0'); } else { - console.log('[lightning] command not found, showing help'); - run_help(); -} - -function run_help() { console.log( - `lightning v${version} - extensible chatbot connecting communities`, + `lightning v0.8.0 - extensible chatbot connecting communities`, ); - console.log(' Usage: lightning [subcommand] '); + console.log(' Usage: lightning [subcommand]'); console.log(' Subcommands:'); - console.log(' run: run a lightning instance'); + console.log(' run : run a lightning instance'); console.log(' migrate: migrate databases'); - console.log(' Options:'); - console.log(' -h, --help: display this help message'); - console.log(' -v, --version: display the version number'); - console.log(' -c, --config: the config file to use'); + console.log(' version: display the version number'); + console.log(' help: display this help message'); console.log(' Environment Variables:'); console.log(' LIGHTNING_ERROR_WEBHOOK: the webhook to send errors to'); console.log(' LIGHTNING_MIGRATE_CONFIRM: confirm migration on startup'); diff --git a/packages/lightning/src/cli_config.ts b/packages/lightning/src/cli_config.ts index 0e53d97f..75f1fca0 100644 --- a/packages/lightning/src/cli_config.ts +++ b/packages/lightning/src/cli_config.ts @@ -1,63 +1,102 @@ -import { - array, - literal, - number, - object, - optional, - parse as parse_schema, - record, - string, - union, - unknown, -} from '@valibot/valibot'; import { parse as parse_toml } from '@std/toml'; import type { database_config } from './database/mod.ts'; import type { core_config } from './core.ts'; +import { log_error } from './structures/errors.ts'; -const cli_config = object({ - database: union([ - object({ - type: literal('postgres'), - config: string(), - }), - object({ - type: literal('redis'), - config: object({ - port: number(), - hostname: optional(string()), - }), - }), - ]), - error_url: optional(string()), - prefix: optional(string(), '!'), - plugins: array(object({ - plugin: string(), - config: record(string(), unknown()), - })), -}); - -export interface config extends core_config { +interface cli_plugin { + plugin: string; + config: Record; +} + +interface config extends core_config { database: database_config; error_url?: string; } -// TODO: error handle -export async function parse_config( - path: string, -): Promise { - const file = await Deno.readTextFile(path); - const raw = parse_toml(file); - const parsed = parse_schema(cli_config, raw); - const new_plugins = []; - - for (const plugin of parsed.plugins) { - new_plugins.push({ - module: await import(plugin.plugin), - config: plugin.config, - }); - } +export async function parse_config(path: URL): Promise { + try { + const file = await Deno.readTextFile(path); + const raw = parse_toml(file) as Record; + + if ( + !('database' in raw) || + typeof raw.database !== 'object' || + raw.database === null || + !('type' in raw.database) || + typeof raw.database.type !== 'string' || + !('config' in raw.database) || + raw.database.config === null || + (raw.database.type === 'postgres' && + typeof raw.database.config !== 'string') || + (raw.database.type === 'redis' && + (typeof raw.database.config !== 'object' || + raw.database.config === null)) + ) { + return log_error('your config has an invalid `database` field', { + without_cause: true, + }); + } + + if ( + !('plugins' in raw) || + !Array.isArray(raw.plugins) || + !raw.plugins.every( + (p): p is cli_plugin => + typeof p.plugin === 'string' && + typeof p.config === 'object' && + p.config !== null, + ) + ) { + return log_error('your config has an invalid `plugins` field', { + without_cause: true, + }); + } + + if ('error_url' in raw && typeof raw.error_url !== 'string') { + return log_error('the `error_url` field is not a valid string', { + without_cause: true, + }); + } - Deno.env.set('LIGHTNING_ERROR_WEBHOOK', parsed.error_url || ''); + if ('prefix' in raw && typeof raw.prefix !== 'string') { + return log_error('the `prefix` field is not a valid string', { + without_cause: true, + }); + } - return { ...parsed, plugins: new_plugins }; + const validated = raw as unknown as config & { plugins: cli_plugin[] }; + + const plugins = []; + + for (const plugin of validated.plugins) { + plugins.push({ + module: await import(plugin.plugin), + config: plugin.config, + }); + } + + Deno.env.set('LIGHTNING_ERROR_WEBHOOK', validated.error_url ?? ''); + + return { ...validated, plugins }; + } catch (e) { + if ( + e instanceof Deno.errors.NotFound || + e instanceof Deno.errors.PermissionDenied + ) { + log_error(e, { + message: `could not open your \`lightning.toml\` at \`${path}\``, + without_cause: true, + }); + } else if (e instanceof SyntaxError) { + log_error(e, { + message: `could not parse your \`lightning.toml\` file at ${path}`, + without_cause: true, + }); + } else { + log_error(e, { + message: `unknown issue with your \`lightning.toml\` file at ${path}`, + without_cause: true, + }); + } + } } diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 2dd1741c..71da7af6 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -1,11 +1,10 @@ import { EventEmitter } from '@denosaurs/event'; import type { plugin, - plugin_events, + events, plugin_module, } from './structures/plugins.ts'; -import { parse } from '@valibot/valibot'; -import { LightningError } from './structures/errors.ts'; +import { LightningError, log_error } from './structures/errors.ts'; import type { command, command_opts, @@ -21,7 +20,7 @@ export interface core_config { }[]; } -export class core extends EventEmitter { +export class core extends EventEmitter { private commands = new Map([ ['help', { name: 'help', @@ -53,13 +52,14 @@ export class core extends EventEmitter { this.prefix = cfg.prefix || '!'; for (const { module, config } of cfg.plugins) { - if (!module.default || !module.config) { - throw new Error(`one or more of you plugins isn't actually a plugin!`); + if (!module.default || !module.parse_config) { + log_error({ ...module }, { + message: `one or more of you plugins isn't actually a plugin!`, + without_cause: true, + }); } - const plugin_config = parse(module.config, config); - - const instance = new module.default(plugin_config); + const instance = new module.default(module.parse_config(config)); this.plugins.set(instance.name, instance); this.handle_events(instance); @@ -83,6 +83,7 @@ export class core extends EventEmitter { await new Promise((res) => setTimeout(res, 150)); if (this.handled.has(`${value[0].plugin}-${value[0].message_id}`)) { + this.handled.delete(`${value[0].plugin}-${value[0].message_id}`); continue; } @@ -124,10 +125,11 @@ export class core extends EventEmitter { plugin: plugin, ): Promise { let command = this.commands.get(opts.command) ?? this.commands.get('help')!; + const subcommand_name = opts.subcommand ?? opts.rest?.shift(); - if (command.subcommands && opts.subcommand) { + if (command.subcommands && subcommand_name) { const subcommand = command.subcommands.find((i) => - i.name === opts.subcommand + i.name === subcommand_name ); if (subcommand) command = subcommand; diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index 213975c9..5d5ec3f5 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -1,5 +1,4 @@ import { Client, type ClientOptions } from '@db/postgres'; -import { ulid } from '@std/ulid'; import type { bridge, bridge_message } from '../structures/bridge.ts'; import type { bridge_data } from './mod.ts'; @@ -48,7 +47,7 @@ export class postgres implements bridge_data { private constructor(private pg: Client) {} async create_bridge(br: Omit): Promise { - const id = ulid(); + const id = crypto.randomUUID(); await this.pg.queryArray` INSERT INTO bridges (id, name, channels, settings) diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index caa686b5..ac57a4c8 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -1,5 +1,4 @@ import { RedisClient } from '@iuioiua/redis'; -import { ulid } from '@std/ulid'; import type { bridge, bridge_message } from '../structures/bridge.ts'; import type { bridge_data } from './mod.ts'; import { log_error } from '../structures/errors.ts'; @@ -110,7 +109,7 @@ export class redis implements bridge_data { } async create_bridge(br: Omit): Promise { - const id = ulid(); + const id = crypto.randomUUID(); await this.edit_bridge({ id, ...br }); diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index e29ef416..a337cef2 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -1,5 +1,5 @@ if (import.meta.main) { - await import('./cli.ts'); + import('./cli.ts'); } export * from './structures/mod.ts'; diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index 4a508158..c8acb654 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -12,7 +12,7 @@ export interface bridge { /** a channel within a bridge */ export interface bridge_channel { - /** from the platform */ + /** the channel's cannonical id */ id: string; /** data needed to bridge this channel */ data: unknown; diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index 56c105c3..6ca91db0 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -8,6 +8,8 @@ export interface error_options { extra?: Record; /** whether to disable the associated channel (when bridging) */ disable?: boolean; + /** whether this should be logged without the cause */ + without_cause?: boolean; } /** logs an error */ @@ -20,27 +22,30 @@ export class LightningError extends Error { /** the id associated with the error */ id: string; /** the cause of the error */ - override cause: Error; + private error_cause: Error; /** extra information associated with the error */ extra: Record; /** the user-facing error message */ msg: message; /** whether to disable the associated channel (when bridging) */ disable_channel?: boolean; + /** whether to show the cause or not */ + without_cause?: boolean; /** create and log an error */ constructor(e: unknown, public options?: error_options) { if (e instanceof LightningError) { super(e.message, { cause: e.cause }); this.id = e.id; - this.cause = e.cause; + this.error_cause = e.error_cause; this.extra = e.extra; this.msg = e.msg; this.disable_channel = e.disable_channel; - return; + this.without_cause = e.without_cause; + return e; } - const cause = e instanceof Error + const cause_err = Error.isError(e) ? e : e instanceof Object ? new Error(JSON.stringify(e)) @@ -48,13 +53,14 @@ export class LightningError extends Error { const id = crypto.randomUUID(); - super(options?.message ?? cause.message, { cause }); + super(options?.message ?? cause_err.message, { cause: e }); this.name = 'LightningError'; this.id = id; - this.cause = cause; + this.error_cause = cause_err; this.extra = options?.extra ?? {}; this.disable_channel = options?.disable; + this.without_cause = options?.without_cause; this.msg = create_message( `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\``, ); @@ -64,8 +70,16 @@ export class LightningError extends Error { /** log the error */ private async log(): Promise { - console.error(`%c[lightning] error ${this.id}`, 'color: red'); - console.error(this.cause, this.options); + console.error(`%c[lightning] ${this.message}`, 'color: red'); + console.error(`%c[lightning] ${this.id}`, 'color: red'); + console.error( + `%c[lightning] this does${ + this.disable_channel ? ' ' : ' not ' + }disable a channel`, + 'color: red', + ); + + if (!this.without_cause) console.error(this.error_cause, this.extra); const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); @@ -95,7 +109,7 @@ export class LightningError extends Error { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - content: `# ${this.cause.message}\n*${this.id}*`, + content: `# ${this.error_cause.message}\n*${this.id}*`, embeds: [ { title: 'extra', diff --git a/packages/lightning/src/structures/plugins.ts b/packages/lightning/src/structures/plugins.ts index 924afdae..e67ed887 100644 --- a/packages/lightning/src/structures/plugins.ts +++ b/packages/lightning/src/structures/plugins.ts @@ -3,8 +3,8 @@ import type { bridge_message_opts } from './bridge.ts'; import type { command, create_command } from './commands.ts'; import type { deleted_message, message } from './messages.ts'; -/** the events emitted by a plugin */ -export type plugin_events = { +/** the events emitted by core/plugins */ +export type events = { /** when a message is created */ create_message: [message]; /** when a message is edited */ @@ -17,12 +17,12 @@ export type plugin_events = { /** a plugin for lightning */ export interface plugin { - /** set commands on the platform, if available */ + /** setup user-facing commands, if available */ set_commands?(commands: command[]): Promise | void; } /** a plugin for lightning */ -export abstract class plugin extends EventEmitter { +export abstract class plugin extends EventEmitter { /** the name of your plugin */ abstract name: string; /** setup a channel to be used in a bridge */ @@ -35,7 +35,7 @@ export abstract class plugin extends EventEmitter { /** edit a message in a given channel */ abstract edit_message( message: message, - opts?: bridge_message_opts & { edit_ids: string[] }, + opts: bridge_message_opts & { edit_ids: string[] }, ): Promise; /** delete messages in a given channel */ abstract delete_messages( @@ -49,6 +49,5 @@ export interface plugin_module { // deno-lint-ignore no-explicit-any default?: { new (cfg: any): plugin }; /** the config to validate use */ - // deno-lint-ignore no-explicit-any - config?: any; + parse_config?: (data: unknown) => unknown; } diff --git a/packages/revolt/README.md b/packages/revolt/README.md index 181532a2..19855a7c 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -12,7 +12,8 @@ telegram [[plugins]] plugin = "jsr:@jersey/lightning-plugin-revolt@0.8.0" -config = { token = "YOUR_REVOLT_TOKEN", user_id = "YOUR_BOT_USER_ID" } +config.token = "YOUR_REVOLT_TOKEN" +config.user_id = "YOUR_BOT_USER_ID" # ... ``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index d72fd8ec..a96d9299 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,13 +1,12 @@ { "name": "@jersey/lightning-plugin-revolt", - "version": "0.8.0-alpha.1", + "version": "0.8.0", "license": "MIT", "exports": "./src/mod.ts", "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.7", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.3", - "@std/ulid": "jsr:@std/ulid@^1.0.0", - "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" + "@std/ulid": "jsr:@std/ulid@^1.0.0" } } diff --git a/packages/revolt/src/cache.ts b/packages/revolt/src/cache.ts index 37318b5f..08a624f0 100644 --- a/packages/revolt/src/cache.ts +++ b/packages/revolt/src/cache.ts @@ -1,3 +1,4 @@ +import type { message_author } from '@jersey/lightning'; import type { Channel, Masquerade, @@ -8,9 +9,8 @@ import type { User, } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; -import type { message_author } from '@jersey/lightning'; -class RevoltCacher { +class cacher { private map = new Map { } } -const authorCache = new RevoltCacher<`${string}/${string}`, message_author>(); -const channelCache = new RevoltCacher(); -const memberCache = new RevoltCacher<`${string}/${string}`, Member>(); -const messageCache = new RevoltCacher<`${string}/${string}`, Message>(); -const roleCache = new RevoltCacher<`${string}/${string}`, Role>(); -const serverCache = new RevoltCacher(); -const userCache = new RevoltCacher(); +const author_cache = new cacher<`${string}/${string}`, message_author>(); +const channel_cache = new cacher(); +const member_cache = new cacher<`${string}/${string}`, Member>(); +const message_cache = new cacher<`${string}/${string}`, Message>(); +const role_cache = new cacher<`${string}/${string}`, Role>(); +const server_cache = new cacher(); +const user_cache = new cacher(); -export async function fetchAuthor( +export async function fetch_author( api: Client, authorID: string, channelID: string, masquerade?: Masquerade, ): Promise { try { - const cached = authorCache.get(`${authorID}/${channelID}`); + const cached = author_cache.get(`${authorID}/${channelID}`); if (cached) return cached; - const channel = await fetchChannel(api, channelID); - const author = await fetchUser(api, authorID); + const channel = await fetch_channel(api, channelID); + const author = await fetch_user(api, authorID); const data = { id: authorID, @@ -64,9 +64,9 @@ export async function fetchAuthor( if (channel.channel_type !== 'TextChannel') return data; try { - const member = await fetchMember(api, channel.server, authorID); + const member = await fetch_member(api, channel.server, authorID); - return authorCache.set(`${authorID}/${channelID}`, { + return author_cache.set(`${authorID}/${channelID}`, { ...data, username: masquerade?.name ?? member.nickname ?? data.username, profile: masquerade?.avatar ?? @@ -75,7 +75,7 @@ export async function fetchAuthor( : data.profile), }); } catch { - return authorCache.set(`${authorID}/${channelID}`, data); + return author_cache.set(`${authorID}/${channelID}`, data); } } catch { return { @@ -88,11 +88,11 @@ export async function fetchAuthor( } } -export async function fetchChannel( +export async function fetch_channel( api: Client, channelID: string, ): Promise { - const cached = channelCache.get(channelID); + const cached = channel_cache.get(channelID); if (cached) return cached; @@ -102,15 +102,15 @@ export async function fetchChannel( undefined, ) as Channel; - return channelCache.set(channelID, channel); + return channel_cache.set(channelID, channel); } -export async function fetchMember( +export async function fetch_member( client: Client, serverID: string, userID: string, ): Promise { - const member = memberCache.get(`${serverID}/${userID}`); + const member = member_cache.get(`${serverID}/${userID}`); if (member) return member; @@ -120,15 +120,15 @@ export async function fetchMember( undefined, ) as Member; - return memberCache.set(`${serverID}/${userID}`, response); + return member_cache.set(`${serverID}/${userID}`, response); } -export async function fetchMessage( +export async function fetch_message( client: Client, channelID: string, messageID: string, ): Promise { - const message = messageCache.get(`${channelID}/${messageID}`); + const message = message_cache.get(`${channelID}/${messageID}`); if (message) return message; @@ -138,15 +138,15 @@ export async function fetchMessage( undefined, ) as Message; - return messageCache.set(`${channelID}/${messageID}`, response); + return message_cache.set(`${channelID}/${messageID}`, response); } -export async function fetchRole( +export async function fetch_role( client: Client, serverID: string, roleID: string, ): Promise { - const role = roleCache.get(`${serverID}/${roleID}`); + const role = role_cache.get(`${serverID}/${roleID}`); if (role) return role; @@ -156,14 +156,14 @@ export async function fetchRole( undefined, ) as Role; - return roleCache.set(`${serverID}/${roleID}`, response); + return role_cache.set(`${serverID}/${roleID}`, response); } -export async function fetchServer( +export async function fetch_server( client: Client, serverID: string, ): Promise { - const server = serverCache.get(serverID); + const server = server_cache.get(serverID); if (server) return server; @@ -173,14 +173,14 @@ export async function fetchServer( undefined, ) as Server; - return serverCache.set(serverID, response); + return server_cache.set(serverID, response); } -export async function fetchUser( +export async function fetch_user( api: Client, userID: string, ): Promise { - const cached = userCache.get(userID); + const cached = user_cache.get(userID); if (cached) return cached; @@ -190,5 +190,5 @@ export async function fetchUser( undefined, ) as User; - return userCache.set(userID, user); + return user_cache.set(userID, user); } diff --git a/packages/revolt/src/errors.ts b/packages/revolt/src/errors.ts index be34ed94..4cbb122d 100644 --- a/packages/revolt/src/errors.ts +++ b/packages/revolt/src/errors.ts @@ -1,5 +1,5 @@ -import { MediaError, RequestError } from '@jersey/rvapi'; import { log_error } from '@jersey/lightning'; +import { MediaError, RequestError } from '@jersey/rvapi'; export function handle_error(err: unknown, edit?: boolean) { if (err instanceof MediaError) { @@ -21,12 +21,12 @@ export function handle_error(err: unknown, edit?: boolean) { }); } else { log_error(err, { - message: 'unknown error', + message: 'unknown revolt request error', }); } } else { log_error(err, { - message: 'unknown error', + message: 'unknown revolt error', }); } } diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts index b9bb1757..35700d0a 100644 --- a/packages/revolt/src/incoming.ts +++ b/packages/revolt/src/incoming.ts @@ -1,10 +1,10 @@ -import type { Client } from '@jersey/rvapi'; +import type { embed, message } from '@jersey/lightning'; import type { Message as APIMessage } from '@jersey/revolt-api-types'; +import type { Client } from '@jersey/rvapi'; import { decodeTime } from '@std/ulid'; -import type { embed, message } from '@jersey/lightning'; -import { fetchAuthor } from './cache.ts'; +import { fetch_author } from './cache.ts'; -export async function getIncomingMessage( +export async function get_incoming( message: APIMessage, api: Client, ): Promise { @@ -16,7 +16,7 @@ export async function getIncomingMessage( size: i.size / 1048576, }; }), - author: await fetchAuthor(api, message.author, message.channel), + author: await fetch_author(api, message.author, message.channel), channel_id: message.channel, content: message.content ?? undefined, embeds: message.embeds?.map((i) => { diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 1511ec76..b275a9c5 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -1,64 +1,76 @@ -import { type Client, createClient } from '@jersey/rvapi'; -import { getIncomingMessage } from './incoming.ts'; -import type { Message as APIMessage } from '@jersey/revolt-api-types'; -import { handle_error } from './errors.ts'; -import { getOutgoingMessage } from './outgoing.ts'; -import { fetchMessage } from './cache.ts'; -import { check_permissions } from './permissions.ts'; import { type bridge_message_opts, type deleted_message, + log_error, type message, plugin, } from '@jersey/lightning'; -import { type InferOutput, object, string } from '@valibot/valibot'; +import type { Message as APIMessage } from '@jersey/revolt-api-types'; +import { type Client, createClient } from '@jersey/rvapi'; +import { fetch_message } from './cache.ts'; +import { handle_error } from './errors.ts'; +import { get_incoming } from './incoming.ts'; +import { get_outgoing } from './outgoing.ts'; +import { check_permissions } from './permissions.ts'; -/** Options for the Revolt plugin */ -export const config = object({ - /** The token to use for the bot plugin */ - token: string(), - /** The bot's user ID */ - user_id: string(), -}); +/** the config for the revolt bot */ +export interface revolt_config { + /** the token for the revolt bot */ + token: string; + /** the user id for the bot */ + user_id: string; +} -export default class RevoltPlugin extends plugin { +/** check if something is actually a config object, return if it is */ +export function parse_config(v: unknown): revolt_config { + if (typeof v !== 'object' || v === null) { + log_error("revolt config isn't an object!", { without_cause: true }); + } + if (!('token' in v) || typeof v.token !== 'string') { + log_error("revolt token isn't a string", { without_cause: true }); + } + if (!('user_id' in v) || typeof v.user_id !== 'string') { + log_error("revolt user ID isn't a string", { without_cause: true }); + } + return { token: v.token, user_id: v.user_id }; +} + +/** revolt support for lightning */ +export default class revolt extends plugin { name = 'bolt-revolt'; private client: Client; private user_id: string; - constructor(opts: InferOutput) { + /** setup revolt using these options */ + constructor(opts: revolt_config) { super(); this.client = createClient({ token: opts.token }); this.user_id = opts.user_id; - this.setupEvents(); + this.setup_events(); } - private setupEvents() { + private setup_events() { this.client.bonfire.on('Message', async (data) => { - const msg = await getIncomingMessage(data, this.client); + const msg = await get_incoming(data, this.client); if (msg) this.emit('create_message', msg); }).on('MessageDelete', (data) => { this.emit('delete_message', { channel_id: data.channel, message_id: data.id, - plugin: this.name, + plugin: "bolt-revolt", timestamp: Temporal.Now.instant(), }); }).on('MessageUpdate', async (data) => { - let oldMessage: APIMessage; - try { - oldMessage = await fetchMessage(this.client, data.channel, data.id); + const msg = await get_incoming({ + ...await fetch_message(this.client, data.channel, data.id), + ...data, + }, this.client); + + if (msg) this.emit('edit_message', msg); } catch { return; } - - const msg = await getIncomingMessage({ - ...oldMessage, - ...data, - }, this.client); - - if (msg) this.emit('edit_message', msg); }).on('Ready', (data) => { console.log( `[revolt] ready as ${ @@ -69,10 +81,12 @@ export default class RevoltPlugin extends plugin { }); } - async setup_channel(channelID: string): Promise { - return await check_permissions(channelID, this.user_id, this.client); + /** ensure masquerading will work in that channel */ + async setup_channel(channel_id: string): Promise { + return await check_permissions(channel_id, this.user_id, this.client); } + /** send a message to a channel */ async create_message( message: message, data?: bridge_message_opts, @@ -82,7 +96,7 @@ export default class RevoltPlugin extends plugin { (await this.client.request( 'post', `/channels/${message.channel_id}/messages`, - await getOutgoingMessage(this.client, message, data !== undefined), + await get_outgoing(this.client, message, data !== undefined), ) as APIMessage)._id, ]; } catch (e) { @@ -90,18 +104,17 @@ export default class RevoltPlugin extends plugin { } } + /** edit a message in a channel */ async edit_message( message: message, - data?: bridge_message_opts & { edit_ids: string[] }, + data: bridge_message_opts & { edit_ids: string[] }, ): Promise { try { return [ (await this.client.request( 'patch', - `/channels/${message.channel_id}/messages/${ - data?.edit_ids[0] ?? message.message_id - }`, - await getOutgoingMessage(this.client, message, data !== undefined), + `/channels/${message.channel_id}/messages/${data.edit_ids[0]}`, + await get_outgoing(this.client, message, true), ) as APIMessage)._id, ]; } catch (e) { @@ -109,22 +122,22 @@ export default class RevoltPlugin extends plugin { } } + /** delete messages in a channel */ async delete_messages(messages: deleted_message[]): Promise { - const successful = []; - - for (const msg of messages) { - try { - await this.client.request( - 'delete', - `/channels/${msg.channel_id}/messages/${msg.message_id}`, - undefined, - ); - successful.push(msg.message_id); - } catch (e) { - handle_error(e); - } - } - - return successful; + return await Promise.all( + messages.map(async (msg) => { + try { + await this.client.request( + 'delete', + `/channels/${msg.channel_id}/messages/${msg.message_id}`, + undefined, + ); + return msg.message_id; + } catch (e) { + handle_error(e); + return msg.message_id; + } + }) + ); } } diff --git a/packages/revolt/src/outgoing.ts b/packages/revolt/src/outgoing.ts index 63ae0754..28823837 100644 --- a/packages/revolt/src/outgoing.ts +++ b/packages/revolt/src/outgoing.ts @@ -1,12 +1,12 @@ -import type { Client } from '@jersey/rvapi'; -import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; import { type attachment, LightningError, type message, } from '@jersey/lightning'; +import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; +import type { Client } from '@jersey/rvapi'; -async function uploadAttachments( +async function upload_files( api: Client, attachments?: attachment[], ): Promise { @@ -31,12 +31,12 @@ async function uploadAttachments( )).filter((i) => i !== undefined); } -export async function getOutgoingMessage( +export async function get_outgoing( api: Client, message: message, masquerade = true, ): Promise { - const attachments = await uploadAttachments(api, message.attachments); + const attachments = await upload_files(api, message.attachments); if ( (!message.content || message.content.length < 1) && diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index 781296a3..7b38eaa8 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -1,7 +1,12 @@ -import type { Client } from '@jersey/rvapi'; import { LightningError, log_error } from '@jersey/lightning'; +import type { Client } from '@jersey/rvapi'; +import { + fetch_channel, + fetch_member, + fetch_role, + fetch_server, +} from './cache.ts'; import { handle_error } from './errors.ts'; -import { fetchChannel, fetchMember, fetchRole, fetchServer } from './cache.ts'; const permission_bits = [ 1 << 23, // ManageMessages @@ -11,12 +16,12 @@ const permission_bits = [ const needed_permissions = permission_bits.reduce((a, b) => a | b, 0); export async function check_permissions( - channelID: string, - botID: string, + channel_id: string, + bot_id: string, client: Client, ) { try { - const channel = await fetchChannel(client, channelID); + const channel = await fetch_channel(client, channel_id); if (channel.channel_type === 'Group') { if (channel.permissions && (channel.permissions & needed_permissions)) { @@ -25,14 +30,14 @@ export async function check_permissions( log_error('missing ManageMessages and/or Masquerade permission'); } else if (channel.channel_type === 'TextChannel') { - const server = await fetchServer(client, channel.server); - const member = await fetchMember(client, channel.server, botID); + const server = await fetch_server(client, channel.server); + const member = await fetch_member(client, channel.server, bot_id); // check server permissions let currentPermissions = server.default_permissions; for (const role of (member.roles || [])) { - const { permissions: role_permissions } = await fetchRole( + const { permissions: role_permissions } = await fetch_role( client, server._id, role, diff --git a/packages/telegram/README.md b/packages/telegram/README.md index b81f79fc..63cd8d7d 100644 --- a/packages/telegram/README.md +++ b/packages/telegram/README.md @@ -12,7 +12,9 @@ telegram (including attachments via the included file proxy) [[plugins]] plugin = "jsr:@jersey/lightning-plugin-telegram@0.8.0" -config = { token = "YOUR_TELEGRAM_TOKEN", proxy_port = 9090, proxy_url = "http://localhost:9090" } +config.token = "YOUR_TELEGRAM_TOKEN" +config.proxy_port = 9090 +config.proxy_url = "http://localhost:9090" # ... ``` diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index 1696bf85..9bc3cd41 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,12 +1,11 @@ { "name": "@jersey/lightning-plugin-telegram", - "version": "0.8.0-alpha.1", + "version": "0.8.0", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@0.8.0", + "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0", - "grammy": "npm:grammy@^1.35.1", - "@valibot/valibot": "jsr:@valibot/valibot@^1.0.0" + "grammy": "npm:grammy@^1.35.1" } } diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts index 7e639fdb..db724380 100644 --- a/packages/telegram/src/incoming.ts +++ b/packages/telegram/src/incoming.ts @@ -1,5 +1,5 @@ -import type { Context } from 'grammy'; import type { message } from '@jersey/lightning'; +import type { Context } from 'grammy'; const types = [ 'text', @@ -16,7 +16,7 @@ const types = [ 'unsupported', ] as const; -export async function getIncomingMessage( +export async function get_incoming( ctx: Context, proxy: string, ): Promise { @@ -71,9 +71,12 @@ export async function getIncomingMessage( case 'unsupported': return; default: { - const fileObj = type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!; - const file = await ctx.api.getFile(fileObj.file_id); + const file = await ctx.api.getFile( + (type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!).file_id, + ); + if (!file.file_path) return; + return { ...base, attachments: [{ diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 3bd68697..80eb5fab 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -1,35 +1,55 @@ import { type bridge_message_opts, type deleted_message, + LightningError, + log_error, type message, plugin, } from '@jersey/lightning'; import { Bot } from 'grammy'; -import { getIncomingMessage } from './incoming.ts'; -import { getOutgoingMessage } from './outgoing.ts'; -import { type InferOutput, number, object, string } from '@valibot/valibot'; +import { get_incoming } from './incoming.ts'; +import { get_outgoing } from './outgoing.ts'; -/** Options for the Revolt plugin */ -export const config = object({ - /** The token to use for the bot */ - token: string(), - /** The port the file proxy should run on */ - proxy_port: number(), - /** The publically accessible url of the plugin */ - proxy_url: string(), -}); +/** options for telegram */ +export type telegram_config = { + /** the token for the bot */ + token: string; + /** the port the file proxy will run on */ + proxy_port: number; + /** the publicly accessible url of the file proxy */ + proxy_url: string; +}; -export default class TelegramPlugin extends plugin { +/** check if something is actually a config object, return if it is */ +export function parse_config(v: unknown): telegram_config { + if (typeof v !== 'object' || v === null) { + log_error("telegram config isn't an object!", { without_cause: true }); + } + if (!('token' in v) || typeof v.token !== 'string') { + log_error("telegram token isn't a string", { without_cause: true }); + } + if (!('proxy_port' in v) || typeof v.proxy_port !== 'number') { + log_error("telegram proxy port isn't a number", { without_cause: true }); + } + if (!('proxy_url' in v) || typeof v.proxy_url !== 'string') { + log_error("telegram proxy url isn't a string", { without_cause: true }); + } + return { token: v.token, proxy_port: v.proxy_port, proxy_url: v.proxy_url }; +} + +/** telegram support for lightning */ +export default class telegram extends plugin { name = 'bolt-telegram'; private bot: Bot; - constructor(opts: InferOutput) { + /** setup telegram and its file proxy */ + constructor(opts: telegram_config) { super(); this.bot = new Bot(opts.token); this.bot.start(); this.bot.on(['message', 'edited_message'], async (ctx) => { - const msg = await getIncomingMessage(ctx, opts.proxy_url); + const msg = await get_incoming(ctx, opts.proxy_url); if (msg) this.emit('create_message', msg); }); @@ -40,6 +60,19 @@ export default class TelegramPlugin extends plugin { `[telegram] proxy available at localhost:${port} or ${opts.proxy_url}`, ); }, + onError: (e) => + new Response( + JSON.stringify( + new LightningError(e, { + message: `something went wrong with the telegram file proxy`, + }).msg, + ), + { + status: 500, + statusText: 'internal server error', + headers: { 'Content-Type': 'application/json' }, + }, + ), }, (req: Request) => { const { pathname } = new URL(req.url); return fetch( @@ -50,17 +83,19 @@ export default class TelegramPlugin extends plugin { }); } + /** stub for setup_channel */ setup_channel(channel: string): unknown { return channel; } + /** send a message in a channel */ async create_message( message: message, - data?: bridge_message_opts, + data: bridge_message_opts, ): Promise { const messages = []; - for (const msg of getOutgoingMessage(message, data !== undefined)) { + for (const msg of get_outgoing(message, data !== undefined)) { const result = await this.bot.api[msg.function]( message.channel_id, msg.value, @@ -80,6 +115,7 @@ export default class TelegramPlugin extends plugin { return messages; } + /** edit a message in a channel */ async edit_message( message: message, opts: bridge_message_opts & { edit_ids: string[] }, @@ -87,7 +123,7 @@ export default class TelegramPlugin extends plugin { await this.bot.api.editMessageText( opts.channel.id, Number(opts.edit_ids[0]), - getOutgoingMessage(message, true)[0].value, + get_outgoing(message, true)[0].value, { parse_mode: 'MarkdownV2', }, @@ -96,14 +132,16 @@ export default class TelegramPlugin extends plugin { return opts.edit_ids; } + /** delete messages in a channel */ async delete_messages(messages: deleted_message[]): Promise { - const successful: string[] = []; - - for (const msg of messages) { - await this.bot.api.deleteMessage(msg.channel_id, Number(msg.message_id)); - successful.push(msg.message_id); - } - - return successful; + return await Promise.all( + messages.map(async (msg) => { + await this.bot.api.deleteMessage( + msg.channel_id, + Number(msg.message_id), + ); + return msg.message_id; + }), + ); } } diff --git a/packages/telegram/src/outgoing.ts b/packages/telegram/src/outgoing.ts index 54eb4b33..d04cf20f 100644 --- a/packages/telegram/src/outgoing.ts +++ b/packages/telegram/src/outgoing.ts @@ -1,7 +1,7 @@ -import convert_markdown from 'telegramify-markdown'; import type { message } from '@jersey/lightning'; +import convert_markdown from 'telegramify-markdown'; -export function getOutgoingMessage( +export function get_outgoing( msg: message, bridged: boolean, ): { function: 'sendMessage' | 'sendDocument'; value: string }[] { diff --git a/todo.md b/todo.md deleted file mode 100644 index 0bc7e23d..00000000 --- a/todo.md +++ /dev/null @@ -1,2 +0,0 @@ -- potentially replace valibot with our own validator -- sort all imports \ No newline at end of file From 30b02d51850768a60218e85251e37e0e158c5438 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 18 Apr 2025 13:47:21 -0400 Subject: [PATCH 55/97] sort imports, fix bridge error messages, make db migration command nicer --- packages/guilded/src/incoming.ts | 110 +++++++--- packages/lightning/README.md | 47 ++--- packages/lightning/deno.json | 3 +- packages/lightning/logo.svg | 30 +-- packages/lightning/src/bridge/handler.ts | 10 +- packages/lightning/src/bridge/setup.ts | 2 +- packages/lightning/src/cli.ts | 6 +- packages/lightning/src/cli_config.ts | 2 +- packages/lightning/src/core.ts | 12 +- packages/lightning/src/database/mod.ts | 23 +-- packages/lightning/src/database/postgres.ts | 51 ++++- packages/lightning/src/database/redis.ts | 215 +++++++++++++------- 12 files changed, 327 insertions(+), 184 deletions(-) diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts index e6909e83..ef971cc5 100644 --- a/packages/guilded/src/incoming.ts +++ b/packages/guilded/src/incoming.ts @@ -1,15 +1,47 @@ import type { Client } from '@jersey/guildapi'; -import type { ChatMessage, ServerMember } from '@jersey/guilded-api-types'; +import type { + ChatMessage, + ServerMember, + Webhook, +} from '@jersey/guilded-api-types'; import type { attachment, message } from '@jersey/lightning'; +class cacher { + private map = new Map(); + public expiry = 30000; + get(key: K): V | undefined { + const time = Temporal.Now.instant().epochMilliseconds; + const v = this.map.get(key); + + if (v && v.expiry >= time) return v.value; + } + set(key: K, val: V): V { + const time = Temporal.Now.instant().epochMilliseconds; + this.map.set(key, { value: val, expiry: time + this.expiry }); + return val; + } +} + +const member_cache = new cacher<`${string}/${string}`, ServerMember>(); +const webhook_cache = new cacher<`${string}/${string}`, Webhook>(); +const asset_cache = new cacher(); +asset_cache.expiry = 86400000; // 1 day! + export async function fetch_author(msg: ChatMessage, client: Client) { try { if (!msg.createdByWebhookId) { - const { member: author } = await client.request( - 'get', - `/servers/${msg.serverId}/members/${msg.createdBy}`, - undefined, - ) as { member: ServerMember }; + const author = member_cache.get(`${msg.serverId}/${msg.createdBy}`) ?? + member_cache.set( + `${msg.serverId}/${msg.createdBy}`, + (await client.request( + 'get', + `/servers/${msg.serverId}/members/${msg.createdBy}`, + undefined, + ) as { member: ServerMember }).member, + ); return { username: author.nickname || author.user.name, @@ -18,11 +50,17 @@ export async function fetch_author(msg: ChatMessage, client: Client) { profile: author.user.avatar || undefined, }; } else { - const { webhook } = await client.request( - 'get', - `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, - undefined, - ); + const webhook = webhook_cache.get( + `${msg.serverId}/${msg.createdByWebhookId}`, + ) ?? + webhook_cache.set( + `${msg.serverId}/${msg.createdByWebhookId}`, + (await client.request( + 'get', + `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, + undefined, + )).webhook, + ); return { username: webhook.name, @@ -40,31 +78,39 @@ export async function fetch_author(msg: ChatMessage, client: Client) { } } -async function fetch_attachments(urls: string[], client: Client) { +async function fetch_attachments(markdown: string[], client: Client) { + const urls = markdown.map( + (url) => (url.split('(').pop())?.split(')')[0], + ).filter((i) => i !== undefined); + const attachments: attachment[] = []; - try { - const signed = await client.request('post', '/url-signatures', { - urls: urls.map( - (url) => (url.split('(').pop())?.split(')')[0], - ).filter((i) => i !== undefined), - }); - - for (const url of signed.urlSignatures) { - if (url.signature) { - const resp = await fetch(url.signature, { - method: 'HEAD', - }); - - attachments.push({ - name: url.signature.split('/').pop()?.split('?')[0] || 'unknown', - file: url.signature, - size: parseInt(resp.headers.get('Content-Length') || '0') / 1048576, - }); + for (const url of urls) { + const cached = asset_cache.get(url); + + if (cached) { + attachments.push(cached); + } else { + try { + const signed = (await client.request('post', '/url-signatures', { + urls: [url], + })).urlSignatures[0]; + + if (signed.retryAfter || !signed.signature) continue; + + attachments.push(asset_cache.set(signed.url, { + name: signed.signature.split('/').pop()?.split('?')[0] || 'unknown', + file: signed.signature, + size: parseInt( + (await fetch(signed.signature, { + method: 'HEAD', + })).headers.get('Content-Length') || '0', + ) / 1048576, + })); + } catch { + continue; } } - } catch { - // ignore } return attachments; diff --git a/packages/lightning/README.md b/packages/lightning/README.md index 9268cec0..a09c2e04 100644 --- a/packages/lightning/README.md +++ b/packages/lightning/README.md @@ -7,34 +7,21 @@ apps via plugins ## [docs](https://williamhorning.eu.org/lightning) -## example config - -```ts -import { discord_plugin } from 'jsr:@jersey/lightning-plugin-discord@0.8.0'; -import { revolt_plugin } from 'jsr:@jersey/lightning-plugin-revolt@0.8.0'; - -export default { - prefix: '!', - database: { - type: 'postgres', - config: { - user: 'server', - database: 'lightning', - hostname: 'postgres', - port: 5432, - host_type: 'tcp', - }, - }, - plugins: [ - discord_plugin.new({ - token: 'your_token', - application_id: 'your_application_id', - slash_commands: true, - }), - revolt_plugin.new({ - token: 'your_token', - user_id: 'your_bot_user_id', - }), - ], -}; +## `lightning.toml` example + +```toml +prefix = "!" + +[database] +type = "postgres" +config = "postgresql://server:password@postgres:5432/lightning" + +[[plugins]] +plugin = "jsr:@jersey/lightning-plugin-discord@0.8.0" +config.token = "your_token" + +[[plugins]] +plugin = "jsr:@jersey/lightning-plugin-revolt@0.8.0" +config.token = "your_token" +config.user_id = "your_bot_user_id" ``` diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 7ddf06fd..e305a1f8 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -7,6 +7,7 @@ "@db/postgres": "jsr:@jersey/test@0.8.6", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", "@iuioiua/redis": "jsr:@iuioiua/redis@^1.0.1", - "@std/toml": "jsr:@std/toml@^1.0.4" + "@std/toml": "jsr:@std/toml@^1.0.4", + "@std/cli": "jsr:@std/cli@^1.0.16" } } diff --git a/packages/lightning/logo.svg b/packages/lightning/logo.svg index c9db9d12..ef0c52ee 100644 --- a/packages/lightning/logo.svg +++ b/packages/lightning/logo.svg @@ -1,28 +1,28 @@ - + - - - + + + - + - - - - - - - + + + + + + + - + - - + + diff --git a/packages/lightning/src/bridge/handler.ts b/packages/lightning/src/bridge/handler.ts index f9d742ea..ade356f4 100644 --- a/packages/lightning/src/bridge/handler.ts +++ b/packages/lightning/src/bridge/handler.ts @@ -1,7 +1,7 @@ -import type { bridge_data } from '../database/mod.ts'; import type { core } from '../core.ts'; -import { LightningError } from '../structures/errors.ts'; +import type { bridge_data } from '../database/mod.ts'; import type { bridge_message, bridged_message } from '../structures/bridge.ts'; +import { LightningError } from '../structures/errors.ts'; import type { deleted_message, message } from '../structures/messages.ts'; export async function bridge_message( @@ -106,7 +106,11 @@ export async function bridge_message( if (!err.disable_channel) { try { - const result_ids = await plugin.create_message(err.msg); + const result_ids = await plugin.create_message({ + ...err.msg, + message_id: prior_bridged_ids?.id[0] ?? '', + channel_id: channel.id, + }); result_ids.forEach((id) => core.set_handled(channel.plugin, id)); } catch (e) { new LightningError(e, { diff --git a/packages/lightning/src/bridge/setup.ts b/packages/lightning/src/bridge/setup.ts index eb034abd..059a1130 100644 --- a/packages/lightning/src/bridge/setup.ts +++ b/packages/lightning/src/bridge/setup.ts @@ -1,5 +1,5 @@ -import { create_database, type database_config } from '../database/mod.ts'; import type { core } from '../core.ts'; +import { create_database, type database_config } from '../database/mod.ts'; import { create, join, leave, status, toggle } from './commands.ts'; import { bridge_message } from './handler.ts'; diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 3d4c93c9..fbe5404b 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -1,8 +1,8 @@ +import { setup_bridge } from './bridge/setup.ts'; import { parse_config } from './cli_config.ts'; -import { log_error } from './structures/errors.ts'; -import { handle_migration } from './database/mod.ts'; import { core } from './core.ts'; -import { setup_bridge } from './bridge/setup.ts'; +import { handle_migration } from './database/mod.ts'; +import { log_error } from './structures/errors.ts'; if (Deno.args[0] === 'migrate') { handle_migration(); diff --git a/packages/lightning/src/cli_config.ts b/packages/lightning/src/cli_config.ts index 75f1fca0..bb5c2a2f 100644 --- a/packages/lightning/src/cli_config.ts +++ b/packages/lightning/src/cli_config.ts @@ -1,6 +1,6 @@ import { parse as parse_toml } from '@std/toml'; -import type { database_config } from './database/mod.ts'; import type { core_config } from './core.ts'; +import type { database_config } from './database/mod.ts'; import { log_error } from './structures/errors.ts'; interface cli_plugin { diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 71da7af6..b0ce04ca 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -1,16 +1,16 @@ import { EventEmitter } from '@denosaurs/event'; -import type { - plugin, - events, - plugin_module, -} from './structures/plugins.ts'; -import { LightningError, log_error } from './structures/errors.ts'; import type { command, command_opts, create_command, } from './structures/commands.ts'; +import { LightningError, log_error } from './structures/errors.ts'; import { create_message, type message } from './structures/messages.ts'; +import type { + events, + plugin, + plugin_module, +} from './structures/plugins.ts'; export interface core_config { prefix?: string; diff --git a/packages/lightning/src/database/mod.ts b/packages/lightning/src/database/mod.ts index 0a48ab22..fa7238e8 100644 --- a/packages/lightning/src/database/mod.ts +++ b/packages/lightning/src/database/mod.ts @@ -1,3 +1,4 @@ +import { promptSelect } from '@std/cli/unstable-prompt-select'; import type { bridge, bridge_message } from '../structures/bridge.ts'; import { postgres } from './postgres.ts'; import { redis, type redis_config } from './redis.ts'; @@ -34,33 +35,29 @@ export async function create_database( case 'redis': return await redis.create(config.config); default: - throw new Error('invalid database type'); + throw new Error('invalid database type', { cause: config }); } } -function get_database( - type: string, -): typeof postgres | typeof redis { +function get_database(message: string): typeof postgres | typeof redis { + const type = promptSelect(message, ['redis', 'postgres']); + switch (type) { case 'postgres': return postgres; case 'redis': return redis; default: - throw new Error('invalid database type'); + throw new Error('invalid database type!'); } } export async function handle_migration() { - const start_type = prompt( - 'Please enter your starting database type (postgres, redis):', - ) ?? ''; - const start = await get_database(start_type).migration_get_instance(); + const start = await get_database('Please enter your starting database type: ') + .migration_get_instance(); - const end_type = prompt( - 'Please enter your ending database type (postgres, redis):', - ) ?? ''; - const end = await get_database(end_type).migration_get_instance(); + const end = await get_database('Please enter your ending database type: ') + .migration_get_instance(); console.log('Downloading bridges...'); let bridges = await start.migration_get_bridges(); diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index 5d5ec3f5..f4b9a7fa 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -1,8 +1,14 @@ -import { Client, type ClientOptions } from '@db/postgres'; +import { Client } from '@db/postgres'; +import { + ProgressBar, + type ProgressBarFormatter, +} from '@std/cli/unstable-progress-bar'; +import { Spinner } from '@std/cli/unstable-spinner'; import type { bridge, bridge_message } from '../structures/bridge.ts'; import type { bridge_data } from './mod.ts'; -export type { ClientOptions as postgres_config }; +const fmt = (fmt: ProgressBarFormatter) => + `[postgres] ${fmt.progressBar} ${fmt.styledTime()} [${fmt.value}/${fmt.max}]\n`; export class postgres implements bridge_data { static async create(pg_url: string): Promise { @@ -125,33 +131,61 @@ export class postgres implements bridge_data { } async migration_get_bridges(): Promise { + const spinner = new Spinner({ message: 'getting bridges from postgres' }); + + spinner.start(); + const res = await this.pg.queryObject(` SELECT * FROM bridges `); + spinner.stop(); + return res.rows; } async migration_get_messages(): Promise { + const spinner = new Spinner({ message: 'getting messages from postgres' }); + + spinner.start(); + const res = await this.pg.queryObject(` SELECT * FROM bridge_messages `); + spinner.stop(); + return res.rows; } async migration_set_messages(messages: bridge_message[]): Promise { + const progress = new ProgressBar(Deno.stdout.writable, { + max: messages.length, + fmt: fmt, + }); + for (const msg of messages) { + progress.add(1); + try { await this.create_message(msg); } catch { console.warn(`failed to insert message ${msg.id}`); } } + + progress.end(); } async migration_set_bridges(bridges: bridge[]): Promise { + const progress = new ProgressBar(Deno.stdout.writable, { + max: bridges.length, + fmt: fmt, + }); + for (const br of bridges) { + progress.add(1); + await this.pg.queryArray` INSERT INTO bridges (id, name, channels, settings) VALUES (${br.id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ @@ -159,14 +193,19 @@ export class postgres implements bridge_data { }) `; } + + progress.end(); } static async migration_get_instance(): Promise { + const default_url = `postgres://${ + Deno.env.get('USER') ?? Deno.env.get('USERNAME') + }@localhost/lightning`; + const pg_url = prompt( - 'Please enter your Postgres connection string (postgres://localhost):', - ) || - 'postgres://localhost'; + `Please enter your Postgres connection string (${default_url}):`, + ); - return await postgres.create(pg_url); + return await postgres.create(pg_url || default_url); } } diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index ac57a4c8..10008cc4 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -1,21 +1,30 @@ import { RedisClient } from '@iuioiua/redis'; -import type { bridge, bridge_message } from '../structures/bridge.ts'; -import type { bridge_data } from './mod.ts'; +import { + ProgressBar, + type ProgressBarFormatter, +} from '@std/cli/unstable-progress-bar'; +import type { + bridge, + bridge_channel, + bridge_message, + bridged_message, +} from '../structures/bridge.ts'; import { log_error } from '../structures/errors.ts'; +import type { bridge_data } from './mod.ts'; export type redis_config = Deno.ConnectOptions; +const fmt = (fmt: ProgressBarFormatter) => + `[redis] ${fmt.progressBar} ${fmt.styledTime()} [${fmt.value}/${fmt.max}]\n`; + export class redis implements bridge_data { - static async create(rd_options: Deno.ConnectOptions): Promise { + static async create( + rd_options: Deno.ConnectOptions, + _do_not_use = false, + ): Promise { const conn = await Deno.connect(rd_options); - const client = new RedisClient(conn); - - await this.migrate(client); + const rd = new RedisClient(conn); - return new this(client); - } - - static async migrate(rd: RedisClient): Promise { let db_data_version = await rd.sendCommand([ 'GET', 'lightning-db-version', @@ -27,80 +36,58 @@ export class redis implements bridge_data { if (number_keys === 0) db_data_version = '0.8.0'; } - if (db_data_version !== '0.8.0') { + if (db_data_version !== '0.8.0' && !_do_not_use) { console.warn( `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, ); - console.log('[lightning-redis] getting keys'); - - const all_keys = await rd.sendCommand([ - 'KEYS', - 'lightning-*', - ]) as string[]; - - console.log('[lightning-redis] got keys'); - - const new_data = await Promise.all(all_keys.map(async (key: string) => { - console.log(`[lightning-redis] migrating key ${key}`); - const type = await rd.sendCommand(['TYPE', key]) as string; - const value = await rd.sendCommand([ - type === 'string' ? 'GET' : 'JSON.GET', - key, - ]) as string; - - try { - const parsed = JSON.parse(value); - return [ - key, - JSON.stringify( - { - id: key.split('-')[2], - bridge_id: parsed.id, - channels: parsed.channels, - messages: parsed.messages, - name: parsed.id, - settings: { - allow_everyone: false, - }, - } as bridge | bridge_message, - ), - ]; - } catch { - return [key, value]; - } - })); + const instance = new this(rd, true); - Deno.writeTextFileSync( + console.log('[lightning-redis] getting bridges...'); + + const bridges = await instance.migration_get_bridges(); + + console.log('[lightning-redis] got bridges!'); + + await Deno.writeTextFile( 'lightning-redis-migration.json', - JSON.stringify(new_data, null, 2), + JSON.stringify(bridges, null, 2), ); - console.warn('[lightning-redis] do you want to continue?'); - - const write = confirm('write the data to the database?'); + const write = confirm( + '[lightning-redis] write the data to the database? see \`lightning-redis-migration.json\` for the data', + ); const env_confirm = Deno.env.get('LIGHTNING_MIGRATE_CONFIRM'); if (write || env_confirm === 'true') { - await rd.sendCommand(['DEL', ...all_keys]); - await rd.sendCommand([ - 'MSET', - 'lightning-db-version', - '0.8.0', - ...new_data.flat(1), - ]); + await instance.migration_set_bridges(bridges); + + const former_messages = await rd.sendCommand([ + 'KEYS', + 'lightning-bridged-*', + ]) as string[]; + + for (const key of former_messages) { + await rd.sendCommand(['DEL', key]); + } console.warn('[lightning-redis] data written to database'); + + return instance; } else { - console.warn('[lightning-redis] data not written to database'); - log_error('migration cancelled'); + log_error('[lightning-redis] data not written to database', { + without_cause: true, + }); } + } else { + return new this(rd, _do_not_use); } } - private constructor(public redis: RedisClient) { - this.redis = redis; - } + private constructor( + public redis: RedisClient, + private seven = false, + ) {} async get_json(key: string): Promise { const reply = await this.redis.sendCommand(['GET', key]); @@ -151,13 +138,13 @@ export class redis implements bridge_data { `lightning-bchannel-${ch}`, ]); if (!channel || channel === 'OK') return; - return await this.get_json(`lightning-bridge-${channel}`); + return await this.get_bridge_by_id(channel as string); } async create_message(msg: bridge_message): Promise { await this.redis.sendCommand([ 'SET', - 'lightning-message-${msg.id}', + `lightning-message-${msg.id}`, JSON.stringify(msg), ]); @@ -192,19 +179,81 @@ export class redis implements bridge_data { const bridges = [] as bridge[]; + const progress = new ProgressBar(Deno.stdout.writable, { + max: keys.length, + fmt, + }); + for (const key of keys) { - const bridge = await this.get_bridge_by_id( - key.replace('lightning-bridge-', ''), - ); + progress.add(1); + if (!this.seven) { + const bridge = await this.get_bridge_by_id( + key.replace('lightning-bridge-', ''), + ); + + if (bridge) bridges.push(bridge); + } else { + // ignore UUIDs and ULIDs + if ( + key.replace('lightning-bridge-', '').match( + /[0-7][0-9A-HJKMNP-TV-Z]{25}/gm, + ) || + key.replace('lightning-bridge-', '').match( + /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + ) + ) { + continue; + } - if (bridge) bridges.push(bridge); + const bridge = await this.get_json<{ + allow_editing: boolean; + channels: bridge_channel[]; + id: string; + messages?: bridged_message[]; + use_rawname: boolean; + }>(key); + + if (bridge && bridge.channels) { + bridges.push({ + id: key.replace('lightning-bridge-', ''), + name: bridge.id, + channels: bridge.channels, + settings: { + allow_everyone: false, + }, + }); + } + } } + progress.end(); + return bridges; } async migration_set_bridges(bridges: bridge[]): Promise { + const progress = new ProgressBar(Deno.stdout.writable, { + max: bridges.length, + fmt, + }); + for (const bridge of bridges) { + progress.add(1); + + await this.redis.sendCommand([ + 'DEL', + `lightning-bridge-${bridge.id}`, + ]); + + for (const channel of bridge.channels) { + await this.redis.sendCommand([ + 'DEL', + `lightning-bchannel-${channel.id}`, + ]); + } + + if (bridge.channels.length < 2) continue; + await this.redis.sendCommand([ 'SET', `lightning-bridge-${bridge.id}`, @@ -219,6 +268,10 @@ export class redis implements bridge_data { ]); } } + + progress.end(); + + await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); } async migration_get_messages(): Promise { @@ -229,19 +282,35 @@ export class redis implements bridge_data { const messages = [] as bridge_message[]; + const progress = new ProgressBar(Deno.stdout.writable, { + max: keys.length, + fmt, + }); + for (const key of keys) { + progress.add(1); const message = await this.get_json(key); if (message) messages.push(message); } + progress.end(); + return messages; } async migration_set_messages(messages: bridge_message[]): Promise { + const progress = new ProgressBar(Deno.stdout.writable, { + max: messages.length, + fmt, + }); + for (const message of messages) { + progress.add(1); await this.create_message(message); } + progress.end(); + await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); } @@ -254,6 +323,6 @@ export class redis implements bridge_data { hostname, port: parseInt(port), transport: 'tcp', - }); + }, true); } } From 25534b774adaf64df04e73231f4d229d3959e4da Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 18 Apr 2025 17:41:03 -0400 Subject: [PATCH 56/97] update dependencies --- .github/workflows/publish.yml | 2 +- packages/guilded/src/mod.ts | 2 +- packages/lightning/deno.json | 2 +- packages/lightning/src/core.ts | 6 +----- packages/revolt/src/mod.ts | 4 ++-- packages/telegram/deno.json | 2 +- 6 files changed, 7 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a7e2b7fa..87cbdb87 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: - name: setup deno uses: denoland/setup-deno@v1 with: - deno-version: v2.2.9 + deno-version: v2.2.11 - name: publish to jsr run: deno publish publish_docker: diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 711fe098..d2406d2f 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -149,6 +149,6 @@ export default class guilded extends plugin { handle_error(e, msg.channel_id, true); return msg.message_id; } - })) + })); } } diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index e305a1f8..5eb46932 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -6,7 +6,7 @@ "imports": { "@db/postgres": "jsr:@jersey/test@0.8.6", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", - "@iuioiua/redis": "jsr:@iuioiua/redis@^1.0.1", + "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.1", "@std/toml": "jsr:@std/toml@^1.0.4", "@std/cli": "jsr:@std/cli@^1.0.16" } diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index b0ce04ca..3631e180 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -6,11 +6,7 @@ import type { } from './structures/commands.ts'; import { LightningError, log_error } from './structures/errors.ts'; import { create_message, type message } from './structures/messages.ts'; -import type { - events, - plugin, - plugin_module, -} from './structures/plugins.ts'; +import type { events, plugin, plugin_module } from './structures/plugins.ts'; export interface core_config { prefix?: string; diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index b275a9c5..b0328597 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -57,7 +57,7 @@ export default class revolt extends plugin { this.emit('delete_message', { channel_id: data.channel, message_id: data.id, - plugin: "bolt-revolt", + plugin: 'bolt-revolt', timestamp: Temporal.Now.instant(), }); }).on('MessageUpdate', async (data) => { @@ -137,7 +137,7 @@ export default class revolt extends plugin { handle_error(e); return msg.message_id; } - }) + }), ); } } diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index 9bc3cd41..d1358cea 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -6,6 +6,6 @@ "imports": { "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0", - "grammy": "npm:grammy@^1.35.1" + "grammy": "npm:grammy@^1.36.0" } } From 565f19a425d5e73a008e54a0350e3e482b1e7515 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 19 Apr 2025 23:56:29 -0400 Subject: [PATCH 57/97] require id to leave bridges --- packages/lightning/src/bridge/commands.ts | 4 ++++ packages/lightning/src/bridge/setup.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/lightning/src/bridge/commands.ts b/packages/lightning/src/bridge/commands.ts index 3b50636a..decc7e3e 100644 --- a/packages/lightning/src/bridge/commands.ts +++ b/packages/lightning/src/bridge/commands.ts @@ -99,6 +99,10 @@ export async function leave( if (!bridge) return `You are not in a bridge`; + if (opts.args.id !== bridge.id) { + return `You must provide the bridge id in order to leave this bridge`; + } + bridge.channels = bridge.channels.filter(( ch, ) => ch.id !== opts.channel_id); diff --git a/packages/lightning/src/bridge/setup.ts b/packages/lightning/src/bridge/setup.ts index 059a1130..ff4ac790 100644 --- a/packages/lightning/src/bridge/setup.ts +++ b/packages/lightning/src/bridge/setup.ts @@ -47,6 +47,11 @@ export async function setup_bridge(core: core, config: database_config) { { name: 'leave', description: 'leave the current bridge', + arguments: [{ + name: 'id', + description: 'id of the current bridge', + required: true, + }], execute: (o) => leave(database, o), }, { From 257657b23d11d90843b0235f9ffe6c484a120878 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 12:07:13 -0400 Subject: [PATCH 58/97] move to the lightning scope for jsr and the github repo --- containerfile | 8 +++---- packages/discord/README.md | 20 +++++++--------- packages/discord/deno.json | 4 ++-- packages/discord/src/commands.ts | 2 +- packages/discord/src/errors.ts | 2 +- packages/discord/src/incoming.ts | 2 +- packages/discord/src/mod.ts | 4 ++-- packages/discord/src/outgoing.ts | 2 +- packages/guilded/README.md | 19 ++++++--------- packages/guilded/deno.json | 4 ++-- packages/guilded/src/errors.ts | 2 +- packages/guilded/src/incoming.ts | 2 +- packages/guilded/src/mod.ts | 2 +- packages/guilded/src/outgoing.ts | 2 +- packages/lightning/README.md | 6 ++--- packages/lightning/deno.json | 2 +- packages/lightning/src/structures/bridge.ts | 2 +- packages/lightning/src/structures/commands.ts | 2 +- packages/lightning/src/structures/messages.ts | 4 ++-- packages/revolt/README.md | 20 ++++++---------- packages/revolt/deno.json | 4 ++-- packages/revolt/src/cache.ts | 2 +- packages/revolt/src/errors.ts | 2 +- packages/revolt/src/incoming.ts | 2 +- packages/revolt/src/mod.ts | 2 +- packages/revolt/src/outgoing.ts | 2 +- packages/revolt/src/permissions.ts | 2 +- packages/telegram/README.md | 24 +++++++++---------- packages/telegram/deno.json | 4 ++-- packages/telegram/src/incoming.ts | 8 +++---- packages/telegram/src/mod.ts | 2 +- packages/telegram/src/outgoing.ts | 2 +- readme.md | 8 +++---- 33 files changed, 79 insertions(+), 96 deletions(-) diff --git a/containerfile b/containerfile index 32da3b71..7ffdbba5 100644 --- a/containerfile +++ b/containerfile @@ -1,16 +1,16 @@ -FROM denoland/deno:alpine-2.2.9@sha256:40a7d91338f861e308c3dfe1346f5487d83097cdb898a2e125c05df3cd4841a8 +FROM denoland/deno:alpine-2.2.11@sha256:c6c801a49a98f295f46176fba6172a1a656decd7dfb086a499fe863f595b922b # metadata LABEL org.opencontainers.image.authors Jersey -LABEL org.opencontainers.image.url https://github.com/williamhorning/bolt -LABEL org.opencontainers.image.source https://github.com/williamhorning/bolt +LABEL org.opencontainers.image.url https://github.com/williamhorning/lightning +LABEL org.opencontainers.image.source https://github.com/williamhorning/lightning LABEL org.opencontainers.image.documentation https://williamhorning.eu.org/lightning LABEL org.opencontainers.image.version 0.8.0 LABEL org.opencontainers.image.licenses MIT LABEL org.opencontainers.image.title lightning # install lightning -RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@jersey/test@0.9.0"] +RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/test@0.9.0"] # run as nobody instead of root USER nobody diff --git a/packages/discord/README.md b/packages/discord/README.md index 8b6d278b..bbdad548 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -1,18 +1,14 @@ -# lightning-plugin-discord +# @lightning/discord -lightning-plugin-discord is a plugin for -[lightning](https://williamhorning.eu.org/lightning) that adds support for -discord +[![JSR](https://jsr.io/badges/@lightning/discord)](https://jsr.io/@lightning/discord) -## example config +@lightning/discord adds support for Discord to Lightning. To use it, you'll +first need to create a Discord bot at the +[Discord Developer Portal](https://discord.com/developers/applications). After +you do that, you will need to add the following to your `lightning.toml` file: ```toml -# lightning.toml -# ... - [[plugins]] -plugin = "jsr:@jersey/lightning-plugin-discord@0.8.0" -config.token = "YOUR_DISCORD_TOKEN" - -# ... +plugin = "jsr:@lightning/discord@0.8.0" +config.token = "your_bot_token" ``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json index 7e93a22f..979c87fa 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,5 +1,5 @@ { - "name": "@jersey/lightning-plugin-discord", + "name": "@lightning/discord", "version": "0.8.0", "license": "MIT", "exports": "./src/mod.ts", @@ -7,6 +7,6 @@ "@discordjs/core": "npm:@discordjs/core@^2.0.1", "@discordjs/rest": "npm:@discordjs/rest@^2.4.3", "@discordjs/ws": "npm:@discordjs/ws@^2.0.1", - "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0" + "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0" } } diff --git a/packages/discord/src/commands.ts b/packages/discord/src/commands.ts index 427bc6ac..37325610 100644 --- a/packages/discord/src/commands.ts +++ b/packages/discord/src/commands.ts @@ -1,5 +1,5 @@ import type { API } from '@discordjs/core'; -import type { command } from '@jersey/lightning'; +import type { command } from '@lightning/lightning'; export async function setup_commands( api: API, diff --git a/packages/discord/src/errors.ts b/packages/discord/src/errors.ts index e804ca89..a5c8c6c6 100644 --- a/packages/discord/src/errors.ts +++ b/packages/discord/src/errors.ts @@ -1,5 +1,5 @@ import { DiscordAPIError } from '@discordjs/rest'; -import { log_error } from '@jersey/lightning'; +import { log_error } from '@lightning/lightning'; export function handle_error( err: unknown, diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts index 329a4a9c..0a29a25b 100644 --- a/packages/discord/src/incoming.ts +++ b/packages/discord/src/incoming.ts @@ -11,7 +11,7 @@ import type { create_command, deleted_message, message, -} from '@jersey/lightning'; +} from '@lightning/lightning'; import { get_outgoing_message } from './outgoing.ts'; export function get_deleted_message( diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 1ca422de..42210987 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -8,7 +8,7 @@ import { log_error, type message, plugin, -} from '@jersey/lightning'; +} from '@lightning/lightning'; import { setup_commands } from './commands.ts'; import { handle_error } from './errors.ts'; import { @@ -141,7 +141,7 @@ export default class discord extends plugin { } } - /** edut a message sent by webhook */ + /** edit a message sent by webhook */ async edit_message( message: message, data: bridge_message_opts & { edit_ids: string[] }, diff --git a/packages/discord/src/outgoing.ts b/packages/discord/src/outgoing.ts index 4b59f75a..8027e6dd 100644 --- a/packages/discord/src/outgoing.ts +++ b/packages/discord/src/outgoing.ts @@ -6,7 +6,7 @@ import { type RESTPostAPIWebhookWithTokenJSONBody, type RESTPostAPIWebhookWithTokenQuery, } from '@discordjs/core'; -import type { attachment, message } from '@jersey/lightning'; +import type { attachment, message } from '@lightning/lightning'; export interface discord_payload extends diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 8b967b1f..72580ac0 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -1,18 +1,13 @@ -# lightning-plugin-guilded +# @lightning/guilded -lightning-plugin-guilded is a plugin for -[lightning](https://williamhorning.eu.org/lightning) that adds support for -guilded +[![JSR](https://jsr.io/badges/@lightning/guilded)](https://jsr.io/@lightning/guilded) -## example config +@lightning/guilded adds support for Guilded. To use it, you'll first need to +create a Guilded bot. After you do that, you'll need to add the following to +your `lightning.toml` file: ```toml -# lightning.toml -# ... - [[plugins]] -plugin = "jsr:@jersey/lightning-plugin-guilded@0.8.0" -config.token = "YOUR_GUILDED_TOKEN" - -# ... +plugin = "jsr:@lightning/guilded@0.8.0" +config.token = "your_bot_token" ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 8b4e0211..bec85d84 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,10 +1,10 @@ { - "name": "@jersey/lightning-plugin-guilded", + "name": "@lightning/guilded", "version": "0.8.0", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", + "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0", "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.4", "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.2" } diff --git a/packages/guilded/src/errors.ts b/packages/guilded/src/errors.ts index c05faf1b..82cfcaf7 100644 --- a/packages/guilded/src/errors.ts +++ b/packages/guilded/src/errors.ts @@ -1,5 +1,5 @@ import { RequestError } from '@jersey/guilded-api-types'; -import { log_error } from '@jersey/lightning'; +import { log_error } from '@lightning/lightning'; export function handle_error(err: unknown, channel: string, edit?: boolean) { if (err instanceof RequestError) { diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts index ef971cc5..cd3bd62d 100644 --- a/packages/guilded/src/incoming.ts +++ b/packages/guilded/src/incoming.ts @@ -4,7 +4,7 @@ import type { ServerMember, Webhook, } from '@jersey/guilded-api-types'; -import type { attachment, message } from '@jersey/lightning'; +import type { attachment, message } from '@lightning/lightning'; class cacher { private map = new Map[]; /** the functionality of the command, returning text */ execute: ( diff --git a/packages/lightning/src/structures/messages.ts b/packages/lightning/src/structures/messages.ts index 82b64dfd..f9e36f46 100644 --- a/packages/lightning/src/structures/messages.ts +++ b/packages/lightning/src/structures/messages.ts @@ -39,7 +39,7 @@ export interface deleted_message { message_id: string; /** the channel the message was sent in */ channel_id: string; - /** the plugin that recieved the message */ + /** the plugin that received the message */ plugin: string; /** the time the message was sent/edited as a temporal instant */ timestamp: Temporal.Instant; @@ -100,7 +100,7 @@ export interface media { width?: number; } -/** a message recieved by a plugin */ +/** a message received by a plugin */ export interface message extends deleted_message { /** the attachments sent with the message */ attachments?: attachment[]; diff --git a/packages/revolt/README.md b/packages/revolt/README.md index 19855a7c..8e3de721 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -1,19 +1,13 @@ -# lightning-plugin-revolt +# @lightning/revolt -lightning-plugin-revolt is a plugin for -[lightning](https://williamhorning.eu.org/lightning) that adds support for -telegram +[![JSR](https://jsr.io/badges/@lightning/revolt)](https://jsr.io/@lightning/revolt) -## example config +@lightning/telegram adds support for Revolt. To use it, you'll need to create a +Revolt bot first. After that, you need to add the following to your config file: ```toml -# lightning.toml -# ... - [[plugins]] -plugin = "jsr:@jersey/lightning-plugin-revolt@0.8.0" -config.token = "YOUR_REVOLT_TOKEN" -config.user_id = "YOUR_BOT_USER_ID" - -# ... +plugin = "jsr:@lightning/revolt@0.8.0" +config.token = "your_bot_token" +config.user_id = "your_bot_user_id" ``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index a96d9299..82a5f931 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,10 +1,10 @@ { - "name": "@jersey/lightning-plugin-revolt", + "name": "@lightning/revolt", "version": "0.8.0", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", + "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.7", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.3", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/revolt/src/cache.ts b/packages/revolt/src/cache.ts index 08a624f0..37081af2 100644 --- a/packages/revolt/src/cache.ts +++ b/packages/revolt/src/cache.ts @@ -1,4 +1,4 @@ -import type { message_author } from '@jersey/lightning'; +import type { message_author } from '@lightning/lightning'; import type { Channel, Masquerade, diff --git a/packages/revolt/src/errors.ts b/packages/revolt/src/errors.ts index 4cbb122d..2dd27a3d 100644 --- a/packages/revolt/src/errors.ts +++ b/packages/revolt/src/errors.ts @@ -1,4 +1,4 @@ -import { log_error } from '@jersey/lightning'; +import { log_error } from '@lightning/lightning'; import { MediaError, RequestError } from '@jersey/rvapi'; export function handle_error(err: unknown, edit?: boolean) { diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts index 35700d0a..ce45e080 100644 --- a/packages/revolt/src/incoming.ts +++ b/packages/revolt/src/incoming.ts @@ -1,4 +1,4 @@ -import type { embed, message } from '@jersey/lightning'; +import type { embed, message } from '@lightning/lightning'; import type { Message as APIMessage } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; import { decodeTime } from '@std/ulid'; diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index b0328597..375a489a 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -4,7 +4,7 @@ import { log_error, type message, plugin, -} from '@jersey/lightning'; +} from '@lightning/lightning'; import type { Message as APIMessage } from '@jersey/revolt-api-types'; import { type Client, createClient } from '@jersey/rvapi'; import { fetch_message } from './cache.ts'; diff --git a/packages/revolt/src/outgoing.ts b/packages/revolt/src/outgoing.ts index 28823837..d831e06c 100644 --- a/packages/revolt/src/outgoing.ts +++ b/packages/revolt/src/outgoing.ts @@ -2,7 +2,7 @@ import { type attachment, LightningError, type message, -} from '@jersey/lightning'; +} from '@lightning/lightning'; import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index 7b38eaa8..64faa7ce 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -1,4 +1,4 @@ -import { LightningError, log_error } from '@jersey/lightning'; +import { LightningError, log_error } from '@lightning/lightning'; import type { Client } from '@jersey/rvapi'; import { fetch_channel, diff --git a/packages/telegram/README.md b/packages/telegram/README.md index 63cd8d7d..812011f5 100644 --- a/packages/telegram/README.md +++ b/packages/telegram/README.md @@ -1,20 +1,18 @@ -# lightning-plugin-telegram +# @lightning/telegram -lightning-plugin-telegram is a plugin for -[lightning](https://williamhorning.eu.org/lightning) that adds support for -telegram (including attachments via the included file proxy) +[![JSR](https://jsr.io/badges/@lightning/telegram)](https://jsr.io/@lightning/telegram) -## example config +@lightning/telegram adds support for Telegram. Before using it, you'll need to +talk with @BotFather to create a bot. After that, you need to add the following +to your config: ```toml -# lightning.toml -# ... - [[plugins]] -plugin = "jsr:@jersey/lightning-plugin-telegram@0.8.0" -config.token = "YOUR_TELEGRAM_TOKEN" +plugin = "jsr:@lightning/telegram" +config.token = "your_bot_token" config.proxy_port = 9090 -config.proxy_url = "http://localhost:9090" - -# ... +config.proxy_url = "https://example.com:9090" ``` + +Additionally, you will need to expose the port provided at the URL provided for +attachments sent from Telegram to work properly diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index d1358cea..1c106b1a 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,10 +1,10 @@ { - "name": "@jersey/lightning-plugin-telegram", + "name": "@lightning/telegram", "version": "0.8.0", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@jersey/lightning": "jsr:@jersey/lightning@^0.8.0", + "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0", "grammy": "npm:grammy@^1.36.0" } diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts index db724380..2698ab6a 100644 --- a/packages/telegram/src/incoming.ts +++ b/packages/telegram/src/incoming.ts @@ -1,4 +1,4 @@ -import type { message } from '@jersey/lightning'; +import type { message } from '@lightning/lightning'; import type { Context } from 'grammy'; const types = [ @@ -23,7 +23,7 @@ export async function get_incoming( const msg = ctx.editedMessage || ctx.msg; if (!msg) return; const author = await ctx.getAuthor(); - const pfps = await ctx.getUserProfilePhotos({ limit: 1 }); + const profile = await ctx.getUserProfilePhotos({ limit: 1 }); const type = types.find((type) => type in msg) ?? 'unsupported'; const base: message = { author: { @@ -32,9 +32,9 @@ export async function get_incoming( : author.user.first_name, rawname: author.user.username || author.user.first_name, color: '#24A1DE', - profile: pfps.total_count + profile: profile.total_count ? `${proxy}/${ - (await ctx.api.getFile(pfps.photos[0][0].file_id)).file_path + (await ctx.api.getFile(profile.photos[0][0].file_id)).file_path }` : undefined, id: author.user.id.toString(), diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 80eb5fab..98017652 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -5,7 +5,7 @@ import { log_error, type message, plugin, -} from '@jersey/lightning'; +} from '@lightning/lightning'; import { Bot } from 'grammy'; import { get_incoming } from './incoming.ts'; import { get_outgoing } from './outgoing.ts'; diff --git a/packages/telegram/src/outgoing.ts b/packages/telegram/src/outgoing.ts index d04cf20f..843b87a8 100644 --- a/packages/telegram/src/outgoing.ts +++ b/packages/telegram/src/outgoing.ts @@ -1,4 +1,4 @@ -import type { message } from '@jersey/lightning'; +import type { message } from '@lightning/lightning'; import convert_markdown from 'telegramify-markdown'; export function get_outgoing( diff --git a/readme.md b/readme.md index b8a23215..a1b94576 100644 --- a/readme.md +++ b/readme.md @@ -32,15 +32,15 @@ messaging apps in your community, and it becomes a mess. Now, you could just say "_X is the only chat app we're using from now on_", but that risks alienating your community. -What other options are there? Bridging! Everyone gets to use their prefered app +What other options are there? Bridging! Everyone gets to use their preferred app of choice, gets the same messages, and is on the same page. ## prior art -Many bridges have existed before the existance of lightning, however, many of +Many bridges have existed before the existence of lightning, however, many of these solutions have had issues. Some bridges didn't play well with others, -others didn't handle attachments, others refused to handle embeded media, and it -was a mess. With lightning, part of the goal was to solve these issues by +others didn't handle attachments, others refused to handle embedded media, and +it was a mess. With lightning, part of the goal was to solve these issues by bringing many platforms into one tool, having it become the handler of truth. ## supported platforms From 19dd430ce3810e757a88b7732f14f2356b2d9d56 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 13:18:10 -0400 Subject: [PATCH 59/97] replace file proxy with oak. adds support for node and bun --- packages/telegram/deno.json | 5 +++-- packages/telegram/src/mod.ts | 42 +++++++++++------------------------- 2 files changed, 16 insertions(+), 31 deletions(-) diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index 1c106b1a..7ca4276e 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -5,7 +5,8 @@ "exports": "./src/mod.ts", "imports": { "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0", - "telegramify-markdown": "npm:telegramify-markdown@^1.3.0", - "grammy": "npm:grammy@^1.36.0" + "@oak/oak": "jsr:@oak/oak@^17.1.4", + "grammy": "npm:grammy@^1.36.0", + "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" } } diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 98017652..52b7e535 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -1,11 +1,12 @@ import { type bridge_message_opts, type deleted_message, - LightningError, log_error, type message, plugin, } from '@lightning/lightning'; +import { Application } from '@oak/oak/application'; +import { proxy } from '@oak/oak/proxy'; import { Bot } from 'grammy'; import { get_incoming } from './incoming.ts'; import { get_outgoing } from './outgoing.ts'; @@ -53,34 +54,17 @@ export default class telegram extends plugin { if (msg) this.emit('create_message', msg); }); - Deno.serve({ - port: opts.proxy_port, - onListen: ({ port }) => { - console.log( - `[telegram] proxy available at localhost:${port} or ${opts.proxy_url}`, - ); - }, - onError: (e) => - new Response( - JSON.stringify( - new LightningError(e, { - message: `something went wrong with the telegram file proxy`, - }).msg, - ), - { - status: 500, - statusText: 'internal server error', - headers: { 'Content-Type': 'application/json' }, - }, - ), - }, (req: Request) => { - const { pathname } = new URL(req.url); - return fetch( - `https://api.telegram.org/file/bot${opts.token}/${ - pathname.replace('/telegram/', '') - }`, - ); - }); + const app = new Application().use( + proxy(`https://api.telegram.org/file/bot${opts.token}/`, { + map: (path) => path.replace('/telegram/', ''), + }), + ); + + app.listen({ port: opts.proxy_port }); + + console.log( + `[telegram] proxy available at localhost:${opts.proxy_port} or ${opts.proxy_url}`, + ); } /** stub for setup_channel */ From 2c474ec18a9277680719a84678ba06f09e3dcdfa Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 15:05:30 -0400 Subject: [PATCH 60/97] move to hopefully be cross-compatible --- packages/lightning/deno.json | 8 +++- packages/lightning/src/cli.ts | 12 ++++-- packages/lightning/src/cli_config.ts | 30 ++++---------- packages/lightning/src/database/postgres.ts | 8 ++-- packages/lightning/src/database/redis.ts | 46 ++++++++++++++++----- packages/lightning/src/structures/errors.ts | 3 +- 6 files changed, 65 insertions(+), 42 deletions(-) diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 6e041b8c..99bfe0c5 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -4,10 +4,14 @@ "license": "MIT", "exports": "./src/mod.ts", "imports": { + "@cross/env": "jsr:@cross/env@^1.0.2", + "@cross/fs": "jsr:@cross/fs@^0.1.12", + "@cross/utils": "jsr:@cross/utils@^0.16.0", "@db/postgres": "jsr:@jersey/test@0.8.6", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.1", - "@std/toml": "jsr:@std/toml@^1.0.4", - "@std/cli": "jsr:@std/cli@^1.0.16" + "@std/cli": "jsr:@std/cli@^1.0.16", + "@std/fs": "jsr:@std/fs@^1.0.16", + "@std/toml": "jsr:@std/toml@^1.0.4" } } diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index fbe5404b..82ed5d7c 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -1,15 +1,19 @@ +import { cwd } from '@cross/fs'; +import { args as getArgs } from '@cross/utils'; import { setup_bridge } from './bridge/setup.ts'; import { parse_config } from './cli_config.ts'; import { core } from './core.ts'; import { handle_migration } from './database/mod.ts'; import { log_error } from './structures/errors.ts'; -if (Deno.args[0] === 'migrate') { +const args = getArgs(); + +if (args[0] === 'migrate') { handle_migration(); -} else if (Deno.args[0] === 'run') { +} else if (args[0] === 'run') { try { const config = await parse_config( - new URL(Deno.args[1] ?? 'lightning.toml', `file://${Deno.cwd()}/`), + new URL(args[1] ?? 'lightning.toml', `file://${cwd()}/`), ); const lightning = new core(config); await setup_bridge(lightning, config.database); @@ -19,7 +23,7 @@ if (Deno.args[0] === 'migrate') { without_cause: true, }); } -} else if (Deno.args[0] === 'version') { +} else if (args[0] === 'version') { console.log('0.8.0'); } else { console.log( diff --git a/packages/lightning/src/cli_config.ts b/packages/lightning/src/cli_config.ts index bb5c2a2f..862c4788 100644 --- a/packages/lightning/src/cli_config.ts +++ b/packages/lightning/src/cli_config.ts @@ -1,3 +1,5 @@ +import { setEnv } from '@cross/env'; +import { readTextFile } from '@std/fs/unstable-read-text-file'; import { parse as parse_toml } from '@std/toml'; import type { core_config } from './core.ts'; import type { database_config } from './database/mod.ts'; @@ -15,7 +17,7 @@ interface config extends core_config { export async function parse_config(path: URL): Promise { try { - const file = await Deno.readTextFile(path); + const file = await readTextFile(path); const raw = parse_toml(file) as Record; if ( @@ -75,28 +77,14 @@ export async function parse_config(path: URL): Promise { }); } - Deno.env.set('LIGHTNING_ERROR_WEBHOOK', validated.error_url ?? ''); + setEnv('LIGHTNING_ERROR_WEBHOOK', validated.error_url ?? ''); return { ...validated, plugins }; } catch (e) { - if ( - e instanceof Deno.errors.NotFound || - e instanceof Deno.errors.PermissionDenied - ) { - log_error(e, { - message: `could not open your \`lightning.toml\` at \`${path}\``, - without_cause: true, - }); - } else if (e instanceof SyntaxError) { - log_error(e, { - message: `could not parse your \`lightning.toml\` file at ${path}`, - without_cause: true, - }); - } else { - log_error(e, { - message: `unknown issue with your \`lightning.toml\` file at ${path}`, - without_cause: true, - }); - } + log_error(e, { + message: + `could not open or parse your \`lightning.toml\` file at ${path}`, + without_cause: true, + }); } } diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index f4b9a7fa..2b55819d 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -1,3 +1,5 @@ +import { getEnv } from '@cross/env'; +import { stdout } from '@cross/utils'; import { Client } from '@db/postgres'; import { ProgressBar, @@ -159,7 +161,7 @@ export class postgres implements bridge_data { } async migration_set_messages(messages: bridge_message[]): Promise { - const progress = new ProgressBar(Deno.stdout.writable, { + const progress = new ProgressBar(stdout(), { max: messages.length, fmt: fmt, }); @@ -178,7 +180,7 @@ export class postgres implements bridge_data { } async migration_set_bridges(bridges: bridge[]): Promise { - const progress = new ProgressBar(Deno.stdout.writable, { + const progress = new ProgressBar(stdout(), { max: bridges.length, fmt: fmt, }); @@ -199,7 +201,7 @@ export class postgres implements bridge_data { static async migration_get_instance(): Promise { const default_url = `postgres://${ - Deno.env.get('USER') ?? Deno.env.get('USERNAME') + getEnv('USER') ?? getEnv('USERNAME') }@localhost/lightning`; const pg_url = prompt( diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index 10008cc4..7b63af7a 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -1,8 +1,11 @@ +import { getEnv } from '@cross/env'; +import { stdout } from '@cross/utils'; import { RedisClient } from '@iuioiua/redis'; import { ProgressBar, type ProgressBarFormatter, } from '@std/cli/unstable-progress-bar'; +import { writeTextFile } from '@std/fs/unstable-write-text-file'; import type { bridge, bridge_channel, @@ -12,18 +15,40 @@ import type { import { log_error } from '../structures/errors.ts'; import type { bridge_data } from './mod.ts'; -export type redis_config = Deno.ConnectOptions; +export interface redis_config { + hostname: string; + port: number; +} const fmt = (fmt: ProgressBarFormatter) => `[redis] ${fmt.progressBar} ${fmt.styledTime()} [${fmt.value}/${fmt.max}]\n`; export class redis implements bridge_data { static async create( - rd_options: Deno.ConnectOptions, + rd_options: redis_config, _do_not_use = false, ): Promise { - const conn = await Deno.connect(rd_options); - const rd = new RedisClient(conn); + let streams: { + readable: ReadableStream; + writable: WritableStream; + }; + + if ('Deno' in globalThis) { + streams = await Deno.connect(rd_options); + } else { + const { createConnection } = await import('node:net'); + const { Readable, Writable } = await import('node:stream'); + const conn = createConnection({ + host: rd_options.hostname, + port: rd_options.port, + }); + streams = { + readable: Readable.toWeb(conn) as ReadableStream, + writable: Writable.toWeb(conn) + }; + } + + const rd = new RedisClient(streams); let db_data_version = await rd.sendCommand([ 'GET', @@ -49,7 +74,7 @@ export class redis implements bridge_data { console.log('[lightning-redis] got bridges!'); - await Deno.writeTextFile( + await writeTextFile( 'lightning-redis-migration.json', JSON.stringify(bridges, null, 2), ); @@ -57,7 +82,7 @@ export class redis implements bridge_data { const write = confirm( '[lightning-redis] write the data to the database? see \`lightning-redis-migration.json\` for the data', ); - const env_confirm = Deno.env.get('LIGHTNING_MIGRATE_CONFIRM'); + const env_confirm = getEnv('LIGHTNING_MIGRATE_CONFIRM'); if (write || env_confirm === 'true') { await instance.migration_set_bridges(bridges); @@ -179,7 +204,7 @@ export class redis implements bridge_data { const bridges = [] as bridge[]; - const progress = new ProgressBar(Deno.stdout.writable, { + const progress = new ProgressBar(stdout(), { max: keys.length, fmt, }); @@ -232,7 +257,7 @@ export class redis implements bridge_data { } async migration_set_bridges(bridges: bridge[]): Promise { - const progress = new ProgressBar(Deno.stdout.writable, { + const progress = new ProgressBar(stdout(), { max: bridges.length, fmt, }); @@ -282,7 +307,7 @@ export class redis implements bridge_data { const messages = [] as bridge_message[]; - const progress = new ProgressBar(Deno.stdout.writable, { + const progress = new ProgressBar(stdout(), { max: keys.length, fmt, }); @@ -299,7 +324,7 @@ export class redis implements bridge_data { } async migration_set_messages(messages: bridge_message[]): Promise { - const progress = new ProgressBar(Deno.stdout.writable, { + const progress = new ProgressBar(stdout(), { max: messages.length, fmt, }); @@ -322,7 +347,6 @@ export class redis implements bridge_data { return await redis.create({ hostname, port: parseInt(port), - transport: 'tcp', }, true); } } diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index 6ca91db0..731537f8 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -1,3 +1,4 @@ +import { getEnv } from '@cross/env'; import { create_message, type message } from './messages.ts'; /** options used to create an error */ @@ -81,7 +82,7 @@ export class LightningError extends Error { if (!this.without_cause) console.error(this.error_cause, this.extra); - const webhook = Deno.env.get('LIGHTNING_ERROR_WEBHOOK'); + const webhook = getEnv('LIGHTNING_ERROR_WEBHOOK'); for (const key in this.options?.extra) { if (key === 'lightning') { From 9c72e010b455064a66d9ccbf3c2f1b43ea0791f0 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 15:56:30 -0400 Subject: [PATCH 61/97] improve node and bun compatibility. node still requires the `--harmony-temporal` flag bun requires `BUN_JSC_useTemporal=1` to be set --- packages/lightning/deno.json | 5 ++++- packages/lightning/package.json | 4 ++++ packages/lightning/src/cli.ts | 5 +++++ packages/lightning/src/database/mod.ts | 3 +-- packages/lightning/src/database/postgres.ts | 9 +++++++-- packages/lightning/src/database/redis.ts | 12 +++++++++--- packages/lightning/src/structures/errors.ts | 20 ++++++++++---------- 7 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 packages/lightning/package.json diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 99bfe0c5..63db06ec 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -2,7 +2,10 @@ "name": "@lightning/lightning", "version": "0.8.0", "license": "MIT", - "exports": "./src/mod.ts", + "exports": { + ".": "./src/mod.ts", + "./cli": "./src/cli.ts" + }, "imports": { "@cross/env": "jsr:@cross/env@^1.0.2", "@cross/fs": "jsr:@cross/fs@^0.1.12", diff --git a/packages/lightning/package.json b/packages/lightning/package.json new file mode 100644 index 00000000..55395f86 --- /dev/null +++ b/packages/lightning/package.json @@ -0,0 +1,4 @@ +{ + "type": "module", + "exports": "./src/mod.ts" +} \ No newline at end of file diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 82ed5d7c..e56b7142 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -6,6 +6,11 @@ import { core } from './core.ts'; import { handle_migration } from './database/mod.ts'; import { log_error } from './structures/errors.ts'; +/** + * This module provides the Lightning CLI, which you can use to run the bot + * @module + */ + const args = getArgs(); if (args[0] === 'migrate') { diff --git a/packages/lightning/src/database/mod.ts b/packages/lightning/src/database/mod.ts index fa7238e8..8879911e 100644 --- a/packages/lightning/src/database/mod.ts +++ b/packages/lightning/src/database/mod.ts @@ -1,4 +1,3 @@ -import { promptSelect } from '@std/cli/unstable-prompt-select'; import type { bridge, bridge_message } from '../structures/bridge.ts'; import { postgres } from './postgres.ts'; import { redis, type redis_config } from './redis.ts'; @@ -40,7 +39,7 @@ export async function create_database( } function get_database(message: string): typeof postgres | typeof redis { - const type = promptSelect(message, ['redis', 'postgres']); + const type = prompt(`${message} (redis,postgres)`); switch (type) { case 'postgres': diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index 2b55819d..9d2863b8 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -1,6 +1,6 @@ import { getEnv } from '@cross/env'; import { stdout } from '@cross/utils'; -import { Client } from '@db/postgres'; +import type { Client } from '@db/postgres'; import { ProgressBar, type ProgressBarFormatter, @@ -13,7 +13,10 @@ const fmt = (fmt: ProgressBarFormatter) => `[postgres] ${fmt.progressBar} ${fmt.styledTime()} [${fmt.value}/${fmt.max}]\n`; export class postgres implements bridge_data { + private pg: Client; + static async create(pg_url: string): Promise { + const { Client } = await import('@db/postgres'); const pg = new Client(pg_url); await pg.connect(); @@ -52,7 +55,9 @@ export class postgres implements bridge_data { `; } - private constructor(private pg: Client) {} + private constructor(pg: Client) { + this.pg = pg; + } async create_bridge(br: Omit): Promise { const id = crypto.randomUUID(); diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index 7b63af7a..8a611161 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -24,6 +24,9 @@ const fmt = (fmt: ProgressBarFormatter) => `[redis] ${fmt.progressBar} ${fmt.styledTime()} [${fmt.value}/${fmt.max}]\n`; export class redis implements bridge_data { + private redis: RedisClient; + private seven: boolean; + static async create( rd_options: redis_config, _do_not_use = false, @@ -110,9 +113,12 @@ export class redis implements bridge_data { } private constructor( - public redis: RedisClient, - private seven = false, - ) {} + redis: RedisClient, + seven = false, + ) { + this.redis = redis; + this.seven = seven; + } async get_json(key: string): Promise { const reply = await this.redis.sendCommand(['GET', key]); diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index 731537f8..40f0668f 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -34,7 +34,7 @@ export class LightningError extends Error { without_cause?: boolean; /** create and log an error */ - constructor(e: unknown, public options?: error_options) { + constructor(e: unknown, options?: error_options) { if (e instanceof LightningError) { super(e.message, { cause: e.cause }); this.id = e.id; @@ -46,8 +46,8 @@ export class LightningError extends Error { return e; } - const cause_err = Error.isError(e) - ? e + const cause_err = ("isError" in Error ? Error.isError(e) : e instanceof Error) + ? e as Error : e instanceof Object ? new Error(JSON.stringify(e)) : new Error(String(e)); @@ -84,24 +84,24 @@ export class LightningError extends Error { const webhook = getEnv('LIGHTNING_ERROR_WEBHOOK'); - for (const key in this.options?.extra) { + for (const key in this.extra) { if (key === 'lightning') { - delete this.options.extra[key]; + delete this.extra[key]; } if ( - typeof this.options.extra[key] === 'object' && - this.options.extra[key] !== null + typeof this.extra[key] === 'object' && + this.extra[key] !== null ) { - if ('lightning' in this.options.extra[key]) { - delete this.options.extra[key].lightning; + if ('lightning' in this.extra[key]) { + delete this.extra[key].lightning; } } } if (webhook && webhook.length > 0) { let json_str = `\`\`\`json\n${ - JSON.stringify(this.options?.extra, null, 2) + JSON.stringify(this.extra, null, 2) }\n\`\`\``; if (json_str.length > 2000) json_str = '*see console*'; From 7b0cf70932a1ff76b0b344cf64d4136d1830759c Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 16:07:22 -0400 Subject: [PATCH 62/97] 0.8.0-alpha.1 release and improved node compatibility --- containerfile | 2 +- packages/discord/README.md | 2 +- packages/discord/deno.json | 4 ++-- packages/guilded/README.md | 2 +- packages/guilded/deno.json | 4 ++-- packages/lightning/README.md | 4 ++-- packages/lightning/deno.json | 2 +- packages/lightning/package.json | 4 ---- packages/lightning/src/cli.ts | 4 ++-- packages/lightning/src/core.ts | 2 +- packages/lightning/src/database/postgres.ts | 2 +- packages/lightning/src/database/redis.ts | 6 +++--- packages/lightning/src/structures/errors.ts | 4 ++-- packages/revolt/README.md | 2 +- packages/revolt/deno.json | 4 ++-- packages/telegram/deno.json | 4 ++-- 16 files changed, 24 insertions(+), 28 deletions(-) delete mode 100644 packages/lightning/package.json diff --git a/containerfile b/containerfile index 7ffdbba5..b53040b4 100644 --- a/containerfile +++ b/containerfile @@ -5,7 +5,7 @@ LABEL org.opencontainers.image.authors Jersey LABEL org.opencontainers.image.url https://github.com/williamhorning/lightning LABEL org.opencontainers.image.source https://github.com/williamhorning/lightning LABEL org.opencontainers.image.documentation https://williamhorning.eu.org/lightning -LABEL org.opencontainers.image.version 0.8.0 +LABEL org.opencontainers.image.version 0.8.0-alpha.1 LABEL org.opencontainers.image.licenses MIT LABEL org.opencontainers.image.title lightning diff --git a/packages/discord/README.md b/packages/discord/README.md index bbdad548..ac89d3f0 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -9,6 +9,6 @@ you do that, you will need to add the following to your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0" +plugin = "jsr:@lightning/discord@0.8.0-alpha.1" config.token = "your_bot_token" ``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json index 979c87fa..52a698e0 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,12 +1,12 @@ { "name": "@lightning/discord", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": "./src/mod.ts", "imports": { "@discordjs/core": "npm:@discordjs/core@^2.0.1", "@discordjs/rest": "npm:@discordjs/rest@^2.4.3", "@discordjs/ws": "npm:@discordjs/ws@^2.0.1", - "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0" + "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1" } } diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 72580ac0..698d3bab 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -8,6 +8,6 @@ your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/guilded@0.8.0" +plugin = "jsr:@lightning/guilded@0.8.0-alpha.1" config.token = "your_bot_token" ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index bec85d84..904d7762 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/guilded", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0", + "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1", "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.4", "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.2" } diff --git a/packages/lightning/README.md b/packages/lightning/README.md index 75f7ee45..19f292cc 100644 --- a/packages/lightning/README.md +++ b/packages/lightning/README.md @@ -17,11 +17,11 @@ type = "postgres" config = "postgresql://server:password@postgres:5432/lightning" [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0" +plugin = "jsr:@lightning/discord@0.8.0-alpha.1" config.token = "your_token" [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.1" config.token = "your_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 63db06ec..9ea950aa 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -1,6 +1,6 @@ { "name": "@lightning/lightning", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": { ".": "./src/mod.ts", diff --git a/packages/lightning/package.json b/packages/lightning/package.json deleted file mode 100644 index 55395f86..00000000 --- a/packages/lightning/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "module", - "exports": "./src/mod.ts" -} \ No newline at end of file diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index e56b7142..db8ae7e7 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -29,10 +29,10 @@ if (args[0] === 'migrate') { }); } } else if (args[0] === 'version') { - console.log('0.8.0'); + console.log('0.8.0-alpha.1'); } else { console.log( - `lightning v0.8.0 - extensible chatbot connecting communities`, + `lightning v0.8.0-alpha.1 - extensible chatbot connecting communities`, ); console.log(' Usage: lightning [subcommand]'); console.log(' Subcommands:'); diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 3631e180..1b554563 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -36,7 +36,7 @@ export class core extends EventEmitter { ['version', { name: 'version', description: 'get the bots version', - execute: () => 'hello from v0.8.0!', + execute: () => 'hello from v0.8.0-alpha.1!', }], ]); private plugins = new Map(); diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index 9d2863b8..d8dfe101 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -14,7 +14,7 @@ const fmt = (fmt: ProgressBarFormatter) => export class postgres implements bridge_data { private pg: Client; - + static async create(pg_url: string): Promise { const { Client } = await import('@db/postgres'); const pg = new Client(pg_url); diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index 8a611161..4845e87f 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -45,9 +45,9 @@ export class redis implements bridge_data { host: rd_options.hostname, port: rd_options.port, }); - streams = { - readable: Readable.toWeb(conn) as ReadableStream, - writable: Writable.toWeb(conn) + streams = { + readable: Readable.toWeb(conn) as ReadableStream, + writable: Writable.toWeb(conn), }; } diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index 40f0668f..604f0c58 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -46,8 +46,8 @@ export class LightningError extends Error { return e; } - const cause_err = ("isError" in Error ? Error.isError(e) : e instanceof Error) - ? e as Error + const cause_err = e instanceof Error + ? e : e instanceof Object ? new Error(JSON.stringify(e)) : new Error(String(e)); diff --git a/packages/revolt/README.md b/packages/revolt/README.md index 8e3de721..02ef59da 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -7,7 +7,7 @@ Revolt bot first. After that, you need to add the following to your config file: ```toml [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.1" config.token = "your_bot_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index 82a5f931..a3a68487 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/revolt", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0", + "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.7", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.3", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index 7ca4276e..f332c01f 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/telegram", - "version": "0.8.0", + "version": "0.8.0-alpha.1", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0", + "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1", "@oak/oak": "jsr:@oak/oak@^17.1.4", "grammy": "npm:grammy@^1.36.0", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" From 6284fc77e92c45fbc6c4e35acf9470cf3352b81d Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 16:13:34 -0400 Subject: [PATCH 63/97] temporarily publish postgres --- packages/lightning/deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 9ea950aa..9ac54e75 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -10,7 +10,7 @@ "@cross/env": "jsr:@cross/env@^1.0.2", "@cross/fs": "jsr:@cross/fs@^0.1.12", "@cross/utils": "jsr:@cross/utils@^0.16.0", - "@db/postgres": "jsr:@jersey/test@0.8.6", + "@db/postgres": "jsr:@jersey/postgres@^0.19.4", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.1", "@std/cli": "jsr:@std/cli@^1.0.16", From be9a89371660ac9666b147133c6ddc3c2d7e319d Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 16:19:42 -0400 Subject: [PATCH 64/97] 0.8.0-alpha.1 --- containerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containerfile b/containerfile index b53040b4..8c1da4cd 100644 --- a/containerfile +++ b/containerfile @@ -10,7 +10,7 @@ LABEL org.opencontainers.image.licenses MIT LABEL org.opencontainers.image.title lightning # install lightning -RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/test@0.9.0"] +RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.1"] # run as nobody instead of root USER nobody From d6f7548e84ae0b5d97129d3cc7d44379b3f309f3 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 16:47:58 -0400 Subject: [PATCH 65/97] fix github action --- .github/workflows/publish.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 87cbdb87..2d337cda 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,14 +25,13 @@ jobs: run: deno publish publish_docker: name: publish to ghcr - strategy: - matrix: - os: [ubuntu-latest, ubuntu-24.04-arm] - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest needs: publish_jsr steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 - name: login to ghcr uses: redhat-actions/podman-login@v1 with: @@ -44,6 +43,7 @@ jobs: uses: redhat-actions/buildah-build@v2 with: image: ghcr.io/williamhorning/lightning + archs: amd64, arm64 tags: latest ${{github.ref_name}} containerfiles: ./containerfile - name: push to ghcr.io From 6f9617c940ae1baf22a493369499a8bc2fc27189 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 17:44:01 -0400 Subject: [PATCH 66/97] fix jsr imports in docker --- containerfile | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/containerfile b/containerfile index 8c1da4cd..60832b52 100644 --- a/containerfile +++ b/containerfile @@ -1,19 +1,24 @@ FROM denoland/deno:alpine-2.2.11@sha256:c6c801a49a98f295f46176fba6172a1a656decd7dfb086a499fe863f595b922b # metadata -LABEL org.opencontainers.image.authors Jersey -LABEL org.opencontainers.image.url https://github.com/williamhorning/lightning -LABEL org.opencontainers.image.source https://github.com/williamhorning/lightning -LABEL org.opencontainers.image.documentation https://williamhorning.eu.org/lightning -LABEL org.opencontainers.image.version 0.8.0-alpha.1 -LABEL org.opencontainers.image.licenses MIT -LABEL org.opencontainers.image.title lightning +LABEL org.opencontainers.image.authors=Jersey +LABEL org.opencontainers.image.url=https://github.com/williamhorning/lightning +LABEL org.opencontainers.image.source=https://github.com/williamhorning/lightning +LABEL org.opencontainers.image.documentation=https://williamhorning.eu.org/lightning +LABEL org.opencontainers.image.version=0.8.0-alpha.1 +LABEL org.opencontainers.image.licenses=MIT +LABEL org.opencontainers.image.title=lightning + +# make a deno cache directory +RUN ["mkdir", "/deno_dir"] +ENV DENO_DIR=/deno_dir # install lightning -RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.1"] +RUN ["deno", "install", "-L", "debug", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.1"] +RUN ["chown", "--recursive", "1001:1001", "/deno_dir"] -# run as nobody instead of root -USER nobody +# run as user instead of root +USER 1001:1001 # the volume containing your lightning.toml file VOLUME [ "/data" ] From c39c3f7bc77f139db0d6574d2b54145ab03a7805 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 17:45:45 -0400 Subject: [PATCH 67/97] remove debug logging --- containerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/containerfile b/containerfile index 60832b52..bdf0ed0b 100644 --- a/containerfile +++ b/containerfile @@ -14,7 +14,7 @@ RUN ["mkdir", "/deno_dir"] ENV DENO_DIR=/deno_dir # install lightning -RUN ["deno", "install", "-L", "debug", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.1"] +RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.1"] RUN ["chown", "--recursive", "1001:1001", "/deno_dir"] # run as user instead of root From 65a0364f7119ce1bb3bd4f6a032769f2217ef2b2 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 20 Apr 2025 19:14:48 -0400 Subject: [PATCH 68/97] actually set commands --- packages/lightning/src/core.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 1b554563..69cd97e1 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -68,6 +68,12 @@ export class core extends EventEmitter { set_command(opts: command): void { this.commands.set(opts.name, opts); + + for (const [_, plugin] of this.plugins) { + if (plugin.set_commands) { + plugin.set_commands(this.commands.values().toArray()); + } + } } get_plugin(name: string): plugin | undefined { From bc304afe2d7d887769954200cf22c1677e5298e1 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 26 Apr 2025 20:48:11 -0400 Subject: [PATCH 69/97] refactor error handling and change revolt cdn endpoints --- .github/workflows/publish.yml | 2 +- packages/guilded/src/errors.ts | 4 +-- packages/guilded/src/mod.ts | 15 +++++---- packages/lightning/src/bridge/handler.ts | 16 +-------- packages/lightning/src/cli.ts | 10 +++--- packages/lightning/src/core.ts | 2 +- packages/lightning/src/structures/errors.ts | 37 +++------------------ packages/revolt/deno.json | 4 +-- packages/revolt/src/cache.ts | 4 +-- packages/revolt/src/errors.ts | 3 +- packages/revolt/src/incoming.ts | 3 +- packages/revolt/src/mod.ts | 4 +-- 12 files changed, 34 insertions(+), 70 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2d337cda..4d9867ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,7 +29,7 @@ jobs: needs: publish_jsr steps: - name: checkout - uses: actions/checkout@v4 + uses: actions/checkout@v4 - name: Set up QEMU uses: docker/setup-qemu-action@v1 - name: login to ghcr diff --git a/packages/guilded/src/errors.ts b/packages/guilded/src/errors.ts index 82cfcaf7..574841e9 100644 --- a/packages/guilded/src/errors.ts +++ b/packages/guilded/src/errors.ts @@ -1,11 +1,9 @@ import { RequestError } from '@jersey/guilded-api-types'; import { log_error } from '@lightning/lightning'; -export function handle_error(err: unknown, channel: string, edit?: boolean) { +export function handle_error(err: unknown, channel: string): never { if (err instanceof RequestError) { if (err.cause.status === 404) { - if (edit) return []; - log_error(err, { message: "resource not found! if you're trying to make a bridge, this is likely an issue with Guilded", diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 7a8af2bd..1c0cc83a 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -32,10 +32,12 @@ export function parse_config(v: unknown): guilded_config { export default class guilded extends plugin { name = 'bolt-guilded'; private client: Client; + private token: string; constructor(opts: guilded_config) { super(); this.client = createClient(opts.token); + this.token = opts.token; this.setup_events(); this.client.socket.connect(); } @@ -139,14 +141,15 @@ export default class guilded extends plugin { async delete_messages(messages: deleted_message[]): Promise { return await Promise.all(messages.map(async (msg) => { try { - await this.client.request( - 'delete', // @ts-expect-error: this is typed wrong - `/channels/${msg.channel_id}/messages/${msg.message_id}`, - undefined, + await fetch( + `https://www.guilded.gg/api/v1/channels/${msg.channel_id}/messages/${msg.message_id}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${this.token}` }, + }, ); return msg.message_id; - } catch (e) { - handle_error(e, msg.channel_id, true); + } catch { return msg.message_id; } })); diff --git a/packages/lightning/src/bridge/handler.ts b/packages/lightning/src/bridge/handler.ts index ade356f4..a6a1c78d 100644 --- a/packages/lightning/src/bridge/handler.ts +++ b/packages/lightning/src/bridge/handler.ts @@ -104,21 +104,7 @@ export async function bridge_message( message: `An error occurred while processing a message in the bridge`, }); - if (!err.disable_channel) { - try { - const result_ids = await plugin.create_message({ - ...err.msg, - message_id: prior_bridged_ids?.id[0] ?? '', - channel_id: channel.id, - }); - result_ids.forEach((id) => core.set_handled(channel.plugin, id)); - } catch (e) { - new LightningError(e, { - message: `Failed to log error message in bridge`, - extra: { channel, original_error: err.id }, - }); - } - } else { + if (err.disable_channel) { new LightningError( `disabling channel ${channel.id} in bridge ${bridge.id}`, { diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index db8ae7e7..efc8cb08 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -1,10 +1,10 @@ import { cwd } from '@cross/fs'; -import { args as getArgs } from '@cross/utils'; +import { args as getArgs, exit } from '@cross/utils'; import { setup_bridge } from './bridge/setup.ts'; import { parse_config } from './cli_config.ts'; import { core } from './core.ts'; import { handle_migration } from './database/mod.ts'; -import { log_error } from './structures/errors.ts'; +import { LightningError } from './structures/errors.ts'; /** * This module provides the Lightning CLI, which you can use to run the bot @@ -23,10 +23,12 @@ if (args[0] === 'migrate') { const lightning = new core(config); await setup_bridge(lightning, config.database); } catch (e) { - log_error(e, { + await new LightningError(e, { extra: { type: 'global class error' }, without_cause: true, - }); + }).log(); + + exit(1); } } else if (args[0] === 'version') { console.log('0.8.0-alpha.1'); diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 69cd97e1..afd89952 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -82,7 +82,7 @@ export class core extends EventEmitter { private async handle_events(plugin: plugin): Promise { for await (const { name, value } of plugin) { - await new Promise((res) => setTimeout(res, 150)); + await new Promise((res) => setTimeout(res, 200)); if (this.handled.has(`${value[0].plugin}-${value[0].message_id}`)) { this.handled.delete(`${value[0].plugin}-${value[0].message_id}`); diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index 604f0c58..35f43bbd 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -66,11 +66,6 @@ export class LightningError extends Error { `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\``, ); - this.log(); - } - - /** log the error */ - private async log(): Promise { console.error(`%c[lightning] ${this.message}`, 'color: red'); console.error(`%c[lightning] ${this.id}`, 'color: red'); console.error( @@ -80,43 +75,21 @@ export class LightningError extends Error { 'color: red', ); + if (!this.without_cause) this.log(); + } + + /** log the error, automatically called in most cases */ + async log(): Promise { if (!this.without_cause) console.error(this.error_cause, this.extra); const webhook = getEnv('LIGHTNING_ERROR_WEBHOOK'); - for (const key in this.extra) { - if (key === 'lightning') { - delete this.extra[key]; - } - - if ( - typeof this.extra[key] === 'object' && - this.extra[key] !== null - ) { - if ('lightning' in this.extra[key]) { - delete this.extra[key].lightning; - } - } - } - if (webhook && webhook.length > 0) { - let json_str = `\`\`\`json\n${ - JSON.stringify(this.extra, null, 2) - }\n\`\`\``; - - if (json_str.length > 2000) json_str = '*see console*'; - await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: `# ${this.error_cause.message}\n*${this.id}*`, - embeds: [ - { - title: 'extra', - description: json_str, - }, - ], }), }); } diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index a3a68487..6d95fbb7 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -5,8 +5,8 @@ "exports": "./src/mod.ts", "imports": { "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1", - "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.7", - "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.3", + "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.9", + "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.4", "@std/ulid": "jsr:@std/ulid@^1.0.0" } } diff --git a/packages/revolt/src/cache.ts b/packages/revolt/src/cache.ts index 37081af2..3ff8e4e9 100644 --- a/packages/revolt/src/cache.ts +++ b/packages/revolt/src/cache.ts @@ -57,7 +57,7 @@ export async function fetch_author( color: masquerade?.colour ?? '#FF4654', profile: masquerade?.avatar ?? (author.avatar - ? `https://autumn.revolt.chat/avatars/${author.avatar._id}` + ? `https://cdn.revoltusercontent.com/avatars/${author.avatar._id}` : undefined), }; @@ -71,7 +71,7 @@ export async function fetch_author( username: masquerade?.name ?? member.nickname ?? data.username, profile: masquerade?.avatar ?? (member.avatar - ? `https://autumn.revolt.chat/avatars/${member.avatar._id}` + ? `https://cdn.revoltusercontent.com/avatars/${member.avatar._id}` : data.profile), }); } catch { diff --git a/packages/revolt/src/errors.ts b/packages/revolt/src/errors.ts index 2dd27a3d..2aa73d39 100644 --- a/packages/revolt/src/errors.ts +++ b/packages/revolt/src/errors.ts @@ -1,5 +1,6 @@ import { log_error } from '@lightning/lightning'; -import { MediaError, RequestError } from '@jersey/rvapi'; +import { MediaError } from '@jersey/rvapi'; +import { RequestError } from '@jersey/revolt-api-types'; export function handle_error(err: unknown, edit?: boolean) { if (err instanceof MediaError) { diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts index ce45e080..d50136c4 100644 --- a/packages/revolt/src/incoming.ts +++ b/packages/revolt/src/incoming.ts @@ -11,7 +11,8 @@ export async function get_incoming( return { attachments: message.attachments?.map((i) => { return { - file: `https://autumn.revolt.chat/attachments/${i._id}/${i.filename}`, + file: + `https://cdn.revoltusercontent.com/attachments/${i._id}/${i.filename}`, name: i.filename, size: i.size / 1048576, }; diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 375a489a..13d9e5db 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -118,7 +118,7 @@ export default class revolt extends plugin { ) as APIMessage)._id, ]; } catch (e) { - return handle_error(e); + return handle_error(e, true); } } @@ -134,7 +134,7 @@ export default class revolt extends plugin { ); return msg.message_id; } catch (e) { - handle_error(e); + handle_error(e, true); return msg.message_id; } }), From f164d8107747225fc9c9c7b367800133671c24ee Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 26 Apr 2025 21:00:22 -0400 Subject: [PATCH 70/97] handle revolt disconnection --- packages/revolt/src/mod.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 13d9e5db..47aeca30 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -6,7 +6,7 @@ import { plugin, } from '@lightning/lightning'; import type { Message as APIMessage } from '@jersey/revolt-api-types'; -import { type Client, createClient } from '@jersey/rvapi'; +import { Bonfire, type Client, createClient } from '@jersey/rvapi'; import { fetch_message } from './cache.ts'; import { handle_error } from './errors.ts'; import { get_incoming } from './incoming.ts'; @@ -46,10 +46,10 @@ export default class revolt extends plugin { super(); this.client = createClient({ token: opts.token }); this.user_id = opts.user_id; - this.setup_events(); + this.setup_events(opts); } - private setup_events() { + private setup_events(opts: revolt_config) { this.client.bonfire.on('Message', async (data) => { const msg = await get_incoming(data, this.client); if (msg) this.emit('create_message', msg); @@ -78,6 +78,12 @@ export default class revolt extends plugin { } in ${data.servers.length} servers`, `\n[revolt] invite me at https://app.revolt.chat/bot/${this.user_id}`, ); + }).on("socket_close", () => { + this.client.bonfire = new Bonfire({ + token: opts.token, + url: 'wss://ws.revolt.chat' + }); + this.setup_events(opts); }); } From 9248115d3121e59af1a34590e3142c437c198f61 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 26 Apr 2025 21:00:31 -0400 Subject: [PATCH 71/97] format --- packages/revolt/src/mod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 47aeca30..5c7374aa 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -78,10 +78,10 @@ export default class revolt extends plugin { } in ${data.servers.length} servers`, `\n[revolt] invite me at https://app.revolt.chat/bot/${this.user_id}`, ); - }).on("socket_close", () => { + }).on('socket_close', () => { this.client.bonfire = new Bonfire({ token: opts.token, - url: 'wss://ws.revolt.chat' + url: 'wss://ws.revolt.chat', }); this.setup_events(opts); }); From 7f92248ad90e30adc00a182beb533c70f1195dc5 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 26 Apr 2025 21:11:40 -0400 Subject: [PATCH 72/97] bump version to 0.8.0-alpha.2 --- containerfile | 4 ++-- packages/discord/README.md | 2 +- packages/discord/deno.json | 10 +++++----- packages/guilded/README.md | 2 +- packages/guilded/deno.json | 4 ++-- packages/lightning/README.md | 4 ++-- packages/lightning/deno.json | 2 +- packages/lightning/src/cli.ts | 4 ++-- packages/lightning/src/core.ts | 2 +- packages/revolt/README.md | 2 +- packages/revolt/deno.json | 4 ++-- packages/telegram/deno.json | 4 ++-- readme.md | 2 +- 13 files changed, 23 insertions(+), 23 deletions(-) diff --git a/containerfile b/containerfile index bdf0ed0b..e5493900 100644 --- a/containerfile +++ b/containerfile @@ -5,7 +5,7 @@ LABEL org.opencontainers.image.authors=Jersey LABEL org.opencontainers.image.url=https://github.com/williamhorning/lightning LABEL org.opencontainers.image.source=https://github.com/williamhorning/lightning LABEL org.opencontainers.image.documentation=https://williamhorning.eu.org/lightning -LABEL org.opencontainers.image.version=0.8.0-alpha.1 +LABEL org.opencontainers.image.version=0.8.0-alpha.2 LABEL org.opencontainers.image.licenses=MIT LABEL org.opencontainers.image.title=lightning @@ -14,7 +14,7 @@ RUN ["mkdir", "/deno_dir"] ENV DENO_DIR=/deno_dir # install lightning -RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.1"] +RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.2"] RUN ["chown", "--recursive", "1001:1001", "/deno_dir"] # run as user instead of root diff --git a/packages/discord/README.md b/packages/discord/README.md index ac89d3f0..0c8dbe0c 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -9,6 +9,6 @@ you do that, you will need to add the following to your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.1" +plugin = "jsr:@lightning/discord@0.8.0-alpha.2" config.token = "your_bot_token" ``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json index 52a698e0..9d86975c 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,12 +1,12 @@ { "name": "@lightning/discord", - "version": "0.8.0-alpha.1", + "version": "0.8.0-alpha.2", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@discordjs/core": "npm:@discordjs/core@^2.0.1", - "@discordjs/rest": "npm:@discordjs/rest@^2.4.3", - "@discordjs/ws": "npm:@discordjs/ws@^2.0.1", - "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1" + "@discordjs/core": "npm:@discordjs/core@^2.1.0", + "@discordjs/rest": "npm:@discordjs/rest@^2.5.0", + "@discordjs/ws": "npm:@discordjs/ws@^2.0.2", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.2" } } diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 698d3bab..80a22d2b 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -8,6 +8,6 @@ your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/guilded@0.8.0-alpha.1" +plugin = "jsr:@lightning/guilded@0.8.0-alpha.2" config.token = "your_bot_token" ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 904d7762..8357b31a 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/guilded", - "version": "0.8.0-alpha.1", + "version": "0.8.0-alpha.2", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.2", "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.4", "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.2" } diff --git a/packages/lightning/README.md b/packages/lightning/README.md index 19f292cc..0b9eaee1 100644 --- a/packages/lightning/README.md +++ b/packages/lightning/README.md @@ -17,11 +17,11 @@ type = "postgres" config = "postgresql://server:password@postgres:5432/lightning" [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.1" +plugin = "jsr:@lightning/discord@0.8.0-alpha.2" config.token = "your_token" [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.1" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.2" config.token = "your_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 9ac54e75..a2f27767 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -1,6 +1,6 @@ { "name": "@lightning/lightning", - "version": "0.8.0-alpha.1", + "version": "0.8.0-alpha.2", "license": "MIT", "exports": { ".": "./src/mod.ts", diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index efc8cb08..9e27c8d2 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -31,10 +31,10 @@ if (args[0] === 'migrate') { exit(1); } } else if (args[0] === 'version') { - console.log('0.8.0-alpha.1'); + console.log('0.8.0-alpha.2'); } else { console.log( - `lightning v0.8.0-alpha.1 - extensible chatbot connecting communities`, + `lightning v0.8.0-alpha.2 - extensible chatbot connecting communities`, ); console.log(' Usage: lightning [subcommand]'); console.log(' Subcommands:'); diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index afd89952..9f3f8aba 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -36,7 +36,7 @@ export class core extends EventEmitter { ['version', { name: 'version', description: 'get the bots version', - execute: () => 'hello from v0.8.0-alpha.1!', + execute: () => 'hello from v0.8.0-alpha.2!', }], ]); private plugins = new Map(); diff --git a/packages/revolt/README.md b/packages/revolt/README.md index 02ef59da..eb6317f8 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -7,7 +7,7 @@ Revolt bot first. After that, you need to add the following to your config file: ```toml [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.1" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.2" config.token = "your_bot_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index 6d95fbb7..b5c95fa3 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/revolt", - "version": "0.8.0-alpha.1", + "version": "0.8.0-alpha.2", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.2", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.9", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.4", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index f332c01f..c45051fe 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/telegram", - "version": "0.8.0-alpha.1", + "version": "0.8.0-alpha.2", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@^0.8.0-alpha.1", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.2", "@oak/oak": "jsr:@oak/oak@^17.1.4", "grammy": "npm:grammy@^1.36.0", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" diff --git a/readme.md b/readme.md index a1b94576..795ad478 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ # lightning - a chatbot > [!NOTE] -> This branch contains the next version of lightning, currently `0.8.0-alpha.1`, +> This branch contains the next version of lightning, currently `0.8.0-alpha.2`, > and reflects active development. To see the latest stable version, go to the > `main` branch. From ac02c720d8896281a94d6d220b8fb00f7a283ed3 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sat, 17 May 2025 18:55:07 -0400 Subject: [PATCH 73/97] redo error handling, centralize cacher, change schema handler, improve node support, fix revolt embed colors, fix guilded reply handling, and bump versions? fixes #78 --- .github/workflows/publish.yml | 17 ++--- containerfile | 16 ++--- packages/lightning/logo.svg => logo.svg | 0 packages/discord/README.md | 2 +- packages/discord/deno.json | 4 +- packages/discord/src/commands.ts | 62 ++++++++---------- packages/discord/src/errors.ts | 41 +++++------- packages/discord/src/incoming.ts | 16 +---- packages/discord/src/mod.ts | 17 ++--- packages/guilded/README.md | 2 +- packages/guilded/deno.json | 6 +- packages/guilded/src/errors.ts | 35 +++++----- packages/guilded/src/incoming.ts | 62 ++++++------------ packages/guilded/src/mod.ts | 33 ++++++---- packages/guilded/src/outgoing.ts | 32 +++------- packages/lightning/README.md | 6 +- packages/lightning/deno.json | 15 ++--- packages/lightning/src/bridge/commands.ts | 6 +- packages/lightning/src/bridge/handler.ts | 4 +- packages/lightning/src/cli.ts | 9 ++- packages/lightning/src/cli_config.ts | 52 ++++++--------- packages/lightning/src/core.ts | 16 ++--- packages/lightning/src/database/mod.ts | 22 ++----- packages/lightning/src/database/postgres.ts | 5 +- packages/lightning/src/database/redis.ts | 34 ++-------- packages/lightning/src/mod.ts | 4 +- packages/lightning/src/structures/bridge.ts | 4 +- packages/lightning/src/structures/cacher.ts | 23 +++++++ packages/lightning/src/structures/commands.ts | 4 +- packages/lightning/src/structures/cross.ts | 63 ++++++++++++++++++ packages/lightning/src/structures/errors.ts | 7 +- packages/lightning/src/structures/mod.ts | 2 + packages/lightning/src/structures/plugins.ts | 6 +- packages/lightning/src/structures/validate.ts | 35 ++++++++++ packages/revolt/README.md | 2 +- packages/revolt/deno.json | 4 +- packages/revolt/src/cache.ts | 64 +++++++------------ packages/revolt/src/errors.ts | 42 +++++------- packages/revolt/src/incoming.ts | 2 +- packages/revolt/src/mod.ts | 27 ++++---- packages/revolt/src/outgoing.ts | 50 +++++---------- packages/revolt/src/permissions.ts | 11 +--- packages/telegram/README.md | 2 +- packages/telegram/deno.json | 7 +- packages/telegram/src/incoming.ts | 64 +++++++++---------- packages/telegram/src/mod.ts | 60 +++++++++-------- readme.md | 10 +-- 47 files changed, 470 insertions(+), 537 deletions(-) rename packages/lightning/logo.svg => logo.svg (100%) create mode 100644 packages/lightning/src/structures/cacher.ts create mode 100644 packages/lightning/src/structures/cross.ts create mode 100644 packages/lightning/src/structures/validate.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4d9867ba..fefdb7f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,27 +11,20 @@ permissions: id-token: write jobs: - publish_jsr: - name: publish to jsr + publish: + name: publish to jsr and ghcr runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@v4 - name: setup deno - uses: denoland/setup-deno@v1 + uses: denoland/setup-deno@v2 with: - deno-version: v2.2.11 + deno-version: v2.3.3 - name: publish to jsr run: deno publish - publish_docker: - name: publish to ghcr - runs-on: ubuntu-latest - needs: publish_jsr - steps: - - name: checkout - uses: actions/checkout@v4 - name: Set up QEMU - uses: docker/setup-qemu-action@v1 + uses: docker/setup-qemu-action@v3 - name: login to ghcr uses: redhat-actions/podman-login@v1 with: diff --git a/containerfile b/containerfile index e5493900..c1c2f962 100644 --- a/containerfile +++ b/containerfile @@ -1,20 +1,11 @@ -FROM denoland/deno:alpine-2.2.11@sha256:c6c801a49a98f295f46176fba6172a1a656decd7dfb086a499fe863f595b922b - -# metadata -LABEL org.opencontainers.image.authors=Jersey -LABEL org.opencontainers.image.url=https://github.com/williamhorning/lightning -LABEL org.opencontainers.image.source=https://github.com/williamhorning/lightning -LABEL org.opencontainers.image.documentation=https://williamhorning.eu.org/lightning -LABEL org.opencontainers.image.version=0.8.0-alpha.2 -LABEL org.opencontainers.image.licenses=MIT -LABEL org.opencontainers.image.title=lightning +FROM denoland/deno:alpine-2.3.3 # make a deno cache directory RUN ["mkdir", "/deno_dir"] ENV DENO_DIR=/deno_dir # install lightning -RUN ["deno", "install", "--global", "--name", "lightning", "--allow-all", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.2"] +RUN ["deno", "install", "-gA", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.3"] RUN ["chown", "--recursive", "1001:1001", "/deno_dir"] # run as user instead of root @@ -22,9 +13,10 @@ USER 1001:1001 # the volume containing your lightning.toml file VOLUME [ "/data" ] +WORKDIR /data # this is the lightning command line ENTRYPOINT [ "lightning" ] # run the bot using the user-provided lightning.toml file -CMD [ "run", "/data/lightning.toml" ] +CMD [ "run" ] diff --git a/packages/lightning/logo.svg b/logo.svg similarity index 100% rename from packages/lightning/logo.svg rename to logo.svg diff --git a/packages/discord/README.md b/packages/discord/README.md index 0c8dbe0c..38e9336b 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -9,6 +9,6 @@ you do that, you will need to add the following to your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.2" +plugin = "jsr:@lightning/discord@0.8.0-alpha.3" config.token = "your_bot_token" ``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json index 9d86975c..a4ad20a3 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,12 +1,12 @@ { "name": "@lightning/discord", - "version": "0.8.0-alpha.2", + "version": "0.8.0-alpha.3", "license": "MIT", "exports": "./src/mod.ts", "imports": { "@discordjs/core": "npm:@discordjs/core@^2.1.0", "@discordjs/rest": "npm:@discordjs/rest@^2.5.0", "@discordjs/ws": "npm:@discordjs/ws@^2.0.2", - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.2" + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3" } } diff --git a/packages/discord/src/commands.ts b/packages/discord/src/commands.ts index 37325610..acbfa3f3 100644 --- a/packages/discord/src/commands.ts +++ b/packages/discord/src/commands.ts @@ -5,44 +5,32 @@ export async function setup_commands( api: API, commands: command[], ): Promise { - await api.applicationCommands.bulkOverwriteGlobalCommands( - (await api.applications.getCurrent()).id, - commands.map((command) => { - const opts = []; - - if (command.arguments) { - for (const argument of command.arguments) { - opts.push({ - name: argument.name, - description: argument.description, - type: 3, - required: argument.required, - }); - } - } + const format_arguments = (args: command['arguments']) => + args?.map((arg) => ({ + name: arg.name, + description: arg.description, + type: 3, + required: arg.required, + })) || []; - if (command.subcommands) { - for (const subcommand of command.subcommands) { - opts.push({ - name: subcommand.name, - description: subcommand.description, - type: 1, - options: subcommand.arguments?.map((opt) => ({ - name: opt.name, - description: opt.description, - type: 3, - required: opt.required, - })), - }); - } - } + const format_subcommands = (subcommands: command['subcommands']) => + subcommands?.map((subcommand) => ({ + name: subcommand.name, + description: subcommand.description, + type: 1, + options: format_arguments(subcommand.arguments), + })) || []; - return { - name: command.name, - type: 1, - description: command.description, - options: opts, - }; - }), + await api.applicationCommands.bulkOverwriteGlobalCommands( + (await api.applications.getCurrent()).id, + commands.map((cmd) => ({ + name: cmd.name, + type: 1, + description: cmd.description, + options: [ + ...format_arguments(cmd.arguments), + ...format_subcommands(cmd.subcommands), + ], + })), ); } diff --git a/packages/discord/src/errors.ts b/packages/discord/src/errors.ts index a5c8c6c6..a674457c 100644 --- a/packages/discord/src/errors.ts +++ b/packages/discord/src/errors.ts @@ -1,36 +1,29 @@ import { DiscordAPIError } from '@discordjs/rest'; import { log_error } from '@lightning/lightning'; +const errors = [ + [30007, 'Too many webhooks in channel, try deleting some', false], + [30058, 'Too many webhooks in guild, try deleting some', false], + [50013, 'Missing permissions to make webhook', false], + [10003, 'Unknown channel, disabling channel', true], + [10015, 'Unknown message, disabling channel', true], + [50027, 'Invalid webhook token, disabling channel', true], + [0, 'Unknown DiscordAPIError, not disabling channel', false], +] as const; + export function handle_error( err: unknown, channel: string, edit?: boolean, ) { if (err instanceof DiscordAPIError) { - if (err.code === 30007 || err.code === 30058) { - log_error(err, { - message: 'too many webhooks in channel/guild. try deleting some', - extra: { channel }, - }); - } else if (err.code === 50013) { - log_error(err, { - message: 'missing permissions to create webhook. check bot permissions', - extra: { channel }, - }); - } else if (err.code === 10003 || err.code === 10015 || err.code === 50027) { - log_error(err, { - disable: true, - message: `disabling channel due to error code ${err.code}`, - extra: { channel }, - }); - } else if (edit && err.code === 10008) { - return []; // message already deleted or non-existent - } else { - log_error(err, { - message: `unknown discord api error`, - extra: { channel, code: err.code }, - }); - } + if (edit && err.code === 10008) return []; // message already deleted or non-existent + + const extra = { channel, code: err.code }; + const [, message, disable] = errors.find((e) => e[0] === err.code) ?? + errors[errors.length - 1]; + + log_error(err, { disable, message, extra }); } else { log_error(err, { message: `unknown discord error`, diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts index 0a29a25b..adb2524d 100644 --- a/packages/discord/src/incoming.ts +++ b/packages/discord/src/incoming.ts @@ -26,7 +26,7 @@ export function get_deleted_message( } async function fetch_author(api: API, data: GatewayMessageUpdateDispatchData) { - let profile = data.author.avatar !== null + let profile = data.author.avatar ? `https://cdn.discordapp.com/avatars/${data.author.id}/${data.author.avatar}.png` : `https://cdn.discordapp.com/embed/avatars/${ Number(BigInt(data.author.id) >> 22n) % 6 @@ -84,17 +84,9 @@ export async function get_incoming_message( { api, data }: { api: API; data: GatewayMessageUpdateDispatchData }, ): Promise { // normal messages, replies, and user joins - if ( - data.type !== 0 && - data.type !== 7 && - data.type !== 19 && - data.type !== 20 && - data.type !== 23 - ) { - return; - } + if (![0, 7, 19, 20, 23].includes(data.type)) return; - const message: message = { + return { attachments: [ ...data.attachments?.map( (i: typeof data['attachments'][0]) => { @@ -135,8 +127,6 @@ export async function get_incoming_message( Number(BigInt(data.id) >> 22n) + 1420070400000, ), }; - - return message; } export function get_incoming_command( diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 42210987..66619532 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -4,8 +4,8 @@ import { WebSocketManager } from '@discordjs/ws'; import { type bridge_message_opts, type command, + type config_schema, type deleted_message, - log_error, type message, plugin, } from '@lightning/lightning'; @@ -24,16 +24,11 @@ export type discord_config = { token: string; }; -/** check if something is actually a config object, return if it is */ -export function parse_config(v: unknown): discord_config { - if (typeof v !== 'object' || v === null) { - log_error("discord config isn't an object!", { without_cause: true }); - } - if (!('token' in v) || typeof v.token !== 'string') { - log_error("discord token isn't a string", { without_cause: true }); - } - return { token: v.token }; -} +/** the config schema for the class */ +export const schema: config_schema = { + name: 'bolt-discord', + keys: { token: { type: 'string', required: true } }, +}; /** discord support for lightning */ export default class discord extends plugin { diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 80a22d2b..09fdcd9e 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -8,6 +8,6 @@ your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/guilded@0.8.0-alpha.2" +plugin = "jsr:@lightning/guilded@0.8.0-alpha.3" config.token = "your_bot_token" ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 8357b31a..7515f30f 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,11 +1,11 @@ { "name": "@lightning/guilded", - "version": "0.8.0-alpha.2", + "version": "0.8.0-alpha.3", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.2", - "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.4", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3", + "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.5", "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.2" } } diff --git a/packages/guilded/src/errors.ts b/packages/guilded/src/errors.ts index 574841e9..09dbeeaf 100644 --- a/packages/guilded/src/errors.ts +++ b/packages/guilded/src/errors.ts @@ -1,27 +1,24 @@ import { RequestError } from '@jersey/guilded-api-types'; import { log_error } from '@lightning/lightning'; +const errors = [ + [403, 'No permission to send/delete messages! Check permissions', true], + [404, 'Not found! This might be a Guilded problem if making a bridge', true], + [0, 'Unknown Guilded error, not disabling channel', false], +] as const; + export function handle_error(err: unknown, channel: string): never { if (err instanceof RequestError) { - if (err.cause.status === 404) { - log_error(err, { - message: - "resource not found! if you're trying to make a bridge, this is likely an issue with Guilded", - extra: { channel_id: channel, response: err.cause }, - disable: true, - }); - } else if (err.cause.status === 403) { - log_error(err, { - message: 'no permission to send/delete messages! check bot permissions', - extra: { channel_id: channel, response: err.cause }, - disable: true, - }); - } else { - log_error(err, { - message: `unknown guilded error with status code ${err.cause.status}`, - extra: { channel_id: channel, response: err.cause }, - }); - } + const [, message, disable] = errors.find((e) => + e[0] === err.cause.status + ) ?? + errors[errors.length - 1]; + + log_error(err, { + disable, + extra: { channel_id: channel, response: err.cause }, + message, + }); } else { log_error(err, { message: `unknown error`, diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts index cd3bd62d..4c61bede 100644 --- a/packages/guilded/src/incoming.ts +++ b/packages/guilded/src/incoming.ts @@ -4,44 +4,24 @@ import type { ServerMember, Webhook, } from '@jersey/guilded-api-types'; -import type { attachment, message } from '@lightning/lightning'; - -class cacher { - private map = new Map(); - public expiry = 30000; - get(key: K): V | undefined { - const time = Temporal.Now.instant().epochMilliseconds; - const v = this.map.get(key); - - if (v && v.expiry >= time) return v.value; - } - set(key: K, val: V): V { - const time = Temporal.Now.instant().epochMilliseconds; - this.map.set(key, { value: val, expiry: time + this.expiry }); - return val; - } -} +import { type attachment, cacher, type message } from '@lightning/lightning'; const member_cache = new cacher<`${string}/${string}`, ServerMember>(); const webhook_cache = new cacher<`${string}/${string}`, Webhook>(); -const asset_cache = new cacher(); -asset_cache.expiry = 86400000; // 1 day! +const asset_cache = new cacher(86400000); export async function fetch_author(msg: ChatMessage, client: Client) { try { if (!msg.createdByWebhookId) { - const author = member_cache.get(`${msg.serverId}/${msg.createdBy}`) ?? - member_cache.set( - `${msg.serverId}/${msg.createdBy}`, - (await client.request( - 'get', - `/servers/${msg.serverId}/members/${msg.createdBy}`, - undefined, - ) as { member: ServerMember }).member, - ); + const key = `${msg.serverId}/${msg.createdBy}` as const; + const author = member_cache.get(key) ?? member_cache.set( + key, + (await client.request( + 'get', + `/servers/${msg.serverId}/members/${msg.createdBy}`, + undefined, + ) as { member: ServerMember }).member, + ); return { username: author.nickname || author.user.name, @@ -50,17 +30,15 @@ export async function fetch_author(msg: ChatMessage, client: Client) { profile: author.user.avatar || undefined, }; } else { - const webhook = webhook_cache.get( - `${msg.serverId}/${msg.createdByWebhookId}`, - ) ?? - webhook_cache.set( - `${msg.serverId}/${msg.createdByWebhookId}`, - (await client.request( - 'get', - `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, - undefined, - )).webhook, - ); + const key = `${msg.serverId}/${msg.createdByWebhookId}` as const; + const webhook = webhook_cache.get(key) ?? webhook_cache.set( + key, + (await client.request( + 'get', + `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, + undefined, + )).webhook, + ); return { username: webhook.name, diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts index 1c0cc83a..c2fd496a 100644 --- a/packages/guilded/src/mod.ts +++ b/packages/guilded/src/mod.ts @@ -2,8 +2,8 @@ import { type Client, createClient } from '@jersey/guildapi'; import type { ServerChannel } from '@jersey/guilded-api-types'; import { type bridge_message_opts, + type config_schema, type deleted_message, - log_error, type message, plugin, } from '@lightning/lightning'; @@ -13,20 +13,20 @@ import { get_outgoing } from './outgoing.ts'; /** options for the guilded bot */ export interface guilded_config { + /** enable debug logging */ + debug?: boolean; /** the token to use */ token: string; } -/** check if something is actually a config object, return if it is */ -export function parse_config(v: unknown): guilded_config { - if (typeof v !== 'object' || v === null) { - log_error("guilded config isn't an object!", { without_cause: true }); - } - if (!('token' in v) || typeof v.token !== 'string') { - log_error("guilded token isn't a string", { without_cause: true }); - } - return { token: v.token }; -} +/** the config schema for the plugin */ +export const schema: config_schema = { + name: 'bolt-guilded', + keys: { + debug: { type: 'boolean', required: false }, + token: { type: 'string', required: true }, + }, +}; /** guilded support for lightning */ export default class guilded extends plugin { @@ -38,11 +38,11 @@ export default class guilded extends plugin { super(); this.client = createClient(opts.token); this.token = opts.token; - this.setup_events(); + this.setup_events(opts.debug); this.client.socket.connect(); } - private setup_events() { + private setup_events(debug?: boolean) { this.client.socket.on('ChatMessageCreated', async (data) => { const msg = await get_incoming(data.d.message, this.client); if (msg) this.emit('create_message', msg); @@ -58,7 +58,12 @@ export default class guilded extends plugin { if (msg) this.emit('edit_message', msg); }).on('ready', (data) => { console.log(`[guilded] ready as ${data.name} (${data.id})`); - }); + }).on('reconnect', () => { + console.log(`[guilded] reconnected`); + }).on( + 'debug', + (data) => debug && console.log(`[guilded] guildapi debug:`, data), + ); } /** create a webhook in a channel */ diff --git a/packages/guilded/src/outgoing.ts b/packages/guilded/src/outgoing.ts index 263d9dc5..e72f10c8 100644 --- a/packages/guilded/src/outgoing.ts +++ b/packages/guilded/src/outgoing.ts @@ -30,20 +30,17 @@ async function fetch_reply( if (!msg.reply_id) return; try { - const replied_to = await client.request( + const { message } = await client.request( 'get', `/channels/${msg.channel_id}/messages/${msg.reply_id}`, undefined, ); - const author = await fetch_author(replied_to.message, client); + const { profile, username } = await fetch_author(message, client); return { - author: { - name: `reply to ${author.username}`, - icon_url: author.profile, - }, - description: replied_to.message.content, + author: { name: `reply to ${username}`, icon_url: profile }, + description: message.content, }; } catch { return; @@ -62,26 +59,17 @@ export async function get_outgoing( embeds: msg.embeds?.map((i) => { return { ...i, - fields: i.fields - ? i.fields.map((j) => { - return { - ...j, - inline: j.inline ?? false, - }; - }) - : undefined, - timestamp: i.timestamp ? i.timestamp.toString() : undefined, + fields: i.fields?.map((j) => ({ ...j, inline: j.inline ?? false })), + timestamp: i.timestamp?.toString(), }; }), }; - if (msg.reply_id) { - const embed = await fetch_reply(msg, client); + const embed = await fetch_reply(msg, client); - if (embed) { - if (!message.embeds) message.embeds = []; - message.embeds.push(embed); - } + if (embed) { + if (!message.embeds) message.embeds = []; + message.embeds.push(embed); } if (msg.attachments?.length) { diff --git a/packages/lightning/README.md b/packages/lightning/README.md index 0b9eaee1..0b44df23 100644 --- a/packages/lightning/README.md +++ b/packages/lightning/README.md @@ -1,4 +1,4 @@ -![lightning](logo.svg) +![lightning](https://raw.githubusercontent.com/williamhorning/lightning/refs/heads/develop/logo.svg) # @lightning/lightning @@ -17,11 +17,11 @@ type = "postgres" config = "postgresql://server:password@postgres:5432/lightning" [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.2" +plugin = "jsr:@lightning/discord@0.8.0-alpha.3" config.token = "your_token" [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.2" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.3" config.token = "your_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index a2f27767..8967aadc 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -1,20 +1,17 @@ { "name": "@lightning/lightning", - "version": "0.8.0-alpha.2", + "version": "0.8.0-alpha.3", "license": "MIT", "exports": { ".": "./src/mod.ts", "./cli": "./src/cli.ts" }, "imports": { - "@cross/env": "jsr:@cross/env@^1.0.2", - "@cross/fs": "jsr:@cross/fs@^0.1.12", - "@cross/utils": "jsr:@cross/utils@^0.16.0", - "@db/postgres": "jsr:@jersey/postgres@^0.19.4", + "@db/postgres": "jsr:@db/postgres@^0.19.5", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", - "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.1", - "@std/cli": "jsr:@std/cli@^1.0.16", - "@std/fs": "jsr:@std/fs@^1.0.16", - "@std/toml": "jsr:@std/toml@^1.0.4" + "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.4", + "@std/cli": "jsr:@std/cli@^1.0.17", + "@std/fs": "jsr:@std/fs@^1.0.17", + "@std/toml": "jsr:@std/toml@^1.0.6" } } diff --git a/packages/lightning/src/bridge/commands.ts b/packages/lightning/src/bridge/commands.ts index decc7e3e..dbaf9365 100644 --- a/packages/lightning/src/bridge/commands.ts +++ b/packages/lightning/src/bridge/commands.ts @@ -14,11 +14,7 @@ export async function create( const data = { name: opts.args.name!, channels: [result], - settings: { - allow_editing: true, - allow_everyone: false, - use_rawname: false, - }, + settings: { allow_everyone: false }, }; try { diff --git a/packages/lightning/src/bridge/handler.ts b/packages/lightning/src/bridge/handler.ts index a6a1c78d..20e7b639 100644 --- a/packages/lightning/src/bridge/handler.ts +++ b/packages/lightning/src/bridge/handler.ts @@ -56,7 +56,7 @@ export async function bridge_message( reply_id = bridged?.messages?.find((message) => message.channel === channel.id && message.plugin === channel.plugin - )?.id[0]; + )?.id[0] ?? bridged?.id; } catch { reply_id = undefined; } @@ -101,7 +101,7 @@ export async function bridge_message( }); } catch (e) { const err = new LightningError(e, { - message: `An error occurred while processing a message in the bridge`, + message: `An error occurred while handling a message in the bridge`, }); if (err.disable_channel) { diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 9e27c8d2..e8eff8c3 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -1,9 +1,8 @@ -import { cwd } from '@cross/fs'; -import { args as getArgs, exit } from '@cross/utils'; import { setup_bridge } from './bridge/setup.ts'; import { parse_config } from './cli_config.ts'; import { core } from './core.ts'; import { handle_migration } from './database/mod.ts'; +import { cwd, exit, get_args } from './structures/cross.ts'; import { LightningError } from './structures/errors.ts'; /** @@ -11,7 +10,7 @@ import { LightningError } from './structures/errors.ts'; * @module */ -const args = getArgs(); +const args = get_args(); if (args[0] === 'migrate') { handle_migration(); @@ -31,10 +30,10 @@ if (args[0] === 'migrate') { exit(1); } } else if (args[0] === 'version') { - console.log('0.8.0-alpha.2'); + console.log('0.8.0-alpha.3'); } else { console.log( - `lightning v0.8.0-alpha.2 - extensible chatbot connecting communities`, + `lightning v0.8.0-alpha.3 - extensible chatbot connecting communities`, ); console.log(' Usage: lightning [subcommand]'); console.log(' Subcommands:'); diff --git a/packages/lightning/src/cli_config.ts b/packages/lightning/src/cli_config.ts index 862c4788..296e52e8 100644 --- a/packages/lightning/src/cli_config.ts +++ b/packages/lightning/src/cli_config.ts @@ -1,9 +1,10 @@ -import { setEnv } from '@cross/env'; import { readTextFile } from '@std/fs/unstable-read-text-file'; import { parse as parse_toml } from '@std/toml'; import type { core_config } from './core.ts'; import type { database_config } from './database/mod.ts'; +import { set_env } from './structures/cross.ts'; import { log_error } from './structures/errors.ts'; +import { validate_config } from './structures/validate.ts'; interface cli_plugin { plugin: string; @@ -20,19 +21,24 @@ export async function parse_config(path: URL): Promise { const file = await readTextFile(path); const raw = parse_toml(file) as Record; + const validated = validate_config(raw, { + name: 'lightning', + keys: { + error_url: { type: 'string', required: false }, + prefix: { type: 'string', required: false }, + }, + }) as Omit & { plugins: cli_plugin[] }; + if ( - !('database' in raw) || - typeof raw.database !== 'object' || - raw.database === null || - !('type' in raw.database) || - typeof raw.database.type !== 'string' || - !('config' in raw.database) || - raw.database.config === null || - (raw.database.type === 'postgres' && - typeof raw.database.config !== 'string') || - (raw.database.type === 'redis' && - (typeof raw.database.config !== 'object' || - raw.database.config === null)) + !('type' in validated.database) || + typeof validated.database.type !== 'string' || + !('config' in validated.database) || + validated.database.config === null || + (validated.database.type === 'postgres' && + typeof validated.database.config !== 'string') || + (validated.database.type === 'redis' && + (typeof validated.database.config !== 'object' || + validated.database.config === null)) ) { return log_error('your config has an invalid `database` field', { without_cause: true, @@ -40,9 +46,7 @@ export async function parse_config(path: URL): Promise { } if ( - !('plugins' in raw) || - !Array.isArray(raw.plugins) || - !raw.plugins.every( + !validated.plugins.every( (p): p is cli_plugin => typeof p.plugin === 'string' && typeof p.config === 'object' && @@ -54,20 +58,6 @@ export async function parse_config(path: URL): Promise { }); } - if ('error_url' in raw && typeof raw.error_url !== 'string') { - return log_error('the `error_url` field is not a valid string', { - without_cause: true, - }); - } - - if ('prefix' in raw && typeof raw.prefix !== 'string') { - return log_error('the `prefix` field is not a valid string', { - without_cause: true, - }); - } - - const validated = raw as unknown as config & { plugins: cli_plugin[] }; - const plugins = []; for (const plugin of validated.plugins) { @@ -77,7 +67,7 @@ export async function parse_config(path: URL): Promise { }); } - setEnv('LIGHTNING_ERROR_WEBHOOK', validated.error_url ?? ''); + set_env('LIGHTNING_ERROR_WEBHOOK', validated.error_url ?? ''); return { ...validated, plugins }; } catch (e) { diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 9f3f8aba..8680b3b4 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -7,6 +7,7 @@ import type { import { LightningError, log_error } from './structures/errors.ts'; import { create_message, type message } from './structures/messages.ts'; import type { events, plugin, plugin_module } from './structures/plugins.ts'; +import { validate_config } from './structures/validate.ts'; export interface core_config { prefix?: string; @@ -22,7 +23,7 @@ export class core extends EventEmitter { name: 'help', description: 'get help with the bot', execute: () => - 'check out [the docs](https://williamhorning.eu.org/lightning/) for help.', + "hi! i'm lightning v0.8.0-alpha.3.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help.", }], ['ping', { name: 'ping', @@ -33,11 +34,6 @@ export class core extends EventEmitter { .total('milliseconds') }ms`, }], - ['version', { - name: 'version', - description: 'get the bots version', - execute: () => 'hello from v0.8.0-alpha.2!', - }], ]); private plugins = new Map(); private handled = new Set(); @@ -48,14 +44,16 @@ export class core extends EventEmitter { this.prefix = cfg.prefix || '!'; for (const { module, config } of cfg.plugins) { - if (!module.default || !module.parse_config) { + if (!module.default || !module.schema) { log_error({ ...module }, { message: `one or more of you plugins isn't actually a plugin!`, without_cause: true, }); } - const instance = new module.default(module.parse_config(config)); + const instance = new module.default( + validate_config(config, module.schema), + ); this.plugins.set(instance.name, instance); this.handle_events(instance); @@ -69,7 +67,7 @@ export class core extends EventEmitter { set_command(opts: command): void { this.commands.set(opts.name, opts); - for (const [_, plugin] of this.plugins) { + for (const [, plugin] of this.plugins) { if (plugin.set_commands) { plugin.set_commands(this.commands.values().toArray()); } diff --git a/packages/lightning/src/database/mod.ts b/packages/lightning/src/database/mod.ts index 8879911e..15b55065 100644 --- a/packages/lightning/src/database/mod.ts +++ b/packages/lightning/src/database/mod.ts @@ -28,27 +28,17 @@ export type database_config = { export async function create_database( config: database_config, ): Promise { - switch (config.type) { - case 'postgres': - return await postgres.create(config.config); - case 'redis': - return await redis.create(config.config); - default: - throw new Error('invalid database type', { cause: config }); - } + if (config.type === 'postgres') return await postgres.create(config.config); + if (config.type === 'redis') return await redis.create(config.config); + throw new Error('invalid database type', { cause: config }); } function get_database(message: string): typeof postgres | typeof redis { const type = prompt(`${message} (redis,postgres)`); - switch (type) { - case 'postgres': - return postgres; - case 'redis': - return redis; - default: - throw new Error('invalid database type!'); - } + if (type === 'postgres') return postgres; + if (type === 'redis') return redis; + throw new Error('invalid database type!'); } export async function handle_migration() { diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index d8dfe101..fac2a3f4 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -1,5 +1,3 @@ -import { getEnv } from '@cross/env'; -import { stdout } from '@cross/utils'; import type { Client } from '@db/postgres'; import { ProgressBar, @@ -7,6 +5,7 @@ import { } from '@std/cli/unstable-progress-bar'; import { Spinner } from '@std/cli/unstable-spinner'; import type { bridge, bridge_message } from '../structures/bridge.ts'; +import { get_env, stdout } from '../structures/cross.ts'; import type { bridge_data } from './mod.ts'; const fmt = (fmt: ProgressBarFormatter) => @@ -206,7 +205,7 @@ export class postgres implements bridge_data { static async migration_get_instance(): Promise { const default_url = `postgres://${ - getEnv('USER') ?? getEnv('USERNAME') + get_env('USER') ?? get_env('USERNAME') }@localhost/lightning`; const pg_url = prompt( diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index 4845e87f..a5c0e341 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -1,5 +1,3 @@ -import { getEnv } from '@cross/env'; -import { stdout } from '@cross/utils'; import { RedisClient } from '@iuioiua/redis'; import { ProgressBar, @@ -12,6 +10,7 @@ import type { bridge_message, bridged_message, } from '../structures/bridge.ts'; +import { get_env, stdout, tcp_connect } from '../structures/cross.ts'; import { log_error } from '../structures/errors.ts'; import type { bridge_data } from './mod.ts'; @@ -31,27 +30,7 @@ export class redis implements bridge_data { rd_options: redis_config, _do_not_use = false, ): Promise { - let streams: { - readable: ReadableStream; - writable: WritableStream; - }; - - if ('Deno' in globalThis) { - streams = await Deno.connect(rd_options); - } else { - const { createConnection } = await import('node:net'); - const { Readable, Writable } = await import('node:stream'); - const conn = createConnection({ - host: rd_options.hostname, - port: rd_options.port, - }); - streams = { - readable: Readable.toWeb(conn) as ReadableStream, - writable: Writable.toWeb(conn), - }; - } - - const rd = new RedisClient(streams); + const rd = new RedisClient(await tcp_connect(rd_options)); let db_data_version = await rd.sendCommand([ 'GET', @@ -61,7 +40,10 @@ export class redis implements bridge_data { if (db_data_version === null) { const number_keys = await rd.sendCommand(['DBSIZE']) as number; - if (number_keys === 0) db_data_version = '0.8.0'; + if (number_keys === 0) { + await rd.sendCommand(['SET', 'lightning-db-version', '0.8.0']); + db_data_version = '0.8.0'; + } } if (db_data_version !== '0.8.0' && !_do_not_use) { @@ -85,7 +67,7 @@ export class redis implements bridge_data { const write = confirm( '[lightning-redis] write the data to the database? see \`lightning-redis-migration.json\` for the data', ); - const env_confirm = getEnv('LIGHTNING_MIGRATE_CONFIRM'); + const env_confirm = get_env('LIGHTNING_MIGRATE_CONFIRM'); if (write || env_confirm === 'true') { await instance.migration_set_bridges(bridges); @@ -237,11 +219,9 @@ export class redis implements bridge_data { } const bridge = await this.get_json<{ - allow_editing: boolean; channels: bridge_channel[]; id: string; messages?: bridged_message[]; - use_rawname: boolean; }>(key); if (bridge && bridge.channels) { diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts index a337cef2..03f20ff9 100644 --- a/packages/lightning/src/mod.ts +++ b/packages/lightning/src/mod.ts @@ -1,5 +1,3 @@ -if (import.meta.main) { - import('./cli.ts'); -} +if (import.meta.main) import('./cli.ts'); export * from './structures/mod.ts'; diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index dbc0e59e..7bd955a8 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -29,9 +29,7 @@ export interface bridge_settings { } /** list of settings for a bridge */ -export const bridge_settings_list = [ - 'allow_everyone', -]; +export const bridge_settings_list = ['allow_everyone']; /** representation of a bridged message collection */ export interface bridge_message extends bridge { diff --git a/packages/lightning/src/structures/cacher.ts b/packages/lightning/src/structures/cacher.ts new file mode 100644 index 00000000..b41e5582 --- /dev/null +++ b/packages/lightning/src/structures/cacher.ts @@ -0,0 +1,23 @@ +export class cacher { + private map = new Map(); + + constructor(private ttl: number = 30000) {} + + get(key: k): v | undefined { + const time = Temporal.Now.instant().epochMilliseconds; + const entry = this.map.get(key); + + if (entry && entry.expiry >= time) return entry.value; + this.map.delete(key); + return undefined; + } + + set(key: k, val: v, customTtl?: number): v { + const time = Temporal.Now.instant().epochMilliseconds; + this.map.set(key, { + value: val, + expiry: time + (customTtl ?? this.ttl), + }); + return val; + } +} diff --git a/packages/lightning/src/structures/commands.ts b/packages/lightning/src/structures/commands.ts index 86fff208..d5127394 100644 --- a/packages/lightning/src/structures/commands.ts +++ b/packages/lightning/src/structures/commands.ts @@ -12,9 +12,7 @@ export interface command { /** possible subcommands (use `${prefix}${cmd} ${subcommand}` if run as text command) */ subcommands?: Omit[]; /** the functionality of the command, returning text */ - execute: ( - opts: command_opts, - ) => Promise | string; + execute: (opts: command_opts) => Promise | string; } /** argument for a command */ diff --git a/packages/lightning/src/structures/cross.ts b/packages/lightning/src/structures/cross.ts new file mode 100644 index 00000000..47a4dc2b --- /dev/null +++ b/packages/lightning/src/structures/cross.ts @@ -0,0 +1,63 @@ +// deno-lint-ignore-file no-process-global +// deno-lint-ignore triple-slash-reference +/// + +const is_deno = 'Deno' in globalThis; + +/** Get environment variable */ +export function get_env(key: string): string | undefined { + return is_deno ? Deno.env.get(key) : process.env[key]; +} + +/** Set environment variable */ +export function set_env(key: string, value: string): void { + if (is_deno) { + Deno.env.set(key, value); + } else { + process.env[key] = value; + } +} + +/** Get current directory */ +export function cwd(): string { + return is_deno ? Deno.cwd() : process.cwd(); +} + +/** Exit the process */ +export function exit(code: number): never { + return is_deno ? Deno.exit(code) : process.exit(code); +} + +/** Get command-line arguments */ +export function get_args(): string[] { + return is_deno ? Deno.args : process.argv.slice(2); +} + +/** Get stdout stream */ +export function stdout(): WritableStream { + return is_deno + ? Deno.stdout.writable + : process.getBuiltinModule('stream').Writable.toWeb( + process.stdout, + ) as WritableStream; +} + +/** Get tcp connection streams */ +export async function tcp_connect( + opts: { hostname: string; port: number }, +): Promise< + { readable: ReadableStream; writable: WritableStream } +> { + if (is_deno) return await Deno.connect(opts); + + const { createConnection } = process.getBuiltinModule('node:net'); + const { Readable, Writable } = process.getBuiltinModule('node:stream'); + const conn = createConnection({ + host: opts.hostname, + port: opts.port, + }); + return { + readable: Readable.toWeb(conn) as ReadableStream, + writable: Writable.toWeb(conn) as WritableStream, + }; +} diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index 35f43bbd..dc5167f3 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -1,4 +1,4 @@ -import { getEnv } from '@cross/env'; +import { get_env } from './cross.ts'; import { create_message, type message } from './messages.ts'; /** options used to create an error */ @@ -66,8 +66,7 @@ export class LightningError extends Error { `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\``, ); - console.error(`%c[lightning] ${this.message}`, 'color: red'); - console.error(`%c[lightning] ${this.id}`, 'color: red'); + console.error(`%c[lightning] ${this.message} - ${this.id}`, 'color: red'); console.error( `%c[lightning] this does${ this.disable_channel ? ' ' : ' not ' @@ -82,7 +81,7 @@ export class LightningError extends Error { async log(): Promise { if (!this.without_cause) console.error(this.error_cause, this.extra); - const webhook = getEnv('LIGHTNING_ERROR_WEBHOOK'); + const webhook = get_env('LIGHTNING_ERROR_WEBHOOK'); if (webhook && webhook.length > 0) { await fetch(webhook, { diff --git a/packages/lightning/src/structures/mod.ts b/packages/lightning/src/structures/mod.ts index 7f5b03fd..9bf90267 100644 --- a/packages/lightning/src/structures/mod.ts +++ b/packages/lightning/src/structures/mod.ts @@ -1,5 +1,7 @@ export * from './bridge.ts'; +export * from './cacher.ts'; export * from './commands.ts'; export * from './errors.ts'; export * from './messages.ts'; export * from './plugins.ts'; +export * from './validate.ts'; diff --git a/packages/lightning/src/structures/plugins.ts b/packages/lightning/src/structures/plugins.ts index e67ed887..ac998027 100644 --- a/packages/lightning/src/structures/plugins.ts +++ b/packages/lightning/src/structures/plugins.ts @@ -2,6 +2,7 @@ import { EventEmitter } from '@denosaurs/event'; import type { bridge_message_opts } from './bridge.ts'; import type { command, create_command } from './commands.ts'; import type { deleted_message, message } from './messages.ts'; +import type { config_schema } from './validate.ts'; /** the events emitted by core/plugins */ export type events = { @@ -46,8 +47,7 @@ export abstract class plugin extends EventEmitter { /** the type core uses to load a module */ export interface plugin_module { /** the plugin constructor */ - // deno-lint-ignore no-explicit-any - default?: { new (cfg: any): plugin }; + default?: { new (cfg: unknown): plugin }; /** the config to validate use */ - parse_config?: (data: unknown) => unknown; + schema?: config_schema; } diff --git a/packages/lightning/src/structures/validate.ts b/packages/lightning/src/structures/validate.ts new file mode 100644 index 00000000..ee373184 --- /dev/null +++ b/packages/lightning/src/structures/validate.ts @@ -0,0 +1,35 @@ +import { log_error } from './errors.ts'; + +/** A config schema */ +export interface config_schema { + name: string; + keys: Record; +} + +/** Validate an item based on a schema */ +export function validate_config(config: unknown, schema: config_schema): T { + if (typeof config !== 'object' || config === null) { + log_error(`[${schema.name}] config is not an object`, { + without_cause: true, + }); + } + + for (const [key, { type, required }] of Object.entries(schema.keys)) { + const value = (config as Record)[key]; + + if (required && value === undefined) { + log_error(`[${schema.name}] missing required config key '${key}'`, { + without_cause: true, + }); + } else if (value !== undefined && typeof value !== type) { + log_error(`[${schema.name}] config key '${key}' must be a ${type}`, { + without_cause: true, + }); + } + } + + return config as T; +} diff --git a/packages/revolt/README.md b/packages/revolt/README.md index eb6317f8..597b7afe 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -7,7 +7,7 @@ Revolt bot first. After that, you need to add the following to your config file: ```toml [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.2" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.3" config.token = "your_bot_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index b5c95fa3..d433b088 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/revolt", - "version": "0.8.0-alpha.2", + "version": "0.8.0-alpha.3", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.2", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.9", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.4", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/revolt/src/cache.ts b/packages/revolt/src/cache.ts index 3ff8e4e9..76c7ccdd 100644 --- a/packages/revolt/src/cache.ts +++ b/packages/revolt/src/cache.ts @@ -1,4 +1,3 @@ -import type { message_author } from '@lightning/lightning'; import type { Channel, Masquerade, @@ -9,32 +8,15 @@ import type { User, } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; +import { cacher, type message_author } from '@lightning/lightning'; -class cacher { - private map = new Map(); - get(key: K): V | undefined { - const time = Temporal.Now.instant().epochMilliseconds; - const v = this.map.get(key); - - if (v && v.expiry >= time) return v.value; - } - set(key: K, val: V): V { - const time = Temporal.Now.instant().epochMilliseconds; - this.map.set(key, { value: val, expiry: time + 30000 }); - return val; - } -} - -const author_cache = new cacher<`${string}/${string}`, message_author>(); -const channel_cache = new cacher(); -const member_cache = new cacher<`${string}/${string}`, Member>(); -const message_cache = new cacher<`${string}/${string}`, Message>(); -const role_cache = new cacher<`${string}/${string}`, Role>(); -const server_cache = new cacher(); -const user_cache = new cacher(); +const authors = new cacher<`${string}/${string}`, message_author>(); +const channels = new cacher(); +const members = new cacher<`${string}/${string}`, Member>(); +const messages = new cacher<`${string}/${string}`, Message>(); +const roles = new cacher<`${string}/${string}`, Role>(); +const servers = new cacher(); +const users = new cacher(); export async function fetch_author( api: Client, @@ -43,7 +25,7 @@ export async function fetch_author( masquerade?: Masquerade, ): Promise { try { - const cached = author_cache.get(`${authorID}/${channelID}`); + const cached = authors.get(`${authorID}/${channelID}`); if (cached) return cached; @@ -66,7 +48,7 @@ export async function fetch_author( try { const member = await fetch_member(api, channel.server, authorID); - return author_cache.set(`${authorID}/${channelID}`, { + return authors.set(`${authorID}/${channelID}`, { ...data, username: masquerade?.name ?? member.nickname ?? data.username, profile: masquerade?.avatar ?? @@ -75,7 +57,7 @@ export async function fetch_author( : data.profile), }); } catch { - return author_cache.set(`${authorID}/${channelID}`, data); + return authors.set(`${authorID}/${channelID}`, data); } } catch { return { @@ -92,7 +74,7 @@ export async function fetch_channel( api: Client, channelID: string, ): Promise { - const cached = channel_cache.get(channelID); + const cached = channels.get(channelID); if (cached) return cached; @@ -102,7 +84,7 @@ export async function fetch_channel( undefined, ) as Channel; - return channel_cache.set(channelID, channel); + return channels.set(channelID, channel); } export async function fetch_member( @@ -110,7 +92,7 @@ export async function fetch_member( serverID: string, userID: string, ): Promise { - const member = member_cache.get(`${serverID}/${userID}`); + const member = members.get(`${serverID}/${userID}`); if (member) return member; @@ -120,7 +102,7 @@ export async function fetch_member( undefined, ) as Member; - return member_cache.set(`${serverID}/${userID}`, response); + return members.set(`${serverID}/${userID}`, response); } export async function fetch_message( @@ -128,7 +110,7 @@ export async function fetch_message( channelID: string, messageID: string, ): Promise { - const message = message_cache.get(`${channelID}/${messageID}`); + const message = messages.get(`${channelID}/${messageID}`); if (message) return message; @@ -138,7 +120,7 @@ export async function fetch_message( undefined, ) as Message; - return message_cache.set(`${channelID}/${messageID}`, response); + return messages.set(`${channelID}/${messageID}`, response); } export async function fetch_role( @@ -146,7 +128,7 @@ export async function fetch_role( serverID: string, roleID: string, ): Promise { - const role = role_cache.get(`${serverID}/${roleID}`); + const role = roles.get(`${serverID}/${roleID}`); if (role) return role; @@ -156,14 +138,14 @@ export async function fetch_role( undefined, ) as Role; - return role_cache.set(`${serverID}/${roleID}`, response); + return roles.set(`${serverID}/${roleID}`, response); } export async function fetch_server( client: Client, serverID: string, ): Promise { - const server = server_cache.get(serverID); + const server = servers.get(serverID); if (server) return server; @@ -173,14 +155,14 @@ export async function fetch_server( undefined, ) as Server; - return server_cache.set(serverID, response); + return servers.set(serverID, response); } export async function fetch_user( api: Client, userID: string, ): Promise { - const cached = user_cache.get(userID); + const cached = users.get(userID); if (cached) return cached; @@ -190,5 +172,5 @@ export async function fetch_user( undefined, ) as User; - return user_cache.set(userID, user); + return users.set(userID, user); } diff --git a/packages/revolt/src/errors.ts b/packages/revolt/src/errors.ts index 2aa73d39..f0ff07f7 100644 --- a/packages/revolt/src/errors.ts +++ b/packages/revolt/src/errors.ts @@ -1,33 +1,25 @@ -import { log_error } from '@lightning/lightning'; -import { MediaError } from '@jersey/rvapi'; import { RequestError } from '@jersey/revolt-api-types'; +import { MediaError } from '@jersey/rvapi'; +import { log_error } from '@lightning/lightning'; + +const errors = [ + [403, 'Insufficient permissions. Please check them', true], + [404, 'Resource not found', true], + [0, 'Unknown Revolt RequestError', false], +] as const; -export function handle_error(err: unknown, edit?: boolean) { +export function handle_error(err: unknown, edit?: boolean): never[] { if (err instanceof MediaError) { - log_error(err, { - message: err.message, - }); + log_error(err); } else if (err instanceof RequestError) { - if (err.cause.status === 403) { - log_error(err, { - message: 'Insufficient permissions', - disable: true, - }); - } else if (err.cause.status === 404) { - if (edit) return []; + if (err.cause.status === 404 && edit) return []; + + const [, message, disable] = errors.find((e) => + e[0] === err.cause.status + ) ?? errors[errors.length - 1]; - log_error(err, { - message: 'Resource not found', - disable: true, - }); - } else { - log_error(err, { - message: 'unknown revolt request error', - }); - } + log_error(err, { message, disable }); } else { - log_error(err, { - message: 'unknown revolt error', - }); + log_error(err, { message: 'unknown revolt error' }); } } diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts index d50136c4..531eeaad 100644 --- a/packages/revolt/src/incoming.ts +++ b/packages/revolt/src/incoming.ts @@ -1,6 +1,6 @@ -import type { embed, message } from '@lightning/lightning'; import type { Message as APIMessage } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; +import type { embed, message } from '@lightning/lightning'; import { decodeTime } from '@std/ulid'; import { fetch_author } from './cache.ts'; diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 5c7374aa..5cbea076 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -1,12 +1,12 @@ +import type { Message as APIMessage } from '@jersey/revolt-api-types'; +import { Bonfire, type Client, createClient } from '@jersey/rvapi'; import { type bridge_message_opts, + type config_schema, type deleted_message, - log_error, type message, plugin, } from '@lightning/lightning'; -import type { Message as APIMessage } from '@jersey/revolt-api-types'; -import { Bonfire, type Client, createClient } from '@jersey/rvapi'; import { fetch_message } from './cache.ts'; import { handle_error } from './errors.ts'; import { get_incoming } from './incoming.ts'; @@ -21,19 +21,14 @@ export interface revolt_config { user_id: string; } -/** check if something is actually a config object, return if it is */ -export function parse_config(v: unknown): revolt_config { - if (typeof v !== 'object' || v === null) { - log_error("revolt config isn't an object!", { without_cause: true }); - } - if (!('token' in v) || typeof v.token !== 'string') { - log_error("revolt token isn't a string", { without_cause: true }); - } - if (!('user_id' in v) || typeof v.user_id !== 'string') { - log_error("revolt user ID isn't a string", { without_cause: true }); - } - return { token: v.token, user_id: v.user_id }; -} +/** the config schema for the revolt plugin */ +export const schema: config_schema = { + name: 'bolt-revolt', + keys: { + token: { type: 'string', required: true }, + user_id: { type: 'string', required: true }, + }, +}; /** revolt support for lightning */ export default class revolt extends plugin { diff --git a/packages/revolt/src/outgoing.ts b/packages/revolt/src/outgoing.ts index d831e06c..56f054e6 100644 --- a/packages/revolt/src/outgoing.ts +++ b/packages/revolt/src/outgoing.ts @@ -1,24 +1,18 @@ -import { - type attachment, - LightningError, - type message, -} from '@lightning/lightning'; import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; +import { LightningError, type message } from '@lightning/lightning'; -async function upload_files( +export async function get_outgoing( api: Client, - attachments?: attachment[], -): Promise { - if (!attachments) return undefined; - - return (await Promise.all( - attachments.map(async (attachment) => { + message: message, + masquerade = true, +): Promise { + const attachments = (await Promise.all( + message.attachments?.map(async (attachment) => { try { - return await api.media.upload_file( - 'attachments', - await (await fetch(attachment.file)).blob(), - ); + const file = await (await fetch(attachment.file)).blob(); + if (file.size < 1) return; + return await api.media.upload_file('attachments', file); } catch (e) { new LightningError(e, { message: 'Failed to upload attachment', @@ -27,16 +21,8 @@ async function upload_files( return; } - }), + }) ?? [], )).filter((i) => i !== undefined); -} - -export async function get_outgoing( - api: Client, - message: message, - masquerade = true, -): Promise { - const attachments = await upload_files(api, message.attachments); if ( (!message.content || message.content.length < 1) && @@ -58,18 +44,16 @@ export async function get_outgoing( title: embed.title, description: embed.description ?? '', media: embed.image?.url, - colour: embed.color ? `#${embed.color.toString(16)}` : null, + colour: embed.color + ? `#${embed.color.toString(16).padStart(6, '0')}` + : undefined, }; - if (embed.fields) { - for (const field of embed.fields) { - data.description += `\n\n**${field.name}**\n${field.value}`; - } + for (const field of embed.fields ?? []) { + data.description += `\n\n**${field.name}**\n${field.value}`; } - if (data.description?.length === 0) { - data.description = null; - } + if (data.description?.length === 0) data.description = undefined; return data; }), diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index 64faa7ce..3d358067 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -1,5 +1,5 @@ -import { LightningError, log_error } from '@lightning/lightning'; import type { Client } from '@jersey/rvapi'; +import { log_error } from '@lightning/lightning'; import { fetch_channel, fetch_member, @@ -8,12 +8,7 @@ import { } from './cache.ts'; import { handle_error } from './errors.ts'; -const permission_bits = [ - 1 << 23, // ManageMessages - 1 << 28, // Masquerade -]; - -const needed_permissions = permission_bits.reduce((a, b) => a | b, 0); +const needed_permissions = 25165824; // ManageMessages and Masquerade export async function check_permissions( channel_id: string, @@ -68,8 +63,6 @@ export async function check_permissions( log_error(`unsupported channel type: ${channel.channel_type}`); } } catch (e) { - if (e instanceof LightningError) throw e; - handle_error(e); } } diff --git a/packages/telegram/README.md b/packages/telegram/README.md index 812011f5..97802417 100644 --- a/packages/telegram/README.md +++ b/packages/telegram/README.md @@ -8,7 +8,7 @@ to your config: ```toml [[plugins]] -plugin = "jsr:@lightning/telegram" +plugin = "jsr:@lightning/telegram@0.8.0-alpha.3" config.token = "your_bot_token" config.proxy_port = 9090 config.proxy_url = "https://example.com:9090" diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index c45051fe..0ca8780a 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,12 +1,11 @@ { "name": "@lightning/telegram", - "version": "0.8.0-alpha.2", + "version": "0.8.0-alpha.3", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.2", - "@oak/oak": "jsr:@oak/oak@^17.1.4", - "grammy": "npm:grammy@^1.36.0", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3", + "grammy": "npm:grammy@^1.36.1", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" } } diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts index 2698ab6a..042784af 100644 --- a/packages/telegram/src/incoming.ts +++ b/packages/telegram/src/incoming.ts @@ -50,41 +50,35 @@ export async function get_incoming( : undefined, }; - switch (type) { - case 'text': - return { - ...base, - content: msg.text, - }; - case 'dice': - return { - ...base, - content: `${msg.dice!.emoji} ${msg.dice!.value}`, - }; - case 'location': - return { - ...base, - content: `https://www.openstreetmap.com/#map=18/${ - msg.location!.latitude - }/${msg.location!.longitude}`, - }; - case 'unsupported': - return; - default: { - const file = await ctx.api.getFile( - (type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!).file_id, - ); + if (type === 'unsupported') return; + if (type === 'text') return { ...base, content: msg.text }; + if (type === 'dice') { + return { + ...base, + content: `${msg.dice!.emoji} ${msg.dice!.value}`, + }; + } + if (type === 'location') { + return { + ...base, + content: `https://www.openstreetmap.com/#map=18/${ + msg.location!.latitude + }/${msg.location!.longitude}`, + }; + } - if (!file.file_path) return; + const file = await ctx.api.getFile( + (type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!).file_id, + ); - return { - ...base, - attachments: [{ - file: `${proxy}/${file.file_path}`, - name: file.file_path, - size: (file.file_size ?? 0) / 1048576, - }], - }; - } - } + if (!file.file_path) return; + + return { + ...base, + attachments: [{ + file: `${proxy}/${file.file_path}`, + name: file.file_path, + size: (file.file_size ?? 0) / 1048576, + }], + }; } diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 52b7e535..d4535fcf 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -1,12 +1,10 @@ import { type bridge_message_opts, + type config_schema, type deleted_message, - log_error, type message, plugin, } from '@lightning/lightning'; -import { Application } from '@oak/oak/application'; -import { proxy } from '@oak/oak/proxy'; import { Bot } from 'grammy'; import { get_incoming } from './incoming.ts'; import { get_outgoing } from './outgoing.ts'; @@ -21,22 +19,15 @@ export type telegram_config = { proxy_url: string; }; -/** check if something is actually a config object, return if it is */ -export function parse_config(v: unknown): telegram_config { - if (typeof v !== 'object' || v === null) { - log_error("telegram config isn't an object!", { without_cause: true }); - } - if (!('token' in v) || typeof v.token !== 'string') { - log_error("telegram token isn't a string", { without_cause: true }); - } - if (!('proxy_port' in v) || typeof v.proxy_port !== 'number') { - log_error("telegram proxy port isn't a number", { without_cause: true }); - } - if (!('proxy_url' in v) || typeof v.proxy_url !== 'string') { - log_error("telegram proxy url isn't a string", { without_cause: true }); - } - return { token: v.token, proxy_port: v.proxy_port, proxy_url: v.proxy_url }; -} +/** the config schema for the plugin */ +export const schema: config_schema = { + name: 'bolt-telegram', + keys: { + token: { type: 'string', required: true }, + proxy_port: { type: 'number', required: true }, + proxy_url: { type: 'string', required: true }, + }, +}; /** telegram support for lightning */ export default class telegram extends plugin { @@ -54,13 +45,32 @@ export default class telegram extends plugin { if (msg) this.emit('create_message', msg); }); - const app = new Application().use( - proxy(`https://api.telegram.org/file/bot${opts.token}/`, { - map: (path) => path.replace('/telegram/', ''), - }), - ); + const handler = async ({ url }: { url: string }) => + await fetch( + `https://api.telegram.org/file/bot${opts.token}/${ + url.replace('/telegram', '/') + }`, + ); - app.listen({ port: opts.proxy_port }); + if ('Deno' in globalThis) { + Deno.serve({ port: opts.proxy_port }, handler); + } else if ('Bun' in globalThis) { + // @ts-ignore: Bun.serve is not typed + Bun.serve({ + fetch: handler, + port: opts.proxy_port, + }); + } else if ('process' in globalThis) { + // deno-lint-ignore no-process-global + process.getBuiltinModule('node:http').createServer(async (req, res) => { + const resp = await handler(req as { url: string }); + res.writeHead(resp.status, Array.from(resp.headers.entries())); + res.write(new Uint8Array(await resp.arrayBuffer())); + res.end(); + }); + } else { + throw new Error('Unsupported environment for file proxy!'); + } console.log( `[telegram] proxy available at localhost:${opts.proxy_port} or ${opts.proxy_url}`, diff --git a/readme.md b/readme.md index 795ad478..85e752b4 100644 --- a/readme.md +++ b/readme.md @@ -1,9 +1,9 @@ -![lightning logo](./packages/lightning/logo.svg) +![lightning logo](./logo.svg) # lightning - a chatbot > [!NOTE] -> This branch contains the next version of lightning, currently `0.8.0-alpha.2`, +> This branch contains the next version of lightning, currently `0.8.0-alpha.3`, > and reflects active development. To see the latest stable version, go to the > `main` branch. @@ -56,9 +56,9 @@ to platform limitations. The Matrix Specification is really difficult to correctly handle, especially with the current state of JavaScript libraries. Solutions that work without a reliance on `matrix-appservice-bridge` but still use JavaScript and are -_consistently reliable_ aren't easy to implement and currently I don't have time -to work on implementing this. If you would like to implement Matrix support, -please take a look at #66 for a prior attempt of mine. +_consistently reliable_ aren't easy to implement, and currently I don't have +time to work on implementing this. If you would like to implement Matrix +support, please take a look at #66 for a prior attempt of mine. ### requesting another platform From ed8562b7932c131c057ae83bf4d0f5c30147947f Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 18 May 2025 13:54:14 -0400 Subject: [PATCH 74/97] add docs for cacher --- packages/lightning/src/structures/cacher.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/lightning/src/structures/cacher.ts b/packages/lightning/src/structures/cacher.ts index b41e5582..3507c565 100644 --- a/packages/lightning/src/structures/cacher.ts +++ b/packages/lightning/src/structures/cacher.ts @@ -1,8 +1,12 @@ +/** a class that wraps map to cache keys */ export class cacher { + /** the map used to internally store keys */ private map = new Map(); + /** create a cacher with a ttl (defaults to 30000) */ constructor(private ttl: number = 30000) {} + /** get a key from the map, returning undefined if expired or not found */ get(key: k): v | undefined { const time = Temporal.Now.instant().epochMilliseconds; const entry = this.map.get(key); @@ -12,6 +16,7 @@ export class cacher { return undefined; } + /** set a key in the map along with its expiry */ set(key: k, val: v, customTtl?: number): v { const time = Temporal.Now.instant().epochMilliseconds; this.map.set(key, { From 7fdcc2fb13e8fac2357f26d008a65908fe4b49f1 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 25 May 2025 15:53:04 -0400 Subject: [PATCH 75/97] fix revolt permissions --- packages/revolt/src/permissions.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index 3d358067..990a73b0 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -8,7 +8,9 @@ import { } from './cache.ts'; import { handle_error } from './errors.ts'; -const needed_permissions = 25165824; // ManageMessages and Masquerade +const needed_permissions = 485495808; +const error_message = 'missing ChangeNickname, ChangeAvatar, ReadMessageHistory, \ +SendMessage, ManageMessages, SendEmbeds, UploadFiles, and/or Masquerade permissions'; export async function check_permissions( channel_id: string, @@ -19,11 +21,9 @@ export async function check_permissions( const channel = await fetch_channel(client, channel_id); if (channel.channel_type === 'Group') { - if (channel.permissions && (channel.permissions & needed_permissions)) { - return channel._id; - } - - log_error('missing ManageMessages and/or Masquerade permission'); + if ( + !(channel.permissions && (channel.permissions & needed_permissions)) + ) log_error(error_message); } else if (channel.channel_type === 'TextChannel') { const server = await fetch_server(client, channel.server); const member = await fetch_member(client, channel.server, bot_id); @@ -56,12 +56,12 @@ export async function check_permissions( } } - if (currentPermissions & needed_permissions) return channel._id; - - log_error('missing ManageMessages and/or Masquerade permission'); + if (!(currentPermissions & needed_permissions)) log_error(error_message); } else { log_error(`unsupported channel type: ${channel.channel_type}`); } + + return channel_id; } catch (e) { handle_error(e); } From 6569d2b720a81c9cb4e4e31580e30126b2ced5f8 Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 26 May 2025 21:18:44 -0400 Subject: [PATCH 76/97] bump telegram version --- packages/telegram/deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index 0ca8780a..12acaee0 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -5,7 +5,7 @@ "exports": "./src/mod.ts", "imports": { "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3", - "grammy": "npm:grammy@^1.36.1", + "grammy": "npm:grammy@^1.36.3", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" } } From 68faa71cd378586acc99a130e0c9a181315bfd44 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 1 Jun 2025 15:04:12 -0400 Subject: [PATCH 77/97] handle emojis and mentions --- packages/discord/src/incoming.ts | 58 +++++++++++++++++++++++++++++++- packages/discord/src/mod.ts | 5 +++ packages/guilded/src/incoming.ts | 2 +- packages/revolt/src/cache.ts | 14 ++++++++ packages/revolt/src/incoming.ts | 54 +++++++++++++++++++++++++++-- 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts index adb2524d..6bdc01e2 100644 --- a/packages/discord/src/incoming.ts +++ b/packages/discord/src/incoming.ts @@ -80,6 +80,62 @@ async function fetch_stickers( }))).flatMap((i) => i.status === 'fulfilled' ? i.value : []); } +async function handle_content( + content: string, + api: API, + guild_id?: string, +): Promise { + // handle user mentions + for (const match of content.matchAll(/<@!?(\d+)>/g)) { + try { + const user = guild_id + ? await api.guilds.getMember(guild_id, match[1]) + : await api.users.get(match[1]); + content = content.replace( + match[0], + `@${ + 'nickname' in user + ? user.nickname + : 'username' in user + ? user.global_name ?? user.username + : user.user.global_name ?? user.user.username + }`, + ); + } catch { + // safe to ignore, we already have content here as a fallback + } + } + + // handle channel mentions + for (const match of content.matchAll(/<#(\d+)>/g)) { + try { + content = content.replace( + match[0], + `#${(await api.channels.get(match[1])).name}`, + ); + } catch { + // safe to ignore, we already have content here as a fallback + } + } + + // handle role mentions + if (guild_id) { + for (const match of content.matchAll(/<@&(\d+)>/g)) { + try { + content = content.replace( + match[0], + `@${(await api.guilds.getRole(guild_id!, match[1])).name}`, + ); + } catch { + // safe to ignore, we already have content here as a fallback + } + } + } + + // handle emojis + return content.replaceAll(/<(a?)?(:\w+:)\d+>/g, (_, _2, emoji) => emoji); +} + export async function get_incoming_message( { api, data }: { api: API; data: GatewayMessageUpdateDispatchData }, ): Promise { @@ -111,7 +167,7 @@ export async function get_incoming_message( ? '*joined on discord*' : (data.flags || 0) & 128 ? '*loading...*' - : data.content, + : await handle_content(data.content, api, data.guild_id), embeds: data.embeds.map((i) => ({ ...i, timestamp: i.timestamp ? Number(i.timestamp) : undefined, diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts index 66619532..6db73fe1 100644 --- a/packages/discord/src/mod.ts +++ b/packages/discord/src/mod.ts @@ -34,6 +34,7 @@ export const schema: config_schema = { export default class discord extends plugin { name = 'bolt-discord'; private client: Client; + private received_messages = new Set(); /** create the plugin */ constructor(cfg: discord_config) { @@ -57,6 +58,10 @@ export default class discord extends plugin { private setup_events() { this.client.on(GatewayDispatchEvents.MessageCreate, async (data) => { + if (this.received_messages.has(data.data.id)) { + return this.received_messages.delete(data.data.id); + } else this.received_messages.add(data.data.id); + const msg = await get_incoming_message(data); if (msg) this.emit('create_message', msg); }).on(GatewayDispatchEvents.MessageDelete, ({ data }) => { diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts index 4c61bede..25683255 100644 --- a/packages/guilded/src/incoming.ts +++ b/packages/guilded/src/incoming.ts @@ -109,7 +109,7 @@ export async function get_incoming( content = content?.replaceAll( /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, '', - ); + )?.replaceAll(/<(:\w+:)\d+>/g, (_, emoji) => emoji); return { attachments: await fetch_attachments(urls, client), diff --git a/packages/revolt/src/cache.ts b/packages/revolt/src/cache.ts index 76c7ccdd..3aeaab2c 100644 --- a/packages/revolt/src/cache.ts +++ b/packages/revolt/src/cache.ts @@ -1,5 +1,6 @@ import type { Channel, + Emoji, Masquerade, Member, Message, @@ -12,6 +13,7 @@ import { cacher, type message_author } from '@lightning/lightning'; const authors = new cacher<`${string}/${string}`, message_author>(); const channels = new cacher(); +const emojis = new cacher(); const members = new cacher<`${string}/${string}`, Member>(); const messages = new cacher<`${string}/${string}`, Message>(); const roles = new cacher<`${string}/${string}`, Role>(); @@ -87,6 +89,18 @@ export async function fetch_channel( return channels.set(channelID, channel); } +export async function fetch_emoji(api: Client, emoji_id: string): Promise { + const cached = emojis.get(emoji_id); + + if (cached) return cached; + + return emojis.set(emoji_id, await api.request( + 'get', + `/custom/emoji/${emoji_id}`, + undefined, + )); +} + export async function fetch_member( client: Client, serverID: string, diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts index 531eeaad..076202da 100644 --- a/packages/revolt/src/incoming.ts +++ b/packages/revolt/src/incoming.ts @@ -2,7 +2,57 @@ import type { Message as APIMessage } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; import type { embed, message } from '@lightning/lightning'; import { decodeTime } from '@std/ulid'; -import { fetch_author } from './cache.ts'; +import { fetch_author, fetch_channel, fetch_emoji } from './cache.ts'; + +async function get_content( + api: Client, + channel_id: string, + content?: string | null, +) { + if (!content) return; + + for ( + const match of content.matchAll(/:([0-7][0-9A-HJKMNP-TV-Z]{25}):/g) + ) { + try { + content = content.replace( + match[0], + `:${(await fetch_emoji(api, match[1])).name}:`, + ); + } catch { + content = content.replace(match[0], `:${match[1]}:`); + } + } + + for ( + const match of content.matchAll(/<@([0-7][0-9A-HJKMNP-TV-Z]{25})>/g) + ) { + try { + content = content.replace( + match[0], + `@${(await fetch_author(api, match[1], channel_id)).username}`, + ); + } catch { + content = content.replace(match[0], `@${match[1]}`); + } + } + + for ( + const match of content.matchAll(/<#([0-7][0-9A-HJKMNP-TV-Z]{25})>/g) + ) { + try { + const channel = await fetch_channel(api, match[1]); + content = content.replace( + match[0], + `#${'name' in channel ? channel.name : `DM${channel._id}`}`, + ); + } catch { + content = content.replace(match[0], `#${match[1]}`); + } + } + + return content; +} export async function get_incoming( message: APIMessage, @@ -19,7 +69,7 @@ export async function get_incoming( }), author: await fetch_author(api, message.author, message.channel), channel_id: message.channel, - content: message.content ?? undefined, + content: await get_content(api, message.channel, message.content), embeds: message.embeds?.map((i) => { return { color: 'colour' in i && i.colour From c88f74d9a5870894251d758908a9f0709416410d Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 2 Jun 2025 10:02:49 -0400 Subject: [PATCH 78/97] bump rvapi version --- packages/revolt/deno.json | 4 ++-- packages/revolt/src/incoming.ts | 4 ++-- packages/revolt/src/mod.ts | 6 +++--- packages/revolt/src/permissions.ts | 3 ++- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index d433b088..76cfd55b 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -5,8 +5,8 @@ "exports": "./src/mod.ts", "imports": { "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3", - "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.9", - "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.4", + "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.11", + "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.7", "@std/ulid": "jsr:@std/ulid@^1.0.0" } } diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts index 076202da..56898c8a 100644 --- a/packages/revolt/src/incoming.ts +++ b/packages/revolt/src/incoming.ts @@ -1,4 +1,4 @@ -import type { Message as APIMessage } from '@jersey/revolt-api-types'; +import type { Message } from '@jersey/revolt-api-types'; import type { Client } from '@jersey/rvapi'; import type { embed, message } from '@lightning/lightning'; import { decodeTime } from '@std/ulid'; @@ -55,7 +55,7 @@ async function get_content( } export async function get_incoming( - message: APIMessage, + message: Message, api: Client, ): Promise { return { diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts index 5cbea076..ba320ee5 100644 --- a/packages/revolt/src/mod.ts +++ b/packages/revolt/src/mod.ts @@ -1,4 +1,4 @@ -import type { Message as APIMessage } from '@jersey/revolt-api-types'; +import type { Message } from '@jersey/revolt-api-types'; import { Bonfire, type Client, createClient } from '@jersey/rvapi'; import { type bridge_message_opts, @@ -98,7 +98,7 @@ export default class revolt extends plugin { 'post', `/channels/${message.channel_id}/messages`, await get_outgoing(this.client, message, data !== undefined), - ) as APIMessage)._id, + ) as Message)._id, ]; } catch (e) { return handle_error(e); @@ -116,7 +116,7 @@ export default class revolt extends plugin { 'patch', `/channels/${message.channel_id}/messages/${data.edit_ids[0]}`, await get_outgoing(this.client, message, true), - ) as APIMessage)._id, + ) as Message)._id, ]; } catch (e) { return handle_error(e, true); diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index 990a73b0..8aaa5533 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -10,7 +10,8 @@ import { handle_error } from './errors.ts'; const needed_permissions = 485495808; const error_message = 'missing ChangeNickname, ChangeAvatar, ReadMessageHistory, \ -SendMessage, ManageMessages, SendEmbeds, UploadFiles, and/or Masquerade permissions'; +SendMessage, ManageMessages, SendEmbeds, UploadFiles, and/or Masquerade permissions \ +please add them to a role, assign that role to the bot, and rejoin the bridge'; export async function check_permissions( channel_id: string, From cf7580fe79fd01f0f1b432c76832da30b064e656 Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 2 Jun 2025 10:07:06 -0400 Subject: [PATCH 79/97] bump to 0.8.0-alpha.4 - fix revolt permissions - handle emojis and mentions - update dependencies --- containerfile | 2 +- packages/discord/README.md | 2 +- packages/discord/deno.json | 4 ++-- packages/guilded/README.md | 2 +- packages/guilded/deno.json | 4 ++-- packages/lightning/README.md | 4 ++-- packages/lightning/deno.json | 2 +- packages/lightning/src/cli.ts | 4 ++-- packages/lightning/src/core.ts | 2 +- packages/revolt/README.md | 2 +- packages/revolt/deno.json | 4 ++-- packages/telegram/README.md | 2 +- packages/telegram/deno.json | 4 ++-- readme.md | 2 +- 14 files changed, 20 insertions(+), 20 deletions(-) diff --git a/containerfile b/containerfile index c1c2f962..0a5e0dfb 100644 --- a/containerfile +++ b/containerfile @@ -5,7 +5,7 @@ RUN ["mkdir", "/deno_dir"] ENV DENO_DIR=/deno_dir # install lightning -RUN ["deno", "install", "-gA", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.3"] +RUN ["deno", "install", "-gA", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.4"] RUN ["chown", "--recursive", "1001:1001", "/deno_dir"] # run as user instead of root diff --git a/packages/discord/README.md b/packages/discord/README.md index 38e9336b..d5af439e 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -9,6 +9,6 @@ you do that, you will need to add the following to your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.3" +plugin = "jsr:@lightning/discord@0.8.0-alpha.4" config.token = "your_bot_token" ``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json index a4ad20a3..d7b6f143 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,12 +1,12 @@ { "name": "@lightning/discord", - "version": "0.8.0-alpha.3", + "version": "0.8.0-alpha.4", "license": "MIT", "exports": "./src/mod.ts", "imports": { "@discordjs/core": "npm:@discordjs/core@^2.1.0", "@discordjs/rest": "npm:@discordjs/rest@^2.5.0", "@discordjs/ws": "npm:@discordjs/ws@^2.0.2", - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3" + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4" } } diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 09fdcd9e..55b327a1 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -8,6 +8,6 @@ your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/guilded@0.8.0-alpha.3" +plugin = "jsr:@lightning/guilded@0.8.0-alpha.4" config.token = "your_bot_token" ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 7515f30f..e9e1097a 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/guilded", - "version": "0.8.0-alpha.3", + "version": "0.8.0-alpha.4", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4", "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.5", "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.2" } diff --git a/packages/lightning/README.md b/packages/lightning/README.md index 0b44df23..bfdc7e3f 100644 --- a/packages/lightning/README.md +++ b/packages/lightning/README.md @@ -17,11 +17,11 @@ type = "postgres" config = "postgresql://server:password@postgres:5432/lightning" [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.3" +plugin = "jsr:@lightning/discord@0.8.0-alpha.4" config.token = "your_token" [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.3" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.4" config.token = "your_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 8967aadc..8ae654d7 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -1,6 +1,6 @@ { "name": "@lightning/lightning", - "version": "0.8.0-alpha.3", + "version": "0.8.0-alpha.4", "license": "MIT", "exports": { ".": "./src/mod.ts", diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index e8eff8c3..13bbf4fa 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -30,10 +30,10 @@ if (args[0] === 'migrate') { exit(1); } } else if (args[0] === 'version') { - console.log('0.8.0-alpha.3'); + console.log('0.8.0-alpha.4'); } else { console.log( - `lightning v0.8.0-alpha.3 - extensible chatbot connecting communities`, + `lightning v0.8.0-alpha.4 - extensible chatbot connecting communities`, ); console.log(' Usage: lightning [subcommand]'); console.log(' Subcommands:'); diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 8680b3b4..8ee80bd7 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -23,7 +23,7 @@ export class core extends EventEmitter { name: 'help', description: 'get help with the bot', execute: () => - "hi! i'm lightning v0.8.0-alpha.3.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help.", + "hi! i'm lightning v0.8.0-alpha.4.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help.", }], ['ping', { name: 'ping', diff --git a/packages/revolt/README.md b/packages/revolt/README.md index 597b7afe..48a3c24f 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -7,7 +7,7 @@ Revolt bot first. After that, you need to add the following to your config file: ```toml [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.3" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.4" config.token = "your_bot_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index 76cfd55b..9b1693d9 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/revolt", - "version": "0.8.0-alpha.3", + "version": "0.8.0-alpha.4", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.11", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.7", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/telegram/README.md b/packages/telegram/README.md index 97802417..22173f3f 100644 --- a/packages/telegram/README.md +++ b/packages/telegram/README.md @@ -8,7 +8,7 @@ to your config: ```toml [[plugins]] -plugin = "jsr:@lightning/telegram@0.8.0-alpha.3" +plugin = "jsr:@lightning/telegram@0.8.0-alpha.4" config.token = "your_bot_token" config.proxy_port = 9090 config.proxy_url = "https://example.com:9090" diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index 12acaee0..791788cd 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/telegram", - "version": "0.8.0-alpha.3", + "version": "0.8.0-alpha.4", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.3", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4", "grammy": "npm:grammy@^1.36.3", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" } diff --git a/readme.md b/readme.md index 85e752b4..b2b3c111 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ # lightning - a chatbot > [!NOTE] -> This branch contains the next version of lightning, currently `0.8.0-alpha.3`, +> This branch contains the next version of lightning, currently `0.8.0-alpha.4`, > and reflects active development. To see the latest stable version, go to the > `main` branch. From c87ba6738d9708fa66232fcd8192401df43b0ec5 Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 2 Jun 2025 10:14:15 -0400 Subject: [PATCH 80/97] fix type errors --- packages/lightning/deno.json | 2 +- packages/lightning/src/database/postgres.ts | 16 ++++++----- packages/lightning/src/database/redis.ts | 30 ++++++++++++--------- packages/revolt/src/cache.ts | 20 +++++++++----- packages/revolt/src/permissions.ts | 3 ++- 5 files changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 8ae654d7..65dc5008 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -10,7 +10,7 @@ "@db/postgres": "jsr:@db/postgres@^0.19.5", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.4", - "@std/cli": "jsr:@std/cli@^1.0.17", + "@std/cli": "jsr:@std/cli@^1.0.19", "@std/fs": "jsr:@std/fs@^1.0.17", "@std/toml": "jsr:@std/toml@^1.0.6" } diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts index fac2a3f4..665f3ae9 100644 --- a/packages/lightning/src/database/postgres.ts +++ b/packages/lightning/src/database/postgres.ts @@ -9,7 +9,7 @@ import { get_env, stdout } from '../structures/cross.ts'; import type { bridge_data } from './mod.ts'; const fmt = (fmt: ProgressBarFormatter) => - `[postgres] ${fmt.progressBar} ${fmt.styledTime()} [${fmt.value}/${fmt.max}]\n`; + `[postgres] ${fmt.progressBar} ${fmt.styledTime} [${fmt.value}/${fmt.max}]\n`; export class postgres implements bridge_data { private pg: Client; @@ -165,13 +165,14 @@ export class postgres implements bridge_data { } async migration_set_messages(messages: bridge_message[]): Promise { - const progress = new ProgressBar(stdout(), { + const progress = new ProgressBar({ max: messages.length, fmt: fmt, + writable: stdout(), }); for (const msg of messages) { - progress.add(1); + progress.value++; try { await this.create_message(msg); @@ -180,17 +181,18 @@ export class postgres implements bridge_data { } } - progress.end(); + progress.stop(); } async migration_set_bridges(bridges: bridge[]): Promise { - const progress = new ProgressBar(stdout(), { + const progress = new ProgressBar({ max: bridges.length, fmt: fmt, + writable: stdout(), }); for (const br of bridges) { - progress.add(1); + progress.value++; await this.pg.queryArray` INSERT INTO bridges (id, name, channels, settings) @@ -200,7 +202,7 @@ export class postgres implements bridge_data { `; } - progress.end(); + progress.stop(); } static async migration_get_instance(): Promise { diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index a5c0e341..d0d7ce7b 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -20,7 +20,7 @@ export interface redis_config { } const fmt = (fmt: ProgressBarFormatter) => - `[redis] ${fmt.progressBar} ${fmt.styledTime()} [${fmt.value}/${fmt.max}]\n`; + `[redis] ${fmt.progressBar} ${fmt.styledTime} [${fmt.value}/${fmt.max}]\n`; export class redis implements bridge_data { private redis: RedisClient; @@ -192,13 +192,14 @@ export class redis implements bridge_data { const bridges = [] as bridge[]; - const progress = new ProgressBar(stdout(), { + const progress = new ProgressBar({ max: keys.length, fmt, + writable: stdout(), }); for (const key of keys) { - progress.add(1); + progress.value++; if (!this.seven) { const bridge = await this.get_bridge_by_id( key.replace('lightning-bridge-', ''), @@ -237,19 +238,20 @@ export class redis implements bridge_data { } } - progress.end(); + progress.stop(); return bridges; } async migration_set_bridges(bridges: bridge[]): Promise { - const progress = new ProgressBar(stdout(), { + const progress = new ProgressBar({ max: bridges.length, fmt, + writable: stdout(), }); for (const bridge of bridges) { - progress.add(1); + progress.value++; await this.redis.sendCommand([ 'DEL', @@ -280,7 +282,7 @@ export class redis implements bridge_data { } } - progress.end(); + progress.stop(); await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); } @@ -293,34 +295,36 @@ export class redis implements bridge_data { const messages = [] as bridge_message[]; - const progress = new ProgressBar(stdout(), { + const progress = new ProgressBar({ max: keys.length, fmt, + writable: stdout(), }); for (const key of keys) { - progress.add(1); + progress.value++; const message = await this.get_json(key); if (message) messages.push(message); } - progress.end(); + progress.stop(); return messages; } async migration_set_messages(messages: bridge_message[]): Promise { - const progress = new ProgressBar(stdout(), { + const progress = new ProgressBar({ max: messages.length, fmt, + writable: stdout(), }); for (const message of messages) { - progress.add(1); + progress.value++; await this.create_message(message); } - progress.end(); + progress.stop(); await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); } diff --git a/packages/revolt/src/cache.ts b/packages/revolt/src/cache.ts index 3aeaab2c..f5241b35 100644 --- a/packages/revolt/src/cache.ts +++ b/packages/revolt/src/cache.ts @@ -89,16 +89,22 @@ export async function fetch_channel( return channels.set(channelID, channel); } -export async function fetch_emoji(api: Client, emoji_id: string): Promise { +export async function fetch_emoji( + api: Client, + emoji_id: string, +): Promise { const cached = emojis.get(emoji_id); if (cached) return cached; - return emojis.set(emoji_id, await api.request( - 'get', - `/custom/emoji/${emoji_id}`, - undefined, - )); + return emojis.set( + emoji_id, + await api.request( + 'get', + `/custom/emoji/${emoji_id}`, + undefined, + ), + ); } export async function fetch_member( @@ -113,7 +119,7 @@ export async function fetch_member( const response = await client.request( 'get', `/servers/${serverID}/members/${userID}`, - undefined, + { roles: false }, ) as Member; return members.set(`${serverID}/${userID}`, response); diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index 8aaa5533..8420c7f5 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -9,7 +9,8 @@ import { import { handle_error } from './errors.ts'; const needed_permissions = 485495808; -const error_message = 'missing ChangeNickname, ChangeAvatar, ReadMessageHistory, \ +const error_message = + 'missing ChangeNickname, ChangeAvatar, ReadMessageHistory, \ SendMessage, ManageMessages, SendEmbeds, UploadFiles, and/or Masquerade permissions \ please add them to a role, assign that role to the bot, and rejoin the bridge'; From 6563e0fa30c80df1b8a66b9883d483f5ce84b14e Mon Sep 17 00:00:00 2001 From: Jersey Date: Mon, 2 Jun 2025 20:53:49 -0400 Subject: [PATCH 81/97] bump rvapi version --- packages/revolt/deno.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index 9b1693d9..990ecbcd 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -5,8 +5,8 @@ "exports": "./src/mod.ts", "imports": { "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4", - "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.11", - "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.7", + "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.12", + "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.8", "@std/ulid": "jsr:@std/ulid@^1.0.0" } } From 4f36dba83609932ae435bc4703604cfc919b0015 Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 4 Jun 2025 09:33:35 -0400 Subject: [PATCH 82/97] add telegram commands --- packages/telegram/src/incoming.ts | 37 ++++++++++++++++++++++++++++--- packages/telegram/src/mod.ts | 22 ++++++++++++++++-- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts index 042784af..57f1845a 100644 --- a/packages/telegram/src/incoming.ts +++ b/packages/telegram/src/incoming.ts @@ -1,5 +1,6 @@ -import type { message } from '@lightning/lightning'; -import type { Context } from 'grammy'; +import type { command, create_command, message } from '@lightning/lightning'; +import type { CommandContext, Context } from 'grammy'; +import { get_outgoing } from './outgoing.ts'; const types = [ 'text', @@ -13,7 +14,6 @@ const types = [ 'video', 'video_note', 'voice', - 'unsupported', ] as const; export async function get_incoming( @@ -82,3 +82,34 @@ export async function get_incoming( }], }; } + +export function get_command( + ctx: CommandContext, + cmd: command, +): create_command { + return { + channel_id: ctx.chat.id.toString(), + command: cmd.name, + message_id: ctx.msgId.toString(), + timestamp: Temporal.Instant.fromEpochMilliseconds(ctx.msg.date * 1000), + plugin: 'bolt-telegram', + prefix: '/', + args: {}, + rest: cmd.subcommands + ? ctx.match.split(' ').slice(1) + : ctx.match.split(' '), + subcommand: cmd.subcommands ? ctx.match.split(' ')[0] : undefined, + reply: async (message: message) => { + for (const msg of await get_outgoing(message, false)) { + await ctx.api[msg.function]( + ctx.chat.id, + msg.value, + { + reply_parameters: { message_id: ctx.msgId }, + parse_mode: 'MarkdownV2', + }, + ); + } + }, + }; +} diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index d4535fcf..1f182076 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -1,12 +1,13 @@ import { type bridge_message_opts, + type command, type config_schema, type deleted_message, type message, plugin, } from '@lightning/lightning'; -import { Bot } from 'grammy'; -import { get_incoming } from './incoming.ts'; +import { Bot, type Composer, type Context } from 'grammy'; +import { get_command, get_incoming } from './incoming.ts'; import { get_outgoing } from './outgoing.ts'; /** options for telegram */ @@ -33,11 +34,13 @@ export const schema: config_schema = { export default class telegram extends plugin { name = 'bolt-telegram'; private bot: Bot; + private composer: Composer; /** setup telegram and its file proxy */ constructor(opts: telegram_config) { super(); this.bot = new Bot(opts.token); + this.composer = this.bot.on('message') as Composer; this.bot.start(); this.bot.on(['message', 'edited_message'], async (ctx) => { @@ -77,6 +80,21 @@ export default class telegram extends plugin { ); } + /** handle commands */ + override async set_commands(commands: command[]): Promise { + await this.bot.api.setMyCommands(commands.map((cmd) => ({ + command: cmd.name, + description: cmd.description, + }))); + + for (const cmd of commands) { + const name = cmd.name === 'help' ? ['help', 'start'] : cmd.name; + this.composer.command(name, (ctx) => { + this.emit('create_command', get_command(ctx, cmd)); + }); + } + } + /** stub for setup_channel */ setup_channel(channel: string): unknown { return channel; From 725444e746a05a6d7f6dd0f953fe3508bd3f00ff Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 4 Jun 2025 11:44:56 -0400 Subject: [PATCH 83/97] bump guildapi version --- packages/guilded/deno.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index e9e1097a..21d17d7a 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -5,7 +5,7 @@ "exports": "./src/mod.ts", "imports": { "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4", - "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.5", - "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.2" + "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.6", + "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.3" } } From 633e9167a2abc8938a7008fe8945a21304b7f759 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 5 Jun 2025 12:34:44 -0400 Subject: [PATCH 84/97] add support for publishing and disabling read/write granularly --- packages/discord/src/errors.ts | 18 ++++---- packages/guilded/src/errors.ts | 10 ++--- packages/lightning/src/bridge/commands.ts | 50 ++++++++++++++++++--- packages/lightning/src/bridge/handler.ts | 13 +++--- packages/lightning/src/bridge/setup.ts | 12 ++++- packages/lightning/src/structures/bridge.ts | 2 +- packages/lightning/src/structures/errors.ts | 17 +++---- packages/revolt/src/errors.ts | 10 ++--- 8 files changed, 90 insertions(+), 42 deletions(-) diff --git a/packages/discord/src/errors.ts b/packages/discord/src/errors.ts index a674457c..7db60b48 100644 --- a/packages/discord/src/errors.ts +++ b/packages/discord/src/errors.ts @@ -2,13 +2,13 @@ import { DiscordAPIError } from '@discordjs/rest'; import { log_error } from '@lightning/lightning'; const errors = [ - [30007, 'Too many webhooks in channel, try deleting some', false], - [30058, 'Too many webhooks in guild, try deleting some', false], - [50013, 'Missing permissions to make webhook', false], - [10003, 'Unknown channel, disabling channel', true], - [10015, 'Unknown message, disabling channel', true], - [50027, 'Invalid webhook token, disabling channel', true], - [0, 'Unknown DiscordAPIError, not disabling channel', false], + [30007, 'Too many webhooks in channel, try deleting some', false, true], + [30058, 'Too many webhooks in guild, try deleting some', false, true], + [50013, 'Missing permissions to make webhook', false, true], + [10003, 'Unknown channel, disabling channel', true, true], + [10015, 'Unknown message, disabling channel', false, true], + [50027, 'Invalid webhook token, disabling channel', false, true], + [0, 'Unknown DiscordAPIError, not disabling channel', false, false], ] as const; export function handle_error( @@ -20,10 +20,10 @@ export function handle_error( if (edit && err.code === 10008) return []; // message already deleted or non-existent const extra = { channel, code: err.code }; - const [, message, disable] = errors.find((e) => e[0] === err.code) ?? + const [, message, read, write] = errors.find((e) => e[0] === err.code) ?? errors[errors.length - 1]; - log_error(err, { disable, message, extra }); + log_error(err, { disable: { read, write }, message, extra }); } else { log_error(err, { message: `unknown discord error`, diff --git a/packages/guilded/src/errors.ts b/packages/guilded/src/errors.ts index 09dbeeaf..0d7f6d0e 100644 --- a/packages/guilded/src/errors.ts +++ b/packages/guilded/src/errors.ts @@ -2,20 +2,20 @@ import { RequestError } from '@jersey/guilded-api-types'; import { log_error } from '@lightning/lightning'; const errors = [ - [403, 'No permission to send/delete messages! Check permissions', true], - [404, 'Not found! This might be a Guilded problem if making a bridge', true], - [0, 'Unknown Guilded error, not disabling channel', false], + [403, 'No permission to send/delete messages! Check permissions', false, true], + [404, 'Not found! This might be a Guilded problem if making a bridge', false, true], + [0, 'Unknown Guilded error, not disabling channel', false, false], ] as const; export function handle_error(err: unknown, channel: string): never { if (err instanceof RequestError) { - const [, message, disable] = errors.find((e) => + const [, message, read, write] = errors.find((e) => e[0] === err.cause.status ) ?? errors[errors.length - 1]; log_error(err, { - disable, + disable: { read, write }, extra: { channel_id: channel, response: err.cause }, message, }); diff --git a/packages/lightning/src/bridge/commands.ts b/packages/lightning/src/bridge/commands.ts index dbaf9365..71e0c9fe 100644 --- a/packages/lightning/src/bridge/commands.ts +++ b/packages/lightning/src/bridge/commands.ts @@ -58,6 +58,39 @@ export async function join( } } +export async function subscribe( + db: bridge_data, + opts: command_opts, +): Promise { + const result = await _add(db, opts); + + if (typeof result === 'string') return result; + + const target_bridge = await db.get_bridge_by_id( + opts.args.id!, + ); + + if (!target_bridge) { + return `Bridge with id \`${opts.args.id}\` not found. Make sure you have the correct id.`; + } + + target_bridge.channels.push({ + ...result, + disabled: { read: true, write: false }, + }); + + try { + await db.edit_bridge(target_bridge); + + return `Bridge subscribed successfully! You will not receive messages from this channel, but you can still send messages to it.`; + } catch (e) { + log_error(e, { + message: 'Failed to update bridge in database', + extra: { target_bridge }, + }); + } +} + async function _add( db: bridge_data, opts: command_opts, @@ -74,7 +107,7 @@ async function _add( return { id: opts.channel_id, data: await opts.plugin.setup_channel(opts.channel_id), - disabled: false, + disabled: { read: false, write: false }, plugin: opts.plugin.name, }; } catch (e) { @@ -128,10 +161,17 @@ export async function status( let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; - for (const [i, value] of bridge.channels.entries()) { - str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\`${ - value.disabled ? ' (disabled)' : '' - }\n`; + for (const [i, value] of bridge.channels.entries()) { + str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\``; + + if (typeof value.disabled === 'object') { + if (value.disabled.read) str += ` (subscribed)`; + if (value.disabled.write) str += ` (write disabled)`; + } else if (value.disabled === true) { + str += ` (disabled)`; + } + + str += `\n`; } str += `\nSettings:\n`; diff --git a/packages/lightning/src/bridge/handler.ts b/packages/lightning/src/bridge/handler.ts index 20e7b639..41d466e4 100644 --- a/packages/lightning/src/bridge/handler.ts +++ b/packages/lightning/src/bridge/handler.ts @@ -22,14 +22,17 @@ export async function bridge_message( (channel) => channel.id === data.channel_id && channel.plugin === data.plugin && - channel.disabled, + (channel.disabled === true || + typeof channel.disabled === 'object' && + channel.disabled.read === true), ) ) return; // remove ourselves & disabled channels const channels = bridge.channels.filter((channel) => (channel.id !== data.channel_id || channel.plugin !== data.plugin) && - (!channel.disabled || !channel.data) + (!(channel.disabled === true || typeof channel.disabled === 'object' && + channel.disabled.write === true) || !channel.data) ); // if there aren't any left, return @@ -104,11 +107,11 @@ export async function bridge_message( message: `An error occurred while handling a message in the bridge`, }); - if (err.disable_channel) { + if (err.disable) { new LightningError( `disabling channel ${channel.id} in bridge ${bridge.id}`, { - extra: { original_error: err.id }, + extra: { original_error: err.id, disable: err.disable }, }, ); @@ -116,7 +119,7 @@ export async function bridge_message( ...bridge, channels: bridge.channels.map((ch) => ch.id === channel.id && ch.plugin === channel.plugin - ? { ...ch, disabled: true } + ? { ...ch, disabled: err.disable! } : ch ), }); diff --git a/packages/lightning/src/bridge/setup.ts b/packages/lightning/src/bridge/setup.ts index ff4ac790..9a14965f 100644 --- a/packages/lightning/src/bridge/setup.ts +++ b/packages/lightning/src/bridge/setup.ts @@ -1,6 +1,6 @@ import type { core } from '../core.ts'; import { create_database, type database_config } from '../database/mod.ts'; -import { create, join, leave, status, toggle } from './commands.ts'; +import { create, join, subscribe, leave, status, toggle } from './commands.ts'; import { bridge_message } from './handler.ts'; export async function setup_bridge(core: core, config: database_config) { @@ -44,6 +44,16 @@ export async function setup_bridge(core: core, config: database_config) { }], execute: (o) => join(database, o), }, + { + name: 'subscribe', + description: 'subscribe to a bridge', + arguments: [{ + name: 'id', + description: 'id of the bridge', + required: true, + }], + execute: (o) => subscribe(database, o), + }, { name: 'leave', description: 'leave the current bridge', diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index 7bd955a8..e0877723 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -17,7 +17,7 @@ export interface bridge_channel { /** data needed to bridge this channel */ data: unknown; /** whether the channel is disabled */ - disabled: boolean; + disabled: boolean | { read: boolean, write: boolean }; /** the plugin used to bridge this channel */ plugin: string; } diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts index dc5167f3..48ea1a87 100644 --- a/packages/lightning/src/structures/errors.ts +++ b/packages/lightning/src/structures/errors.ts @@ -8,7 +8,7 @@ export interface error_options { /** the extra data to log */ extra?: Record; /** whether to disable the associated channel (when bridging) */ - disable?: boolean; + disable?: { read: boolean; write: boolean }; /** whether this should be logged without the cause */ without_cause?: boolean; } @@ -29,7 +29,7 @@ export class LightningError extends Error { /** the user-facing error message */ msg: message; /** whether to disable the associated channel (when bridging) */ - disable_channel?: boolean; + disable?: { read: boolean; write: boolean }; /** whether to show the cause or not */ without_cause?: boolean; @@ -41,7 +41,7 @@ export class LightningError extends Error { this.error_cause = e.error_cause; this.extra = e.extra; this.msg = e.msg; - this.disable_channel = e.disable_channel; + this.disable = e.disable; this.without_cause = e.without_cause; return e; } @@ -60,20 +60,15 @@ export class LightningError extends Error { this.id = id; this.error_cause = cause_err; this.extra = options?.extra ?? {}; - this.disable_channel = options?.disable; + this.disable = options?.disable; this.without_cause = options?.without_cause; this.msg = create_message( `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\``, ); console.error(`%c[lightning] ${this.message} - ${this.id}`, 'color: red'); - console.error( - `%c[lightning] this does${ - this.disable_channel ? ' ' : ' not ' - }disable a channel`, - 'color: red', - ); - + if (this.disable?.read) console.log(`[lightning] channel reads disabled`); + if (this.disable?.write) console.log(`[lightning] channel writes disabled`); if (!this.without_cause) this.log(); } diff --git a/packages/revolt/src/errors.ts b/packages/revolt/src/errors.ts index f0ff07f7..1f2e5854 100644 --- a/packages/revolt/src/errors.ts +++ b/packages/revolt/src/errors.ts @@ -3,9 +3,9 @@ import { MediaError } from '@jersey/rvapi'; import { log_error } from '@lightning/lightning'; const errors = [ - [403, 'Insufficient permissions. Please check them', true], - [404, 'Resource not found', true], - [0, 'Unknown Revolt RequestError', false], + [403, 'Insufficient permissions. Please check them', false, true], + [404, 'Resource not found', false, true], + [0, 'Unknown Revolt RequestError', false, false], ] as const; export function handle_error(err: unknown, edit?: boolean): never[] { @@ -14,11 +14,11 @@ export function handle_error(err: unknown, edit?: boolean): never[] { } else if (err instanceof RequestError) { if (err.cause.status === 404 && edit) return []; - const [, message, disable] = errors.find((e) => + const [, message, read, write] = errors.find((e) => e[0] === err.cause.status ) ?? errors[errors.length - 1]; - log_error(err, { message, disable }); + log_error(err, { message, disable: { read, write } }); } else { log_error(err, { message: 'unknown revolt error' }); } From e68a998dedae60e62d53f80da2352577b3c2551f Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 5 Jun 2025 13:44:38 -0400 Subject: [PATCH 85/97] update dependencies and use ?? instead of || --- .github/workflows/publish.yml | 2 +- containerfile | 2 +- packages/discord/src/commands.ts | 4 ++-- packages/discord/src/incoming.ts | 8 ++++---- packages/guilded/src/errors.ts | 4 ++-- packages/guilded/src/incoming.ts | 16 ++++++++-------- packages/guilded/src/outgoing.ts | 2 +- packages/lightning/deno.json | 6 +++--- packages/lightning/src/bridge/commands.ts | 2 +- packages/lightning/src/bridge/setup.ts | 2 +- packages/lightning/src/core.ts | 2 +- packages/lightning/src/database/redis.ts | 2 +- packages/lightning/src/structures/bridge.ts | 2 +- packages/revolt/src/outgoing.ts | 2 +- packages/revolt/src/permissions.ts | 4 ++-- packages/telegram/src/incoming.ts | 6 +++--- 16 files changed, 33 insertions(+), 33 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fefdb7f3..45669e24 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,7 +20,7 @@ jobs: - name: setup deno uses: denoland/setup-deno@v2 with: - deno-version: v2.3.3 + deno-version: v2.3.5 - name: publish to jsr run: deno publish - name: Set up QEMU diff --git a/containerfile b/containerfile index 0a5e0dfb..d88076f9 100644 --- a/containerfile +++ b/containerfile @@ -1,4 +1,4 @@ -FROM denoland/deno:alpine-2.3.3 +FROM denoland/deno:alpine-2.3.5 # make a deno cache directory RUN ["mkdir", "/deno_dir"] diff --git a/packages/discord/src/commands.ts b/packages/discord/src/commands.ts index acbfa3f3..c137b2cf 100644 --- a/packages/discord/src/commands.ts +++ b/packages/discord/src/commands.ts @@ -11,7 +11,7 @@ export async function setup_commands( description: arg.description, type: 3, required: arg.required, - })) || []; + })) ?? []; const format_subcommands = (subcommands: command['subcommands']) => subcommands?.map((subcommand) => ({ @@ -19,7 +19,7 @@ export async function setup_commands( description: subcommand.description, type: 1, options: format_arguments(subcommand.arguments), - })) || []; + })) ?? []; await api.applicationCommands.bulkOverwriteGlobalCommands( (await api.applications.getCurrent()).id, diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts index 6bdc01e2..582932fb 100644 --- a/packages/discord/src/incoming.ts +++ b/packages/discord/src/incoming.ts @@ -32,11 +32,11 @@ async function fetch_author(api: API, data: GatewayMessageUpdateDispatchData) { Number(BigInt(data.author.id) >> 22n) % 6 }.png`; - let username = data.author.global_name || data.author.username; + let username = data.author.global_name ?? data.author.username; if (data.guild_id) { try { - const member = data.member || await api.guilds.getMember( + const member = data.member ?? await api.guilds.getMember( data.guild_id, data.author.id, ); @@ -165,7 +165,7 @@ export async function get_incoming_message( channel_id: data.channel_id, content: data.type === 7 ? '*joined on discord*' - : (data.flags || 0) & 128 + : (data.flags ?? 0) & 128 ? '*loading...*' : await handle_content(data.content, api, data.guild_id), embeds: data.embeds.map((i) => ({ @@ -193,7 +193,7 @@ export function get_incoming_command( const args: Record = {}; let subcommand: string | undefined; - for (const option of interaction.data.data.options || []) { + for (const option of interaction.data.data.options ?? []) { if (option.type === 1) { subcommand = option.name; for (const suboption of option.options ?? []) { diff --git a/packages/guilded/src/errors.ts b/packages/guilded/src/errors.ts index 0d7f6d0e..d445f6a7 100644 --- a/packages/guilded/src/errors.ts +++ b/packages/guilded/src/errors.ts @@ -2,8 +2,8 @@ import { RequestError } from '@jersey/guilded-api-types'; import { log_error } from '@lightning/lightning'; const errors = [ - [403, 'No permission to send/delete messages! Check permissions', false, true], - [404, 'Not found! This might be a Guilded problem if making a bridge', false, true], + [403, 'The bot lacks some permissions, please check them', false, true], + [404, 'Not found! This might be a Guilded problem', false, true], [0, 'Unknown Guilded error, not disabling channel', false, false], ] as const; diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts index 25683255..17ea69d1 100644 --- a/packages/guilded/src/incoming.ts +++ b/packages/guilded/src/incoming.ts @@ -24,10 +24,10 @@ export async function fetch_author(msg: ChatMessage, client: Client) { ); return { - username: author.nickname || author.user.name, + username: author.nickname ?? author.user.name, rawname: author.user.name, id: msg.createdBy, - profile: author.user.avatar || undefined, + profile: author.user.avatar, }; } else { const key = `${msg.serverId}/${msg.createdByWebhookId}` as const; @@ -77,12 +77,12 @@ async function fetch_attachments(markdown: string[], client: Client) { if (signed.retryAfter || !signed.signature) continue; attachments.push(asset_cache.set(signed.url, { - name: signed.signature.split('/').pop()?.split('?')[0] || 'unknown', + name: signed.signature.split('/').pop()?.split('?')[0] ?? 'unknown', file: signed.signature, size: parseInt( (await fetch(signed.signature, { method: 'HEAD', - })).headers.get('Content-Length') || '0', + })).headers.get('Content-Length') ?? '0', ) / 1048576, })); } catch { @@ -104,7 +104,7 @@ export async function get_incoming( const urls = content?.match( /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, - ) || []; + ) ?? []; content = content?.replaceAll( /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, @@ -124,19 +124,19 @@ export async function get_incoming( author: embed.author ? { ...embed.author, - name: embed.author.name || '', + name: embed.author.name ?? '', } : undefined, image: embed.image ? { ...embed.image, - url: embed.image.url || '', + url: embed.image.url ?? '', } : undefined, thumbnail: embed.thumbnail ? { ...embed.thumbnail, - url: embed.thumbnail.url || '', + url: embed.thumbnail.url ?? '', } : undefined, timestamp: embed.timestamp ? Number(embed.timestamp) : undefined, diff --git a/packages/guilded/src/outgoing.ts b/packages/guilded/src/outgoing.ts index e72f10c8..c05f58ae 100644 --- a/packages/guilded/src/outgoing.ts +++ b/packages/guilded/src/outgoing.ts @@ -79,7 +79,7 @@ export async function get_outgoing( description: msg.attachments .slice(0, 5) .map((a) => { - return `![${a.alt || a.name}](${a.file})`; + return `![${a.alt ?? a.name}](${a.file})`; }) .join('\n'), }); diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index 65dc5008..fa067b24 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -9,9 +9,9 @@ "imports": { "@db/postgres": "jsr:@db/postgres@^0.19.5", "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", - "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.4", + "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.8", "@std/cli": "jsr:@std/cli@^1.0.19", - "@std/fs": "jsr:@std/fs@^1.0.17", - "@std/toml": "jsr:@std/toml@^1.0.6" + "@std/fs": "jsr:@std/fs@^1.0.18", + "@std/toml": "jsr:@std/toml@^1.0.7" } } diff --git a/packages/lightning/src/bridge/commands.ts b/packages/lightning/src/bridge/commands.ts index 71e0c9fe..b7516a01 100644 --- a/packages/lightning/src/bridge/commands.ts +++ b/packages/lightning/src/bridge/commands.ts @@ -161,7 +161,7 @@ export async function status( let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; - for (const [i, value] of bridge.channels.entries()) { + for (const [i, value] of bridge.channels.entries()) { str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\``; if (typeof value.disabled === 'object') { diff --git a/packages/lightning/src/bridge/setup.ts b/packages/lightning/src/bridge/setup.ts index 9a14965f..c288f002 100644 --- a/packages/lightning/src/bridge/setup.ts +++ b/packages/lightning/src/bridge/setup.ts @@ -1,6 +1,6 @@ import type { core } from '../core.ts'; import { create_database, type database_config } from '../database/mod.ts'; -import { create, join, subscribe, leave, status, toggle } from './commands.ts'; +import { create, join, leave, status, subscribe, toggle } from './commands.ts'; import { bridge_message } from './handler.ts'; export async function setup_bridge(core: core, config: database_config) { diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 8ee80bd7..443f33b3 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -135,7 +135,7 @@ export class core extends EventEmitter { if (subcommand) command = subcommand; } - for (const arg of (command.arguments || [])) { + for (const arg of (command.arguments ?? [])) { if (!opts.args[arg.name]) { opts.args[arg.name] = opts.rest?.shift(); } diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts index d0d7ce7b..5d60e9ca 100644 --- a/packages/lightning/src/database/redis.ts +++ b/packages/lightning/src/database/redis.ts @@ -119,7 +119,7 @@ export class redis implements bridge_data { async edit_bridge(br: bridge): Promise { const old_bridge = await this.get_bridge_by_id(br.id); - for (const channel of old_bridge?.channels || []) { + for (const channel of old_bridge?.channels ?? []) { await this.redis.sendCommand([ 'DEL', `lightning-bchannel-${channel.id}`, diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts index e0877723..01c21f45 100644 --- a/packages/lightning/src/structures/bridge.ts +++ b/packages/lightning/src/structures/bridge.ts @@ -17,7 +17,7 @@ export interface bridge_channel { /** data needed to bridge this channel */ data: unknown; /** whether the channel is disabled */ - disabled: boolean | { read: boolean, write: boolean }; + disabled: boolean | { read: boolean; write: boolean }; /** the plugin used to bridge this channel */ plugin: string; } diff --git a/packages/revolt/src/outgoing.ts b/packages/revolt/src/outgoing.ts index 56f054e6..67708636 100644 --- a/packages/revolt/src/outgoing.ts +++ b/packages/revolt/src/outgoing.ts @@ -34,7 +34,7 @@ export async function get_outgoing( return { attachments, - content: (message.content?.length || 0) > 2000 + content: (message.content?.length ?? 0) > 2000 ? `${message.content?.substring(0, 1997)}...` : message.content, embeds: message.embeds?.map((embed) => { diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts index 8420c7f5..26b5a112 100644 --- a/packages/revolt/src/permissions.ts +++ b/packages/revolt/src/permissions.ts @@ -33,7 +33,7 @@ export async function check_permissions( // check server permissions let currentPermissions = server.default_permissions; - for (const role of (member.roles || [])) { + for (const role of (member.roles ?? [])) { const { permissions: role_permissions } = await fetch_role( client, server._id, @@ -52,7 +52,7 @@ export async function check_permissions( // apply role permissions if (channel.role_permissions) { - for (const role of (member.roles || [])) { + for (const role of (member.roles ?? [])) { currentPermissions |= channel.role_permissions[role]?.a || 0; currentPermissions &= ~channel.role_permissions[role]?.d || 0; } diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts index 57f1845a..2579b8fc 100644 --- a/packages/telegram/src/incoming.ts +++ b/packages/telegram/src/incoming.ts @@ -20,7 +20,7 @@ export async function get_incoming( ctx: Context, proxy: string, ): Promise { - const msg = ctx.editedMessage || ctx.msg; + const msg = ctx.editedMessage ?? ctx.msg; if (!msg) return; const author = await ctx.getAuthor(); const profile = await ctx.getUserProfilePhotos({ limit: 1 }); @@ -30,7 +30,7 @@ export async function get_incoming( username: author.user.last_name ? `${author.user.first_name} ${author.user.last_name}` : author.user.first_name, - rawname: author.user.username || author.user.first_name, + rawname: author.user.username ?? author.user.first_name, color: '#24A1DE', profile: profile.total_count ? `${proxy}/${ @@ -42,7 +42,7 @@ export async function get_incoming( channel_id: msg.chat.id.toString(), message_id: msg.message_id.toString(), timestamp: Temporal.Instant.fromEpochMilliseconds( - (msg.edit_date || msg.date) * 1000, + (msg.edit_date ?? msg.date) * 1000, ), plugin: 'bolt-telegram', reply_id: msg.reply_to_message From a6ec9f9142e45bb7b8caacc965e4db489d798264 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 6 Jun 2025 09:25:01 -0400 Subject: [PATCH 86/97] bump to 0.8.0-alpha.5 --- containerfile | 2 +- packages/discord/README.md | 2 +- packages/discord/deno.json | 4 ++-- packages/guilded/README.md | 2 +- packages/guilded/deno.json | 4 ++-- packages/lightning/README.md | 4 ++-- packages/lightning/deno.json | 2 +- packages/lightning/src/cli.ts | 4 ++-- packages/lightning/src/core.ts | 2 +- packages/revolt/README.md | 2 +- packages/revolt/deno.json | 4 ++-- packages/telegram/README.md | 2 +- packages/telegram/deno.json | 4 ++-- readme.md | 2 +- 14 files changed, 20 insertions(+), 20 deletions(-) diff --git a/containerfile b/containerfile index d88076f9..9b538d8b 100644 --- a/containerfile +++ b/containerfile @@ -5,7 +5,7 @@ RUN ["mkdir", "/deno_dir"] ENV DENO_DIR=/deno_dir # install lightning -RUN ["deno", "install", "-gA", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.4"] +RUN ["deno", "install", "-gA", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.5"] RUN ["chown", "--recursive", "1001:1001", "/deno_dir"] # run as user instead of root diff --git a/packages/discord/README.md b/packages/discord/README.md index d5af439e..e9b54b40 100644 --- a/packages/discord/README.md +++ b/packages/discord/README.md @@ -9,6 +9,6 @@ you do that, you will need to add the following to your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.4" +plugin = "jsr:@lightning/discord@0.8.0-alpha.5" config.token = "your_bot_token" ``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json index d7b6f143..3a7835fe 100644 --- a/packages/discord/deno.json +++ b/packages/discord/deno.json @@ -1,12 +1,12 @@ { "name": "@lightning/discord", - "version": "0.8.0-alpha.4", + "version": "0.8.0-alpha.5", "license": "MIT", "exports": "./src/mod.ts", "imports": { "@discordjs/core": "npm:@discordjs/core@^2.1.0", "@discordjs/rest": "npm:@discordjs/rest@^2.5.0", "@discordjs/ws": "npm:@discordjs/ws@^2.0.2", - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4" + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5" } } diff --git a/packages/guilded/README.md b/packages/guilded/README.md index 55b327a1..33c77945 100644 --- a/packages/guilded/README.md +++ b/packages/guilded/README.md @@ -8,6 +8,6 @@ your `lightning.toml` file: ```toml [[plugins]] -plugin = "jsr:@lightning/guilded@0.8.0-alpha.4" +plugin = "jsr:@lightning/guilded@0.8.0-alpha.5" config.token = "your_bot_token" ``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json index 21d17d7a..d6b4518a 100644 --- a/packages/guilded/deno.json +++ b/packages/guilded/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/guilded", - "version": "0.8.0-alpha.4", + "version": "0.8.0-alpha.5", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.6", "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.3" } diff --git a/packages/lightning/README.md b/packages/lightning/README.md index bfdc7e3f..1901e4df 100644 --- a/packages/lightning/README.md +++ b/packages/lightning/README.md @@ -17,11 +17,11 @@ type = "postgres" config = "postgresql://server:password@postgres:5432/lightning" [[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.4" +plugin = "jsr:@lightning/discord@0.8.0-alpha.5" config.token = "your_token" [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.4" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.5" config.token = "your_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json index fa067b24..5bbba12c 100644 --- a/packages/lightning/deno.json +++ b/packages/lightning/deno.json @@ -1,6 +1,6 @@ { "name": "@lightning/lightning", - "version": "0.8.0-alpha.4", + "version": "0.8.0-alpha.5", "license": "MIT", "exports": { ".": "./src/mod.ts", diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts index 13bbf4fa..7b4bfb07 100644 --- a/packages/lightning/src/cli.ts +++ b/packages/lightning/src/cli.ts @@ -30,10 +30,10 @@ if (args[0] === 'migrate') { exit(1); } } else if (args[0] === 'version') { - console.log('0.8.0-alpha.4'); + console.log('0.8.0-alpha.5'); } else { console.log( - `lightning v0.8.0-alpha.4 - extensible chatbot connecting communities`, + `lightning v0.8.0-alpha.5 - extensible chatbot connecting communities`, ); console.log(' Usage: lightning [subcommand]'); console.log(' Subcommands:'); diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts index 443f33b3..bea3adaa 100644 --- a/packages/lightning/src/core.ts +++ b/packages/lightning/src/core.ts @@ -23,7 +23,7 @@ export class core extends EventEmitter { name: 'help', description: 'get help with the bot', execute: () => - "hi! i'm lightning v0.8.0-alpha.4.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help.", + "hi! i'm lightning v0.8.0-alpha.5.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help.", }], ['ping', { name: 'ping', diff --git a/packages/revolt/README.md b/packages/revolt/README.md index 48a3c24f..ea0efddc 100644 --- a/packages/revolt/README.md +++ b/packages/revolt/README.md @@ -7,7 +7,7 @@ Revolt bot first. After that, you need to add the following to your config file: ```toml [[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.4" +plugin = "jsr:@lightning/revolt@0.8.0-alpha.5" config.token = "your_bot_token" config.user_id = "your_bot_user_id" ``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json index 990ecbcd..b136815e 100644 --- a/packages/revolt/deno.json +++ b/packages/revolt/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/revolt", - "version": "0.8.0-alpha.4", + "version": "0.8.0-alpha.5", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.12", "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.8", "@std/ulid": "jsr:@std/ulid@^1.0.0" diff --git a/packages/telegram/README.md b/packages/telegram/README.md index 22173f3f..097648e4 100644 --- a/packages/telegram/README.md +++ b/packages/telegram/README.md @@ -8,7 +8,7 @@ to your config: ```toml [[plugins]] -plugin = "jsr:@lightning/telegram@0.8.0-alpha.4" +plugin = "jsr:@lightning/telegram@0.8.0-alpha.5" config.token = "your_bot_token" config.proxy_port = 9090 config.proxy_url = "https://example.com:9090" diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json index 791788cd..2fa850c8 100644 --- a/packages/telegram/deno.json +++ b/packages/telegram/deno.json @@ -1,10 +1,10 @@ { "name": "@lightning/telegram", - "version": "0.8.0-alpha.4", + "version": "0.8.0-alpha.5", "license": "MIT", "exports": "./src/mod.ts", "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.4", + "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", "grammy": "npm:grammy@^1.36.3", "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" } diff --git a/readme.md b/readme.md index b2b3c111..59d48f32 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ # lightning - a chatbot > [!NOTE] -> This branch contains the next version of lightning, currently `0.8.0-alpha.4`, +> This branch contains the next version of lightning, currently `0.8.0-alpha.5`, > and reflects active development. To see the latest stable version, go to the > `main` branch. From 4366f425127af2b238b8a2ce35467db2850a5adf Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 8 Jun 2025 16:34:41 -0400 Subject: [PATCH 87/97] fix some problems --- packages/lightning/src/bridge/handler.ts | 2 +- packages/telegram/src/incoming.ts | 2 +- packages/telegram/src/mod.ts | 24 ++++++++++++++---------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/lightning/src/bridge/handler.ts b/packages/lightning/src/bridge/handler.ts index 41d466e4..723a2164 100644 --- a/packages/lightning/src/bridge/handler.ts +++ b/packages/lightning/src/bridge/handler.ts @@ -107,7 +107,7 @@ export async function bridge_message( message: `An error occurred while handling a message in the bridge`, }); - if (err.disable) { + if (err.disable?.read || err.disable?.write) { new LightningError( `disabling channel ${channel.id} in bridge ${bridge.id}`, { diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts index 2579b8fc..302f0058 100644 --- a/packages/telegram/src/incoming.ts +++ b/packages/telegram/src/incoming.ts @@ -100,7 +100,7 @@ export function get_command( : ctx.match.split(' '), subcommand: cmd.subcommands ? ctx.match.split(' ')[0] : undefined, reply: async (message: message) => { - for (const msg of await get_outgoing(message, false)) { + for (const msg of get_outgoing(message, false)) { await ctx.api[msg.function]( ctx.chat.id, msg.value, diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 1f182076..e69b675e 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -6,7 +6,7 @@ import { type message, plugin, } from '@lightning/lightning'; -import { Bot, type Composer, type Context } from 'grammy'; +import { Bot, type Composer, type Context, GrammyError } from 'grammy'; import { get_command, get_incoming } from './incoming.ts'; import { get_outgoing } from './outgoing.ts'; @@ -51,7 +51,7 @@ export default class telegram extends plugin { const handler = async ({ url }: { url: string }) => await fetch( `https://api.telegram.org/file/bot${opts.token}/${ - url.replace('/telegram', '/') + (new URL(url)).pathname.replace('/telegram', '/') }`, ); @@ -132,14 +132,18 @@ export default class telegram extends plugin { message: message, opts: bridge_message_opts & { edit_ids: string[] }, ): Promise { - await this.bot.api.editMessageText( - opts.channel.id, - Number(opts.edit_ids[0]), - get_outgoing(message, true)[0].value, - { - parse_mode: 'MarkdownV2', - }, - ); + try { + await this.bot.api.editMessageText( + opts.channel.id, + Number(opts.edit_ids[0]), + get_outgoing(message, true)[0].value, + { parse_mode: 'MarkdownV2' }, + ); + } catch (e) { + if (!(e instanceof GrammyError && e.error_code === 400)) { + throw e; + } + } return opts.edit_ids; } From e3b792918a507f8f79f717bdef8810e6daef7920 Mon Sep 17 00:00:00 2001 From: Jersey Date: Sun, 8 Jun 2025 16:43:18 -0400 Subject: [PATCH 88/97] handle telegram id numbers better --- packages/telegram/src/incoming.ts | 12 ++++++------ packages/telegram/src/mod.ts | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts index 302f0058..f0d8ecbc 100644 --- a/packages/telegram/src/incoming.ts +++ b/packages/telegram/src/incoming.ts @@ -37,16 +37,16 @@ export async function get_incoming( (await ctx.api.getFile(profile.photos[0][0].file_id)).file_path }` : undefined, - id: author.user.id.toString(), + id: `${author.user.id}`, }, - channel_id: msg.chat.id.toString(), - message_id: msg.message_id.toString(), + channel_id: `${msg.chat.id}`, + message_id: `${msg.message_id}`, timestamp: Temporal.Instant.fromEpochMilliseconds( (msg.edit_date ?? msg.date) * 1000, ), plugin: 'bolt-telegram', reply_id: msg.reply_to_message - ? msg.reply_to_message.message_id.toString() + ? `${msg.reply_to_message.message_id}` : undefined, }; @@ -88,9 +88,9 @@ export function get_command( cmd: command, ): create_command { return { - channel_id: ctx.chat.id.toString(), + channel_id: `${ctx.chat.id}`, command: cmd.name, - message_id: ctx.msgId.toString(), + message_id: `${ctx.msgId}`, timestamp: Temporal.Instant.fromEpochMilliseconds(ctx.msg.date * 1000), plugin: 'bolt-telegram', prefix: '/', diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index e69b675e..3277dd51 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -114,14 +114,14 @@ export default class telegram extends plugin { { reply_parameters: message.reply_id ? { - message_id: Number(message.reply_id), + message_id: parseInt(message.reply_id), } : undefined, parse_mode: 'MarkdownV2', }, ); - messages.push(String(result.message_id)); + messages.push(`${result.message_id}`); } return messages; @@ -135,7 +135,7 @@ export default class telegram extends plugin { try { await this.bot.api.editMessageText( opts.channel.id, - Number(opts.edit_ids[0]), + parseInt(opts.edit_ids[0]), get_outgoing(message, true)[0].value, { parse_mode: 'MarkdownV2' }, ); @@ -154,7 +154,7 @@ export default class telegram extends plugin { messages.map(async (msg) => { await this.bot.api.deleteMessage( msg.channel_id, - Number(msg.message_id), + parseInt(msg.message_id), ); return msg.message_id; }), From 20848267491f65152c41a5ebfd19e61b1e2fc241 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 13 Jun 2025 14:03:18 -0400 Subject: [PATCH 89/97] support multiple replies and fix discord file sizes? --- packages/discord/src/incoming.ts | 2 +- packages/discord/src/outgoing.ts | 68 +++++++++++-------- packages/guilded/src/incoming.ts | 4 +- packages/guilded/src/outgoing.ts | 2 +- packages/lightning/src/structures/messages.ts | 2 +- packages/revolt/src/incoming.ts | 2 +- packages/revolt/src/outgoing.ts | 8 ++- packages/telegram/src/incoming.ts | 2 +- packages/telegram/src/mod.ts | 4 +- 9 files changed, 54 insertions(+), 40 deletions(-) diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts index 582932fb..658afd61 100644 --- a/packages/discord/src/incoming.ts +++ b/packages/discord/src/incoming.ts @@ -177,7 +177,7 @@ export async function get_incoming_message( plugin: 'bolt-discord', reply_id: data.message_reference && data.message_reference.type === 0 - ? data.message_reference.message_id + ? [data.message_reference.message_id!] : undefined, timestamp: Temporal.Instant.fromEpochMilliseconds( Number(BigInt(data.id) >> 22n) + 1420070400000, diff --git a/packages/discord/src/outgoing.ts b/packages/discord/src/outgoing.ts index 8027e6dd..96e691ac 100644 --- a/packages/discord/src/outgoing.ts +++ b/packages/discord/src/outgoing.ts @@ -20,26 +20,32 @@ export interface discord_payload async function fetch_reply( channelID: string, - replyID?: string, + replies?: string[], api?: API, ) { try { - if (!replyID || !api) return; + if (!replies || !api) return; const channel = await api.channels.get(channelID); const channelPath = 'guild_id' in channel ? `${channel.guild_id}/${channelID}` : `@me/${channelID}`; - const msg = await api.channels.getMessage(channelID, replyID); return [{ - type: 1 as const, - components: [{ - type: 2 as const, - style: 5 as const, - label: `reply to ${msg.author.username}`, - url: `https://discord.com/channels/${channelPath}/${replyID}`, - }], + type: 1, + components: await Promise.all( + replies.slice(0, 5).map(async (reply) => ({ + type: 1 as const, + components: [{ + type: 2 as const, + style: 5 as const, + label: `reply to ${ + (await api.channels.getMessage(channelID, reply)).author.username + }`, + url: `https://discord.com/channels/${channelPath}/${replies}`, + }], + })), + ), }]; } catch { return; @@ -47,29 +53,37 @@ async function fetch_reply( } async function fetch_files( + api: API, + channel_id: string, attachments: attachment[] | undefined, ): Promise { if (!attachments) return; - let totalSize = 0; + let attachment_max = 10; + + try { + const channel = await api.channels.get(channel_id); + if ('guild_id' in channel && channel.guild_id) { + const server = await api.guilds.get(channel.guild_id, { with_counts: false }); + if (server.premium_tier === 2) attachment_max = 50; + if (server.premium_tier === 3) attachment_max = 100; + } + } catch { + // If we can't get the server's attachment limit, default to 10MB + } return (await Promise.all( attachments.map(async (attachment) => { try { - if (attachment.size >= 25) return; - if (totalSize + attachment.size >= 25) return; - - const data = new Uint8Array( - await (await fetch(attachment.file, { - signal: AbortSignal.timeout(5000), - })).arrayBuffer(), - ); - - const name = attachment.name ?? attachment.file?.split('/').pop()!; - - totalSize += attachment.size; - - return { data, name }; + if (attachment.size >= attachment_max) return; + return { + data: new Uint8Array( + await (await fetch(attachment.file, { + signal: AbortSignal.timeout(5000), + })).arrayBuffer(), + ), + name: attachment.name ?? attachment.file?.split('/').pop()!, + }; } catch { return; } @@ -99,9 +113,9 @@ export async function get_outgoing_message( ...e, timestamp: e.timestamp?.toString(), })), - files: await fetch_files(msg.attachments), + files: await fetch_files(api, msg.channel_id, msg.attachments), message_reference: !button_reply && msg.reply_id - ? { type: 0, channel_id: msg.channel_id, message_id: msg.reply_id } + ? { type: 0, channel_id: msg.channel_id, message_id: msg.reply_id[0] } : undefined, username: msg.author.username, wait: true, diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts index 17ea69d1..9e59abff 100644 --- a/packages/guilded/src/incoming.ts +++ b/packages/guilded/src/incoming.ts @@ -143,9 +143,7 @@ export async function get_incoming( })), message_id: msg.id, plugin: 'bolt-guilded', - reply_id: msg.replyMessageIds && msg.replyMessageIds.length > 0 - ? msg.replyMessageIds[0] - : undefined, + reply_id: msg.replyMessageIds, timestamp: Temporal.Instant.from( msg.createdAt, ), diff --git a/packages/guilded/src/outgoing.ts b/packages/guilded/src/outgoing.ts index c05f58ae..c12b7363 100644 --- a/packages/guilded/src/outgoing.ts +++ b/packages/guilded/src/outgoing.ts @@ -32,7 +32,7 @@ async function fetch_reply( try { const { message } = await client.request( 'get', - `/channels/${msg.channel_id}/messages/${msg.reply_id}`, + `/channels/${msg.channel_id}/messages/${msg.reply_id[0]}`, undefined, ); diff --git a/packages/lightning/src/structures/messages.ts b/packages/lightning/src/structures/messages.ts index f9e36f46..16a12686 100644 --- a/packages/lightning/src/structures/messages.ts +++ b/packages/lightning/src/structures/messages.ts @@ -111,7 +111,7 @@ export interface message extends deleted_message { /** discord-style embeds */ embeds?: embed[]; /** the id of the message replied to */ - reply_id?: string; + reply_id?: string[]; } /** an author of a message */ diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts index 56898c8a..78e82ede 100644 --- a/packages/revolt/src/incoming.ts +++ b/packages/revolt/src/incoming.ts @@ -83,6 +83,6 @@ export async function get_incoming( timestamp: message.edited ? Temporal.Instant.from(message.edited) : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), - reply_id: message.replies?.[0] ?? undefined, + reply_id: message.replies ?? undefined, }; } diff --git a/packages/revolt/src/outgoing.ts b/packages/revolt/src/outgoing.ts index 67708636..43e213c4 100644 --- a/packages/revolt/src/outgoing.ts +++ b/packages/revolt/src/outgoing.ts @@ -57,9 +57,11 @@ export async function get_outgoing( return data; }), - replies: message.reply_id - ? [{ id: message.reply_id, mention: true }] - : undefined, + replies: message.reply_id?.map((reply) => ({ + id: reply, + mention: false, + fail_if_not_exists: false, + })), masquerade: masquerade ? { name: message.author.username, diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts index f0d8ecbc..ecca69c2 100644 --- a/packages/telegram/src/incoming.ts +++ b/packages/telegram/src/incoming.ts @@ -46,7 +46,7 @@ export async function get_incoming( ), plugin: 'bolt-telegram', reply_id: msg.reply_to_message - ? `${msg.reply_to_message.message_id}` + ? [`${msg.reply_to_message.message_id}`] : undefined, }; diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts index 3277dd51..43bcf8fb 100644 --- a/packages/telegram/src/mod.ts +++ b/packages/telegram/src/mod.ts @@ -112,9 +112,9 @@ export default class telegram extends plugin { message.channel_id, msg.value, { - reply_parameters: message.reply_id + reply_parameters: message.reply_id && message.reply_id.length > 0 ? { - message_id: parseInt(message.reply_id), + message_id: parseInt(message.reply_id[0]), } : undefined, parse_mode: 'MarkdownV2', From 15d8e9851be45fc15c470fa6be25d304ce6e3d27 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 19 Jun 2025 20:51:33 -0400 Subject: [PATCH 90/97] rewrite it in go --- .github/workflows/publish.yml | 9 +- cli/go.mod | 5 + cli/go.sum | 2 + cli/main.go | 139 +++++++ containerfile | 35 +- core/bridge.go | 315 +++++++++++++++ core/bridge_commands.go | 227 +++++++++++ core/bridge_types.go | 63 +++ core/commands.go | 213 +++++++++++ core/config.go | 47 +++ core/database.go | 46 +++ core/errors.go | 88 +++++ core/go.mod | 31 ++ core/go.sum | 65 ++++ core/messages.go | 89 +++++ core/plugin.go | 171 +++++++++ core/postgres.go | 274 +++++++++++++ core/redis.go | 362 ++++++++++++++++++ deno.jsonc | 25 -- discord/command.go | 105 +++++ discord/errors.go | 44 +++ discord/go.mod | 11 + discord/go.sum | 15 + discord/incoming.go | 230 +++++++++++ discord/outgoing.go | 306 +++++++++++++++ discord/plugin.go | 218 +++++++++++ go.work | 10 + go.work.sum | 21 + guilded/api.go | 258 +++++++++++++ guilded/cache.go | 114 ++++++ guilded/go.mod | 5 + guilded/go.sum | 2 + guilded/guilded.gen.go | 183 +++++++++ guilded/incoming.go | 351 +++++++++++++++++ guilded/outgoing.go | 189 +++++++++ guilded/plugin.go | 112 ++++++ guilded/send.go | 126 ++++++ guilded/setup.go | 96 +++++ packages/discord/README.md | 14 - packages/discord/deno.json | 12 - packages/discord/src/commands.ts | 36 -- packages/discord/src/errors.ts | 33 -- packages/discord/src/incoming.ts | 227 ----------- packages/discord/src/mod.ts | 187 --------- packages/discord/src/outgoing.ts | 130 ------- packages/guilded/README.md | 13 - packages/guilded/deno.json | 11 - packages/guilded/src/errors.ts | 28 -- packages/guilded/src/incoming.ts | 151 -------- packages/guilded/src/mod.ts | 162 -------- packages/guilded/src/outgoing.ts | 96 ----- packages/lightning/README.md | 27 -- packages/lightning/deno.json | 17 - packages/lightning/src/bridge/commands.ts | 219 ----------- packages/lightning/src/bridge/handler.ts | 136 ------- packages/lightning/src/bridge/setup.ts | 84 ---- packages/lightning/src/cli.ts | 47 --- packages/lightning/src/cli_config.ts | 80 ---- packages/lightning/src/core.ts | 174 --------- packages/lightning/src/database/mod.ts | 66 ---- packages/lightning/src/database/postgres.ts | 219 ----------- packages/lightning/src/database/redis.ts | 342 ----------------- packages/lightning/src/mod.ts | 3 - packages/lightning/src/structures/bridge.ts | 60 --- packages/lightning/src/structures/cacher.ts | 28 -- packages/lightning/src/structures/commands.ts | 56 --- packages/lightning/src/structures/cross.ts | 63 --- packages/lightning/src/structures/errors.ts | 91 ----- packages/lightning/src/structures/messages.ts | 129 ------- packages/lightning/src/structures/mod.ts | 7 - packages/lightning/src/structures/plugins.ts | 53 --- packages/lightning/src/structures/validate.ts | 35 -- packages/revolt/README.md | 13 - packages/revolt/deno.json | 12 - packages/revolt/src/cache.ts | 196 ---------- packages/revolt/src/errors.ts | 25 -- packages/revolt/src/incoming.ts | 88 ----- packages/revolt/src/mod.ts | 144 ------- packages/revolt/src/outgoing.ts | 73 ---- packages/revolt/src/permissions.ts | 70 ---- packages/telegram/README.md | 18 - packages/telegram/deno.json | 11 - packages/telegram/src/incoming.ts | 115 ------ packages/telegram/src/mod.ts | 163 -------- packages/telegram/src/outgoing.ts | 32 -- readme.md | 16 +- revolt/errors.go | 37 ++ revolt/go.mod | 14 + revolt/go.sum | 13 + revolt/incoming.go | 262 +++++++++++++ revolt/outgoing.go | 185 +++++++++ revolt/plugin.go | 191 +++++++++ telegram/go.mod | 5 + telegram/incoming.go | 162 ++++++++ telegram/outgoing.go | 214 +++++++++++ telegram/plugin.go | 297 ++++++++++++++ telegram/proxy.go | 51 +++ 97 files changed, 5993 insertions(+), 4052 deletions(-) create mode 100644 cli/go.mod create mode 100644 cli/go.sum create mode 100644 cli/main.go create mode 100644 core/bridge.go create mode 100644 core/bridge_commands.go create mode 100644 core/bridge_types.go create mode 100644 core/commands.go create mode 100644 core/config.go create mode 100644 core/database.go create mode 100644 core/errors.go create mode 100644 core/go.mod create mode 100644 core/go.sum create mode 100644 core/messages.go create mode 100644 core/plugin.go create mode 100644 core/postgres.go create mode 100644 core/redis.go delete mode 100644 deno.jsonc create mode 100644 discord/command.go create mode 100644 discord/errors.go create mode 100644 discord/go.mod create mode 100644 discord/go.sum create mode 100644 discord/incoming.go create mode 100644 discord/outgoing.go create mode 100644 discord/plugin.go create mode 100644 go.work create mode 100644 go.work.sum create mode 100644 guilded/api.go create mode 100644 guilded/cache.go create mode 100644 guilded/go.mod create mode 100644 guilded/go.sum create mode 100644 guilded/guilded.gen.go create mode 100644 guilded/incoming.go create mode 100644 guilded/outgoing.go create mode 100644 guilded/plugin.go create mode 100644 guilded/send.go create mode 100644 guilded/setup.go delete mode 100644 packages/discord/README.md delete mode 100644 packages/discord/deno.json delete mode 100644 packages/discord/src/commands.ts delete mode 100644 packages/discord/src/errors.ts delete mode 100644 packages/discord/src/incoming.ts delete mode 100644 packages/discord/src/mod.ts delete mode 100644 packages/discord/src/outgoing.ts delete mode 100644 packages/guilded/README.md delete mode 100644 packages/guilded/deno.json delete mode 100644 packages/guilded/src/errors.ts delete mode 100644 packages/guilded/src/incoming.ts delete mode 100644 packages/guilded/src/mod.ts delete mode 100644 packages/guilded/src/outgoing.ts delete mode 100644 packages/lightning/README.md delete mode 100644 packages/lightning/deno.json delete mode 100644 packages/lightning/src/bridge/commands.ts delete mode 100644 packages/lightning/src/bridge/handler.ts delete mode 100644 packages/lightning/src/bridge/setup.ts delete mode 100644 packages/lightning/src/cli.ts delete mode 100644 packages/lightning/src/cli_config.ts delete mode 100644 packages/lightning/src/core.ts delete mode 100644 packages/lightning/src/database/mod.ts delete mode 100644 packages/lightning/src/database/postgres.ts delete mode 100644 packages/lightning/src/database/redis.ts delete mode 100644 packages/lightning/src/mod.ts delete mode 100644 packages/lightning/src/structures/bridge.ts delete mode 100644 packages/lightning/src/structures/cacher.ts delete mode 100644 packages/lightning/src/structures/commands.ts delete mode 100644 packages/lightning/src/structures/cross.ts delete mode 100644 packages/lightning/src/structures/errors.ts delete mode 100644 packages/lightning/src/structures/messages.ts delete mode 100644 packages/lightning/src/structures/mod.ts delete mode 100644 packages/lightning/src/structures/plugins.ts delete mode 100644 packages/lightning/src/structures/validate.ts delete mode 100644 packages/revolt/README.md delete mode 100644 packages/revolt/deno.json delete mode 100644 packages/revolt/src/cache.ts delete mode 100644 packages/revolt/src/errors.ts delete mode 100644 packages/revolt/src/incoming.ts delete mode 100644 packages/revolt/src/mod.ts delete mode 100644 packages/revolt/src/outgoing.ts delete mode 100644 packages/revolt/src/permissions.ts delete mode 100644 packages/telegram/README.md delete mode 100644 packages/telegram/deno.json delete mode 100644 packages/telegram/src/incoming.ts delete mode 100644 packages/telegram/src/mod.ts delete mode 100644 packages/telegram/src/outgoing.ts create mode 100644 revolt/errors.go create mode 100644 revolt/go.mod create mode 100644 revolt/go.sum create mode 100644 revolt/incoming.go create mode 100644 revolt/outgoing.go create mode 100644 revolt/plugin.go create mode 100644 telegram/go.mod create mode 100644 telegram/incoming.go create mode 100644 telegram/outgoing.go create mode 100644 telegram/plugin.go create mode 100644 telegram/proxy.go diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 45669e24..1fcf5289 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -8,21 +8,14 @@ on: permissions: contents: read packages: write - id-token: write jobs: publish: - name: publish to jsr and ghcr + name: publish to ghcr runs-on: ubuntu-latest steps: - name: checkout uses: actions/checkout@v4 - - name: setup deno - uses: denoland/setup-deno@v2 - with: - deno-version: v2.3.5 - - name: publish to jsr - run: deno publish - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: login to ghcr diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 00000000..ce8bc410 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,5 @@ +module github.com/williamhorning/lightning/cli + +go 1.24.4 + +require github.com/urfave/cli/v3 v3.3.8 diff --git a/cli/go.sum b/cli/go.sum new file mode 100644 index 00000000..d6bf8819 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,2 @@ +github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E= +github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 00000000..947a81b4 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/urfave/cli/v3" + "github.com/williamhorning/lightning" + _ "github.com/williamhorning/lightning/discord" + _ "github.com/williamhorning/lightning/guilded" + _ "github.com/williamhorning/lightning/revolt" + _ "github.com/williamhorning/lightning/telegram" +) + +func main() { + (&cli.Command{ + Name: "lightning", + Usage: "extensible chatbot connecting communities", + Version: "0.8.0-alpha.6", + DefaultCommand: "help", + EnableShellCompletion: true, + Authors: []any{"William Horning", "Lightning contributors"}, + Copyright: "2025 William Horning and contributors.\nAvailible under the MIT license", + Commands: []*cli.Command{ + { + Name: "migrate", + Usage: "migrate databases", + Action: migrate, + }, + { + Name: "run", + Usage: "run a lightning instance", + Arguments: []cli.Argument{ + &cli.StringArg{ + Name: "config", + UsageText: "the path to the configuration file", + Value: "lightning.toml", + Config: cli.StringConfig{TrimSpace: true}, + }, + }, + Action: run, + }, + }, + }).Run(context.Background(), os.Args) +} + +func run(ctx context.Context, c *cli.Command) error { + config, err := lightning.LoadConfig(c.StringArg("config")) + if err != nil { + lightning.LogError(err, "something went wrong with loading the config", nil, lightning.ReadWriteDisabled{}) + os.Exit(1) + } + + db, err := config.DatabaseConfig.GetDatabase() + if err != nil { + lightning.LogError(err, "something went wrong with setting up the database", nil, lightning.ReadWriteDisabled{}) + os.Exit(1) + } + + lightning.SetupBridge(db) + + quitChannel := make(chan os.Signal, 1) + signal.Notify(quitChannel, syscall.SIGINT, syscall.SIGTERM) + <-quitChannel + + lightning.LogError(errors.New("lightning instance stopped"), "lightning instance stopped", nil, lightning.ReadWriteDisabled{}) + return nil +} + +func migrate(ctx context.Context, c *cli.Command) error { + sourceConfig := getDatabaseConfig("source") + destConfig := getDatabaseConfig("destination") + + fmt.Print("Do you want to proceed with migration? (y/n): ") + var confirm string + fmt.Scanln(&confirm) + + if confirm != "y" { + fmt.Println("Migration cancelled") + return nil + } + + sourceDB, err := sourceConfig.GetDatabase() + if err != nil { + lightning.LogError(err, "error connecting to source database", nil, lightning.ReadWriteDisabled{}) + os.Exit(1) + } + + destDB, err := destConfig.GetDatabase() + if err != nil { + lightning.LogError(err, "error connecting to destination database", nil, lightning.ReadWriteDisabled{}) + os.Exit(1) + } + + if err := migrateBridges(sourceDB, destDB); err != nil { + lightning.LogError(err, "error migrating bridges", nil, lightning.ReadWriteDisabled{}) + os.Exit(1) + } + + if err := migrateMessages(sourceDB, destDB); err != nil { + lightning.LogError(err, "error migrating messages", nil, lightning.ReadWriteDisabled{}) + os.Exit(1) + } + + fmt.Println("Migration completed successfully") + return nil +} + +func getDatabaseConfig(name string) lightning.DatabaseConfig { + fmt.Printf("Enter %s database type (postgres/redis): ", name) + var dbType string + fmt.Scanln(&dbType) + + fmt.Printf("Enter %s database connection string: ", name) + var connection string + fmt.Scanln(&connection) + + return lightning.DatabaseConfig{Type: dbType, Connection: connection} +} + +func migrateBridges(sourceDB, destDB lightning.Database) error { + bridges, err := sourceDB.GetAllBridges() + if err != nil { + return err + } + return destDB.SetAllBridges(bridges) +} + +func migrateMessages(sourceDB, destDB lightning.Database) error { + messages, err := sourceDB.GetAllMessages() + if err != nil { + return err + } + return destDB.SetAllMessages(messages) +} diff --git a/containerfile b/containerfile index 9b538d8b..507e1b15 100644 --- a/containerfile +++ b/containerfile @@ -1,22 +1,29 @@ -FROM denoland/deno:alpine-2.3.5 +# build the cli +FROM golang:1.24-alpine AS builder -# make a deno cache directory -RUN ["mkdir", "/deno_dir"] -ENV DENO_DIR=/deno_dir +WORKDIR /app +COPY . . +RUN go build -o lightning ./cli/main.go -# install lightning -RUN ["deno", "install", "-gA", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.5"] -RUN ["chown", "--recursive", "1001:1001", "/deno_dir"] +# build the final image +FROM scratch -# run as user instead of root -USER 1001:1001 +# metadata +LABEL maintainer="William Horning" +LABEL version="0.8.0-alpha.6" +LABEL description="Lightning" +LABEL org.opencontainers.image.title="Lightning" +LABEL org.opencontainers.image.description="extensible chatbot connecting communities" +LABEL org.opencontainers.image.version="0.8.0-alpha.6" +LABEL org.opencontainers.image.source="https://github.com/williamhorning/lightning" +LABEL org.opencontainers.image.licenses="MIT" -# the volume containing your lightning.toml file +# copy stuff over +USER 1001:1001 +COPY --from=builder /app/lightning /lightning VOLUME [ "/data" ] WORKDIR /data -# this is the lightning command line -ENTRYPOINT [ "lightning" ] - -# run the bot using the user-provided lightning.toml file +# run the bot +ENTRYPOINT ["/lightning"] CMD [ "run" ] diff --git a/core/bridge.go b/core/bridge.go new file mode 100644 index 00000000..595e3fe2 --- /dev/null +++ b/core/bridge.go @@ -0,0 +1,315 @@ +package lightning + +import ( + "errors" + "fmt" + "strings" +) + +func SetupBridge(db Database) { + Log.Info().Msg("Setting up bridge system") + RegisterCommand(bridgeCommand(db)) + + go func() { + for event := range ListenMessages() { + Log.Trace().Str("event_id", event.EventID).Str("channel", event.ChannelID).Msg("Received message creation event") + if err := handleBridgeMessage(db, "create_message", event); err != nil { + LogError(err, "Failed to handle bridge message creation", nil, ReadWriteDisabled{}) + } + } + }() + + go func() { + for event := range ListenEdits() { + Log.Trace().Str("event_id", event.EventID).Str("channel", event.ChannelID).Msg("Received message edit event") + if err := handleBridgeMessage(db, "edit_message", event); err != nil { + LogError(err, "Failed to handle bridge message edit", nil, ReadWriteDisabled{}) + } + } + }() + + go func() { + for event := range ListenDeletes() { + Log.Trace().Str("event_id", event.EventID).Str("channel", event.ChannelID).Msg("Received message deletion event") + if err := handleBridgeMessage(db, "delete_message", event); err != nil { + LogError(err, "Failed to handle bridge message deletion", nil, ReadWriteDisabled{}) + } + } + }() + + Log.Info().Msg("Bridge system setup!") +} + +func handleBridgeMessage(db Database, event string, data any) error { + Log.Trace().Str("event", event).Interface("data_type", data).Msg("Handling bridge message") + + var bridge Bridge + var err error + var base BaseMessage + + switch msg := data.(type) { + case Message: + base = msg.BaseMessage + Log.Trace().Str("channel", base.ChannelID).Str("plugin", base.Plugin).Str("event_id", base.EventID).Msg("Processing Message type") + case BaseMessage: + base = msg + Log.Trace().Str("channel", base.ChannelID).Str("plugin", base.Plugin).Str("event_id", base.EventID).Msg("Processing BaseMessage type") + default: + return fmt.Errorf("unsupported message type: %T", data) + } + + if event == "create_message" { + Log.Trace().Str("channel", base.ChannelID).Str("event", event).Msg("Getting bridge by channel for new message") + bridge, err = db.getBridgeByChannel(base.ChannelID) + } else { + Log.Trace().Str("event_id", base.EventID).Str("event", event).Msg("Getting bridge message collection for existing message") + var bridgeMsg BridgeMessageCollection + bridgeMsg, err = db.getMessage(base.EventID) + if err == nil { + bridge = Bridge{ + ID: bridgeMsg.BridgeID, + Name: bridgeMsg.Name, + Channels: bridgeMsg.Channels, + Settings: bridgeMsg.Settings, + } + Log.Trace().Str("bridge_id", bridge.ID).Str("event_id", base.EventID).Msg("Retrieved bridge information") + } + } + + if err != nil { + return LogError(err, "Failed to get bridge from database", map[string]any{ + "channel": base.ChannelID, + "event": event, + }, ReadWriteDisabled{}) + } + + if bridge.ID == "" { + Log.Trace().Str("channel", base.ChannelID).Msg("No bridge found for channel, skipping") + return nil + } + + Log.Trace().Str("bridge_id", bridge.ID).Str("channel", base.ChannelID).Int("channel_count", len(bridge.Channels)).Msg("Processing message for bridge") + + for _, channel := range bridge.Channels { + if channel.ID == base.ChannelID && channel.Plugin == base.Plugin { + if channel.IsDisabled().Read { + Log.Debug().Str("channel", channel.ID).Str("plugin", channel.Plugin).Msg("Channel is subscribed (read disabled), skipping") + return nil + } + break + } + } + + var channels []BridgeChannel + for _, channel := range bridge.Channels { + if channel.ID == base.ChannelID && channel.Plugin == base.Plugin { + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Msg("Skipping source channel") + continue + } + if channel.IsDisabled().Write { + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Msg("Channel has write disabled, skipping") + continue + } + channels = append(channels, channel) + } + + if len(channels) == 0 { + Log.Debug().Str("bridge_id", bridge.ID).Msg("No valid target channels found, skipping") + return nil + } + + Log.Trace().Str("bridge_id", bridge.ID).Int("target_channels", len(channels)).Msg("Processing message for target channels") + + var messages []BridgeMessage + + for _, channel := range channels { + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Str("event", event).Msg("Processing channel") + + var priorMessageIDs []string + if event != "create_message" { + Log.Trace().Str("event_id", base.EventID).Msg("Looking up prior message IDs") + bridgeMsg, _ := db.getMessage(base.EventID) + for _, msg := range bridgeMsg.Messages { + println(msg.ID[0], base.EventID, msg.Plugin, channel.Plugin) + if msg.Channel == channel.ID && msg.Plugin == channel.Plugin { + priorMessageIDs = msg.ID + Log.Trace().Strs("prior_ids", priorMessageIDs).Msg("Found prior message IDs") + break + } + } + + if len(priorMessageIDs) == 0 { + if bridgeMsg.ID == base.EventID { + Log.Trace().Str("event_id", base.EventID).Msg("Using bridge message collection ID as prior message ID") + priorMessageIDs = []string{bridgeMsg.ID} + } else { + Log.Debug().Str("channel", channel.ID).Str("plugin", channel.Plugin).Msg("No prior message IDs found, skipping") + continue + } + } + } + + Log.Trace().Str("plugin", channel.Plugin).Msg("Getting plugin") + plugin, ok := GetPlugin(channel.Plugin) + if !ok { + Log.Debug().Str("plugin", channel.Plugin).Msg("Plugin not found, skipping channel") + continue + } + + var replyIDs []string + if msg, ok := data.(Message); ok && len(msg.RepliedTo) > 0 { + Log.Trace().Strs("replied_to", msg.RepliedTo).Msg("Processing reply chain") + for _, replyID := range msg.RepliedTo { + bridgedReply, err := db.getMessage(replyID) + if err != nil { + Log.Trace().Str("reply_id", replyID).Err(err).Msg("Failed to get bridged reply") + continue + } + + for _, replyMsg := range bridgedReply.Messages { + if replyMsg.Channel == channel.ID && replyMsg.Plugin == channel.Plugin && len(replyMsg.ID) > 0 { + replyIDs = append(replyIDs, replyMsg.ID[0]) + Log.Trace().Str("reply_id", replyMsg.ID[0]).Msg("Added reply ID") + } + } + } + } + + var resultIDs []string + opts := &BridgeMessageOptions{ + Channel: channel, + Settings: bridge.Settings, + } + + func() { + defer func() { + if r := recover(); r != nil { + LogError(fmt.Errorf("%v", r), "Panic in bridge message handling", map[string]any{ + "channel": channel.ID, + "plugin": channel.Plugin, + }, ReadWriteDisabled{}) + } + }() + + var err error + Log.Trace().Str("event", event).Str("channel", channel.ID).Str("plugin", channel.Plugin).Msg("Handling event") + + switch event { + case "create_message": + if msg, ok := data.(Message); ok { + newMsg := msg + newMsg.BaseMessage.ChannelID = channel.ID + if len(replyIDs) > 0 { + newMsg.RepliedTo = replyIDs + Log.Trace().Strs("reply_ids", replyIDs).Msg("Setting reply IDs for new message") + } + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Msg("Sending message") + resultIDs, err = plugin.SendMessage(newMsg, opts) + if err == nil { + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Strs("message_ids", resultIDs).Msg("Message sent successfully") + } + } + case "edit_message": + if msg, ok := data.(Message); ok { + newMsg := msg + newMsg.BaseMessage.ChannelID = channel.ID + if len(replyIDs) > 0 { + newMsg.RepliedTo = replyIDs + Log.Trace().Strs("reply_ids", replyIDs).Msg("Setting reply IDs for edit") + } + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Strs("prior_ids", priorMessageIDs).Msg("Editing message") + err = plugin.EditMessage(newMsg, priorMessageIDs, opts) + resultIDs = priorMessageIDs + if err == nil { + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Strs("message_ids", resultIDs).Msg("Message edited successfully") + } + } + case "delete_message": + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Strs("prior_ids", priorMessageIDs).Msg("Deleting message") + err = plugin.DeleteMessage(priorMessageIDs, opts) + resultIDs = priorMessageIDs + if err == nil { + Log.Trace().Str("channel", channel.ID).Str("plugin", channel.Plugin).Strs("message_ids", resultIDs).Msg("Message deleted successfully") + } + } + + if err != nil { + err := LogError(err, "Error handling bridge message", map[string]any{ + "channel": channel.ID, + "plugin": channel.Plugin, + "bridge": bridge.ID, + "event": event, + }, ReadWriteDisabled{}) + + if err.Disable.Read || err.Disable.Write { + Log.Warn().Str("channel", channel.ID).Str("plugin", channel.Plugin).Bool("disable_read", err.Disable.Read).Bool("disable_write", err.Disable.Write).Msg("Disabling channel functionality due to error") + + updatedChannels := make([]BridgeChannel, len(bridge.Channels)) + copy(updatedChannels, bridge.Channels) + + for i, ch := range updatedChannels { + if ch.ID == channel.ID && ch.Plugin == channel.Plugin { + updatedChannels[i].Disabled = err.Disable + break + } + } + + bridge.Channels = updatedChannels + db.createBridge(bridge) + + msg := fmt.Sprintf("Disabling channel %s in bridge %s", channel.ID, bridge.ID) + + LogError(errors.New(msg), msg, map[string]any{ + "disable": map[string]bool{ + "read": err.Disable.Read, + "write": err.Disable.Write, + }, + }, err.Disable) + } + + return + } + + for _, id := range resultIDs { + setHandled(channel.Plugin, id, strings.Replace(event, "_message", "", 1)) + Log.Trace().Str("plugin", channel.Plugin).Str("message_id", id).Msg("Marked message as handled") + } + + messages = append(messages, BridgeMessage{ + ID: resultIDs, + Channel: channel.ID, + Plugin: channel.Plugin, + }) + }() + } + + messages = append(messages, BridgeMessage{ + ID: []string{base.EventID}, + Channel: base.ChannelID, + Plugin: base.Plugin, + }) + + Log.Trace().Str("bridge_id", bridge.ID).Str("event", event).Int("message_count", len(messages)).Msg("Creating bridge message collection") + + bridgeMsg := BridgeMessageCollection{ + Bridge: Bridge{ + ID: base.EventID, + Name: bridge.Name, + Channels: bridge.Channels, + Settings: bridge.Settings, + }, + BridgeID: bridge.ID, + Messages: messages, + } + + switch event { + case "create_message", "edit_message": + err := db.createMessage(bridgeMsg) + return err + case "delete_message": + err := db.deleteMessage(bridgeMsg.ID) + return err + default: + return fmt.Errorf("unknown event type: %s", event) + } +} diff --git a/core/bridge_commands.go b/core/bridge_commands.go new file mode 100644 index 00000000..6d8a4936 --- /dev/null +++ b/core/bridge_commands.go @@ -0,0 +1,227 @@ +package lightning + +import ( + "slices" + "strconv" + + "github.com/oklog/ulid/v2" +) + +func bridgeCommand(db Database) Command { + return Command{ + Name: "bridge", + Description: "manage bridges between channels", + Executor: func(opts CommandOptions) (string, error) { + return "take a look at the subcommands of this command", nil + }, + Subcommands: []Command{ + { + Name: "create", + Description: "create a new bridge", + Arguments: []CommandArgument{{"name", "the name to use for the bridge", true}}, + Executor: func(options CommandOptions) (string, error) { + return createCommand(db, options) + }, + }, + { + Name: "join", + Description: "join an existing bridge", + Arguments: []CommandArgument{{"id", "the ID of the bridge to join", true}}, + Executor: func(options CommandOptions) (string, error) { + return joinCommand(db, options, false) + }, + }, + { + Name: "subscribe", + Description: "subscribe to a bridge", + Arguments: []CommandArgument{{"id", "the ID of the bridge to subscribe to", true}}, + Executor: func(options CommandOptions) (string, error) { + return joinCommand(db, options, true) + }, + }, + { + Name: "leave", + Description: "leave a bridge", + Arguments: []CommandArgument{{"id", "the ID of the bridge to leave", true}}, + Executor: func(options CommandOptions) (string, error) { + return leaveCommand(db, options) + }, + }, + { + Name: "toggle", + Description: "toggle settings for a bridge", + Arguments: []CommandArgument{{"setting", "the bridge setting to toggle", true}}, + Executor: func(options CommandOptions) (string, error) { + return toggleCommand(db, options) + }, + }, + { + Name: "status", + Description: "get the status of the bridge in this channel", + Executor: func(options CommandOptions) (string, error) { + return statusCommand(db, options) + }, + }, + }, + } +} + +func prepareChannelForBridge(db Database, opts CommandOptions) (BridgeChannel, string) { + Log.Trace().Str("channel", opts.Channel).Str("plugin", opts.Plugin).Msg("Adding channel to bridge") + + if br, err := db.getBridgeByChannel(opts.Channel); br.ID != "" || err != nil { + return BridgeChannel{}, "This channel is already part of a bridge. Please leave the bridge first." + } + + plugin, ok := GetPlugin(opts.Plugin) + if !ok { + return BridgeChannel{}, LogError(ErrPluginNotFound, "Failed to add channel to bridge using plugin", + map[string]any{"plugin": opts.Plugin, "channel": opts.Channel}, ReadWriteDisabled{}).Error() + } + + data, err := plugin.SetupChannel(opts.Channel) + if err != nil { + return BridgeChannel{}, LogError(err, "Failed to setup channel for bridge", + map[string]any{"plugin": plugin.Name(), "channel": opts.Channel}, ReadWriteDisabled{}).Error() + } + + return BridgeChannel{ + ID: opts.Channel, + Data: data, + Plugin: plugin.Name(), + Disabled: ReadWriteDisabled{false, false}, + }, "" +} + +func createCommand(db Database, opts CommandOptions) (string, error) { + ch, errMsg := prepareChannelForBridge(db, opts) + if errMsg != "" { + return errMsg, nil + } + + bridge := Bridge{ + ID: ulid.Make().String(), + Name: opts.Arguments["name"], + Channels: []BridgeChannel{ch}, + Settings: BridgeSettings{false}, + } + + if err := db.createBridge(bridge); err != nil { + return LogError(err, "Failed to create bridge in database", + map[string]any{"bridge": bridge}, ReadWriteDisabled{}).Error(), nil + } + + Log.Debug().Str("bridge_id", bridge.ID).Str("channel", opts.Channel).Msg("Bridge created successfully") + return "Bridge created successfully! You can now join it using `" + opts.Prefix + "bridge join " + bridge.ID + "`.", nil +} + +func joinCommand(db Database, opts CommandOptions, subscribe bool) (string, error) { + id := opts.Arguments["id"] + + ch, errMsg := prepareChannelForBridge(db, opts) + if errMsg != "" { + return errMsg, nil + } + + br, err := db.getBridge(id) + if err != nil { + return LogError(err, "Failed to get bridge from database", + map[string]any{"bridge_id": id}, ReadWriteDisabled{}).Error(), nil + } else if br.ID == "" { + return "No bridge found with the provided ID.", nil + } + + ch.Disabled = ReadWriteDisabled{subscribe, false} + br.Channels = append(br.Channels, ch) + + if err := db.createBridge(br); err != nil { + return LogError(err, "Failed to update bridge in database", + map[string]any{"bridge": br}, ReadWriteDisabled{}).Error(), nil + } + + Log.Debug().Str("bridge_id", br.ID).Str("channel", opts.Channel).Msg("Channel joined bridge successfully") + return "Bridge joined successfully!", nil +} + +func leaveCommand(db Database, opts CommandOptions) (string, error) { + id := opts.Arguments["id"] + + br, err := db.getBridgeByChannel(opts.Channel) + if err != nil { + return LogError(err, "Failed to get bridge from database", + map[string]any{"channel": opts.Channel}, ReadWriteDisabled{}).Error(), nil + } else if br.ID == "" { + return "You are not in a bridge.", nil + } + + if br.ID != id { + return "This channel is not part of the specified bridge.", nil + } + + for i, channel := range br.Channels { + if channel.ID == opts.Channel { + br.Channels = slices.Delete(br.Channels, i, i+1) + break + } + } + + if err := db.createBridge(br); err != nil { + return LogError(err, "Failed to update bridge in database", + map[string]any{"bridge": br}, ReadWriteDisabled{}).Error(), nil + } + + return "You have successfully left the bridge.", nil +} + +func toggleCommand(db Database, opts CommandOptions) (string, error) { + setting := opts.Arguments["setting"] + + br, err := db.getBridgeByChannel(opts.Channel) + if err != nil { + return LogError(err, "Failed to get bridge from database", + map[string]any{"channel": opts.Channel}, ReadWriteDisabled{}).Error(), nil + } else if br.ID == "" { + return "You are not in a bridge.", nil + } + + if setting != "allow_everyone" { + return "That setting does not exist. Available settings are: `allow_everyone`.", nil + } + + br.Settings.AllowEveryone = !br.Settings.AllowEveryone + + if err := db.createBridge(br); err != nil { + return LogError(err, "Failed to update bridge in database", + map[string]any{"bridge": br}, ReadWriteDisabled{}).Error(), nil + } + + return "Bridge settings updated successfully", nil +} + +func statusCommand(db Database, opts CommandOptions) (string, error) { + br, err := db.getBridgeByChannel(opts.Channel) + if err != nil { + return LogError(err, "Failed to get bridge from database", + map[string]any{"channel": opts.Channel}, ReadWriteDisabled{}).Error(), nil + } else if br.ID == "" { + return "You are not in a bridge.", nil + } + + status := "Name: `" + br.Name + "`\n\nChannels:\n" + + for i, channel := range br.Channels { + status += strconv.Itoa(i) + ". `" + channel.ID + "` on `" + channel.Plugin + "`" + if channel.IsDisabled().Read { + status += " (subscribed)" + } + if channel.IsDisabled().Write { + status += " (write disabled)" + } + status += "\n" + } + + status += "\nSettings:\n" + status += "- Allow Everyone: `" + (map[bool]string{true: "โœ”", false: "โŒ"})[br.Settings.AllowEveryone] + "`\n" + + return status, nil +} diff --git a/core/bridge_types.go b/core/bridge_types.go new file mode 100644 index 00000000..bd3d2029 --- /dev/null +++ b/core/bridge_types.go @@ -0,0 +1,63 @@ +package lightning + +type BridgeSettings struct { + AllowEveryone bool `json:"allow_everyone"` +} + +type ReadWriteDisabled struct { + Read bool `json:"read"` + Write bool `json:"write"` +} + +type BridgeChannel struct { + ID string `json:"id"` + Data any `json:"data"` + Disabled any `json:"disabled"` + Plugin string `json:"plugin"` +} + +type BridgeMessageOptions struct { + Channel BridgeChannel `json:"channel"` + Settings BridgeSettings `json:"settings"` +} + +type BridgeMessage struct { + ID []string `json:"id"` + Channel string `json:"channel"` + Plugin string `json:"plugin"` +} + +type Bridge struct { + ID string `json:"id"` + Name string `json:"name"` + Channels []BridgeChannel `json:"channels"` + Settings BridgeSettings `json:"settings"` +} + +type BridgeMessageCollection struct { + Bridge + BridgeID string `json:"bridge_id"` + Messages []BridgeMessage `json:"messages"` +} + +func (b *BridgeChannel) IsDisabled() ReadWriteDisabled { + switch v := b.Disabled.(type) { + case bool: + return ReadWriteDisabled{v, v} + case map[string]any: + read, okRead := v["read"].(bool) + write, okWrite := v["write"].(bool) + if okRead && okWrite { + return ReadWriteDisabled{read, write} + } else if okRead { + return ReadWriteDisabled{read, false} + } else if okWrite { + return ReadWriteDisabled{false, write} + } else { + return ReadWriteDisabled{false, false} + } + case ReadWriteDisabled: + return v + } + return ReadWriteDisabled{false, false} +} diff --git a/core/commands.go b/core/commands.go new file mode 100644 index 00000000..465172d2 --- /dev/null +++ b/core/commands.go @@ -0,0 +1,213 @@ +package lightning + +import ( + "fmt" + "strings" + "time" +) + +var ( + commandRegistry = make(map[string]Command) + CommandPrefix = "" +) + +func RegisterCommand(command Command) { + Log.Debug().Str("command", command.Name).Msg("Registering command") + commandRegistry[command.Name] = command + commands := make([]Command, 0, len(commandRegistry)) + for _, cmd := range commandRegistry { + commands = append(commands, cmd) + } + + for _, plugin := range pluginRegistry { + if err := plugin.SetupCommands(commands); err != nil { + LogError(err, "Failed to setup commands for plugin", map[string]any{ + "plugin": plugin.Name(), + }, ReadWriteDisabled{}) + } + } +} + +func GetCommand(name string) (Command, bool) { + command, exists := commandRegistry[name] + return command, exists +} + +type CommandArgument struct { + Name string + Description string + Required bool +} + +type CommandOptions struct { + Arguments map[string]string + Channel string + Plugin string + Prefix string + Time time.Time +} + +type Command struct { + Name string + Description string + Arguments []CommandArgument + Subcommands []Command + Executor func(options CommandOptions) (string, error) +} + +type CommandEvent struct { + CommandOptions + Command string + Subcommand *string + Options *[]string + EventID string + Reply func(message string) error +} + +func HelpCommand() Command { + return Command{ + Name: "help", + Description: "get help with the bot", + Arguments: []CommandArgument{}, + Subcommands: []Command{}, + Executor: func(options CommandOptions) (string, error) { + return "hi! i'm lightning v0.8.0-alpha.6.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help!", nil + }, + } +} + +func PingCommand() Command { + return Command{ + Name: "ping", + Description: "check if the bot is alive", + Arguments: []CommandArgument{}, + Subcommands: []Command{}, + Executor: func(options CommandOptions) (string, error) { + return fmt.Sprintf("Pong! ๐Ÿ“ %dms", (time.Since(options.Time)).Milliseconds()), nil + }, + } +} + +func SetupCommands(prefix string) { + CommandPrefix = prefix + + RegisterCommand(HelpCommand()) + RegisterCommand(PingCommand()) + + go func() { + for event := range ListenCommands() { + handleCommandEvent(event) + } + }() + + go func() { + for event := range ListenMessages() { + handleMessageCommand(event, prefix) + } + }() +} + +func handleMessageCommand(event Message, prefix string) { + if !strings.HasPrefix(event.Content, prefix) { + return + } + + Log.Trace().Str("event_id", event.EventID).Str("plugin", event.Plugin).Msg("Handling command message") + + content := strings.TrimPrefix(event.Content, prefix) + args := strings.Fields(content) + if len(args) == 0 { + return + } + + commandName := args[0] + options := args[1:] + + handleCommandEvent(CommandEvent{ + CommandOptions: CommandOptions{ + Arguments: make(map[string]string), + Channel: event.ChannelID, + Plugin: event.Plugin, + Prefix: prefix, + Time: event.Time, + }, + Command: commandName, + Options: &options, + EventID: event.EventID, + Reply: func(message string) error { + plugin, exists := GetPlugin(event.Plugin) + if !exists { + return LogError(ErrPluginNotFound, "Plugin not found for command reply", map[string]any{ + "plugin": event.Plugin, + "event": event.EventID, + }, ReadWriteDisabled{}) + } + + msg := CreateMessage(message) + msg.ChannelID = event.ChannelID + _, err := plugin.SendMessage(msg, nil) + return err + }, + }) +} + +func handleCommandEvent(event CommandEvent) error { + Log.Trace().Str("event_id", event.EventID).Str("command", event.Command).Msg("Handling command event") + + command, exists := GetCommand(event.Command) + if !exists { + Log.Trace().Str("command", event.Command).Msg("Command not found, using help command") + command = HelpCommand() + } + + if event.Options != nil && len(*event.Options) > 0 { + event.Subcommand = &(*event.Options)[0] + *event.Options = (*event.Options)[1:] + + for _, subcommand := range command.Subcommands { + if subcommand.Name == *event.Subcommand { + command = subcommand + break + } + } + } + + for _, arg := range command.Arguments { + if event.CommandOptions.Arguments[arg.Name] == "" && event.Options != nil && len(*event.Options) > 0 { + event.CommandOptions.Arguments[arg.Name] = (*event.Options)[0] + *event.Options = (*event.Options)[1:] + } + + if arg.Required && event.CommandOptions.Arguments[arg.Name] == "" { + Log.Trace().Str("argument", arg.Name).Msg("Required argument missing") + err := event.Reply("Please provide the " + arg.Name + " argument. Try using the " + event.Prefix + "help command.") + if err != nil { + return LogError(err, "Error sending missing argument response", map[string]any{ + "argument": arg.Name, + "command": command.Name, + "event": event.EventID, + }, ReadWriteDisabled{}) + } + } + } + + response, err := command.Executor(event.CommandOptions) + + if err != nil { + response = LogError(err, "Error executing command", map[string]any{ + "command": command.Name, + "event": event.EventID, + }, ReadWriteDisabled{}).Error() + } + + if err = event.Reply(response); err != nil { + return LogError(err, "Error sending command response", map[string]any{ + "command": command.Name, + "event": event.EventID, + }, ReadWriteDisabled{}) + } + + Log.Trace().Str("event_id", event.EventID).Str("command", command.Name).Msg("Command handled successfully") + + return nil +} diff --git a/core/config.go b/core/config.go new file mode 100644 index 00000000..9dd1fa0a --- /dev/null +++ b/core/config.go @@ -0,0 +1,47 @@ +package lightning + +import ( + "os" + + "github.com/BurntSushi/toml" + "github.com/rs/zerolog" +) + +type Config struct { + CommandPrefix string `toml:"prefix,omitempty"` + DatabaseConfig DatabaseConfig `toml:"database"` + ErrorURL string `toml:"error_url"` + LogLevel *int8 `toml:"log_level"` + Plugins map[string]any `toml:"plugins"` +} + +func LoadConfig(path string) (Config, error) { + var config Config + + if _, err := toml.DecodeFile(path, &config); err != nil { + return Config{}, err + } + + if config.LogLevel == nil { + defaultLogLevel := int8(1) + config.LogLevel = &defaultLogLevel + } + + Log = Log.Level(zerolog.Level(*config.LogLevel)) + + Log.WithLevel(zerolog.Level(*config.LogLevel)).Msg("Set log level!") + + err := os.Setenv("LIGHTNING_ERROR_WEBHOOK", config.ErrorURL) + + if err != nil { + return Config{}, err + } + + SetupCommands(config.CommandPrefix) + + for plugin, cfg := range config.Plugins { + registerPlugin(plugin, cfg) + } + + return config, nil +} diff --git a/core/database.go b/core/database.go new file mode 100644 index 00000000..9acb2b30 --- /dev/null +++ b/core/database.go @@ -0,0 +1,46 @@ +package lightning + +import ( + "errors" + "os" + "time" + + "github.com/briandowns/spinner" +) + +var ErrUnsupportedDatabaseType = errors.New("unsupported database type, must be 'postgres' or 'redis'") + +type Database interface { + createBridge(bridge Bridge) error + getBridge(id string) (Bridge, error) + getBridgeByChannel(channelID string) (Bridge, error) + createMessage(message BridgeMessageCollection) error + deleteMessage(id string) error + getMessage(id string) (BridgeMessageCollection, error) + GetAllBridges() ([]Bridge, error) + GetAllMessages() ([]BridgeMessageCollection, error) + SetAllBridges(bridges []Bridge) error + SetAllMessages(messages []BridgeMessageCollection) error +} + +type DatabaseConfig struct { + Type string `toml:"type"` + Connection string `toml:"connection"` +} + +func (config DatabaseConfig) GetDatabase() (Database, error) { + switch config.Type { + case "postgres": + return newPostgresDatabase(config.Connection) + case "redis": + return newRedisDatabase(config.Connection) + default: + return nil, ErrUnsupportedDatabaseType + } +} + +func startSpinner() *spinner.Spinner { + spin := spinner.New(spinner.CharSets[11], 100*time.Millisecond, spinner.WithWriter(os.Stderr)) + spin.Start() + return spin +} diff --git a/core/errors.go b/core/errors.go new file mode 100644 index 00000000..1a4486eb --- /dev/null +++ b/core/errors.go @@ -0,0 +1,88 @@ +package lightning + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "time" + + "github.com/oklog/ulid/v2" + "github.com/rs/zerolog" +) + +var ( + Log = zerolog.New(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "Jan 02 15:04:05"}).With().Timestamp().Logger().Level(zerolog.InfoLevel) + ErrLogErrorNilError = errors.New("LogError called with nil error. Please provide a valid error") +) + +type LightningError struct { + Disable ReadWriteDisabled + Message string +} + +func (e LightningError) Error() string { + return e.Message +} + +func LogError(err error, message string, extra map[string]any, disable ReadWriteDisabled) LightningError { + if lightningErr, ok := err.(*LightningError); ok { + return *lightningErr + } + + if lightningErr, ok := err.(LightningError); ok { + return lightningErr + } + + if err == nil { + err = ErrLogErrorNilError + } + + if extra == nil { + extra = make(map[string]any) + } + + id := ulid.Make().String() + + Log.Error(). + Str("id", id). + Str("message", message). + Bool("read_disabled", disable.Read). + Bool("write_disabled", disable.Write). + Fields(extra). + Err(err).Msg("[lightning] error") + + fmt.Fprintf(os.Stderr, "%+v\n", err) + + if os.Getenv("LIGHTNING_ERROR_WEBHOOK") != "" { + body, err := json.Marshal(map[string]any{ + "content": fmt.Sprintf("Error: %s", message), + "embeds": []map[string]any{ + { + "title": id, + "color": 15158332, + "fields": []map[string]any{ + {"name": "Channel Status", "value": fmt.Sprintf("Read: %t, Write: %t", disable.Read, disable.Write), "inline": true}, + {"name": "Full Error", "value": fmt.Sprintf("```\n%s\n```", err.Error())}, + }, + "timestamp": time.Now().Format(time.RFC3339), + }, + }, + }) + + if err != nil { + Log.Error().Err(err).Msg("Error marshaling error webhook body") + } else { + resp, err := http.Post(os.Getenv("LIGHTNING_ERROR_WEBHOOK"), "application/json", bytes.NewReader(body)) + if err != nil { + Log.Error().Err(err).Msg("Error sending error webhook request") + } else { + resp.Body.Close() + } + } + } + + return LightningError{disable, "Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\n```\n" + id + "\n\n" + message + "\n```"} +} diff --git a/core/go.mod b/core/go.mod new file mode 100644 index 00000000..a66e3472 --- /dev/null +++ b/core/go.mod @@ -0,0 +1,31 @@ +module github.com/williamhorning/lightning + +go 1.24.4 + +require github.com/jackc/pgx/v5 v5.7.5 + +require github.com/BurntSushi/toml v1.5.0 + +require github.com/briandowns/spinner v1.23.2 + +require github.com/oklog/ulid/v2 v2.1.1 + +require github.com/redis/go-redis/v9 v9.10.0 + +require github.com/rs/zerolog v1.34.0 + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fatih/color v1.7.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/sync v0.13.0 // indirect + golang.org/x/sys v0.32.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/text v0.24.0 // indirect +) diff --git a/core/go.sum b/core/go.sum new file mode 100644 index 00000000..7e502c79 --- /dev/null +++ b/core/go.sum @@ -0,0 +1,65 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= +github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= +github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= +golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/core/messages.go b/core/messages.go new file mode 100644 index 00000000..5290a0db --- /dev/null +++ b/core/messages.go @@ -0,0 +1,89 @@ +package lightning + +import "time" + +var profilePicURL = "https://williamhorning.eu.org/assets/lightning/logo_color.svg" + +type BaseMessage struct { + EventID string + ChannelID string + Plugin string + Time time.Time +} + +type Attachment struct { + URL string + Name string + Size float64 +} + +type Media struct { + URL string + Height int + Width int +} + +type EmbedAuthor struct { + Name string + URL *string + IconURL *string +} + +type EmbedField struct { + Name string + Value string + Inline bool +} + +type EmbedFooter struct { + Text string + IconURL *string +} + +type Embed struct { + Author *EmbedAuthor + Color *int + Description *string + Fields []EmbedField + Footer *EmbedFooter + Image *Media + Thumbnail *Media + Timestamp *int64 + Title *string + URL *string + Video *Media +} + +type MessageAuthor struct { + ID string + Nickname string + Username string + ProfilePicture *string + Color string +} + +type Message struct { + BaseMessage + Author MessageAuthor + Content string + Attachments []Attachment + Embeds []Embed + RepliedTo []string +} + +func CreateMessage(content string) Message { + return Message{ + Content: content, + Author: MessageAuthor{ + ID: "lightning", + Nickname: "lightning", + Username: "lightning", + ProfilePicture: &profilePicURL, + Color: "#487C7E", + }, + BaseMessage: BaseMessage{ + Time: time.Now(), + Plugin: "lightning", + }, + } +} diff --git a/core/plugin.go b/core/plugin.go new file mode 100644 index 00000000..33e00b53 --- /dev/null +++ b/core/plugin.go @@ -0,0 +1,171 @@ +package lightning + +import ( + "errors" + "sync" + "time" +) + +var ( + ErrPluginNotFound = errors.New("plugin not found internally: this is a bug or misconfiguration") + ErrPluginConfigInvalid = errors.New("plugin config is invalid") + + pluginConstructors = make(map[string]PluginConstructor) + pluginRegistry = make(map[string]Plugin) + handledEvents = make(map[string]struct{}) + + constructorsLock sync.RWMutex + pluginRegistryLock sync.RWMutex + + messages []chan Message + edits []chan Message + deletes []chan BaseMessage + commands []chan CommandEvent + mutex sync.RWMutex +) + +type PluginConstructor func(config any) (Plugin, error) + +type Plugin interface { + Name() string + SetupChannel(channel string) (any, error) + SendMessage(message Message, opts *BridgeMessageOptions) ([]string, error) + EditMessage(message Message, ids []string, opts *BridgeMessageOptions) error + DeleteMessage(ids []string, opts *BridgeMessageOptions) error + SetupCommands(command []Command) error + ListenMessages() <-chan Message + ListenEdits() <-chan Message + ListenDeletes() <-chan BaseMessage + ListenCommands() <-chan CommandEvent +} + +func RegisterPluginType(name string, constructor PluginConstructor) { + constructorsLock.Lock() + defer constructorsLock.Unlock() + + Log.Debug().Str("plugin", name).Msg("Registering plugin type") + + if _, exists := pluginConstructors[name]; exists { + Log.Panic().Str("plugin", name).Msg("Plugin type already registered") + } + + pluginConstructors[name] = constructor +} + +func GetPlugin(name string) (Plugin, bool) { + pluginRegistryLock.RLock() + defer pluginRegistryLock.RUnlock() + plugin, exists := pluginRegistry[name] + return plugin, exists +} + +func distributeEvents[T any](ev string, plugin Plugin, source <-chan T, destinations *[]chan T) { + for event := range source { + key := getEventKey(event) + "-" + ev + + time.Sleep(100 * time.Millisecond) + + if _, exists := handledEvents[key]; exists { + Log.Trace().Str("plugin", plugin.Name()).Str("event", key).Msg("Event already handled, skipping") + continue + } + + mutex.RLock() + for _, ch := range *destinations { + select { + case ch <- event: + Log.Trace().Str("plugin", plugin.Name()).Str("event", key).Msg("Event distributed") + default: + Log.Warn().Str("plugin", plugin.Name()).Msg("Skipped event - channel full or closed") + } + } + mutex.RUnlock() + } +} + +func getEventKey(event any) string { + switch e := event.(type) { + case Message: + return e.Plugin + "-" + e.EventID + case BaseMessage: + return e.Plugin + "-" + e.EventID + case CommandEvent: + return e.Plugin + "-" + e.EventID + default: + return "-" + } +} + +func registerPlugin(plugin string, config any) { + pluginRegistryLock.Lock() + defer pluginRegistryLock.Unlock() + + Log.Debug().Str("plugin", plugin).Msg("Registering plugin") + + if _, exists := pluginRegistry[plugin]; exists { + Log.Panic().Str("plugin", plugin).Msg("Plugin already registered") + } + + constructorsLock.RLock() + constructor, exists := pluginConstructors[plugin] + constructorsLock.RUnlock() + + if !exists { + Log.Panic().Str("plugin", plugin).Msg("Plugin type not found") + } + + instance, err := constructor(config) + if err != nil { + Log.Panic().Str("plugin", plugin).Err(err).Msg("Failed to setup plugin") + } + + commands_list := make([]Command, 0, len(commandRegistry)) + for _, cmd := range commandRegistry { + commands_list = append(commands_list, cmd) + } + + if err := instance.SetupCommands(commands_list); err != nil { + Log.Warn().Str("plugin", plugin).Err(err).Msg("Failed to setup commands for plugin") + } + + pluginRegistry[plugin] = instance + go distributeEvents("create", instance, instance.ListenMessages(), &messages) + go distributeEvents("edit", instance, instance.ListenEdits(), &edits) + go distributeEvents("delete", instance, instance.ListenDeletes(), &deletes) + go distributeEvents("command", instance, instance.ListenCommands(), &commands) + + Log.Debug().Str("plugin", plugin).Msg("Plugin registered and listening!") +} + +func setHandled(plugin string, event string, ev string) { + Log.Trace().Str("plugin", plugin).Str("event", event).Str("ev", ev).Msg("Setting handled event") + handledEvents[plugin+"-"+event+"-"+ev] = struct{}{} +} + +func createEventChannel[T any](bufferSize int, channelList *[]chan T) <-chan T { + ch := make(chan T, bufferSize) + mutex.Lock() + *channelList = append(*channelList, ch) + mutex.Unlock() + return ch +} + +func ListenMessages() <-chan Message { + Log.Trace().Msg("Creating message event channel") + return createEventChannel(100, &messages) +} + +func ListenEdits() <-chan Message { + Log.Trace().Msg("Creating edit event channel") + return createEventChannel(100, &edits) +} + +func ListenDeletes() <-chan BaseMessage { + Log.Trace().Msg("Creating delete event channel") + return createEventChannel(100, &deletes) +} + +func ListenCommands() <-chan CommandEvent { + Log.Trace().Msg("Creating command event channel") + return createEventChannel(100, &commands) +} diff --git a/core/postgres.go b/core/postgres.go new file mode 100644 index 00000000..e0a3e5e5 --- /dev/null +++ b/core/postgres.go @@ -0,0 +1,274 @@ +package lightning + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const ( + sqlCreateTables = ` + CREATE TABLE IF NOT EXISTS lightning ( + prop TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + INSERT INTO lightning (prop, value) + VALUES ('db_data_version', '0.8.0') + ON CONFLICT (prop) DO NOTHING; + + CREATE TABLE IF NOT EXISTS bridges ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + channels JSONB NOT NULL, + settings JSONB NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_channels ON bridges USING GIN (channels); + + CREATE TABLE IF NOT EXISTS bridge_messages ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + bridge_id TEXT NOT NULL, + channels JSONB NOT NULL, + messages JSONB NOT NULL, + settings JSONB NOT NULL + );` + + sqlInsertBridge = ` + INSERT INTO bridges (id, name, channels, settings) + VALUES ($1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + channels = EXCLUDED.channels, + settings = EXCLUDED.settings` + + sqlInsertMessage = ` + INSERT INTO bridge_messages (id, name, bridge_id, channels, messages, settings) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + channels = EXCLUDED.channels, + messages = EXCLUDED.messages, + settings = EXCLUDED.settings` +) + +type postgresDatabase struct { + conn *pgxpool.Pool + ctx context.Context +} + +func newPostgresDatabase(connection string) (Database, error) { + ctx := context.Background() + conn, err := pgxpool.New(ctx, connection) + if err != nil { + return nil, err + } + + _, err = conn.Exec(ctx, sqlCreateTables) + if err != nil { + conn.Close() + return nil, err + } + + return &postgresDatabase{conn, ctx}, nil +} + +func (p *postgresDatabase) createBridge(bridge Bridge) error { + channels, err := json.Marshal(bridge.Channels) + if err != nil { + return err + } + + settings, err := json.Marshal(bridge.Settings) + if err != nil { + return err + } + + _, err = p.conn.Exec(p.ctx, sqlInsertBridge, bridge.ID, bridge.Name, channels, settings) + return err +} + +func (p *postgresDatabase) getBridge(id string) (Bridge, error) { + row := p.conn.QueryRow(p.ctx, ` + SELECT id, name, channels, settings + FROM bridges + WHERE id = $1 + `, id) + return handleBridgeRow(row) +} + +func (p *postgresDatabase) getBridgeByChannel(channelID string) (Bridge, error) { + row := p.conn.QueryRow(p.ctx, ` + SELECT * FROM bridges + WHERE channels @> jsonb_build_array(jsonb_build_object('id', $1)) + `, channelID) + return handleBridgeRow(row) +} + +func (p *postgresDatabase) createMessage(message BridgeMessageCollection) error { + channels, err := json.Marshal(message.Channels) + if err != nil { + return err + } + + messages, err := json.Marshal(message.Messages) + if err != nil { + return err + } + + settings, err := json.Marshal(message.Settings) + if err != nil { + return err + } + + _, err = p.conn.Exec(p.ctx, sqlInsertMessage, message.ID, message.Name, message.BridgeID, channels, messages, settings) + return err +} + +func (p *postgresDatabase) deleteMessage(id string) error { + _, err := p.conn.Exec(p.ctx, `DELETE FROM bridge_messages WHERE id = $1`, id) + return err +} + +func (p *postgresDatabase) getMessage(id string) (BridgeMessageCollection, error) { + row := p.conn.QueryRow(p.ctx, ` + SELECT * FROM bridge_messages + WHERE id = $1 OR jsonb_path_exists(messages, '$[*].id ? (@ == $1)', $1) + `, id) + return handleMessageRow(row) +} + +func (p *postgresDatabase) GetAllBridges() ([]Bridge, error) { + defer startSpinner().Stop() + + rows, err := p.conn.Query(p.ctx, `SELECT id, name, channels, settings FROM bridges`) + if err != nil { + return nil, err + } + defer rows.Close() + + var bridges []Bridge + for rows.Next() { + bridge, err := handleBridgeRow(rows) + if err != nil { + return nil, err + } + bridges = append(bridges, bridge) + } + + return bridges, rows.Err() +} + +func (p *postgresDatabase) GetAllMessages() ([]BridgeMessageCollection, error) { + defer startSpinner().Stop() + + rows, err := p.conn.Query(p.ctx, ` + SELECT id, name, bridge_id, channels, messages, settings + FROM bridge_messages + `) + if err != nil { + return nil, err + } + defer rows.Close() + + var messages []BridgeMessageCollection + for rows.Next() { + message, err := handleMessageRow(rows) + if err != nil { + return nil, err + } + messages = append(messages, message) + } + + return messages, rows.Err() +} + +func (p *postgresDatabase) SetAllBridges(bridges []Bridge) error { + defer startSpinner().Stop() + + for _, bridge := range bridges { + if err := p.createBridge(bridge); err != nil { + return err + } + } + return nil +} + +func (p *postgresDatabase) SetAllMessages(messages []BridgeMessageCollection) error { + defer startSpinner().Stop() + + batch := &pgx.Batch{} + for _, message := range messages { + channels, err := json.Marshal(message.Channels) + if err != nil { + return err + } + + messagesJSON, err := json.Marshal(message.Messages) + if err != nil { + return err + } + + settings, err := json.Marshal(message.Settings) + if err != nil { + return err + } + + batch.Queue(sqlInsertMessage, message.ID, message.Name, message.BridgeID, channels, messagesJSON, settings) + } + + br := p.conn.SendBatch(p.ctx, batch) + defer br.Close() + + for range messages { + if _, err := br.Exec(); err != nil { + return err + } + } + return nil +} + +func handleBridgeRow(row pgx.Row) (Bridge, error) { + var bridge Bridge + var channelsJSON, settingsJSON []byte + + if err := row.Scan(&bridge.ID, &bridge.Name, &channelsJSON, &settingsJSON); err != nil { + return Bridge{}, err + } + + if err := json.Unmarshal(channelsJSON, &bridge.Channels); err != nil { + return Bridge{}, err + } + + if err := json.Unmarshal(settingsJSON, &bridge.Settings); err != nil { + return Bridge{}, err + } + + return bridge, nil +} + +func handleMessageRow(row pgx.Row) (BridgeMessageCollection, error) { + var message BridgeMessageCollection + var channelsJSON, messagesJSON, settingsJSON []byte + + if err := row.Scan(&message.ID, &message.Name, &message.BridgeID, &channelsJSON, &messagesJSON, &settingsJSON); err != nil { + return BridgeMessageCollection{}, err + } + + if err := json.Unmarshal(channelsJSON, &message.Channels); err != nil { + return BridgeMessageCollection{}, err + } + + if err := json.Unmarshal(messagesJSON, &message.Messages); err != nil { + return BridgeMessageCollection{}, err + } + + if err := json.Unmarshal(settingsJSON, &message.Settings); err != nil { + return BridgeMessageCollection{}, err + } + + return message, nil +} diff --git a/core/redis.go b/core/redis.go new file mode 100644 index 00000000..2cc1a69e --- /dev/null +++ b/core/redis.go @@ -0,0 +1,362 @@ +package lightning + +import ( + "context" + "encoding/json" + "errors" + "os" + "regexp" + "strings" + + "github.com/redis/go-redis/v9" +) + +var ErrUnsupportedVersion = errors.New("unsupported database version, please upgrade to the latest version of Lightning") + +const ( + keyVersion = "lightning-db-version" + keyBridge = "lightning-bridge-" + keyBChannel = "lightning-bchannel-" + keyMessage = "lightning-message-" + validVersion = "0.8.0" +) + +type redisDatabase struct { + rdb *redis.Client + ctx context.Context + svn bool +} + +func newRedisDatabase(addr string) (Database, error) { + client := redis.NewClient(&redis.Options{Addr: addr}) + ctx := context.Background() + + if _, err := client.Ping(ctx).Result(); err != nil { + return nil, err + } + + version, err := client.Get(ctx, keyVersion).Result() + if err != nil && err != redis.Nil { + return nil, err + } else if err == redis.Nil { + keys, err := client.DBSize(ctx).Result() + if err != nil { + return nil, err + } + + if keys > 0 { + Log.Warn().Msg("Migrating from 0.7.x to 0.8.0, this may take a while") + + self := redisDatabase{client, ctx, true} + + bridges, err := self.GetAllBridges() + + if err != nil { + return nil, err + } + + jsonData, err := json.Marshal(bridges) + + if err != nil { + return nil, err + } + + err = os.WriteFile("lightning-redis-migration.json", jsonData, 0777) + + if err != nil { + return nil, err + } + + Log.Warn().Msg("Do you want to write the migrated data to the database? See lightning-redis-migration.json for the data to be written. [y/N]") + + b := make([]byte, 1) + + _, err = os.Stdin.Read(b) + + if err != nil { + return nil, err + } + + if !(os.Getenv("LIGHTNING_MIGRATE_CONFIG") != "" || b[0] == 'y') { + Log.Warn().Msg("Migration aborted, please run the command again with LIGHTNING_MIGRATE_CONFIG=1 to write the data to the database") + return nil, ErrUnsupportedVersion + } + + Log.Info().Msg("Writing migrated data to the database") + + err = self.SetAllBridges(bridges) + + if err != nil { + return nil, err + } + + Log.Info().Msg("Migration completed successfully") + + self.svn = false + + return self, nil + } + + if _, err := client.Set(ctx, keyVersion, validVersion, 0).Result(); err != nil { + return nil, err + } + version = validVersion + } else if version != validVersion { + return nil, ErrUnsupportedVersion + } + + return redisDatabase{client, ctx, false}, nil +} + +func (r redisDatabase) createBridge(bridge Bridge) error { + bridgeJSON, err := json.Marshal(bridge) + if err != nil { + return err + } + + if val, err := r.rdb.Get(r.ctx, keyBridge+bridge.ID).Result(); err == nil { + var oldBridge Bridge + if err := json.Unmarshal([]byte(val), &oldBridge); err == nil { + for _, channel := range oldBridge.Channels { + if err := r.rdb.Del(r.ctx, keyBChannel+channel.ID).Err(); err != nil { + return err + } + } + } + } + + if err := r.rdb.Set(r.ctx, keyBridge+bridge.ID, bridgeJSON, 0).Err(); err != nil { + return err + } + + for _, channel := range bridge.Channels { + if err := r.rdb.Set(r.ctx, keyBChannel+channel.ID, bridge.ID, 0).Err(); err != nil { + return err + } + } + + return nil +} + +func (r redisDatabase) getBridge(id string) (Bridge, error) { + val, err := r.rdb.Get(r.ctx, keyBridge+id).Result() + if err != nil { + if err == redis.Nil { + return Bridge{}, nil + } + return Bridge{}, err + } + + var bridge Bridge + if err := json.Unmarshal([]byte(val), &bridge); err != nil { + return Bridge{}, err + } + + return bridge, nil +} + +func (r redisDatabase) getBridgeByChannel(channelID string) (Bridge, error) { + val, err := r.rdb.Get(r.ctx, keyBChannel+channelID).Result() + if err != nil { + if err == redis.Nil { + return Bridge{}, nil + } + return Bridge{}, err + } + + return r.getBridge(val) +} + +func (r redisDatabase) createMessage(message BridgeMessageCollection) error { + messageJSON, err := json.Marshal(message) + if err != nil { + return err + } + + if err := r.rdb.Set(r.ctx, keyMessage+message.ID, messageJSON, 0).Err(); err != nil { + return err + } + + for _, msg := range message.Messages { + for _, id := range msg.ID { + if err := r.rdb.Set(r.ctx, keyMessage+id, messageJSON, 0).Err(); err != nil { + return err + } + } + } + + return nil +} + +func (r redisDatabase) deleteMessage(id string) error { + message, err := r.getMessage(id) + if err != nil { + return err + } + + if err := r.rdb.Del(r.ctx, keyMessage+id).Err(); err != nil { + return err + } + + for _, msg := range message.Messages { + for _, msgID := range msg.ID { + if err := r.rdb.Del(r.ctx, keyMessage+msgID).Err(); err != nil { + return err + } + } + } + + return nil +} + +func (r redisDatabase) getMessage(id string) (BridgeMessageCollection, error) { + val, err := r.rdb.Get(r.ctx, keyMessage+id).Result() + if err != nil { + if err == redis.Nil { + return BridgeMessageCollection{}, nil + } + return BridgeMessageCollection{}, err + } + + var message BridgeMessageCollection + if err := json.Unmarshal([]byte(val), &message); err != nil { + return BridgeMessageCollection{}, err + } + + return message, nil +} + +var ulidRegex = regexp.MustCompile("[0-7][0-9A-HJKMNP-TV-Z]{25}") +var uuidRegex = regexp.MustCompile("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") + +func (r redisDatabase) GetAllBridges() ([]Bridge, error) { + defer startSpinner().Stop() + + var cursor uint64 + bridges := make([]Bridge, 0) + + for { + keys, nextCursor, err := r.rdb.Scan(r.ctx, cursor, keyBridge+"*", 10).Result() + if err != nil { + return nil, err + } + + for _, key := range keys { + val, err := r.rdb.Get(r.ctx, key).Result() + if err != nil { + return nil, err + } + var bridge Bridge + + if !r.svn { + if err := json.Unmarshal([]byte(val), &bridge); err != nil { + return nil, err + } + } else { + if !ulidRegex.MatchString(strings.TrimPrefix(key, keyBridge)) && !uuidRegex.MatchString(strings.TrimPrefix(key, keyBridge)) { + continue + } + + var br struct { + ID string `json:"id"` + Channels []BridgeChannel `json:"channels"` + } + if err := json.Unmarshal([]byte(val), &br); err != nil { + return nil, err + } + bridge = Bridge{ + ID: strings.TrimPrefix(key, keyBridge), + Channels: br.Channels, + Name: br.ID, + Settings: BridgeSettings{false}, + } + } + + bridges = append(bridges, bridge) + } + + cursor = nextCursor + if cursor == 0 { + break + } + } + + return bridges, nil +} + +func (r redisDatabase) GetAllMessages() ([]BridgeMessageCollection, error) { + defer startSpinner().Stop() + + var cursor uint64 + messages := make([]BridgeMessageCollection, 0) + + for { + keys, nextCursor, err := r.rdb.Scan(r.ctx, cursor, keyMessage+"*", 10).Result() + if err != nil { + return nil, err + } + + for _, key := range keys { + val, err := r.rdb.Get(r.ctx, key).Result() + if err != nil { + return nil, err + } + + var message BridgeMessageCollection + if err := json.Unmarshal([]byte(val), &message); err != nil { + return nil, err + } + + messages = append(messages, message) + } + + cursor = nextCursor + if cursor == 0 { + break + } + } + + return messages, nil +} + +func (r redisDatabase) SetAllBridges(bridges []Bridge) error { + defer startSpinner().Stop() + + pipe := r.rdb.Pipeline() + for _, bridge := range bridges { + bridgeJSON, err := json.Marshal(bridge) + if err != nil { + return err + } + + pipe.Set(r.ctx, keyBridge+bridge.ID, bridgeJSON, 0) + for _, channel := range bridge.Channels { + pipe.Set(r.ctx, keyBChannel+channel.ID, bridge.ID, 0) + } + } + + _, err := pipe.Exec(r.ctx) + return err +} + +func (r redisDatabase) SetAllMessages(messages []BridgeMessageCollection) error { + defer startSpinner().Stop() + + pipe := r.rdb.Pipeline() + for _, message := range messages { + messageJSON, err := json.Marshal(message) + if err != nil { + return err + } + + pipe.Set(r.ctx, keyMessage+message.ID, messageJSON, 0) + for _, msg := range message.Messages { + for _, id := range msg.ID { + pipe.Set(r.ctx, keyMessage+id, messageJSON, 0) + } + } + } + + _, err := pipe.Exec(r.ctx) + return err +} diff --git a/deno.jsonc b/deno.jsonc deleted file mode 100644 index 90cd4170..00000000 --- a/deno.jsonc +++ /dev/null @@ -1,25 +0,0 @@ -{ - "fmt": { - "lineWidth": 80, - "proseWrap": "always", - "semiColons": true, - "useTabs": true, - "singleQuote": true - }, - "lint": { - "rules": { - "include": [ - "ban-untagged-todo", - "default-param-last", - "eqeqeq", - "no-eval", - "no-external-import", - "triple-slash-reference", - "verbatim-module-syntax" - ] - } - }, - "workspace": ["./packages/*"], - "lock": false, - "unstable": ["net", "temporal"] -} diff --git a/discord/command.go b/discord/command.go new file mode 100644 index 00000000..11aad215 --- /dev/null +++ b/discord/command.go @@ -0,0 +1,105 @@ +package discord + +import ( + "time" + + "github.com/bwmarrin/discordgo" + "github.com/williamhorning/lightning" +) + +func getDiscordCommandOptions(arguments lightning.Command) []*discordgo.ApplicationCommandOption { + options := make([]*discordgo.ApplicationCommandOption, 0) + + for _, arg := range arguments.Arguments { + options = append(options, &discordgo.ApplicationCommandOption{ + Name: arg.Name, + Description: arg.Description, + Required: arg.Required, + Type: discordgo.ApplicationCommandOptionString, + }) + } + + for _, subcommand := range arguments.Subcommands { + options = append(options, &discordgo.ApplicationCommandOption{ + Name: subcommand.Name, + Description: subcommand.Description, + Type: discordgo.ApplicationCommandOptionSubCommand, + Options: getDiscordCommandOptions(subcommand), + }) + } + + return options +} + +func getDiscordCommand(command []lightning.Command) []*discordgo.ApplicationCommand { + commands := make([]*discordgo.ApplicationCommand, len(command)) + + for i, cmd := range command { + commands[i] = &discordgo.ApplicationCommand{ + Name: cmd.Name, + Type: discordgo.ChatApplicationCommand, + Description: cmd.Description, + Options: getDiscordCommandOptions(cmd), + } + } + + return commands +} + +func getLightningCommand(session *discordgo.Session, interaction *discordgo.InteractionCreate) *lightning.CommandEvent { + if interaction.Type != discordgo.InteractionApplicationCommand || interaction.Data.Type() != discordgo.InteractionApplicationCommand { + return nil + } + + args := make(map[string]string) + data := interaction.Data.(discordgo.ApplicationCommandInteractionData) + var subcommand *string = nil + + for _, option := range data.Options { + if option.Type == discordgo.ApplicationCommandOptionSubCommand { + subcommand = &option.Name + + for _, subOption := range option.Options { + if subOption.Type == discordgo.ApplicationCommandOptionString { + args[subOption.Name] = subOption.StringValue() + } + } + } else if option.Type == discordgo.ApplicationCommandOptionString { + args[option.Name] = option.StringValue() + } + } + + timestamp, err := discordgo.SnowflakeTimestamp(interaction.ID) + + if err != nil { + lightning.LogError( + err, + "Failed to parse interaction timestamp", + map[string]any{"interaction_id": interaction.ID}, + lightning.ReadWriteDisabled{Read: false, Write: false}, + ) + + timestamp = time.Now() + } + + return &lightning.CommandEvent{ + CommandOptions: lightning.CommandOptions{ + Arguments: args, + Channel: interaction.ChannelID, + Plugin: "bolt-discord", + Prefix: "/", + Time: timestamp, + }, + Command: data.Name, + Subcommand: subcommand, + EventID: interaction.ID, + Reply: func(message string) error { + return session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: message, + }, + }) + }, + } +} diff --git a/discord/errors.go b/discord/errors.go new file mode 100644 index 00000000..0a528dac --- /dev/null +++ b/discord/errors.go @@ -0,0 +1,44 @@ +package discord + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/williamhorning/lightning" +) + +type ErrorConfig struct { + Code int + Message string + DisableRead bool + DisableWrite bool +} + +var discordErrors = map[int]ErrorConfig{ + 30007: {30007, "too many webhooks in channel, try deleting some", false, true}, + 30058: {30058, "too many webhooks in guild, try deleting some", false, true}, + 50013: {50013, "missing permissions to make webhook", false, true}, + 10003: {10003, "unknown channel, disabling channel", true, true}, + 10015: {10015, "unknown message, disabling channel", false, true}, + 50027: {50027, "invalid webhook token, disabling channel", false, true}, + 0: {0, "unknown RESTError, not disabling channel", false, false}, // Default case +} + +func getError(err error, extra map[string]any, message string) error { + if restErr, ok := err.(*discordgo.RESTError); ok { + if restErr.Message.Code == 10008 { + return nil + } + + e, found := discordErrors[restErr.Message.Code] + + if !found { + e = discordErrors[0] + e.Code = restErr.Message.Code + } + + return lightning.LogError(fmt.Errorf(e.Message+": %w", err), message, extra, lightning.ReadWriteDisabled{Read: e.DisableRead, Write: e.DisableWrite}) + } else { + return lightning.LogError(fmt.Errorf("unknown error: %w", err), message, extra, lightning.ReadWriteDisabled{}) + } +} diff --git a/discord/go.mod b/discord/go.mod new file mode 100644 index 00000000..abcf5647 --- /dev/null +++ b/discord/go.mod @@ -0,0 +1,11 @@ +module github.com/williamhorning/lightning/discord + +go 1.24.4 + +require github.com/bwmarrin/discordgo v0.29.0 + +require ( + github.com/gorilla/websocket v1.5.3 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/sys v0.33.0 // indirect +) diff --git a/discord/go.sum b/discord/go.sum new file mode 100644 index 00000000..4bb2f46a --- /dev/null +++ b/discord/go.sum @@ -0,0 +1,15 @@ +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/discord/incoming.go b/discord/incoming.go new file mode 100644 index 00000000..dc559b3c --- /dev/null +++ b/discord/incoming.go @@ -0,0 +1,230 @@ +package discord + +import ( + "regexp" + "slices" + "strconv" + + "github.com/bwmarrin/discordgo" + "github.com/williamhorning/lightning" +) + +var allowedTypes = []discordgo.MessageType{0, 7, 19, 20, 23} + +func getLightningMessage(s *discordgo.Session, m *discordgo.Message) *lightning.Message { + if !slices.Contains(allowedTypes, m.Type) { + return nil + } + + return &lightning.Message{ + BaseMessage: lightning.BaseMessage{ + EventID: m.ID, + ChannelID: m.ChannelID, + Plugin: "bolt-discord", + Time: m.Timestamp, + }, + Attachments: getLightningAttachments(m.Attachments, m.StickerItems), + Author: getLightningAuthor(s, m), + Content: getLightningContent(s, m), + Embeds: getLightningEmbeds(m.Embeds), + RepliedTo: getLightningReplies(m), + } +} + +func getLightningAttachments(attachments []*discordgo.MessageAttachment, stickers []*discordgo.StickerItem) []lightning.Attachment { + if len(attachments) == 0 { + return nil + } + + result := make([]lightning.Attachment, 0) + for _, a := range attachments { + result = append(result, lightning.Attachment{ + URL: a.URL, + Name: a.Filename, + Size: float64(a.Size) / 1048576, // bytes -> MiB + }) + } + + for _, sticker := range stickers { + stickerURL := "" + + // Handle different sticker formats + switch sticker.FormatType { + case discordgo.StickerFormatTypePNG, discordgo.StickerFormatTypeAPNG: + stickerURL = "https://cdn.discordapp.com/stickers/" + sticker.ID + ".png" + case discordgo.StickerFormatTypeLottie: + stickerURL = "https://cdn.discordapp.com/stickers/" + sticker.ID + ".json" + case discordgo.StickerFormatTypeGIF: + stickerURL = "https://cdn.discordapp.com/stickers/" + sticker.ID + ".gif" + } + + result = append(result, lightning.Attachment{ + URL: stickerURL, + Name: sticker.Name + " (Sticker)", + Size: 0, // size information isn't available for stickers? + }) + } + + return result +} + +func getLightningAuthor(s *discordgo.Session, m *discordgo.Message) lightning.MessageAuthor { + profilePicture := m.Author.AvatarURL("") + author := lightning.MessageAuthor{ + ID: m.Author.ID, + Nickname: m.Author.DisplayName(), + Username: m.Author.Username, + Color: "#5865F2", + ProfilePicture: &profilePicture, + } + + if m.GuildID == "" { + return author + } + + if m.Member == nil { + if member, err := s.State.Member(m.GuildID, m.Author.ID); err == nil { + m.Member = member + } else { + return author + } + } + + m.Member.User = m.Author + author.Nickname = m.Member.DisplayName() + profilePicture = m.Member.AvatarURL("") + + return author +} + +var ( + userMention = regexp.MustCompile(`<@!?(\d+)>`) + channelMention = regexp.MustCompile(`<#(\d+)>`) + roleMention = regexp.MustCompile(`<@&(\d+)>`) + emojiMention = regexp.MustCompile(``) +) + +func getLightningContent(s *discordgo.Session, m *discordgo.Message) string { + content := userMention.ReplaceAllStringFunc(m.Content, func(match string) string { + userID := userMention.FindStringSubmatch(match)[1] + + if m.GuildID != "" { + if member, err := s.State.Member(m.GuildID, userID); err == nil { + return "@" + member.DisplayName() + } + } + + if user, err := s.User(userID); err == nil { + return "@" + user.DisplayName() + } + return "@" + match + }) + + content = channelMention.ReplaceAllStringFunc(content, func(match string) string { + channelID := channelMention.FindStringSubmatch(match)[1] + if channel, err := s.State.Channel(channelID); err == nil { + return "#" + channel.Name + } + return "#" + match + }) + + content = roleMention.ReplaceAllStringFunc(content, func(match string) string { + roleID := roleMention.FindStringSubmatch(match)[1] + if guild, err := s.State.Guild(m.GuildID); err == nil { + for _, role := range guild.Roles { + if role.ID == roleID { + return "@" + role.Name + } + } + } + return "@&" + match + }) + + return emojiMention.ReplaceAllStringFunc(content, func(match string) string { + return emojiMention.FindStringSubmatch(match)[0] + }) +} + +func getLightningEmbeds(embeds []*discordgo.MessageEmbed) []lightning.Embed { + if len(embeds) == 0 { + return nil + } + + result := make([]lightning.Embed, 0, len(embeds)) + for _, e := range embeds { + embed := lightning.Embed{} + + if e.Title != "" { + embed.Title = &e.Title + } + + if e.Timestamp != "" { + if timestamp, err := strconv.ParseInt(e.Timestamp, 10, 64); err == nil { + embed.Timestamp = ×tamp + } + } + + if e.URL != "" { + embed.URL = &e.URL + } + + if e.Color != 0 { + embed.Color = &e.Color + } + + if e.Description != "" { + embed.Description = &e.Description + } + + if e.Footer != nil { + footer := &lightning.EmbedFooter{Text: e.Footer.Text} + if e.Footer.IconURL != "" { + footer.IconURL = &e.Footer.IconURL + } + embed.Footer = footer + } + + if e.Image != nil && e.Image.URL != "" { + embed.Image = &lightning.Media{URL: e.Image.URL} + } + + if e.Thumbnail != nil && e.Thumbnail.URL != "" { + embed.Thumbnail = &lightning.Media{URL: e.Thumbnail.URL} + } + + if e.Author != nil { + author := &lightning.EmbedAuthor{Name: e.Author.Name} + if e.Author.URL != "" { + author.URL = &e.Author.URL + } + if e.Author.IconURL != "" { + author.IconURL = &e.Author.IconURL + } + embed.Author = author + } + + if len(e.Fields) > 0 { + fields := make([]lightning.EmbedField, len(e.Fields)) + for i, field := range e.Fields { + fields[i] = lightning.EmbedField{ + Name: field.Name, + Value: field.Value, + Inline: field.Inline, + } + } + embed.Fields = fields + } + + result = append(result, embed) + } + + return result +} + +func getLightningReplies(m *discordgo.Message) []string { + if m.MessageReference == nil || m.MessageReference.MessageID == "" || + m.MessageReference.Type != discordgo.MessageReferenceTypeDefault { + return []string{} + } + return []string{m.MessageReference.MessageID} +} diff --git a/discord/outgoing.go b/discord/outgoing.go new file mode 100644 index 00000000..145480af --- /dev/null +++ b/discord/outgoing.go @@ -0,0 +1,306 @@ +package discord + +import ( + "context" + "errors" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/bwmarrin/discordgo" + "github.com/williamhorning/lightning" +) + +const ( + maxContentLength = 2000 + maxButtonReplies = 5 + defaultMaxFileMiB = float64(10) + boostTier2FileMax = 50 + boostTier3FileMax = 100 + fileDownloadTimeout = 10 * time.Second +) + +type discordOutgoingMessage struct { + AllowedMentions *discordgo.MessageAllowedMentions `json:"allowed_mentions,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Components []discordgo.MessageComponent `json:"components"` + Content string `json:"content,omitempty"` + Embeds []*discordgo.MessageEmbed `json:"embeds,omitempty"` + Files []*discordgo.File `json:"-"` + Reference *discordgo.MessageReference `json:"message_reference,omitempty"` + Username string `json:"username,omitempty"` +} + +func (o *discordOutgoingMessage) Webhook() *discordgo.WebhookParams { + return &discordgo.WebhookParams{ + AllowedMentions: o.AllowedMentions, + AvatarURL: o.AvatarURL, + Components: o.Components, + Content: o.Content, + Embeds: o.Embeds, + Files: o.Files, + Username: o.Username, + } +} + +func (o *discordOutgoingMessage) WebhookEdit() *discordgo.WebhookEdit { + return &discordgo.WebhookEdit{ + AllowedMentions: o.AllowedMentions, + Content: &o.Content, + Components: &o.Components, + Embeds: &o.Embeds, + Files: o.Files, + } +} + +func (o *discordOutgoingMessage) Message() *discordgo.MessageSend { + return &discordgo.MessageSend{ + AllowedMentions: o.AllowedMentions, + Components: o.Components, + Content: o.Content, + Embeds: o.Embeds, + Files: o.Files, + Reference: o.Reference, + } +} + +func getWebhookFromChannel(channel lightning.BridgeChannel) (id string, token string, err error) { + webhookData, ok := channel.Data.(map[string]any) + if !ok { + return "", "", lightning.LogError( + errors.New("invalid webhook data for Discord channel"), + "Failed to use webhook for Discord", + map[string]any{"channel": channel.ID}, + lightning.ReadWriteDisabled{Read: false, Write: true}, + ) + } + + id, _ = webhookData["id"].(string) + token, _ = webhookData["token"].(string) + return id, token, nil +} + +func getOutgoingMessage(session *discordgo.Session, message lightning.Message, opts *lightning.BridgeMessageOptions, button bool) *discordOutgoingMessage { + msg := discordOutgoingMessage{ + AllowedMentions: getOutgoingMention(opts), + AvatarURL: getOutgoingProfile(message), + Components: getOutgoingComponents(session, message, button), + Content: getOutgoingContent(message), + Embeds: getOutgoingEmbeds(message), + Files: getOutgoingFiles(session, message), + Reference: getOutgoingReference(message, button), + Username: message.Author.Nickname, + } + + if msg.Content == "" && len(msg.Embeds) == 0 && len(msg.Files) == 0 { + msg.Content = "_ _" + } + + return &msg +} + +func getOutgoingMention(opts *lightning.BridgeMessageOptions) *discordgo.MessageAllowedMentions { + if opts == nil || opts.Settings.AllowEveryone { + return nil + } + return &discordgo.MessageAllowedMentions{ + Parse: []discordgo.AllowedMentionType{ + discordgo.AllowedMentionTypeRoles, + discordgo.AllowedMentionTypeUsers, + }, + } +} + +func getOutgoingProfile(message lightning.Message) string { + if message.Author.ProfilePicture != nil { + return *message.Author.ProfilePicture + } + return discordgo.EndpointDefaultUserAvatar(1) +} + +func getOutgoingContent(message lightning.Message) string { + if len(message.Content) > maxContentLength { + return string([]rune(message.Content)[:maxContentLength-3]) + "..." + } + return message.Content +} + +func getOutgoingComponents(session *discordgo.Session, message lightning.Message, button bool) []discordgo.MessageComponent { + if !button || message.RepliedTo == nil || len(message.RepliedTo) == 0 { + return nil + } + + var buttons []discordgo.MessageComponent + + for i, replyID := range message.RepliedTo { + if i >= maxButtonReplies || replyID == "" { + continue + } + + replyMsg, err := session.State.Message(message.ChannelID, replyID) + if err != nil { + continue + } + + channel, err := session.State.Channel(replyMsg.ChannelID) + if err != nil { + continue + } + + displayName := replyMsg.Author.DisplayName() + if displayName == "" { + displayName = "unknown user" + } + + btn := discordgo.Button{ + Label: "reply to " + displayName, + Style: discordgo.LinkButton, + URL: "https://discord.com/channels/" + channel.GuildID + "/" + + replyMsg.ChannelID + "/" + replyMsg.ID, + } + buttons = append(buttons, btn) + } + + if len(buttons) == 0 { + return nil + } + + return []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: buttons}, + } +} + +func getOutgoingEmbeds(message lightning.Message) []*discordgo.MessageEmbed { + if len(message.Embeds) == 0 { + return nil + } + + var embeds []*discordgo.MessageEmbed + + for _, embed := range message.Embeds { + discordEmbed := &discordgo.MessageEmbed{} + + if embed.Title != nil { + discordEmbed.Title = *embed.Title + } + if embed.Timestamp != nil { + discordEmbed.Timestamp = strconv.FormatInt(*embed.Timestamp, 10) + } + if embed.URL != nil { + discordEmbed.URL = *embed.URL + } + if embed.Color != nil { + discordEmbed.Color = *embed.Color + } + + if embed.Footer != nil { + footer := &discordgo.MessageEmbedFooter{Text: embed.Footer.Text} + if embed.Footer.IconURL != nil { + footer.IconURL = *embed.Footer.IconURL + } + discordEmbed.Footer = footer + } + + if embed.Image != nil && embed.Image.URL != "" { + discordEmbed.Image = &discordgo.MessageEmbedImage{URL: embed.Image.URL} + } + + if embed.Thumbnail != nil && embed.Thumbnail.URL != "" { + discordEmbed.Thumbnail = &discordgo.MessageEmbedThumbnail{URL: embed.Thumbnail.URL} + } + + if embed.Author != nil { + author := &discordgo.MessageEmbedAuthor{Name: embed.Author.Name} + if embed.Author.URL != nil { + author.URL = *embed.Author.URL + } + if embed.Author.IconURL != nil { + author.IconURL = *embed.Author.IconURL + } + discordEmbed.Author = author + } + + embeds = append(embeds, discordEmbed) + } + + return embeds +} + +func getOutgoingFiles(session *discordgo.Session, message lightning.Message) []*discordgo.File { + if len(message.Attachments) == 0 { + return nil + } + + maxFileSizeMiB := defaultMaxFileMiB + + if ch, err := session.State.Channel(message.ChannelID); err == nil && ch.GuildID != "" { + if guild, err := session.State.Guild(ch.GuildID); err == nil { + switch guild.PremiumTier { + case 2: + maxFileSizeMiB = boostTier2FileMax + case 3: + maxFileSizeMiB = boostTier3FileMax + } + } + } + + var files []*discordgo.File + + for _, attachment := range message.Attachments { + if attachment.Size > maxFileSizeMiB { + continue + } + + name := attachment.Name + if name == "" { + parts := strings.Split(attachment.URL, "/") + name = parts[len(parts)-1] + } + + ctx, cancel := context.WithTimeout(context.Background(), fileDownloadTimeout) + req, err := http.NewRequestWithContext(ctx, "GET", attachment.URL, nil) + if err != nil { + cancel() + continue + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + cancel() + continue + } + + files = append(files, &discordgo.File{ + Name: name, + ContentType: resp.Header.Get("Content-Type"), + Reader: &cancelableReadCloser{resp.Body, cancel}, + }) + } + + return files +} + +type cancelableReadCloser struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (c *cancelableReadCloser) Close() error { + err := c.ReadCloser.Close() + c.cancel() + return err +} + +func getOutgoingReference(message lightning.Message, button bool) *discordgo.MessageReference { + if button || message.RepliedTo == nil || len(message.RepliedTo) == 0 { + return nil + } + + return &discordgo.MessageReference{ + Type: discordgo.MessageReferenceTypeDefault, + MessageID: message.RepliedTo[0], + ChannelID: message.ChannelID, + } +} diff --git a/discord/plugin.go b/discord/plugin.go new file mode 100644 index 00000000..a84685a6 --- /dev/null +++ b/discord/plugin.go @@ -0,0 +1,218 @@ +package discord + +import ( + "github.com/bwmarrin/discordgo" + "github.com/williamhorning/lightning" +) + +func init() { + lightning.RegisterPluginType("discord", newDiscordPlugin) +} + +func newDiscordPlugin(config any) (lightning.Plugin, error) { + if cfg, ok := config.(map[string]any); !ok { + return nil, lightning.LogError( + lightning.ErrPluginConfigInvalid, + "Invalid config for Discord plugin", + nil, + lightning.ReadWriteDisabled{}, + ) + } else { + token, ok := cfg["token"].(string) + if !ok || token == "" { + return nil, lightning.LogError( + lightning.ErrPluginConfigInvalid, + "Missing or invalid token in Discord plugin config", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + discord, err := discordgo.New("Bot " + token) + + discord.Identify.Intents = 16813601 + discord.StateEnabled = true + + if err != nil { + return nil, lightning.LogError( + err, + "Failed to create Discord session", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + err = discord.Open() + + if err != nil { + return nil, lightning.LogError( + err, + "Failed to open Discord session", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + app, err := discord.Application("@me") + lightning.Log.Info().Str("plugin", "discord").Str("username", discord.State.User.Username).Int("servers", len(discord.State.Guilds)).Msg("ready!") + if err == nil { + lightning.Log.Info().Str("plugin", "discord").Msg("invite me at https://discord.com/oauth2/authorize?client_id=" + app.ID + "%s&scope=bot&permissions=8") + } + + return &discordPlugin{cfg, discord}, nil + } +} + +type discordPlugin struct { + config map[string]any + discord *discordgo.Session +} + +func (p *discordPlugin) Name() string { + return "bolt-discord" +} + +func (p *discordPlugin) SetupChannel(channel string) (any, error) { + wh, err := p.discord.WebhookCreate(channel, "Lightning Bridge", "") + + if err != nil { + return nil, getError(err, map[string]any{"channel": channel}, "Failed to create webhook for channel") + } + + return map[string]string{"id": wh.ID, "token": wh.Token}, nil +} + +func (p *discordPlugin) SendMessage(message lightning.Message, opts *lightning.BridgeMessageOptions) ([]string, error) { + msg := getOutgoingMessage(p.discord, message, opts, opts != nil) + + if opts != nil { + id, token, err := getWebhookFromChannel(opts.Channel) + + if err != nil { + return nil, err + } + + res, err := p.discord.WebhookExecute(id, token, true, msg.Webhook()) + + if err != nil { + return nil, getError(err, map[string]any{"msg": msg}, "Failed to send message to Discord via webhook") + } + + return []string{res.ID}, nil + } else { + if res, err := p.discord.ChannelMessageSendComplex(message.ChannelID, msg.Message()); err == nil { + return []string{res.ID}, nil + } else { + return nil, getError(err, map[string]any{"msg": msg}, "Failed to send message to Discord") + } + } +} + +func (p *discordPlugin) EditMessage(message lightning.Message, ids []string, opts *lightning.BridgeMessageOptions) error { + id, token, err := getWebhookFromChannel(opts.Channel) + + if err != nil { + return err + } + + if _, err = p.discord.WebhookMessageEdit(id, token, ids[0], getOutgoingMessage(p.discord, message, opts, true).WebhookEdit()); err == nil { + return nil + } else if err = getError(err, map[string]any{"ids": ids, "msg": message}, "Failed to edit message in Discord via webhook"); err != nil { + return err + } else { + return nil + } +} + +func (p *discordPlugin) DeleteMessage(ids []string, opts *lightning.BridgeMessageOptions) error { + if err := p.discord.ChannelMessagesBulkDelete(opts.Channel.ID, ids); err != nil { + if err = getError(err, map[string]any{"ids": ids}, "Failed to delete messages in Discord"); err != nil { + return err + } + } + + return nil +} + +func (p *discordPlugin) SetupCommands(command []lightning.Command) error { + if p.config["slash_commands"] != true { + return nil + } + + app, err := p.discord.Application("@me") + + if err != nil { + return lightning.LogError( + err, + "Failed to get application info for Discord commands", + nil, + lightning.ReadWriteDisabled{Read: false, Write: false}, + ) + } + + _, err = p.discord.ApplicationCommandBulkOverwrite(app.ID, "", getDiscordCommand(command)) + + if err != nil { + return lightning.LogError( + err, + "Failed to setup commands in Discord", + map[string]any{"commands": command}, + lightning.ReadWriteDisabled{Read: false, Write: false}, + ) + } + + return nil +} + +func (p *discordPlugin) ListenMessages() <-chan lightning.Message { + ch := make(chan lightning.Message, 100) + + p.discord.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + if msg := getLightningMessage(s, m.Message); msg != nil { + ch <- *msg + } + }) + + return ch +} + +func (p *discordPlugin) ListenEdits() <-chan lightning.Message { + ch := make(chan lightning.Message, 100) + + p.discord.AddHandler(func(s *discordgo.Session, m *discordgo.MessageUpdate) { + if msg := getLightningMessage(s, m.Message); msg != nil { + ch <- *msg + } + }) + + return ch +} + +func (p *discordPlugin) ListenDeletes() <-chan lightning.BaseMessage { + ch := make(chan lightning.BaseMessage, 100) + + p.discord.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) { + ch <- lightning.BaseMessage{ + EventID: m.ID, + ChannelID: m.ChannelID, + Plugin: p.Name(), + Time: m.Timestamp, + } + }) + + return ch +} + +func (p *discordPlugin) ListenCommands() <-chan lightning.CommandEvent { + ch := make(chan lightning.CommandEvent, 100) + + p.discord.AddHandler(func(s *discordgo.Session, m *discordgo.InteractionCreate) { + cmd := getLightningCommand(s, m) + + if cmd != nil { + ch <- *cmd + } + }) + + return ch +} diff --git a/go.work b/go.work new file mode 100644 index 00000000..7150ce92 --- /dev/null +++ b/go.work @@ -0,0 +1,10 @@ +go 1.24.4 + +use ( + ./cli + ./core + ./discord + ./guilded + ./revolt + ./telegram +) \ No newline at end of file diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 00000000..29fc1d12 --- /dev/null +++ b/go.work.sum @@ -0,0 +1,21 @@ +github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32 h1:+YzI72wzNTcaPUDVcSxeYQdHfvEk8mPGZh/yTk5kkRg= +github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32/go.mod h1:BSzsfjlE0wakLw2/U1FtO8rdVt+Z+4VyoGo/YcGD9QQ= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/guilded/api.go b/guilded/api.go new file mode 100644 index 00000000..34bf8ddf --- /dev/null +++ b/guilded/api.go @@ -0,0 +1,258 @@ +package guilded + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +func guildedMakeRequest(token, method, endpoint string, body *io.Reader) (*http.Response, error) { + url := "https://www.guilded.gg/api/v1" + endpoint + + var req *http.Request + var err error + if body != nil { + req, err = http.NewRequest(method, url, *body) + } else { + req, err = http.NewRequest(method, url, nil) + } + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "guildapi/0.0.5") + req.Header.Set("x-guilded-bot-api-use-official-markdown", "true") + + return http.DefaultClient.Do(req) +} + +type guildedCloseInfo struct { + Code int + Reason string +} + +type guildedSocketManager struct { + conn *websocket.Conn + Alive bool + LastMessageID string + ReconnectCount int + Token string + listeners map[string][]func(...any) + mu sync.RWMutex + done chan struct{} +} + +func guildedNewSocketManager(token string) *guildedSocketManager { + return &guildedSocketManager{ + Token: token, + listeners: make(map[string][]func(...any)), + done: make(chan struct{}), + } +} + +func (s *guildedSocketManager) On(event string, handler any) { + s.mu.Lock() + defer s.mu.Unlock() + + // Convert handler to func(...any) + var fn func(...any) + switch h := handler.(type) { + case func(...any): + fn = h + case func(): + fn = func(...any) { h() } + case func(any): + fn = func(args ...any) { + if len(args) > 0 { + h(args[0]) + } + } + default: + fn = func(args ...any) { + if len(args) > 0 { + if f, ok := handler.(func(any)); ok { + f(args[0]) + } + } + } + } + s.listeners[event] = append(s.listeners[event], fn) +} + +func (s *guildedSocketManager) Emit(event string, args ...any) { + s.mu.RLock() + handlers := s.listeners[event] + s.mu.RUnlock() + + for _, handler := range handlers { + handler(args...) + } +} + +func (s *guildedSocketManager) Connect() error { + header := http.Header{} + header.Set("Authorization", "Bearer "+s.Token) + header.Set("User-Agent", "guildapi/0.0.5") + header.Set("x-guilded-bot-api-use-official-markdown", "true") + + dialer := websocket.Dialer{HandshakeTimeout: 10 * time.Second} + + var err error + s.conn, _, err = dialer.Dial("wss://www.guilded.gg/websocket/v1", header) + if err != nil { + return err + } + s.Alive = true + s.done = make(chan struct{}) + + go s.readMessages() + return nil +} + +func (s *guildedSocketManager) readMessages() { + defer func() { + s.conn.Close() + s.Alive = false + close(s.done) + }() + + for { + _, message, err := s.conn.ReadMessage() + if err != nil { + s.Emit("debug", fmt.Sprintf("Error reading from socket: %v", err)) + closeInfo := guildedCloseInfo{Code: websocket.CloseNormalClosure} + if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + if ce, ok := err.(*websocket.CloseError); ok { + closeInfo.Code = ce.Code + closeInfo.Reason = ce.Text + } + } + s.Emit("close", closeInfo) + s.handleReconnect() + return + } + + s.Emit("debug", fmt.Sprintf("received packet: %s", message)) + s.handleMessage(message) + } +} + +func (s *guildedSocketManager) handleMessage(message []byte) { + var data guildedSocketEventEnvelope + if err := json.Unmarshal(message, &data); err != nil { + s.Emit("debug", "received invalid packet") + return + } + + if data.S != nil { + s.LastMessageID = *data.S + } + + switch data.Op { + case guildedSocketOPSuccess: + s.handleEvent(data) + case guildedSocketOPWelcome: + s.handleWelcome(data) + case guildedSocketOPResume: + s.Emit("debug", "received resume packet") + s.LastMessageID = "" + case guildedSocketOPError: + s.handleError(data) + case guildedSocketOPPing: + s.handlePing() + default: + s.Emit("debug", "received unknown opcode") + } +} + +func (s *guildedSocketManager) handleEvent(data guildedSocketEventEnvelope) { + if data.T == nil { + return + } + eventType := *data.T + eventJSON, _ := json.Marshal(data.D) + + var evt any + var err error + switch eventType { + case "ChatMessageCreated": + evt = &guildedChatMessageCreated{} + case "ChatMessageUpdated": + evt = &guildedChatMessageUpdated{} + case "ChatMessageDeleted": + evt = &guildedChatMessageDeleted{} + default: + s.Emit(eventType, data.D) + return + } + + if err = json.Unmarshal(eventJSON, evt); err != nil { + s.Emit("debug", fmt.Sprintf("Failed to parse %s: %v", eventType, err)) + return + } + s.Emit(eventType, evt) +} + +func (s *guildedSocketManager) handleWelcome(data guildedSocketEventEnvelope) { + var welcome guildedWelcomeMessage + welcomeJSON, _ := json.Marshal(data.D) + if err := json.Unmarshal(welcomeJSON, &welcome); err != nil { + s.Emit("debug", "received invalid welcome packet") + return + } + s.Emit("ready", &welcome) +} + +func (s *guildedSocketManager) handleError(data guildedSocketEventEnvelope) { + s.Emit("debug", "received error packet") + var errorData struct { + Message string `json:"message"` + } + errJSON, _ := json.Marshal(data.D) + if err := json.Unmarshal(errJSON, &errorData); err == nil { + s.Emit("error", errors.New(errorData.Message), data) + } + s.LastMessageID = "" + s.conn.Close() +} + +func (s *guildedSocketManager) handlePing() { + s.Emit("debug", "received ping packet, sending pong") + pong := map[string]any{"op": guildedSocketOPPong} + if pongData, err := json.Marshal(pong); err == nil { + s.conn.WriteMessage(websocket.TextMessage, pongData) + } +} + +func (s *guildedSocketManager) handleReconnect() { + s.Emit("debug", "disconnecting due to close") + s.Emit("debug", "reconnecting to Guilded") + s.Emit("reconnect") + s.ReconnectCount++ + + backoff := time.Duration(math.Min(float64(s.ReconnectCount*s.ReconnectCount), 30)) * time.Second + time.Sleep(backoff) + s.Connect() +} + +func (s *guildedSocketManager) Close() error { + if s.conn == nil { + return nil + } + + err := s.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) + if err != nil { + return err + } + <-s.done + return nil +} diff --git a/guilded/cache.go b/guilded/cache.go new file mode 100644 index 00000000..8e9de4bb --- /dev/null +++ b/guilded/cache.go @@ -0,0 +1,114 @@ +package guilded + +import ( + "sync" + "time" + + "github.com/williamhorning/lightning" +) + +const ( + assetCacheTTL = 24 * time.Hour + defaultCacheTTL = 30 * time.Second +) + +type cacheItem[T any] struct { + Value T + ExpiresAt time.Time +} + +type expiringCache[K comparable, V any] struct { + items map[K]cacheItem[V] + mu sync.RWMutex + ttl time.Duration +} + +func newExpiringCache[K comparable, V any](ttl time.Duration) *expiringCache[K, V] { + cache := &expiringCache[K, V]{ + items: make(map[K]cacheItem[V]), + ttl: ttl, + } + + go cache.startCleanupRoutine() + return cache +} + +func (c *expiringCache[K, V]) Get(key K) (V, bool) { + c.mu.RLock() + item, exists := c.items[key] + c.mu.RUnlock() + + if !exists { + var zero V + return zero, false + } + + if time.Now().After(item.ExpiresAt) { + c.mu.Lock() + delete(c.items, key) + c.mu.Unlock() + var zero V + return zero, false + } + + return item.Value, true +} + +func (c *expiringCache[K, V]) Set(key K, value V) { + c.mu.Lock() + defer c.mu.Unlock() + + c.items[key] = cacheItem[V]{ + Value: value, + ExpiresAt: time.Now().Add(c.ttl), + } +} + +func (c *expiringCache[K, V]) Delete(key K) { + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.items, key) +} + +func (c *expiringCache[K, V]) Cleanup() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for key, item := range c.items { + if now.After(item.ExpiresAt) { + delete(c.items, key) + } + } +} + +func (c *expiringCache[K, V]) startCleanupRoutine() { + cleanupInterval := c.ttl / 2 + if cleanupInterval < time.Second { + cleanupInterval = time.Second + } + + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + + for range ticker.C { + c.Cleanup() + } +} + +type guildedCache struct { + Assets *expiringCache[string, lightning.Attachment] + Members *expiringCache[string, guildedServerMember] + Webhooks *expiringCache[string, guildedWebhook] +} + +func newGuildedCache() *guildedCache { + return &guildedCache{ + Assets: newExpiringCache[string, lightning.Attachment](assetCacheTTL), + Members: newExpiringCache[string, guildedServerMember](defaultCacheTTL), + Webhooks: newExpiringCache[string, guildedWebhook](defaultCacheTTL), + } +} + +var cache = newGuildedCache() diff --git a/guilded/go.mod b/guilded/go.mod new file mode 100644 index 00000000..16215e27 --- /dev/null +++ b/guilded/go.mod @@ -0,0 +1,5 @@ +module github.com/williamhorning/lightning/guilded + +go 1.24.4 + +require github.com/gorilla/websocket v1.5.3 diff --git a/guilded/go.sum b/guilded/go.sum new file mode 100644 index 00000000..25a9fc4b --- /dev/null +++ b/guilded/go.sum @@ -0,0 +1,2 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/guilded/guilded.gen.go b/guilded/guilded.gen.go new file mode 100644 index 00000000..661cc3b9 --- /dev/null +++ b/guilded/guilded.gen.go @@ -0,0 +1,183 @@ +package guilded + +import ( + "time" +) + +type guildedChatEmbedAuthor struct { + IconUrl *string `json:"icon_url,omitempty"` + Name *string `json:"name,omitempty"` + Url *string `json:"url,omitempty"` +} + +type guildedChatEmbed struct { + Author *guildedChatEmbedAuthor `json:"author,omitempty"` + Color *int `json:"color,omitempty"` + Description *string `json:"description,omitempty"` + Fields *[]struct { + Inline *bool `json:"inline,omitempty"` + Name string `json:"name"` + Value string `json:"value"` + } `json:"fields,omitempty"` + Footer *struct { + IconUrl *string `json:"icon_url,omitempty"` + Text string `json:"text"` + } `json:"footer,omitempty"` + Image *struct { + Url *string `json:"url,omitempty"` + } `json:"image,omitempty"` + Thumbnail *struct { + Url *string `json:"url,omitempty"` + } `json:"thumbnail,omitempty"` + Timestamp *time.Time `json:"timestamp,omitempty"` + Title *string `json:"title,omitempty"` + Url *string `json:"url,omitempty"` +} + +type guildedChatMessage struct { + ChannelId string `json:"channelId"` + Content *string `json:"content,omitempty"` + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy"` + CreatedByWebhookId *string `json:"createdByWebhookId,omitempty"` + Embeds *[]guildedChatEmbed `json:"embeds,omitempty"` + GroupId *string `json:"groupId,omitempty"` + HiddenLinkPreviewUrls *[]string `json:"hiddenLinkPreviewUrls,omitempty"` + Id string `json:"id"` + IsPinned *bool `json:"isPinned,omitempty"` + IsPrivate *bool `json:"isPrivate,omitempty"` + IsSilent *bool `json:"isSilent,omitempty"` + Mentions *guildedMentions `json:"mentions,omitempty"` + ReplyMessageIds *[]string `json:"replyMessageIds,omitempty"` + ServerId *string `json:"serverId,omitempty"` + Type string `json:"type"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` +} + +type guildedChatMessageCreated struct { + Message guildedChatMessage `json:"message"` + ServerId string `json:"serverId"` +} + +type guildedChatMessageDeleted struct { + DeletedAt time.Time `json:"deletedAt"` + Message guildedChatMessage `json:"message"` + ServerId string `json:"serverId"` +} + +type guildedChatMessageUpdated struct { + Message guildedChatMessage `json:"message"` + ServerId string `json:"serverId"` +} + +type guildedMentions struct { + Channels *[]struct { + Id string `json:"id"` + } `json:"channels,omitempty"` + + Everyone *bool `json:"everyone,omitempty"` + + Here *bool `json:"here,omitempty"` + + Roles *[]struct { + Id int `json:"id"` + } `json:"roles,omitempty"` + + Users *[]struct { + Id string `json:"id"` + } `json:"users,omitempty"` +} + +type guildedServerChannel struct { + ArchivedAt *time.Time `json:"archivedAt,omitempty"` + ArchivedBy *string `json:"archivedBy,omitempty"` + CategoryId *int `json:"categoryId,omitempty"` + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy"` + GroupId string `json:"groupId"` + Id string `json:"id"` + MessageId *string `json:"messageId,omitempty"` + Name string `json:"name"` + ParentId *string `json:"parentId,omitempty"` + Priority *int `json:"priority,omitempty"` + RootId *string `json:"rootId,omitempty"` + ServerId string `json:"serverId"` + Topic *string `json:"topic,omitempty"` + Type string `json:"type"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` + Visibility *string `json:"visibility"` +} + +type guildedServerMember struct { + IsOwner *bool `json:"isOwner,omitempty"` + JoinedAt time.Time `json:"joinedAt"` + Nickname *string `json:"nickname,omitempty"` + RoleIds []int `json:"roleIds"` + User guildedUser `json:"user"` +} + +type guildedSocketEventEnvelope struct { + D *map[string]interface{} `json:"d,omitempty"` + Op guildedSocketEventEnvelopeOp `json:"op"` + S *string `json:"s,omitempty"` + T *string `json:"t,omitempty"` +} + +const ( + guildedSocketOPSuccess guildedSocketEventEnvelopeOp = 0 + guildedSocketOPWelcome guildedSocketEventEnvelopeOp = 1 + guildedSocketOPResume guildedSocketEventEnvelopeOp = 2 + guildedSocketOPError guildedSocketEventEnvelopeOp = 8 + guildedSocketOPPing guildedSocketEventEnvelopeOp = 9 + guildedSocketOPPong guildedSocketEventEnvelopeOp = 10 +) + +type guildedSocketEventEnvelopeOp int + +type guildedUrlSignature struct { + RetryAfter *int `json:"retryAfter,omitempty"` + Signature *string `json:"signature,omitempty"` + Url string `json:"url"` +} + +type guildedUser struct { + Avatar *string `json:"avatar,omitempty"` + Banner *string `json:"banner,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Id string `json:"id"` + Name string `json:"name"` + Status *guildedUserStatus `json:"status,omitempty"` + Type *string `json:"type,omitempty"` +} + +type guildedUserStatus struct { + Content *string `json:"content,omitempty"` + EmoteId int `json:"emoteId"` +} + +type guildedWebhook struct { + Avatar *string `json:"avatar,omitempty"` + ChannelId string `json:"channelId"` + CreatedAt time.Time `json:"createdAt"` + CreatedBy string `json:"createdBy"` + DeletedAt *time.Time `json:"deletedAt,omitempty"` + Id string `json:"id"` + Name string `json:"name"` + ServerId string `json:"serverId"` + Token *string `json:"token,omitempty"` +} + +type guildedWelcomeMessage struct { + BotId string `json:"botId"` + HeartbeatIntervalMs int `json:"heartbeatIntervalMs"` + LastMessageId string `json:"lastMessageId"` + User struct { + Avatar *string `json:"avatar,omitempty"` + Banner *string `json:"banner,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Id string `json:"id"` + Name string `json:"name"` + Status *guildedUserStatus `json:"status,omitempty"` + Type *string `json:"type,omitempty"` + } `json:"user"` +} diff --git a/guilded/incoming.go b/guilded/incoming.go new file mode 100644 index 00000000..0aa24642 --- /dev/null +++ b/guilded/incoming.go @@ -0,0 +1,351 @@ +package guilded + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "path" + "regexp" + "strconv" + "strings" + + "github.com/williamhorning/lightning" +) + +var attachmentRegex = regexp.MustCompile(`!\[.*?\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)`) +var emojiRegex = regexp.MustCompile(`<(:\w+:)\d+>`) + +func extractURLFromMarkdown(markdown string) string { + startIdx := strings.LastIndex(markdown, "(") + endIdx := strings.LastIndex(markdown, ")") + + if startIdx != -1 && endIdx != -1 && startIdx < endIdx { + return markdown[startIdx+1 : endIdx] + } + return "" +} + +type signatureResponse struct { + URLSignatures []guildedUrlSignature `json:"urlSignatures"` +} + +func getIncomingAttachments(token string, markdownURLs []string) []lightning.Attachment { + var attachments []lightning.Attachment + + for _, markdownURL := range markdownURLs { + url := extractURLFromMarkdown(markdownURL) + if url == "" { + continue + } + + if cached, exists := cache.Assets.Get(url); exists { + attachments = append(attachments, cached) + continue + } + + requestBody := map[string][]string{ + "urls": {url}, + } + jsonBody, err := json.Marshal(requestBody) + if err != nil { + continue + } + + var bodyReader io.Reader = bytes.NewReader(jsonBody) + resp, err := guildedMakeRequest(token, http.MethodPost, "/url-signatures", &bodyReader) + if err != nil { + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + continue + } + + var signatureResp signatureResponse + if err := json.Unmarshal(body, &signatureResp); err != nil { + continue + } + + if len(signatureResp.URLSignatures) == 0 { + continue + } + + signed := signatureResp.URLSignatures[0] + if signed.RetryAfter == nil || *signed.RetryAfter > 0 || signed.Signature == nil { + continue + } + + filename := path.Base(*signed.Signature) + if idx := strings.Index(filename, "?"); idx > 0 { + filename = filename[:idx] + } + if filename == "" { + filename = "unknown" + } + + headResp, err := http.Head(*signed.Signature) + if err != nil { + continue + } + + contentLength := headResp.Header.Get("Content-Length") + size := 0.0 + if contentLength != "" { + if sizeBytes, err := strconv.ParseInt(contentLength, 10, 64); err == nil { + size = float64(sizeBytes) / 1048576 + } + } + headResp.Body.Close() + + attachment := lightning.Attachment{ + Name: filename, + URL: *signed.Signature, + Size: size, + } + + cache.Assets.Set(url, attachment) + + attachments = append(attachments, attachment) + } + + return attachments +} + +func getIncomingMessage(token string, msg *guildedChatMessage) *lightning.Message { + if msg.ServerId == nil { + return nil + } + + timestamp := msg.CreatedAt + + if msg.UpdatedAt != nil { + timestamp = *msg.UpdatedAt + } + + content := "" + + if msg.Content != nil { + content = *msg.Content + } + + urls := attachmentRegex.FindAllString(content, -1) + + content = attachmentRegex.ReplaceAllString(content, "") + content = emojiRegex.ReplaceAllString(content, "$1") + + var repliedTo []string + if msg.ReplyMessageIds != nil { + repliedTo = *msg.ReplyMessageIds + } + + return &lightning.Message{ + BaseMessage: lightning.BaseMessage{ + EventID: msg.Id, + ChannelID: msg.ChannelId, + Plugin: "bolt-guilded", + Time: timestamp, + }, + Attachments: getIncomingAttachments(token, urls), + Author: getIncomingAuthor(token, msg), + Content: content, + Embeds: getIncomingEmbeds(msg.Embeds), + RepliedTo: repliedTo, + } +} + +func getIncomingAuthor(token string, msg *guildedChatMessage) lightning.MessageAuthor { + defaultAuthor := lightning.MessageAuthor{ + Nickname: "Guilded User", + Username: "GuildedUser", + ID: msg.CreatedBy, + } + + if defaultAuthor.ID == "" { + defaultAuthor.ID = msg.CreatedBy + } + + try := func() (lightning.MessageAuthor, error) { + if msg.CreatedByWebhookId == nil { + key := *msg.ServerId + "/" + msg.CreatedBy + + if cached, exists := cache.Members.Get(key); exists { + return lightning.MessageAuthor{ + Nickname: getNickname(cached), + Username: cached.User.Name, + ID: msg.CreatedBy, + ProfilePicture: cached.User.Avatar, + }, nil + } + + endpoint := fmt.Sprintf("/servers/%s/members/%s", *msg.ServerId, msg.CreatedBy) + resp, err := guildedMakeRequest(token, http.MethodGet, endpoint, nil) + if err != nil { + return lightning.MessageAuthor{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return lightning.MessageAuthor{}, err + } + + var memberResp struct { + Member guildedServerMember `json:"member"` + } + + if err := json.Unmarshal(body, &memberResp); err != nil { + return lightning.MessageAuthor{}, err + } + + cache.Members.Set(key, memberResp.Member) + + author := memberResp.Member + return lightning.MessageAuthor{ + Nickname: getNickname(author), + Username: author.User.Name, + ID: msg.CreatedBy, + ProfilePicture: author.User.Avatar, + }, nil + + } else { + key := *msg.ServerId + "/" + *msg.CreatedByWebhookId + + if cached, exists := cache.Webhooks.Get(key); exists { + return lightning.MessageAuthor{ + Nickname: cached.Name, + Username: cached.Name, + ID: cached.Id, + ProfilePicture: cached.Avatar, + }, nil + } + + endpoint := fmt.Sprintf("/servers/%s/webhooks/%s", *msg.ServerId, *msg.CreatedByWebhookId) + resp, err := guildedMakeRequest(token, http.MethodGet, endpoint, nil) + if err != nil { + return lightning.MessageAuthor{}, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return lightning.MessageAuthor{}, err + } + + var webhookResp struct { + Webhook guildedWebhook `json:"webhook"` + } + + if err := json.Unmarshal(body, &webhookResp); err != nil { + return lightning.MessageAuthor{}, err + } + + cache.Webhooks.Set(key, webhookResp.Webhook) + + webhook := webhookResp.Webhook + return lightning.MessageAuthor{ + Nickname: webhook.Name, + Username: webhook.Name, + ID: webhook.Id, + ProfilePicture: webhook.Avatar, + }, nil + } + } + + author, err := try() + if err != nil { + return defaultAuthor + } + return author +} + +func getNickname(member guildedServerMember) string { + if member.Nickname != nil { + return *member.Nickname + } + return member.User.Name +} + +func getIncomingEmbeds(embeds *[]guildedChatEmbed) []lightning.Embed { + if embeds == nil { + return nil + } + + incomingEmbeds := make([]lightning.Embed, 0) + + for _, embed := range *embeds { + var author *lightning.EmbedAuthor + if embed.Author != nil { + author = &lightning.EmbedAuthor{ + Name: "", + URL: embed.Author.Url, + } + if embed.Author.Name != nil { + author.Name = *embed.Author.Name + } + if embed.Author.IconUrl != nil { + author.IconURL = embed.Author.IconUrl + } + } + + var footer *lightning.EmbedFooter + if embed.Footer != nil { + footer = &lightning.EmbedFooter{ + Text: embed.Footer.Text, + } + if embed.Footer.IconUrl != nil { + footer.IconURL = embed.Footer.IconUrl + } + } + + var image *lightning.Media + if embed.Image != nil && embed.Image.Url != nil { + image = &lightning.Media{ + URL: *embed.Image.Url, + } + } + + var thumbnail *lightning.Media + if embed.Thumbnail != nil && embed.Thumbnail.Url != nil { + thumbnail = &lightning.Media{ + URL: *embed.Thumbnail.Url, + } + } + + var fields []lightning.EmbedField + if embed.Fields != nil { + fields = make([]lightning.EmbedField, len(*embed.Fields)) + for i, field := range *embed.Fields { + fields[i] = lightning.EmbedField{ + Name: field.Name, + Value: field.Value, + Inline: field.Inline != nil && *field.Inline, + } + } + } + + var timestamp *int64 + if embed.Timestamp != nil { + ts := embed.Timestamp.Unix() + timestamp = &ts + } + + incomingEmbeds = append(incomingEmbeds, lightning.Embed{ + Title: embed.Title, + Description: embed.Description, + URL: embed.Url, + Color: embed.Color, + Author: author, + Fields: fields, + Footer: footer, + Image: image, + Thumbnail: thumbnail, + Timestamp: timestamp, + }) + } + + return incomingEmbeds +} diff --git a/guilded/outgoing.go b/guilded/outgoing.go new file mode 100644 index 00000000..fa4341a9 --- /dev/null +++ b/guilded/outgoing.go @@ -0,0 +1,189 @@ +package guilded + +import ( + "encoding/json" + "io" + "regexp" + "strings" + "time" + + "github.com/williamhorning/lightning" +) + +type guildedPayload struct { + Content string `json:"content"` + Embeds []guildedChatEmbed `json:"embeds,omitempty"` + ReplyMessageIds []string `json:"replyMessageIds,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Username string `json:"username,omitempty"` +} + +var usernameRegex = regexp.MustCompile(`(?ms)^[a-zA-Z0-9_ ()-]{1,25}$`) + +func getValidUsername(author lightning.MessageAuthor) string { + if usernameRegex.MatchString(author.Nickname) { + return author.Nickname + } else if usernameRegex.MatchString(author.Username) { + return author.Username + } else { + return author.ID + } +} + +func getOutgoingMessage(message lightning.Message, opts *lightning.BridgeMessageOptions, token string) *guildedPayload { + base := &guildedPayload{ + Content: message.Content, + AvatarURL: *message.Author.ProfilePicture, + Username: getValidUsername(message.Author), + ReplyMessageIds: message.RepliedTo, + Embeds: getOutgoingEmbeds(message, opts != nil, token), + } + + if len(base.Content) <= 0 && len(base.Embeds) <= 0 { + base.Content = "\u2800" + } + + if opts != nil && !opts.Settings.AllowEveryone { + base.Content = strings.ReplaceAll(base.Content, "@everyone", "@\u2800everyone") + base.Content = strings.ReplaceAll(base.Content, "@here", "@\u2800here") + } + + return base +} +func getOutgoingEmbeds(message lightning.Message, incl bool, token string) []guildedChatEmbed { + guildedEmbeds := make([]guildedChatEmbed, 0) + for _, embed := range message.Embeds { + var image *struct { + Url *string `json:"url,omitempty"` + } + if embed.Image != nil && embed.Image.URL != "" { + image = &struct { + Url *string `json:"url,omitempty"` + }{ + Url: &embed.Image.URL, + } + } + + var thumbnail *struct { + Url *string `json:"url,omitempty"` + } + if embed.Thumbnail != nil && embed.Thumbnail.URL != "" { + thumbnail = &struct { + Url *string `json:"url,omitempty"` + }{ + Url: &embed.Thumbnail.URL, + } + } + var timestamp *time.Time + if embed.Timestamp != nil { + t := time.Unix(*embed.Timestamp, 0) + timestamp = &t + } + + var footer *struct { + IconUrl *string `json:"icon_url,omitempty"` + Text string `json:"text"` + } + if embed.Footer != nil { + footer = &struct { + IconUrl *string `json:"icon_url,omitempty"` + Text string `json:"text"` + }{ + Text: embed.Footer.Text, + } + if embed.Footer.IconURL != nil { + footer.IconUrl = embed.Footer.IconURL + } + } + + var author *guildedChatEmbedAuthor + if embed.Author != nil { + author = &guildedChatEmbedAuthor{ + Name: &embed.Author.Name, + Url: embed.Author.URL, + } + if embed.Author.IconURL != nil { + author.IconUrl = embed.Author.IconURL + } + } + + var fields *[]struct { + Inline *bool `json:"inline,omitempty"` + Name string `json:"name"` + Value string `json:"value"` + } + + if len(embed.Fields) > 0 { + convertedFields := make([]struct { + Inline *bool `json:"inline,omitempty"` + Name string `json:"name"` + Value string `json:"value"` + }, len(embed.Fields)) + + for i, field := range embed.Fields { + convertedFields[i] = struct { + Inline *bool `json:"inline,omitempty"` + Name string `json:"name"` + Value string `json:"value"` + }{ + Inline: &field.Inline, + Name: field.Name, + Value: field.Value, + } + } + + fields = &convertedFields + } + + guildedEmbeds = append(guildedEmbeds, guildedChatEmbed{ + Title: embed.Title, + Description: embed.Description, + Color: embed.Color, + Image: image, + Thumbnail: thumbnail, + Footer: footer, + Author: author, + Fields: fields, + Timestamp: timestamp, + Url: embed.URL, + }) + } + + if len(message.Attachments) > 0 { + title := "Attachments" + attachmentStr := "" + + for _, attachment := range message.Attachments { + attachmentStr += "[" + attachment.Name + "](" + attachment.URL + ")\n" + } + + guildedEmbeds = append(guildedEmbeds, guildedChatEmbed{ + Title: &title, + Description: &attachmentStr, + }) + } + if incl && len(message.RepliedTo) > 0 { + resp, err := guildedMakeRequest(token, "GET", "/channels/"+message.ChannelID+"/messages/"+message.RepliedTo[0], nil) + + if err == nil { + var messageResp struct { + Message guildedChatMessage `json:"message"` + } + + body, err := io.ReadAll(resp.Body) + if err == nil && json.Unmarshal(body, &messageResp) == nil { + author := getIncomingAuthor(token, &messageResp.Message) + title := "reply to " + author.Nickname + guildedEmbeds = append(guildedEmbeds, guildedChatEmbed{ + Author: &guildedChatEmbedAuthor{ + Name: &title, + IconUrl: author.ProfilePicture, + }, + Description: messageResp.Message.Content, + }) + } + } + } + + return guildedEmbeds +} diff --git a/guilded/plugin.go b/guilded/plugin.go new file mode 100644 index 00000000..dcbd438e --- /dev/null +++ b/guilded/plugin.go @@ -0,0 +1,112 @@ +package guilded + +import ( + "github.com/williamhorning/lightning" +) + +func init() { + lightning.RegisterPluginType("guilded", newGuildedPlugin) +} + +func newGuildedPlugin(config any) (lightning.Plugin, error) { + if cfg, ok := config.(map[string]any); !ok { + return nil, lightning.LogError( + lightning.ErrPluginConfigInvalid, + "Invalid config for Guilded plugin", + nil, + lightning.ReadWriteDisabled{}, + ) + } else { + token := cfg["token"].(string) + + socket := guildedNewSocketManager(token) + + if err := socket.Connect(); err != nil { + return nil, lightning.LogError( + err, + "Failed to connect to Guilded socket", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + socket.On("ready", func(msg *guildedWelcomeMessage) { + lightning.Log.Info().Str("plugin", "guilded").Str("username", msg.User.Name).Msg("ready!") + }) + + return &guildedPlugin{token, socket}, nil + } +} + +type guildedPlugin struct { + token string + socket *guildedSocketManager +} + +func (p *guildedPlugin) Name() string { + return "bolt-guilded" +} + +func (p *guildedPlugin) EditMessage(message lightning.Message, ids []string, opts *lightning.BridgeMessageOptions) error { + return nil +} + +func (p *guildedPlugin) DeleteMessage(ids []string, opts *lightning.BridgeMessageOptions) error { + for _, id := range ids { + _, err := guildedMakeRequest(p.token, "DELETE", "/channels/"+opts.Channel.ID+"/messages/"+id, nil) + + if err != nil { + return lightning.LogError( + err, + "Failed to delete message", + map[string]any{"messageID": id, "channelID": opts.Channel.ID}, + lightning.ReadWriteDisabled{}, + ) + } + } + + return nil +} + +func (p *guildedPlugin) SetupCommands(command []lightning.Command) error { + return nil +} + +func (p *guildedPlugin) ListenMessages() <-chan lightning.Message { + ch := make(chan lightning.Message) + + p.socket.On("ChatMessageCreated", func(msg *guildedChatMessageCreated) { + ch <- *getIncomingMessage(p.token, &msg.Message) + }) + + return ch +} + +func (p *guildedPlugin) ListenEdits() <-chan lightning.Message { + ch := make(chan lightning.Message) + + p.socket.On("ChatMessageUpdated", func(msg *guildedChatMessageUpdated) { + ch <- *getIncomingMessage(p.token, &msg.Message) + }) + + return ch +} + +func (p *guildedPlugin) ListenDeletes() <-chan lightning.BaseMessage { + ch := make(chan lightning.BaseMessage) + + p.socket.On("ChatMessageDeleted", func(msg *guildedChatMessageDeleted) { + ch <- lightning.BaseMessage{ + EventID: msg.Message.Id, + ChannelID: msg.Message.ChannelId, + Plugin: "bolt-guilded", + Time: msg.DeletedAt, + } + }) + + return ch +} + +func (p *guildedPlugin) ListenCommands() <-chan lightning.CommandEvent { + return make(chan lightning.CommandEvent) +} diff --git a/guilded/send.go b/guilded/send.go new file mode 100644 index 00000000..b632a044 --- /dev/null +++ b/guilded/send.go @@ -0,0 +1,126 @@ +package guilded + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + + "github.com/williamhorning/lightning" +) + +func (p *guildedPlugin) SendMessage(message lightning.Message, opts *lightning.BridgeMessageOptions) ([]string, error) { + msg := getOutgoingMessage(message, opts, p.token) + jsonMsg, err := json.Marshal(msg) + if err != nil { + return nil, lightning.LogError(err, "Failed to marshal outgoing message", + map[string]any{"message": message}, lightning.ReadWriteDisabled{}) + } + + reader := bytes.NewReader(jsonMsg) + + if opts == nil { + return p.sendMessage(message, reader) + } + return p.sendWebhookMessage(message, opts, reader) +} + +func (p *guildedPlugin) sendMessage(message lightning.Message, reader io.Reader) ([]string, error) { + resp, err := guildedMakeRequest(p.token, "POST", "/channels/"+message.ChannelID+"/messages", &reader) + if err != nil { + return nil, lightning.LogError(err, "Failed to send message", + map[string]any{"message": message, "channelID": message.ChannelID}, lightning.ReadWriteDisabled{}) + } + defer resp.Body.Close() + + if err := p.checkStatusCode(resp, message.ChannelID); err != nil { + return nil, err + } + + var msg struct { + Message guildedChatMessage `json:"message"` + } + if err := p.readResponse(resp, &msg, message.ChannelID); err != nil { + return nil, err + } + + return []string{msg.Message.Id}, nil +} + +func (p *guildedPlugin) sendWebhookMessage(message lightning.Message, opts *lightning.BridgeMessageOptions, reader io.Reader) ([]string, error) { + webhookData, ok := opts.Channel.Data.(map[string]any) + if !ok { + return nil, lightning.LogError(errors.New("invalid webhook data for Guilded channel"), + "Failed to use webhook for Guilded", map[string]any{"channel": opts.Channel.ID}, + lightning.ReadWriteDisabled{Read: false, Write: true}) + } + + id, _ := webhookData["id"].(string) + token, _ := webhookData["token"].(string) + url := fmt.Sprintf("https://media.guilded.gg/webhooks/%s/%s", id, token) + + req, err := http.NewRequest("POST", url, reader) + if err != nil { + return nil, lightning.LogError(err, "Failed to send message request", + map[string]any{"message": message, "channelID": opts.Channel.ID}, lightning.ReadWriteDisabled{}) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, lightning.LogError(err, "Failed to send message", + map[string]any{"message": message, "channelID": opts.Channel.ID}, lightning.ReadWriteDisabled{}) + } + defer resp.Body.Close() + + if err := p.checkStatusCode(resp, opts.Channel.ID); err != nil { + return nil, err + } + + var response struct { + ID string `json:"id"` + } + if err := p.readResponse(resp, &response, opts.Channel.ID); err != nil { + return nil, err + } + + return []string{response.ID}, nil +} + +func (p *guildedPlugin) checkStatusCode(resp *http.Response, channelID string) error { + if resp.StatusCode == 200 || resp.StatusCode == 201 { + return nil + } + + var errMsg string + switch resp.StatusCode { + case 404: + errMsg = "not found! this might be a Guilded problem" + case 403: + errMsg = "the bot lacks some permissions, please check them" + default: + errMsg = "unexpected status code: " + resp.Status + } + + return lightning.LogError(errors.New(errMsg), "Failed to send message", + map[string]any{"channelID": channelID}, + lightning.ReadWriteDisabled{Read: false, Write: true}) +} + +func (p *guildedPlugin) readResponse(resp *http.Response, target any, channelID string) error { + bodyBytes, err := io.ReadAll(resp.Body) + + if err != nil { + return lightning.LogError(err, "Failed to read response body", + map[string]any{"channelID": channelID}, lightning.ReadWriteDisabled{}) + } + + if err := json.Unmarshal(bodyBytes, target); err != nil { + return lightning.LogError(err, "Failed to unmarshal response", + map[string]any{"channelID": channelID}, lightning.ReadWriteDisabled{}) + } + + return nil +} diff --git a/guilded/setup.go b/guilded/setup.go new file mode 100644 index 00000000..61b4f2d3 --- /dev/null +++ b/guilded/setup.go @@ -0,0 +1,96 @@ +package guilded + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "strconv" + + "github.com/williamhorning/lightning" +) + +func (p *guildedPlugin) SetupChannel(channel string) (any, error) { + resp, err := guildedMakeRequest(p.token, "GET", "/channels/"+channel, nil) + + if err != nil { + return nil, lightning.LogError(err, "Failed to get channel for setup", nil, lightning.ReadWriteDisabled{}) + } + + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, lightning.LogError(err, "Failed to read response body", nil, lightning.ReadWriteDisabled{}) + } + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + return nil, lightning.LogError( + errors.New("Failed to get channel: "+strconv.Itoa(resp.StatusCode)), + "Failed to get channel for setup", + map[string]any{"status": resp.StatusCode, "body": string(bodyBytes)}, + lightning.ReadWriteDisabled{}, + ) + } + + var channelData struct { + Channel guildedServerChannel `json:"channel"` + } + + if err := json.Unmarshal(bodyBytes, &channelData); err != nil { + return nil, lightning.LogError( + err, + "Failed to unmarshal channel data", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + body, _ := json.Marshal(map[string]string{"channelId": channel, "name": "Lightning Bridges"}) + var reader io.Reader = bytes.NewReader(body) + + resp, err = guildedMakeRequest(p.token, "POST", "/servers/"+channelData.Channel.ServerId+"/webhooks", &reader) + + if err != nil { + return nil, lightning.LogError(err, "Failed to create webhook for channel", nil, lightning.ReadWriteDisabled{}) + } + + if resp.StatusCode != 200 && resp.StatusCode != 201 { + extra := map[string]any{"status": resp.StatusCode, "body": string(body)} + defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err == nil { + extra["resp"] = string(bodyBytes) + } + + return nil, lightning.LogError( + errors.New("Failed to create webhook: "+strconv.Itoa(resp.StatusCode)), + "Failed to create webhook for channel", + extra, + lightning.ReadWriteDisabled{}, + ) + } + + var webhookData struct { + Webhook guildedWebhook `json:"webhook"` + } + + if err := json.NewDecoder(resp.Body).Decode(&webhookData); err != nil { + return nil, lightning.LogError( + err, + "Failed to decode webhook data", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + if webhookData.Webhook.Token == nil { + return nil, lightning.LogError( + errors.New("webhook token is nil"), + "Failed to create webhook for channel", + map[string]any{"channelID": channel, "webhook": webhookData}, + lightning.ReadWriteDisabled{}, + ) + } + + return map[string]string{"id": webhookData.Webhook.Id, "token": *webhookData.Webhook.Token}, nil +} diff --git a/packages/discord/README.md b/packages/discord/README.md deleted file mode 100644 index e9b54b40..00000000 --- a/packages/discord/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# @lightning/discord - -[![JSR](https://jsr.io/badges/@lightning/discord)](https://jsr.io/@lightning/discord) - -@lightning/discord adds support for Discord to Lightning. To use it, you'll -first need to create a Discord bot at the -[Discord Developer Portal](https://discord.com/developers/applications). After -you do that, you will need to add the following to your `lightning.toml` file: - -```toml -[[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.5" -config.token = "your_bot_token" -``` diff --git a/packages/discord/deno.json b/packages/discord/deno.json deleted file mode 100644 index 3a7835fe..00000000 --- a/packages/discord/deno.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@lightning/discord", - "version": "0.8.0-alpha.5", - "license": "MIT", - "exports": "./src/mod.ts", - "imports": { - "@discordjs/core": "npm:@discordjs/core@^2.1.0", - "@discordjs/rest": "npm:@discordjs/rest@^2.5.0", - "@discordjs/ws": "npm:@discordjs/ws@^2.0.2", - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5" - } -} diff --git a/packages/discord/src/commands.ts b/packages/discord/src/commands.ts deleted file mode 100644 index c137b2cf..00000000 --- a/packages/discord/src/commands.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { API } from '@discordjs/core'; -import type { command } from '@lightning/lightning'; - -export async function setup_commands( - api: API, - commands: command[], -): Promise { - const format_arguments = (args: command['arguments']) => - args?.map((arg) => ({ - name: arg.name, - description: arg.description, - type: 3, - required: arg.required, - })) ?? []; - - const format_subcommands = (subcommands: command['subcommands']) => - subcommands?.map((subcommand) => ({ - name: subcommand.name, - description: subcommand.description, - type: 1, - options: format_arguments(subcommand.arguments), - })) ?? []; - - await api.applicationCommands.bulkOverwriteGlobalCommands( - (await api.applications.getCurrent()).id, - commands.map((cmd) => ({ - name: cmd.name, - type: 1, - description: cmd.description, - options: [ - ...format_arguments(cmd.arguments), - ...format_subcommands(cmd.subcommands), - ], - })), - ); -} diff --git a/packages/discord/src/errors.ts b/packages/discord/src/errors.ts deleted file mode 100644 index 7db60b48..00000000 --- a/packages/discord/src/errors.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { DiscordAPIError } from '@discordjs/rest'; -import { log_error } from '@lightning/lightning'; - -const errors = [ - [30007, 'Too many webhooks in channel, try deleting some', false, true], - [30058, 'Too many webhooks in guild, try deleting some', false, true], - [50013, 'Missing permissions to make webhook', false, true], - [10003, 'Unknown channel, disabling channel', true, true], - [10015, 'Unknown message, disabling channel', false, true], - [50027, 'Invalid webhook token, disabling channel', false, true], - [0, 'Unknown DiscordAPIError, not disabling channel', false, false], -] as const; - -export function handle_error( - err: unknown, - channel: string, - edit?: boolean, -) { - if (err instanceof DiscordAPIError) { - if (edit && err.code === 10008) return []; // message already deleted or non-existent - - const extra = { channel, code: err.code }; - const [, message, read, write] = errors.find((e) => e[0] === err.code) ?? - errors[errors.length - 1]; - - log_error(err, { disable: { read, write }, message, extra }); - } else { - log_error(err, { - message: `unknown discord error`, - extra: { channel }, - }); - } -} diff --git a/packages/discord/src/incoming.ts b/packages/discord/src/incoming.ts deleted file mode 100644 index 658afd61..00000000 --- a/packages/discord/src/incoming.ts +++ /dev/null @@ -1,227 +0,0 @@ -import type { - API, - APIInteraction, - APIStickerItem, - GatewayMessageDeleteDispatchData, - GatewayMessageUpdateDispatchData, - ToEventProps, -} from '@discordjs/core'; -import type { - attachment, - create_command, - deleted_message, - message, -} from '@lightning/lightning'; -import { get_outgoing_message } from './outgoing.ts'; - -export function get_deleted_message( - data: GatewayMessageDeleteDispatchData, -): deleted_message { - return { - message_id: data.id, - channel_id: data.channel_id, - plugin: 'bolt-discord', - timestamp: Temporal.Now.instant(), - }; -} - -async function fetch_author(api: API, data: GatewayMessageUpdateDispatchData) { - let profile = data.author.avatar - ? `https://cdn.discordapp.com/avatars/${data.author.id}/${data.author.avatar}.png` - : `https://cdn.discordapp.com/embed/avatars/${ - Number(BigInt(data.author.id) >> 22n) % 6 - }.png`; - - let username = data.author.global_name ?? data.author.username; - - if (data.guild_id) { - try { - const member = data.member ?? await api.guilds.getMember( - data.guild_id, - data.author.id, - ); - - if (member.avatar) { - profile = - `https://cdn.discordapp.com/guilds/${data.guild_id}/users/${data.author.id}/avatars/${member.avatar}.png`; - } - - if (member.nick) username = member.nick; - } catch { - // safe to ignore, we already have a name and avatar - } - } - - return { profile, username }; -} - -async function fetch_stickers( - stickers: APIStickerItem[], -): Promise { - return (await Promise.allSettled(stickers.map(async (sticker) => { - let type; - - if (sticker.format_type === 1) type = 'png'; - if (sticker.format_type === 2) type = 'apng'; - if (sticker.format_type === 3) type = 'lottie'; - if (sticker.format_type === 4) type = 'gif'; - - const url = `https://media.discordapp.net/stickers/${sticker.id}.${type}`; - - const request = await fetch(url, { method: 'HEAD' }); - - return { - file: url, - alt: sticker.name, - name: `${sticker.name}.${type}`, - size: parseInt(request.headers.get('Content-Length') ?? '0', 10) / - 1048576, - }; - }))).flatMap((i) => i.status === 'fulfilled' ? i.value : []); -} - -async function handle_content( - content: string, - api: API, - guild_id?: string, -): Promise { - // handle user mentions - for (const match of content.matchAll(/<@!?(\d+)>/g)) { - try { - const user = guild_id - ? await api.guilds.getMember(guild_id, match[1]) - : await api.users.get(match[1]); - content = content.replace( - match[0], - `@${ - 'nickname' in user - ? user.nickname - : 'username' in user - ? user.global_name ?? user.username - : user.user.global_name ?? user.user.username - }`, - ); - } catch { - // safe to ignore, we already have content here as a fallback - } - } - - // handle channel mentions - for (const match of content.matchAll(/<#(\d+)>/g)) { - try { - content = content.replace( - match[0], - `#${(await api.channels.get(match[1])).name}`, - ); - } catch { - // safe to ignore, we already have content here as a fallback - } - } - - // handle role mentions - if (guild_id) { - for (const match of content.matchAll(/<@&(\d+)>/g)) { - try { - content = content.replace( - match[0], - `@${(await api.guilds.getRole(guild_id!, match[1])).name}`, - ); - } catch { - // safe to ignore, we already have content here as a fallback - } - } - } - - // handle emojis - return content.replaceAll(/<(a?)?(:\w+:)\d+>/g, (_, _2, emoji) => emoji); -} - -export async function get_incoming_message( - { api, data }: { api: API; data: GatewayMessageUpdateDispatchData }, -): Promise { - // normal messages, replies, and user joins - if (![0, 7, 19, 20, 23].includes(data.type)) return; - - return { - attachments: [ - ...data.attachments?.map( - (i: typeof data['attachments'][0]) => { - return { - file: i.url, - alt: i.description, - name: i.filename, - size: i.size / 1048576, // bytes -> MiB - }; - }, - ), - ...data.sticker_items ? await fetch_stickers(data.sticker_items) : [], - ], - author: { - rawname: data.author.username, - id: data.author.id, - color: '#5865F2', - ...await fetch_author(api, data), - }, - channel_id: data.channel_id, - content: data.type === 7 - ? '*joined on discord*' - : (data.flags ?? 0) & 128 - ? '*loading...*' - : await handle_content(data.content, api, data.guild_id), - embeds: data.embeds.map((i) => ({ - ...i, - timestamp: i.timestamp ? Number(i.timestamp) : undefined, - video: i.video ? { ...i.video, url: i.video.url ?? '' } : undefined, - })), - message_id: data.id, - plugin: 'bolt-discord', - reply_id: data.message_reference && - data.message_reference.type === 0 - ? [data.message_reference.message_id!] - : undefined, - timestamp: Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(data.id) >> 22n) + 1420070400000, - ), - }; -} - -export function get_incoming_command( - interaction: ToEventProps, -): create_command | undefined { - if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; - - const args: Record = {}; - let subcommand: string | undefined; - - for (const option of interaction.data.data.options ?? []) { - if (option.type === 1) { - subcommand = option.name; - for (const suboption of option.options ?? []) { - if (suboption.type === 3) { - args[suboption.name] = suboption.value; - } - } - } else if (option.type === 3) { - args[option.name] = option.value; - } - } - - return { - args, - channel_id: interaction.data.channel.id, - command: interaction.data.data.name, - message_id: interaction.data.id, - prefix: '/', - plugin: 'bolt-discord', - reply: async (msg) => - await interaction.api.interactions.reply( - interaction.data.id, - interaction.data.token, - await get_outgoing_message(msg, interaction.api, false, false), - ), - subcommand, - timestamp: Temporal.Instant.fromEpochMilliseconds( - Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, - ), - }; -} diff --git a/packages/discord/src/mod.ts b/packages/discord/src/mod.ts deleted file mode 100644 index 6db73fe1..00000000 --- a/packages/discord/src/mod.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Client, GatewayDispatchEvents } from '@discordjs/core'; -import { REST, type RESTOptions } from '@discordjs/rest'; -import { WebSocketManager } from '@discordjs/ws'; -import { - type bridge_message_opts, - type command, - type config_schema, - type deleted_message, - type message, - plugin, -} from '@lightning/lightning'; -import { setup_commands } from './commands.ts'; -import { handle_error } from './errors.ts'; -import { - get_deleted_message, - get_incoming_command, - get_incoming_message, -} from './incoming.ts'; -import { get_outgoing_message } from './outgoing.ts'; - -/** options for the discord bot */ -export type discord_config = { - /** the token for your bot */ - token: string; -}; - -/** the config schema for the class */ -export const schema: config_schema = { - name: 'bolt-discord', - keys: { token: { type: 'string', required: true } }, -}; - -/** discord support for lightning */ -export default class discord extends plugin { - name = 'bolt-discord'; - private client: Client; - private received_messages = new Set(); - - /** create the plugin */ - constructor(cfg: discord_config) { - super(); - - const rest = new REST({ - makeRequest: fetch as RESTOptions['makeRequest'], - version: '10', - }).setToken(cfg.token); - - const gateway = new WebSocketManager({ - token: cfg.token, - intents: 0 | 16813601, - rest, - }); - - this.client = new Client({ gateway, rest }); - this.setup_events(); - gateway.connect(); - } - - private setup_events() { - this.client.on(GatewayDispatchEvents.MessageCreate, async (data) => { - if (this.received_messages.has(data.data.id)) { - return this.received_messages.delete(data.data.id); - } else this.received_messages.add(data.data.id); - - const msg = await get_incoming_message(data); - if (msg) this.emit('create_message', msg); - }).on(GatewayDispatchEvents.MessageDelete, ({ data }) => { - this.emit('delete_message', get_deleted_message(data)); - }).on(GatewayDispatchEvents.MessageDeleteBulk, ({ data }) => { - for (const id of data.ids) { - this.emit('delete_message', get_deleted_message({ id, ...data })); - } - }).on(GatewayDispatchEvents.MessageUpdate, async (data) => { - const msg = await get_incoming_message(data); - if (msg) this.emit('edit_message', msg); - }).on(GatewayDispatchEvents.InteractionCreate, (data) => { - const cmd = get_incoming_command(data); - if (cmd) this.emit('create_command', cmd); - }).on(GatewayDispatchEvents.Ready, async ({ data }) => { - console.log( - `[discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} servers`, - `\n[discord] invite me at https://discord.com/oauth2/authorize?client_id=${ - (await this.client.api.applications.getCurrent()).id - }&scope=bot&permissions=8`, - ); - }); - } - - /** setup slash commands */ - override async set_commands(commands: command[]): Promise { - await setup_commands(this.client.api, commands); - } - - /** create a webhook */ - async setup_channel(channelID: string): Promise { - try { - const { id, token } = await this.client.api.channels.createWebhook( - channelID, - { name: 'lightning bridge' }, - ); - - return { id, token }; - } catch (e) { - return handle_error(e, channelID); - } - } - - /** send a message using the bot itself or a webhook */ - async create_message( - message: message, - data?: bridge_message_opts, - ): Promise { - try { - const msg = await get_outgoing_message( - message, - this.client.api, - data !== undefined, - data?.settings?.allow_everyone ?? false, - ); - - if (data) { - const webhook = data.channel.data as { id: string; token: string }; - return [ - (await this.client.api.webhooks.execute( - webhook.id, - webhook.token, - msg, - )).id, - ]; - } else { - return [ - (await this.client.api.channels.createMessage( - message.channel_id, - msg, - )) - .id, - ]; - } - } catch (e) { - return handle_error(e, message.channel_id); - } - } - - /** edit a message sent by webhook */ - async edit_message( - message: message, - data: bridge_message_opts & { edit_ids: string[] }, - ): Promise { - try { - const webhook = data.channel.data as { id: string; token: string }; - - await this.client.api.webhooks.editMessage( - webhook.id, - webhook.token, - data.edit_ids[0], - await get_outgoing_message( - message, - this.client.api, - true, - data?.settings?.allow_everyone ?? false, - ), - ); - return data.edit_ids; - } catch (e) { - return handle_error(e, data.channel.id, true); - } - } - - /** delete messages */ - async delete_messages(msgs: deleted_message[]): Promise { - return await Promise.all( - msgs.map(async (msg) => { - try { - await this.client.api.channels.deleteMessage( - msg.channel_id, - msg.message_id, - ); - return msg.message_id; - } catch (e) { - // if this doesn't throw, it's fine - handle_error(e, msg.channel_id, true); - return msg.message_id; - } - }), - ); - } -} diff --git a/packages/discord/src/outgoing.ts b/packages/discord/src/outgoing.ts deleted file mode 100644 index 96e691ac..00000000 --- a/packages/discord/src/outgoing.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { - AllowedMentionsTypes, - type API, - type APIEmbed, - type DescriptiveRawFile, - type RESTPostAPIWebhookWithTokenJSONBody, - type RESTPostAPIWebhookWithTokenQuery, -} from '@discordjs/core'; -import type { attachment, message } from '@lightning/lightning'; - -export interface discord_payload - extends - RESTPostAPIWebhookWithTokenJSONBody, - RESTPostAPIWebhookWithTokenQuery { - embeds: APIEmbed[]; - files?: DescriptiveRawFile[]; - message_reference?: { type: number; channel_id: string; message_id: string }; - wait: true; -} - -async function fetch_reply( - channelID: string, - replies?: string[], - api?: API, -) { - try { - if (!replies || !api) return; - - const channel = await api.channels.get(channelID); - const channelPath = 'guild_id' in channel - ? `${channel.guild_id}/${channelID}` - : `@me/${channelID}`; - - return [{ - type: 1, - components: await Promise.all( - replies.slice(0, 5).map(async (reply) => ({ - type: 1 as const, - components: [{ - type: 2 as const, - style: 5 as const, - label: `reply to ${ - (await api.channels.getMessage(channelID, reply)).author.username - }`, - url: `https://discord.com/channels/${channelPath}/${replies}`, - }], - })), - ), - }]; - } catch { - return; - } -} - -async function fetch_files( - api: API, - channel_id: string, - attachments: attachment[] | undefined, -): Promise { - if (!attachments) return; - - let attachment_max = 10; - - try { - const channel = await api.channels.get(channel_id); - if ('guild_id' in channel && channel.guild_id) { - const server = await api.guilds.get(channel.guild_id, { with_counts: false }); - if (server.premium_tier === 2) attachment_max = 50; - if (server.premium_tier === 3) attachment_max = 100; - } - } catch { - // If we can't get the server's attachment limit, default to 10MB - } - - return (await Promise.all( - attachments.map(async (attachment) => { - try { - if (attachment.size >= attachment_max) return; - return { - data: new Uint8Array( - await (await fetch(attachment.file, { - signal: AbortSignal.timeout(5000), - })).arrayBuffer(), - ), - name: attachment.name ?? attachment.file?.split('/').pop()!, - }; - } catch { - return; - } - }), - )).filter((i) => i !== undefined); -} - -export async function get_outgoing_message( - msg: message, - api: API, - button_reply: boolean, - limit_mentions: boolean, -): Promise { - const payload: discord_payload = { - allowed_mentions: limit_mentions - ? { parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User] } - : undefined, - avatar_url: msg.author.profile, - // TODO(jersey): since telegram forced multiple message support, split the message into two? - content: (msg.content?.length || 0) > 2000 - ? `${msg.content?.substring(0, 1997)}...` - : msg.content, - components: button_reply - ? await fetch_reply(msg.channel_id, msg.reply_id, api) - : undefined, - embeds: (msg.embeds ?? []).map((e) => ({ - ...e, - timestamp: e.timestamp?.toString(), - })), - files: await fetch_files(api, msg.channel_id, msg.attachments), - message_reference: !button_reply && msg.reply_id - ? { type: 0, channel_id: msg.channel_id, message_id: msg.reply_id[0] } - : undefined, - username: msg.author.username, - wait: true, - }; - - if (!payload.content && (!payload.embeds || payload.embeds.length === 0)) { - // this acts like a blank message and renders nothing - payload.content = '_ _'; - } - - return payload; -} diff --git a/packages/guilded/README.md b/packages/guilded/README.md deleted file mode 100644 index 33c77945..00000000 --- a/packages/guilded/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# @lightning/guilded - -[![JSR](https://jsr.io/badges/@lightning/guilded)](https://jsr.io/@lightning/guilded) - -@lightning/guilded adds support for Guilded. To use it, you'll first need to -create a Guilded bot. After you do that, you'll need to add the following to -your `lightning.toml` file: - -```toml -[[plugins]] -plugin = "jsr:@lightning/guilded@0.8.0-alpha.5" -config.token = "your_bot_token" -``` diff --git a/packages/guilded/deno.json b/packages/guilded/deno.json deleted file mode 100644 index d6b4518a..00000000 --- a/packages/guilded/deno.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@lightning/guilded", - "version": "0.8.0-alpha.5", - "license": "MIT", - "exports": "./src/mod.ts", - "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", - "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.6", - "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.3" - } -} diff --git a/packages/guilded/src/errors.ts b/packages/guilded/src/errors.ts deleted file mode 100644 index d445f6a7..00000000 --- a/packages/guilded/src/errors.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { RequestError } from '@jersey/guilded-api-types'; -import { log_error } from '@lightning/lightning'; - -const errors = [ - [403, 'The bot lacks some permissions, please check them', false, true], - [404, 'Not found! This might be a Guilded problem', false, true], - [0, 'Unknown Guilded error, not disabling channel', false, false], -] as const; - -export function handle_error(err: unknown, channel: string): never { - if (err instanceof RequestError) { - const [, message, read, write] = errors.find((e) => - e[0] === err.cause.status - ) ?? - errors[errors.length - 1]; - - log_error(err, { - disable: { read, write }, - extra: { channel_id: channel, response: err.cause }, - message, - }); - } else { - log_error(err, { - message: `unknown error`, - extra: { channel_id: channel }, - }); - } -} diff --git a/packages/guilded/src/incoming.ts b/packages/guilded/src/incoming.ts deleted file mode 100644 index 9e59abff..00000000 --- a/packages/guilded/src/incoming.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { Client } from '@jersey/guildapi'; -import type { - ChatMessage, - ServerMember, - Webhook, -} from '@jersey/guilded-api-types'; -import { type attachment, cacher, type message } from '@lightning/lightning'; - -const member_cache = new cacher<`${string}/${string}`, ServerMember>(); -const webhook_cache = new cacher<`${string}/${string}`, Webhook>(); -const asset_cache = new cacher(86400000); - -export async function fetch_author(msg: ChatMessage, client: Client) { - try { - if (!msg.createdByWebhookId) { - const key = `${msg.serverId}/${msg.createdBy}` as const; - const author = member_cache.get(key) ?? member_cache.set( - key, - (await client.request( - 'get', - `/servers/${msg.serverId}/members/${msg.createdBy}`, - undefined, - ) as { member: ServerMember }).member, - ); - - return { - username: author.nickname ?? author.user.name, - rawname: author.user.name, - id: msg.createdBy, - profile: author.user.avatar, - }; - } else { - const key = `${msg.serverId}/${msg.createdByWebhookId}` as const; - const webhook = webhook_cache.get(key) ?? webhook_cache.set( - key, - (await client.request( - 'get', - `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, - undefined, - )).webhook, - ); - - return { - username: webhook.name, - rawname: webhook.name, - id: webhook.id, - profile: webhook.avatar, - }; - } - } catch { - return { - username: 'Guilded User', - rawname: 'GuildedUser', - id: msg.createdByWebhookId ?? msg.createdBy, - }; - } -} - -async function fetch_attachments(markdown: string[], client: Client) { - const urls = markdown.map( - (url) => (url.split('(').pop())?.split(')')[0], - ).filter((i) => i !== undefined); - - const attachments: attachment[] = []; - - for (const url of urls) { - const cached = asset_cache.get(url); - - if (cached) { - attachments.push(cached); - } else { - try { - const signed = (await client.request('post', '/url-signatures', { - urls: [url], - })).urlSignatures[0]; - - if (signed.retryAfter || !signed.signature) continue; - - attachments.push(asset_cache.set(signed.url, { - name: signed.signature.split('/').pop()?.split('?')[0] ?? 'unknown', - file: signed.signature, - size: parseInt( - (await fetch(signed.signature, { - method: 'HEAD', - })).headers.get('Content-Length') ?? '0', - ) / 1048576, - })); - } catch { - continue; - } - } - } - - return attachments; -} - -export async function get_incoming( - msg: ChatMessage, - client: Client, -): Promise { - if (!msg.serverId) return; - - let content = msg.content?.replaceAll('\n```\n```\n', '\n'); - - const urls = content?.match( - /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, - ) ?? []; - - content = content?.replaceAll( - /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, - '', - )?.replaceAll(/<(:\w+:)\d+>/g, (_, emoji) => emoji); - - return { - attachments: await fetch_attachments(urls, client), - author: { - ...await fetch_author(msg, client), - color: '#F5C400', - }, - channel_id: msg.channelId, - content, - embeds: msg.embeds?.map((embed) => ({ - ...embed, - author: embed.author - ? { - ...embed.author, - name: embed.author.name ?? '', - } - : undefined, - image: embed.image - ? { - ...embed.image, - url: embed.image.url ?? '', - } - : undefined, - thumbnail: embed.thumbnail - ? { - ...embed.thumbnail, - url: embed.thumbnail.url ?? '', - } - : undefined, - timestamp: embed.timestamp ? Number(embed.timestamp) : undefined, - })), - message_id: msg.id, - plugin: 'bolt-guilded', - reply_id: msg.replyMessageIds, - timestamp: Temporal.Instant.from( - msg.createdAt, - ), - }; -} diff --git a/packages/guilded/src/mod.ts b/packages/guilded/src/mod.ts deleted file mode 100644 index c2fd496a..00000000 --- a/packages/guilded/src/mod.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { type Client, createClient } from '@jersey/guildapi'; -import type { ServerChannel } from '@jersey/guilded-api-types'; -import { - type bridge_message_opts, - type config_schema, - type deleted_message, - type message, - plugin, -} from '@lightning/lightning'; -import { handle_error } from './errors.ts'; -import { get_incoming } from './incoming.ts'; -import { get_outgoing } from './outgoing.ts'; - -/** options for the guilded bot */ -export interface guilded_config { - /** enable debug logging */ - debug?: boolean; - /** the token to use */ - token: string; -} - -/** the config schema for the plugin */ -export const schema: config_schema = { - name: 'bolt-guilded', - keys: { - debug: { type: 'boolean', required: false }, - token: { type: 'string', required: true }, - }, -}; - -/** guilded support for lightning */ -export default class guilded extends plugin { - name = 'bolt-guilded'; - private client: Client; - private token: string; - - constructor(opts: guilded_config) { - super(); - this.client = createClient(opts.token); - this.token = opts.token; - this.setup_events(opts.debug); - this.client.socket.connect(); - } - - private setup_events(debug?: boolean) { - this.client.socket.on('ChatMessageCreated', async (data) => { - const msg = await get_incoming(data.d.message, this.client); - if (msg) this.emit('create_message', msg); - }).on('ChatMessageDeleted', ({ d }) => { - this.emit('delete_message', { - channel_id: d.message.channelId, - message_id: d.message.id, - plugin: 'bolt-guilded', - timestamp: Temporal.Instant.from(d.deletedAt), - }); - }).on('ChatMessageUpdated', async (data) => { - const msg = await get_incoming(data.d.message, this.client); - if (msg) this.emit('edit_message', msg); - }).on('ready', (data) => { - console.log(`[guilded] ready as ${data.name} (${data.id})`); - }).on('reconnect', () => { - console.log(`[guilded] reconnected`); - }).on( - 'debug', - (data) => debug && console.log(`[guilded] guildapi debug:`, data), - ); - } - - /** create a webhook in a channel */ - async setup_channel(channel_id: string): Promise { - try { - const { channel: { serverId } } = await this.client.request( - 'get', - `/channels/${channel_id}`, - undefined, - ) as { channel: ServerChannel }; - - const { webhook } = await this.client.request( - 'post', - `/servers/${serverId}/webhooks`, - { - channelId: channel_id, - name: 'Lightning Bridges', - }, - ); - - if (!webhook.id || !webhook.token) { - throw 'failed to create webhook: missing id or token'; - } - - return { id: webhook.id, token: webhook.token }; - } catch (e) { - return handle_error(e, channel_id); - } - } - - /** send a message either as the bot or using a webhook */ - async create_message( - message: message, - data?: bridge_message_opts, - ): Promise { - try { - const msg = await get_outgoing( - message, - this.client, - data?.settings?.allow_everyone ?? false, - ); - - if (data) { - const webhook = data.channel.data as { id: string; token: string }; - - const res = await (await fetch( - `https://media.guilded.gg/webhooks/${webhook.id}/${webhook.token}`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(msg), - }, - )).json(); - - return [res.id]; - } else { - const resp = await this.client.request( - 'post', - `/channels/${message.channel_id}/messages`, - msg, - ); - - return [resp.message.id]; - } - } catch (e) { - return handle_error(e, message.channel_id); - } - } - - /** edit stub function */ - // deno-lint-ignore require-await - async edit_message( - _message: message, - data: bridge_message_opts & { edit_ids: string[] }, - ): Promise { - return data.edit_ids; - } - - /** delete messages from guilded */ - async delete_messages(messages: deleted_message[]): Promise { - return await Promise.all(messages.map(async (msg) => { - try { - await fetch( - `https://www.guilded.gg/api/v1/channels/${msg.channel_id}/messages/${msg.message_id}`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${this.token}` }, - }, - ); - return msg.message_id; - } catch { - return msg.message_id; - } - })); - } -} diff --git a/packages/guilded/src/outgoing.ts b/packages/guilded/src/outgoing.ts deleted file mode 100644 index c12b7363..00000000 --- a/packages/guilded/src/outgoing.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Client } from '@jersey/guildapi'; -import type { ChatEmbed } from '@jersey/guilded-api-types'; -import type { message } from '@lightning/lightning'; -import { fetch_author } from './incoming.ts'; - -type guilded_payload = { - content?: string; - embeds?: ChatEmbed[]; - replyMessageIds?: string[]; - avatar_url?: string; - username?: string; -}; - -const username = /^[a-zA-Z0-9_ ()-]{1,25}$/ms; - -function get_name(msg: message): string { - if (username.test(msg.author.username)) { - return msg.author.username; - } else if (username.test(msg.author.rawname)) { - return msg.author.rawname; - } else { - return `${msg.author.id}`; - } -} - -async function fetch_reply( - msg: message, - client: Client, -): Promise { - if (!msg.reply_id) return; - - try { - const { message } = await client.request( - 'get', - `/channels/${msg.channel_id}/messages/${msg.reply_id[0]}`, - undefined, - ); - - const { profile, username } = await fetch_author(message, client); - - return { - author: { name: `reply to ${username}`, icon_url: profile }, - description: message.content, - }; - } catch { - return; - } -} - -export async function get_outgoing( - msg: message, - client: Client, - limitMentions?: boolean, -): Promise { - const message: guilded_payload = { - content: msg.content, - avatar_url: msg.author.profile, - username: get_name(msg), - embeds: msg.embeds?.map((i) => { - return { - ...i, - fields: i.fields?.map((j) => ({ ...j, inline: j.inline ?? false })), - timestamp: i.timestamp?.toString(), - }; - }), - }; - - const embed = await fetch_reply(msg, client); - - if (embed) { - if (!message.embeds) message.embeds = []; - message.embeds.push(embed); - } - - if (msg.attachments?.length) { - if (!message.embeds) message.embeds = []; - message.embeds.push({ - title: 'attachments', - description: msg.attachments - .slice(0, 5) - .map((a) => { - return `![${a.alt ?? a.name}](${a.file})`; - }) - .join('\n'), - }); - } - - if (!message.content && !message.embeds) message.content = '\u2800'; - - if (limitMentions && message.content) { - message.content = message.content.replace(/@everyone/gi, '(a)everyone'); - message.content = message.content.replace(/@here/gi, '(a)here'); - } - - return message; -} diff --git a/packages/lightning/README.md b/packages/lightning/README.md deleted file mode 100644 index 1901e4df..00000000 --- a/packages/lightning/README.md +++ /dev/null @@ -1,27 +0,0 @@ -![lightning](https://raw.githubusercontent.com/williamhorning/lightning/refs/heads/develop/logo.svg) - -# @lightning/lightning - -lightning is a typescript-based chatbot that supports bridging multiple chat -apps via plugins - -## [docs](https://williamhorning.eu.org/lightning) - -## `lightning.toml` example - -```toml -prefix = "!" - -[database] -type = "postgres" -config = "postgresql://server:password@postgres:5432/lightning" - -[[plugins]] -plugin = "jsr:@lightning/discord@0.8.0-alpha.5" -config.token = "your_token" - -[[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.5" -config.token = "your_token" -config.user_id = "your_bot_user_id" -``` diff --git a/packages/lightning/deno.json b/packages/lightning/deno.json deleted file mode 100644 index 5bbba12c..00000000 --- a/packages/lightning/deno.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "@lightning/lightning", - "version": "0.8.0-alpha.5", - "license": "MIT", - "exports": { - ".": "./src/mod.ts", - "./cli": "./src/cli.ts" - }, - "imports": { - "@db/postgres": "jsr:@db/postgres@^0.19.5", - "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", - "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.8", - "@std/cli": "jsr:@std/cli@^1.0.19", - "@std/fs": "jsr:@std/fs@^1.0.18", - "@std/toml": "jsr:@std/toml@^1.0.7" - } -} diff --git a/packages/lightning/src/bridge/commands.ts b/packages/lightning/src/bridge/commands.ts deleted file mode 100644 index b7516a01..00000000 --- a/packages/lightning/src/bridge/commands.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { bridge_data } from '../database/mod.ts'; -import { bridge_settings_list } from '../structures/bridge.ts'; -import { log_error } from '../structures/errors.ts'; -import type { bridge_channel, command_opts } from '../structures/mod.ts'; - -export async function create( - db: bridge_data, - opts: command_opts, -): Promise { - const result = await _add(db, opts); - - if (typeof result === 'string') return result; - - const data = { - name: opts.args.name!, - channels: [result], - settings: { allow_everyone: false }, - }; - - try { - const { id } = await db.create_bridge(data); - return `Bridge created successfully!\nYou can now join it using \`${opts.prefix}bridge join ${id}\`.\nKeep this ID safe, don't share it with anyone, and delete this message.`; - } catch (e) { - log_error(e, { - message: 'Failed to insert bridge into database', - extra: data, - }); - } -} - -export async function join( - db: bridge_data, - opts: command_opts, -): Promise { - const result = await _add(db, opts); - - if (typeof result === 'string') return result; - - const target_bridge = await db.get_bridge_by_id( - opts.args.id!, - ); - - if (!target_bridge) { - return `Bridge with id \`${opts.args.id}\` not found. Make sure you have the correct id.`; - } - - target_bridge.channels.push(result); - - try { - await db.edit_bridge(target_bridge); - - return `Bridge joined successfully!`; - } catch (e) { - log_error(e, { - message: 'Failed to update bridge in database', - extra: { target_bridge }, - }); - } -} - -export async function subscribe( - db: bridge_data, - opts: command_opts, -): Promise { - const result = await _add(db, opts); - - if (typeof result === 'string') return result; - - const target_bridge = await db.get_bridge_by_id( - opts.args.id!, - ); - - if (!target_bridge) { - return `Bridge with id \`${opts.args.id}\` not found. Make sure you have the correct id.`; - } - - target_bridge.channels.push({ - ...result, - disabled: { read: true, write: false }, - }); - - try { - await db.edit_bridge(target_bridge); - - return `Bridge subscribed successfully! You will not receive messages from this channel, but you can still send messages to it.`; - } catch (e) { - log_error(e, { - message: 'Failed to update bridge in database', - extra: { target_bridge }, - }); - } -} - -async function _add( - db: bridge_data, - opts: command_opts, -): Promise { - const existing_bridge = await db.get_bridge_by_channel( - opts.channel_id, - ); - - if (existing_bridge) { - return `You are already in a bridge called \`${existing_bridge.name}\`. You must leave it before being in another bridge. Try using \`${opts.prefix}bridge leave\` or \`${opts.prefix}help\` commands.`; - } - - try { - return { - id: opts.channel_id, - data: await opts.plugin.setup_channel(opts.channel_id), - disabled: { read: false, write: false }, - plugin: opts.plugin.name, - }; - } catch (e) { - log_error(e, { - message: 'Failed to create bridge using plugin', - extra: { channel: opts.channel_id, plugin_name: opts.plugin }, - }); - } -} - -export async function leave( - db: bridge_data, - opts: command_opts, -): Promise { - const bridge = await db.get_bridge_by_channel( - opts.channel_id, - ); - - if (!bridge) return `You are not in a bridge`; - - if (opts.args.id !== bridge.id) { - return `You must provide the bridge id in order to leave this bridge`; - } - - bridge.channels = bridge.channels.filter(( - ch, - ) => ch.id !== opts.channel_id); - - try { - await db.edit_bridge( - bridge, - ); - return `Bridge left successfully`; - } catch (e) { - log_error(e, { - message: 'Failed to update bridge in database', - extra: { bridge }, - }); - } -} - -export async function status( - db: bridge_data, - opts: command_opts, -): Promise { - const bridge = await db.get_bridge_by_channel( - opts.channel_id, - ); - - if (!bridge) return `You are not in a bridge`; - - let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; - - for (const [i, value] of bridge.channels.entries()) { - str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\``; - - if (typeof value.disabled === 'object') { - if (value.disabled.read) str += ` (subscribed)`; - if (value.disabled.write) str += ` (write disabled)`; - } else if (value.disabled === true) { - str += ` (disabled)`; - } - - str += `\n`; - } - - str += `\nSettings:\n`; - - for ( - const [key, value] of Object.entries(bridge.settings).filter(([key]) => - bridge_settings_list.includes(key) - ) - ) { - str += `- \`${key}\` ${value ? 'โœ”' : 'โŒ'}\n`; - } - - return str; -} - -export async function toggle( - db: bridge_data, - opts: command_opts, -): Promise { - const bridge = await db.get_bridge_by_channel( - opts.channel_id, - ); - - if (!bridge) return `You are not in a bridge`; - - if (!bridge_settings_list.includes(opts.args.setting!)) { - return `That setting does not exist`; - } - - const key = opts.args.setting as keyof typeof bridge.settings; - - bridge.settings[key] = !bridge.settings[key]; - - try { - await db.edit_bridge( - bridge, - ); - return `Bridge settings updated successfully`; - } catch (e) { - log_error(e, { - message: 'Failed to update bridge in database', - extra: { bridge }, - }); - } -} diff --git a/packages/lightning/src/bridge/handler.ts b/packages/lightning/src/bridge/handler.ts deleted file mode 100644 index 723a2164..00000000 --- a/packages/lightning/src/bridge/handler.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { core } from '../core.ts'; -import type { bridge_data } from '../database/mod.ts'; -import type { bridge_message, bridged_message } from '../structures/bridge.ts'; -import { LightningError } from '../structures/errors.ts'; -import type { deleted_message, message } from '../structures/messages.ts'; - -export async function bridge_message( - core: core, - bridge_data: bridge_data, - event: 'create_message' | 'edit_message' | 'delete_message', - data: message | deleted_message, -) { - const bridge = event === 'create_message' - ? await bridge_data.get_bridge_by_channel(data.channel_id) - : await bridge_data.get_message(data.message_id); - - if (!bridge) return; - - // if the channel is disabled, return - if ( - bridge.channels.some( - (channel) => - channel.id === data.channel_id && - channel.plugin === data.plugin && - (channel.disabled === true || - typeof channel.disabled === 'object' && - channel.disabled.read === true), - ) - ) return; - - // remove ourselves & disabled channels - const channels = bridge.channels.filter((channel) => - (channel.id !== data.channel_id || channel.plugin !== data.plugin) && - (!(channel.disabled === true || typeof channel.disabled === 'object' && - channel.disabled.write === true) || !channel.data) - ); - - // if there aren't any left, return - if (channels.length < 1) return; - - const messages: bridged_message[] = []; - - for (const channel of channels) { - const prior_bridged_ids = event === 'create_message' - ? undefined - : (bridge as bridge_message).messages.find((i) => - i.channel === channel.id && i.plugin === channel.plugin - ); - - if (event !== 'create_message' && !prior_bridged_ids) continue; - - const plugin = core.get_plugin(channel.plugin)!; - - let reply_id: string | undefined; - - if ('reply_id' in data && data.reply_id) { - try { - const bridged = await bridge_data.get_message(data.reply_id); - - reply_id = bridged?.messages?.find((message) => - message.channel === channel.id && message.plugin === channel.plugin - )?.id[0] ?? bridged?.id; - } catch { - reply_id = undefined; - } - } - - try { - let result_ids: string[]; - - switch (event) { - case 'create_message': - case 'edit_message': - result_ids = await plugin[event]( - { - ...(data as message), - reply_id, - channel_id: channel.id, - message_id: prior_bridged_ids?.id[0] ?? '', - }, - { - channel, - settings: bridge.settings, - edit_ids: prior_bridged_ids?.id as string[], - }, - ); - break; - case 'delete_message': - result_ids = await plugin.delete_messages( - prior_bridged_ids!.id.map((id) => ({ - ...(data as deleted_message), - message_id: id, - channel_id: channel.id, - })), - ); - } - - result_ids.forEach((id) => core.set_handled(channel.plugin, id)); - - messages.push({ - id: result_ids, - channel: channel.id, - plugin: channel.plugin, - }); - } catch (e) { - const err = new LightningError(e, { - message: `An error occurred while handling a message in the bridge`, - }); - - if (err.disable?.read || err.disable?.write) { - new LightningError( - `disabling channel ${channel.id} in bridge ${bridge.id}`, - { - extra: { original_error: err.id, disable: err.disable }, - }, - ); - - await bridge_data.edit_bridge({ - ...bridge, - channels: bridge.channels.map((ch) => - ch.id === channel.id && ch.plugin === channel.plugin - ? { ...ch, disabled: err.disable! } - : ch - ), - }); - } - } - } - - await bridge_data[event]({ - ...bridge, - id: data.message_id, - messages, - bridge_id: bridge.id, - }); -} diff --git a/packages/lightning/src/bridge/setup.ts b/packages/lightning/src/bridge/setup.ts deleted file mode 100644 index c288f002..00000000 --- a/packages/lightning/src/bridge/setup.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { core } from '../core.ts'; -import { create_database, type database_config } from '../database/mod.ts'; -import { create, join, leave, status, subscribe, toggle } from './commands.ts'; -import { bridge_message } from './handler.ts'; - -export async function setup_bridge(core: core, config: database_config) { - const database = await create_database(config); - - core.on( - 'create_message', - (msg) => bridge_message(core, database, 'create_message', msg), - ); - core.on( - 'edit_message', - (msg) => bridge_message(core, database, 'edit_message', msg), - ); - core.on( - 'delete_message', - (msg) => bridge_message(core, database, 'delete_message', msg), - ); - - core.set_command({ - name: 'bridge', - description: 'bridge commands', - execute: () => 'take a look at the subcommands of this command', - subcommands: [ - { - name: 'create', - description: 'create a new bridge', - arguments: [{ - name: 'name', - description: 'name of the bridge', - required: true, - }], - execute: (o) => create(database, o), - }, - { - name: 'join', - description: 'join an existing bridge', - arguments: [{ - name: 'id', - description: 'id of the bridge', - required: true, - }], - execute: (o) => join(database, o), - }, - { - name: 'subscribe', - description: 'subscribe to a bridge', - arguments: [{ - name: 'id', - description: 'id of the bridge', - required: true, - }], - execute: (o) => subscribe(database, o), - }, - { - name: 'leave', - description: 'leave the current bridge', - arguments: [{ - name: 'id', - description: 'id of the current bridge', - required: true, - }], - execute: (o) => leave(database, o), - }, - { - name: 'toggle', - description: 'toggle a setting on the current bridge', - arguments: [{ - name: 'setting', - description: 'setting to toggle', - required: true, - }], - execute: (o) => toggle(database, o), - }, - { - name: 'status', - description: 'get the status of the current bridge', - execute: (o) => status(database, o), - }, - ], - }); -} diff --git a/packages/lightning/src/cli.ts b/packages/lightning/src/cli.ts deleted file mode 100644 index 7b4bfb07..00000000 --- a/packages/lightning/src/cli.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { setup_bridge } from './bridge/setup.ts'; -import { parse_config } from './cli_config.ts'; -import { core } from './core.ts'; -import { handle_migration } from './database/mod.ts'; -import { cwd, exit, get_args } from './structures/cross.ts'; -import { LightningError } from './structures/errors.ts'; - -/** - * This module provides the Lightning CLI, which you can use to run the bot - * @module - */ - -const args = get_args(); - -if (args[0] === 'migrate') { - handle_migration(); -} else if (args[0] === 'run') { - try { - const config = await parse_config( - new URL(args[1] ?? 'lightning.toml', `file://${cwd()}/`), - ); - const lightning = new core(config); - await setup_bridge(lightning, config.database); - } catch (e) { - await new LightningError(e, { - extra: { type: 'global class error' }, - without_cause: true, - }).log(); - - exit(1); - } -} else if (args[0] === 'version') { - console.log('0.8.0-alpha.5'); -} else { - console.log( - `lightning v0.8.0-alpha.5 - extensible chatbot connecting communities`, - ); - console.log(' Usage: lightning [subcommand]'); - console.log(' Subcommands:'); - console.log(' run : run a lightning instance'); - console.log(' migrate: migrate databases'); - console.log(' version: display the version number'); - console.log(' help: display this help message'); - console.log(' Environment Variables:'); - console.log(' LIGHTNING_ERROR_WEBHOOK: the webhook to send errors to'); - console.log(' LIGHTNING_MIGRATE_CONFIRM: confirm migration on startup'); -} diff --git a/packages/lightning/src/cli_config.ts b/packages/lightning/src/cli_config.ts deleted file mode 100644 index 296e52e8..00000000 --- a/packages/lightning/src/cli_config.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { readTextFile } from '@std/fs/unstable-read-text-file'; -import { parse as parse_toml } from '@std/toml'; -import type { core_config } from './core.ts'; -import type { database_config } from './database/mod.ts'; -import { set_env } from './structures/cross.ts'; -import { log_error } from './structures/errors.ts'; -import { validate_config } from './structures/validate.ts'; - -interface cli_plugin { - plugin: string; - config: Record; -} - -interface config extends core_config { - database: database_config; - error_url?: string; -} - -export async function parse_config(path: URL): Promise { - try { - const file = await readTextFile(path); - const raw = parse_toml(file) as Record; - - const validated = validate_config(raw, { - name: 'lightning', - keys: { - error_url: { type: 'string', required: false }, - prefix: { type: 'string', required: false }, - }, - }) as Omit & { plugins: cli_plugin[] }; - - if ( - !('type' in validated.database) || - typeof validated.database.type !== 'string' || - !('config' in validated.database) || - validated.database.config === null || - (validated.database.type === 'postgres' && - typeof validated.database.config !== 'string') || - (validated.database.type === 'redis' && - (typeof validated.database.config !== 'object' || - validated.database.config === null)) - ) { - return log_error('your config has an invalid `database` field', { - without_cause: true, - }); - } - - if ( - !validated.plugins.every( - (p): p is cli_plugin => - typeof p.plugin === 'string' && - typeof p.config === 'object' && - p.config !== null, - ) - ) { - return log_error('your config has an invalid `plugins` field', { - without_cause: true, - }); - } - - const plugins = []; - - for (const plugin of validated.plugins) { - plugins.push({ - module: await import(plugin.plugin), - config: plugin.config, - }); - } - - set_env('LIGHTNING_ERROR_WEBHOOK', validated.error_url ?? ''); - - return { ...validated, plugins }; - } catch (e) { - log_error(e, { - message: - `could not open or parse your \`lightning.toml\` file at ${path}`, - without_cause: true, - }); - } -} diff --git a/packages/lightning/src/core.ts b/packages/lightning/src/core.ts deleted file mode 100644 index bea3adaa..00000000 --- a/packages/lightning/src/core.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { EventEmitter } from '@denosaurs/event'; -import type { - command, - command_opts, - create_command, -} from './structures/commands.ts'; -import { LightningError, log_error } from './structures/errors.ts'; -import { create_message, type message } from './structures/messages.ts'; -import type { events, plugin, plugin_module } from './structures/plugins.ts'; -import { validate_config } from './structures/validate.ts'; - -export interface core_config { - prefix?: string; - plugins: { - module: plugin_module; - config: Record; - }[]; -} - -export class core extends EventEmitter { - private commands = new Map([ - ['help', { - name: 'help', - description: 'get help with the bot', - execute: () => - "hi! i'm lightning v0.8.0-alpha.5.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help.", - }], - ['ping', { - name: 'ping', - description: 'check if the bot is alive', - execute: ({ timestamp }: command_opts) => - `Pong! ๐Ÿ“ ${ - Temporal.Now.instant().since(timestamp).round('millisecond') - .total('milliseconds') - }ms`, - }], - ]); - private plugins = new Map(); - private handled = new Set(); - private prefix: string; - - constructor(cfg: core_config) { - super(); - this.prefix = cfg.prefix || '!'; - - for (const { module, config } of cfg.plugins) { - if (!module.default || !module.schema) { - log_error({ ...module }, { - message: `one or more of you plugins isn't actually a plugin!`, - without_cause: true, - }); - } - - const instance = new module.default( - validate_config(config, module.schema), - ); - - this.plugins.set(instance.name, instance); - this.handle_events(instance); - } - } - - set_handled(plugin: string, message_id: string): void { - this.handled.add(`${plugin}-${message_id}`); - } - - set_command(opts: command): void { - this.commands.set(opts.name, opts); - - for (const [, plugin] of this.plugins) { - if (plugin.set_commands) { - plugin.set_commands(this.commands.values().toArray()); - } - } - } - - get_plugin(name: string): plugin | undefined { - return this.plugins.get(name); - } - - private async handle_events(plugin: plugin): Promise { - for await (const { name, value } of plugin) { - await new Promise((res) => setTimeout(res, 200)); - - if (this.handled.has(`${value[0].plugin}-${value[0].message_id}`)) { - this.handled.delete(`${value[0].plugin}-${value[0].message_id}`); - continue; - } - - if (name === 'create_command') { - this.handle_command(value[0] as create_command, plugin); - } - - if (name === 'create_message') { - const msg = value[0] as message; - - if (msg.content?.startsWith(this.prefix)) { - const [command, ...rest] = msg.content.replace(this.prefix, '').split( - ' ', - ); - - this.handle_command({ - ...msg, - args: {}, - command, - prefix: this.prefix, - reply: async (message: message) => { - await plugin.create_message({ - ...message, - channel_id: msg.channel_id, - reply_id: msg.message_id, - }); - }, - rest, - }, plugin); - } - } - - this.emit(name, ...value); - } - } - - private async handle_command( - opts: create_command, - plugin: plugin, - ): Promise { - let command = this.commands.get(opts.command) ?? this.commands.get('help')!; - const subcommand_name = opts.subcommand ?? opts.rest?.shift(); - - if (command.subcommands && subcommand_name) { - const subcommand = command.subcommands.find((i) => - i.name === subcommand_name - ); - - if (subcommand) command = subcommand; - } - - for (const arg of (command.arguments ?? [])) { - if (!opts.args[arg.name]) { - opts.args[arg.name] = opts.rest?.shift(); - } - - if (!opts.args[arg.name]) { - return opts.reply( - create_message( - `Please provide the \`${arg.name}\` argument. Try using the \`${opts.prefix}help\` command.`, - ), - ); - } - } - - let resp: string | LightningError; - - try { - resp = await command.execute({ ...opts, plugin }); - } catch (e) { - resp = new LightningError(e, { - message: 'An error occurred while executing the command', - extra: { command: command.name }, - }); - } - - try { - await opts.reply( - resp instanceof LightningError ? resp.msg : create_message(resp), - ); - } catch (e) { - new LightningError(e, { - message: 'An error occurred while sending the command response', - extra: { command: command.name }, - }); - } - } -} diff --git a/packages/lightning/src/database/mod.ts b/packages/lightning/src/database/mod.ts deleted file mode 100644 index 15b55065..00000000 --- a/packages/lightning/src/database/mod.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { bridge, bridge_message } from '../structures/bridge.ts'; -import { postgres } from './postgres.ts'; -import { redis, type redis_config } from './redis.ts'; - -export interface bridge_data { - create_bridge(br: Omit): Promise; - edit_bridge(br: bridge): Promise; - get_bridge_by_id(id: string): Promise; - get_bridge_by_channel(ch: string): Promise; - create_message(msg: bridge_message): Promise; - edit_message(msg: bridge_message): Promise; - delete_message(msg: bridge_message): Promise; - get_message(id: string): Promise; - migration_get_bridges(): Promise; - migration_get_messages(): Promise; - migration_set_bridges(bridges: bridge[]): Promise; - migration_set_messages(messages: bridge_message[]): Promise; -} - -export type database_config = { - type: 'postgres'; - config: string; -} | { - type: 'redis'; - config: redis_config; -}; - -export async function create_database( - config: database_config, -): Promise { - if (config.type === 'postgres') return await postgres.create(config.config); - if (config.type === 'redis') return await redis.create(config.config); - throw new Error('invalid database type', { cause: config }); -} - -function get_database(message: string): typeof postgres | typeof redis { - const type = prompt(`${message} (redis,postgres)`); - - if (type === 'postgres') return postgres; - if (type === 'redis') return redis; - throw new Error('invalid database type!'); -} - -export async function handle_migration() { - const start = await get_database('Please enter your starting database type: ') - .migration_get_instance(); - - const end = await get_database('Please enter your ending database type: ') - .migration_get_instance(); - - console.log('Downloading bridges...'); - let bridges = await start.migration_get_bridges(); - - console.log(`Copying ${bridges.length} bridges...`); - await end.migration_set_bridges(bridges); - bridges = []; - - console.log('Downloading messages...'); - let messages = await start.migration_get_messages(); - - console.log(`Copying ${messages.length} messages...`); - await end.migration_set_messages(messages); - messages = []; - - console.log('Migration complete!'); -} diff --git a/packages/lightning/src/database/postgres.ts b/packages/lightning/src/database/postgres.ts deleted file mode 100644 index 665f3ae9..00000000 --- a/packages/lightning/src/database/postgres.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { Client } from '@db/postgres'; -import { - ProgressBar, - type ProgressBarFormatter, -} from '@std/cli/unstable-progress-bar'; -import { Spinner } from '@std/cli/unstable-spinner'; -import type { bridge, bridge_message } from '../structures/bridge.ts'; -import { get_env, stdout } from '../structures/cross.ts'; -import type { bridge_data } from './mod.ts'; - -const fmt = (fmt: ProgressBarFormatter) => - `[postgres] ${fmt.progressBar} ${fmt.styledTime} [${fmt.value}/${fmt.max}]\n`; - -export class postgres implements bridge_data { - private pg: Client; - - static async create(pg_url: string): Promise { - const { Client } = await import('@db/postgres'); - const pg = new Client(pg_url); - - await pg.connect(); - await postgres.setup_schema(pg); - - return new this(pg); - } - - private static async setup_schema(pg: Client) { - await pg.queryArray` - CREATE TABLE IF NOT EXISTS lightning ( - prop TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - - INSERT INTO lightning (prop, value) - VALUES ('db_data_version', '0.8.0') - /* the database shouldn't have been created before 0.8.0 so this is safe */ - ON CONFLICT (prop) DO NOTHING; - - CREATE TABLE IF NOT EXISTS bridges ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - channels JSONB NOT NULL, - settings JSONB NOT NULL - ); - - CREATE TABLE IF NOT EXISTS bridge_messages ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - bridge_id TEXT NOT NULL, - channels JSONB NOT NULL, - messages JSONB NOT NULL, - settings JSONB NOT NULL - ); - `; - } - - private constructor(pg: Client) { - this.pg = pg; - } - - async create_bridge(br: Omit): Promise { - const id = crypto.randomUUID(); - - await this.pg.queryArray` - INSERT INTO bridges (id, name, channels, settings) - VALUES (${id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ - JSON.stringify(br.settings) - }) - `; - - return { id, ...br }; - } - - async edit_bridge(br: bridge): Promise { - await this.pg.queryArray` - UPDATE bridges - SET channels = ${JSON.stringify(br.channels)}, - settings = ${JSON.stringify(br.settings)} - WHERE id = ${br.id} - `; - } - - async get_bridge_by_id(id: string): Promise { - const res = await this.pg.queryObject` - SELECT * FROM bridges WHERE id = ${id} - `; - - return res.rows[0]; - } - - async get_bridge_by_channel(ch: string): Promise { - const res = await this.pg.queryObject(` - SELECT * FROM bridges WHERE EXISTS ( - SELECT 1 FROM jsonb_array_elements(channels) AS ch - WHERE ch->>'id' = '${ch}' - ) - `); - - return res.rows[0]; - } - - async create_message(msg: bridge_message): Promise { - await this.pg.queryArray`INSERT INTO bridge_messages - (id, name, bridge_id, channels, messages, settings) VALUES - (${msg.id}, ${msg.name}, ${msg.bridge_id}, ${ - JSON.stringify(msg.channels) - }, ${JSON.stringify(msg.messages)}, ${JSON.stringify(msg.settings)})`; - } - - async edit_message(msg: bridge_message): Promise { - await this.pg.queryArray` - UPDATE bridge_messages - SET messages = ${JSON.stringify(msg.messages)}, - channels = ${JSON.stringify(msg.channels)}, - settings = ${JSON.stringify(msg.settings)} - WHERE id = ${msg.id} - `; - } - - async delete_message({ id }: bridge_message): Promise { - await this.pg.queryArray` - DELETE FROM bridge_messages WHERE id = ${id} - `; - } - - async get_message(id: string): Promise { - const res = await this.pg.queryObject(` - SELECT * FROM bridge_messages - WHERE id = '${id}' OR EXISTS ( - SELECT 1 FROM jsonb_array_elements(messages) AS msg - CROSS JOIN jsonb_array_elements_text(msg->'id') AS id_element - WHERE id_element = '${id}' - ) - `); - - return res.rows[0]; - } - - async migration_get_bridges(): Promise { - const spinner = new Spinner({ message: 'getting bridges from postgres' }); - - spinner.start(); - - const res = await this.pg.queryObject(` - SELECT * FROM bridges - `); - - spinner.stop(); - - return res.rows; - } - - async migration_get_messages(): Promise { - const spinner = new Spinner({ message: 'getting messages from postgres' }); - - spinner.start(); - - const res = await this.pg.queryObject(` - SELECT * FROM bridge_messages - `); - - spinner.stop(); - - return res.rows; - } - - async migration_set_messages(messages: bridge_message[]): Promise { - const progress = new ProgressBar({ - max: messages.length, - fmt: fmt, - writable: stdout(), - }); - - for (const msg of messages) { - progress.value++; - - try { - await this.create_message(msg); - } catch { - console.warn(`failed to insert message ${msg.id}`); - } - } - - progress.stop(); - } - - async migration_set_bridges(bridges: bridge[]): Promise { - const progress = new ProgressBar({ - max: bridges.length, - fmt: fmt, - writable: stdout(), - }); - - for (const br of bridges) { - progress.value++; - - await this.pg.queryArray` - INSERT INTO bridges (id, name, channels, settings) - VALUES (${br.id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ - JSON.stringify(br.settings) - }) - `; - } - - progress.stop(); - } - - static async migration_get_instance(): Promise { - const default_url = `postgres://${ - get_env('USER') ?? get_env('USERNAME') - }@localhost/lightning`; - - const pg_url = prompt( - `Please enter your Postgres connection string (${default_url}):`, - ); - - return await postgres.create(pg_url || default_url); - } -} diff --git a/packages/lightning/src/database/redis.ts b/packages/lightning/src/database/redis.ts deleted file mode 100644 index 5d60e9ca..00000000 --- a/packages/lightning/src/database/redis.ts +++ /dev/null @@ -1,342 +0,0 @@ -import { RedisClient } from '@iuioiua/redis'; -import { - ProgressBar, - type ProgressBarFormatter, -} from '@std/cli/unstable-progress-bar'; -import { writeTextFile } from '@std/fs/unstable-write-text-file'; -import type { - bridge, - bridge_channel, - bridge_message, - bridged_message, -} from '../structures/bridge.ts'; -import { get_env, stdout, tcp_connect } from '../structures/cross.ts'; -import { log_error } from '../structures/errors.ts'; -import type { bridge_data } from './mod.ts'; - -export interface redis_config { - hostname: string; - port: number; -} - -const fmt = (fmt: ProgressBarFormatter) => - `[redis] ${fmt.progressBar} ${fmt.styledTime} [${fmt.value}/${fmt.max}]\n`; - -export class redis implements bridge_data { - private redis: RedisClient; - private seven: boolean; - - static async create( - rd_options: redis_config, - _do_not_use = false, - ): Promise { - const rd = new RedisClient(await tcp_connect(rd_options)); - - let db_data_version = await rd.sendCommand([ - 'GET', - 'lightning-db-version', - ]); - - if (db_data_version === null) { - const number_keys = await rd.sendCommand(['DBSIZE']) as number; - - if (number_keys === 0) { - await rd.sendCommand(['SET', 'lightning-db-version', '0.8.0']); - db_data_version = '0.8.0'; - } - } - - if (db_data_version !== '0.8.0' && !_do_not_use) { - console.warn( - `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, - ); - - const instance = new this(rd, true); - - console.log('[lightning-redis] getting bridges...'); - - const bridges = await instance.migration_get_bridges(); - - console.log('[lightning-redis] got bridges!'); - - await writeTextFile( - 'lightning-redis-migration.json', - JSON.stringify(bridges, null, 2), - ); - - const write = confirm( - '[lightning-redis] write the data to the database? see \`lightning-redis-migration.json\` for the data', - ); - const env_confirm = get_env('LIGHTNING_MIGRATE_CONFIRM'); - - if (write || env_confirm === 'true') { - await instance.migration_set_bridges(bridges); - - const former_messages = await rd.sendCommand([ - 'KEYS', - 'lightning-bridged-*', - ]) as string[]; - - for (const key of former_messages) { - await rd.sendCommand(['DEL', key]); - } - - console.warn('[lightning-redis] data written to database'); - - return instance; - } else { - log_error('[lightning-redis] data not written to database', { - without_cause: true, - }); - } - } else { - return new this(rd, _do_not_use); - } - } - - private constructor( - redis: RedisClient, - seven = false, - ) { - this.redis = redis; - this.seven = seven; - } - - async get_json(key: string): Promise { - const reply = await this.redis.sendCommand(['GET', key]); - if (!reply || reply === 'OK') return; - return JSON.parse(reply as string) as T; - } - - async create_bridge(br: Omit): Promise { - const id = crypto.randomUUID(); - - await this.edit_bridge({ id, ...br }); - - return { id, ...br }; - } - - async edit_bridge(br: bridge): Promise { - const old_bridge = await this.get_bridge_by_id(br.id); - - for (const channel of old_bridge?.channels ?? []) { - await this.redis.sendCommand([ - 'DEL', - `lightning-bchannel-${channel.id}`, - ]); - } - - await this.redis.sendCommand([ - 'SET', - `lightning-bridge-${br.id}`, - JSON.stringify(br), - ]); - - for (const channel of br.channels) { - await this.redis.sendCommand([ - 'SET', - `lightning-bchannel-${channel.id}`, - br.id, - ]); - } - } - - async get_bridge_by_id(id: string): Promise { - return await this.get_json(`lightning-bridge-${id}`); - } - - async get_bridge_by_channel(ch: string): Promise { - const channel = await this.redis.sendCommand([ - 'GET', - `lightning-bchannel-${ch}`, - ]); - if (!channel || channel === 'OK') return; - return await this.get_bridge_by_id(channel as string); - } - - async create_message(msg: bridge_message): Promise { - await this.redis.sendCommand([ - 'SET', - `lightning-message-${msg.id}`, - JSON.stringify(msg), - ]); - - for (const message of msg.messages) { - await this.redis.sendCommand([ - 'SET', - `lightning-message-${message.id}`, - JSON.stringify(msg), - ]); - } - } - - async edit_message(msg: bridge_message): Promise { - await this.create_message(msg); - } - - async delete_message(msg: bridge_message): Promise { - await this.redis.sendCommand(['DEL', `lightning-message-${msg.id}`]); - } - - async get_message(id: string): Promise { - return await this.get_json( - `lightning-message-${id}`, - ); - } - - async migration_get_bridges(): Promise { - const keys = await this.redis.sendCommand([ - 'KEYS', - 'lightning-bridge-*', - ]) as string[]; - - const bridges = [] as bridge[]; - - const progress = new ProgressBar({ - max: keys.length, - fmt, - writable: stdout(), - }); - - for (const key of keys) { - progress.value++; - if (!this.seven) { - const bridge = await this.get_bridge_by_id( - key.replace('lightning-bridge-', ''), - ); - - if (bridge) bridges.push(bridge); - } else { - // ignore UUIDs and ULIDs - if ( - key.replace('lightning-bridge-', '').match( - /[0-7][0-9A-HJKMNP-TV-Z]{25}/gm, - ) || - key.replace('lightning-bridge-', '').match( - /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, - ) - ) { - continue; - } - - const bridge = await this.get_json<{ - channels: bridge_channel[]; - id: string; - messages?: bridged_message[]; - }>(key); - - if (bridge && bridge.channels) { - bridges.push({ - id: key.replace('lightning-bridge-', ''), - name: bridge.id, - channels: bridge.channels, - settings: { - allow_everyone: false, - }, - }); - } - } - } - - progress.stop(); - - return bridges; - } - - async migration_set_bridges(bridges: bridge[]): Promise { - const progress = new ProgressBar({ - max: bridges.length, - fmt, - writable: stdout(), - }); - - for (const bridge of bridges) { - progress.value++; - - await this.redis.sendCommand([ - 'DEL', - `lightning-bridge-${bridge.id}`, - ]); - - for (const channel of bridge.channels) { - await this.redis.sendCommand([ - 'DEL', - `lightning-bchannel-${channel.id}`, - ]); - } - - if (bridge.channels.length < 2) continue; - - await this.redis.sendCommand([ - 'SET', - `lightning-bridge-${bridge.id}`, - JSON.stringify(bridge), - ]); - - for (const channel of bridge.channels) { - await this.redis.sendCommand([ - 'SET', - `lightning-bchannel-${channel.id}`, - bridge.id, - ]); - } - } - - progress.stop(); - - await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); - } - - async migration_get_messages(): Promise { - const keys = await this.redis.sendCommand([ - 'KEYS', - 'lightning-message-*', - ]) as string[]; - - const messages = [] as bridge_message[]; - - const progress = new ProgressBar({ - max: keys.length, - fmt, - writable: stdout(), - }); - - for (const key of keys) { - progress.value++; - const message = await this.get_json(key); - if (message) messages.push(message); - } - - progress.stop(); - - return messages; - } - - async migration_set_messages(messages: bridge_message[]): Promise { - const progress = new ProgressBar({ - max: messages.length, - fmt, - writable: stdout(), - }); - - for (const message of messages) { - progress.value++; - await this.create_message(message); - } - - progress.stop(); - - await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); - } - - static async migration_get_instance(): Promise { - const hostname = prompt('Please enter your Redis hostname (localhost):') || - 'localhost'; - const port = prompt('Please enter your Redis port (6379):') || '6379'; - - return await redis.create({ - hostname, - port: parseInt(port), - }, true); - } -} diff --git a/packages/lightning/src/mod.ts b/packages/lightning/src/mod.ts deleted file mode 100644 index 03f20ff9..00000000 --- a/packages/lightning/src/mod.ts +++ /dev/null @@ -1,3 +0,0 @@ -if (import.meta.main) import('./cli.ts'); - -export * from './structures/mod.ts'; diff --git a/packages/lightning/src/structures/bridge.ts b/packages/lightning/src/structures/bridge.ts deleted file mode 100644 index 01c21f45..00000000 --- a/packages/lightning/src/structures/bridge.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** representation of a bridge */ -export interface bridge { - /** primary key */ - id: string; - /** user-facing name of the bridge */ - name: string; - /** channels in the bridge */ - channels: bridge_channel[]; - /** settings for the bridge */ - settings: bridge_settings; -} - -/** a channel within a bridge */ -export interface bridge_channel { - /** the channel's canonical id */ - id: string; - /** data needed to bridge this channel */ - data: unknown; - /** whether the channel is disabled */ - disabled: boolean | { read: boolean; write: boolean }; - /** the plugin used to bridge this channel */ - plugin: string; -} - -/** possible settings for a bridge */ -export interface bridge_settings { - /** `@everyone/@here/@room` */ - allow_everyone: boolean; -} - -/** list of settings for a bridge */ -export const bridge_settings_list = ['allow_everyone']; - -/** representation of a bridged message collection */ -export interface bridge_message extends bridge { - /** original bridge id */ - bridge_id: string; - /** messages bridged */ - messages: bridged_message[]; -} - -/** representation of an individual bridged message */ -export interface bridged_message { - /** ids of the message */ - id: string[]; - /** the channel id sent to */ - channel: string; - /** the plugin used */ - plugin: string; -} - -/** options for a message to be bridged */ -export interface bridge_message_opts { - /** the channel to use */ - channel: bridge_channel; - /** ids of messages to edit, if any */ - edit_ids?: string[]; - /** the settings to use */ - settings: bridge_settings; -} diff --git a/packages/lightning/src/structures/cacher.ts b/packages/lightning/src/structures/cacher.ts deleted file mode 100644 index 3507c565..00000000 --- a/packages/lightning/src/structures/cacher.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** a class that wraps map to cache keys */ -export class cacher { - /** the map used to internally store keys */ - private map = new Map(); - - /** create a cacher with a ttl (defaults to 30000) */ - constructor(private ttl: number = 30000) {} - - /** get a key from the map, returning undefined if expired or not found */ - get(key: k): v | undefined { - const time = Temporal.Now.instant().epochMilliseconds; - const entry = this.map.get(key); - - if (entry && entry.expiry >= time) return entry.value; - this.map.delete(key); - return undefined; - } - - /** set a key in the map along with its expiry */ - set(key: k, val: v, customTtl?: number): v { - const time = Temporal.Now.instant().epochMilliseconds; - this.map.set(key, { - value: val, - expiry: time + (customTtl ?? this.ttl), - }); - return val; - } -} diff --git a/packages/lightning/src/structures/commands.ts b/packages/lightning/src/structures/commands.ts deleted file mode 100644 index d5127394..00000000 --- a/packages/lightning/src/structures/commands.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { message } from './messages.ts'; -import type { plugin } from './plugins.ts'; - -/** representation of a command */ -export interface command { - /** user-facing command name */ - name: string; - /** user-facing command description */ - description: string; - /** possible arguments */ - arguments?: command_argument[]; - /** possible subcommands (use `${prefix}${cmd} ${subcommand}` if run as text command) */ - subcommands?: Omit[]; - /** the functionality of the command, returning text */ - execute: (opts: command_opts) => Promise | string; -} - -/** argument for a command */ -export interface command_argument { - /** user-facing name for the argument */ - name: string; - /** description of the argument */ - description: string; - /** whether the argument is required */ - required: boolean; -} - -/** options given to a command */ -export interface command_opts { - /** arguments to use */ - args: Record; - /** the channel the command was run in */ - channel_id: string; - /** the plugin the command was run with */ - plugin: plugin; - /** the command prefix used */ - prefix: string; - /** the time the command was sent */ - timestamp: Temporal.Instant; -} - -/** options used for a command event */ -export interface create_command extends Omit { - /** the command to run */ - command: string; - /** id of the associated event */ - message_id: string; - /** the plugin id used to run this with */ - plugin: string; - /** other, additional, options */ - rest?: string[]; - /** event reply function */ - reply: (message: message) => Promise; - /** the subcommand, if any, to use */ - subcommand?: string; -} diff --git a/packages/lightning/src/structures/cross.ts b/packages/lightning/src/structures/cross.ts deleted file mode 100644 index 47a4dc2b..00000000 --- a/packages/lightning/src/structures/cross.ts +++ /dev/null @@ -1,63 +0,0 @@ -// deno-lint-ignore-file no-process-global -// deno-lint-ignore triple-slash-reference -/// - -const is_deno = 'Deno' in globalThis; - -/** Get environment variable */ -export function get_env(key: string): string | undefined { - return is_deno ? Deno.env.get(key) : process.env[key]; -} - -/** Set environment variable */ -export function set_env(key: string, value: string): void { - if (is_deno) { - Deno.env.set(key, value); - } else { - process.env[key] = value; - } -} - -/** Get current directory */ -export function cwd(): string { - return is_deno ? Deno.cwd() : process.cwd(); -} - -/** Exit the process */ -export function exit(code: number): never { - return is_deno ? Deno.exit(code) : process.exit(code); -} - -/** Get command-line arguments */ -export function get_args(): string[] { - return is_deno ? Deno.args : process.argv.slice(2); -} - -/** Get stdout stream */ -export function stdout(): WritableStream { - return is_deno - ? Deno.stdout.writable - : process.getBuiltinModule('stream').Writable.toWeb( - process.stdout, - ) as WritableStream; -} - -/** Get tcp connection streams */ -export async function tcp_connect( - opts: { hostname: string; port: number }, -): Promise< - { readable: ReadableStream; writable: WritableStream } -> { - if (is_deno) return await Deno.connect(opts); - - const { createConnection } = process.getBuiltinModule('node:net'); - const { Readable, Writable } = process.getBuiltinModule('node:stream'); - const conn = createConnection({ - host: opts.hostname, - port: opts.port, - }); - return { - readable: Readable.toWeb(conn) as ReadableStream, - writable: Writable.toWeb(conn) as WritableStream, - }; -} diff --git a/packages/lightning/src/structures/errors.ts b/packages/lightning/src/structures/errors.ts deleted file mode 100644 index 48ea1a87..00000000 --- a/packages/lightning/src/structures/errors.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { get_env } from './cross.ts'; -import { create_message, type message } from './messages.ts'; - -/** options used to create an error */ -export interface error_options { - /** the user-facing message of the error */ - message?: string; - /** the extra data to log */ - extra?: Record; - /** whether to disable the associated channel (when bridging) */ - disable?: { read: boolean; write: boolean }; - /** whether this should be logged without the cause */ - without_cause?: boolean; -} - -/** logs an error */ -export function log_error(e: unknown, options?: error_options): never { - throw new LightningError(e, options); -} - -/** lightning error */ -export class LightningError extends Error { - /** the id associated with the error */ - id: string; - /** the cause of the error */ - private error_cause: Error; - /** extra information associated with the error */ - extra: Record; - /** the user-facing error message */ - msg: message; - /** whether to disable the associated channel (when bridging) */ - disable?: { read: boolean; write: boolean }; - /** whether to show the cause or not */ - without_cause?: boolean; - - /** create and log an error */ - constructor(e: unknown, options?: error_options) { - if (e instanceof LightningError) { - super(e.message, { cause: e.cause }); - this.id = e.id; - this.error_cause = e.error_cause; - this.extra = e.extra; - this.msg = e.msg; - this.disable = e.disable; - this.without_cause = e.without_cause; - return e; - } - - const cause_err = e instanceof Error - ? e - : e instanceof Object - ? new Error(JSON.stringify(e)) - : new Error(String(e)); - - const id = crypto.randomUUID(); - - super(options?.message ?? cause_err.message, { cause: e }); - - this.name = 'LightningError'; - this.id = id; - this.error_cause = cause_err; - this.extra = options?.extra ?? {}; - this.disable = options?.disable; - this.without_cause = options?.without_cause; - this.msg = create_message( - `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\``, - ); - - console.error(`%c[lightning] ${this.message} - ${this.id}`, 'color: red'); - if (this.disable?.read) console.log(`[lightning] channel reads disabled`); - if (this.disable?.write) console.log(`[lightning] channel writes disabled`); - if (!this.without_cause) this.log(); - } - - /** log the error, automatically called in most cases */ - async log(): Promise { - if (!this.without_cause) console.error(this.error_cause, this.extra); - - const webhook = get_env('LIGHTNING_ERROR_WEBHOOK'); - - if (webhook && webhook.length > 0) { - await fetch(webhook, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - content: `# ${this.error_cause.message}\n*${this.id}*`, - }), - }); - } - } -} diff --git a/packages/lightning/src/structures/messages.ts b/packages/lightning/src/structures/messages.ts deleted file mode 100644 index 16a12686..00000000 --- a/packages/lightning/src/structures/messages.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * 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 { - return { - author: { - username: 'lightning', - profile: - 'https://williamhorning.eu.org/assets/lightning/logo_monocolor_dark.svg', - rawname: 'lightning', - id: 'lightning', - }, - content: text, - channel_id: '', - message_id: '', - timestamp: Temporal.Now.instant(), - plugin: 'lightning', - }; -} - -/** attachments within a message */ -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 in MiB */ - size: number; -} - -/** a representation of a message that has been deleted */ -export interface deleted_message { - /** the message's id */ - message_id: string; - /** the channel the message was sent in */ - channel_id: string; - /** the plugin that received the message */ - plugin: string; - /** the time the message was sent/edited as a temporal instant */ - timestamp: Temporal.Instant; -} - -/** a discord-style embed */ -export interface embed { - /** the author of the embed */ - author?: { - /** the name of the author */ - name: string; - /** the url of the author */ - url?: string; - /** the icon of the author */ - icon_url?: string; - }; - /** the color of the embed */ - color?: number; - /** the text in an embed */ - description?: string; - /** fields within the embed */ - fields?: { - /** the name of the field */ - name: string; - /** the value of the field */ - value: string; - /** whether or not the field is inline */ - inline?: boolean; - }[]; - /** a footer shown in the embed */ - footer?: { - /** the footer text */ - text: string; - /** the icon of the footer */ - icon_url?: string; - }; - /** an image shown in the embed */ - image?: media; - /** a thumbnail shown in the embed */ - thumbnail?: media; - /** the time (in epoch ms) shown in the embed */ - timestamp?: number; - /** the title of the embed */ - title?: string; - /** a site linked to by the embed */ - url?: string; - /** a video inside of the embed */ - video?: media; -} - -/** media inside of an embed */ -export interface media { - /** the height of the media */ - height?: number; - /** the url of the media */ - url: string; - /** the width of the media */ - width?: number; -} - -/** a message received by a plugin */ -export interface message extends deleted_message { - /** the attachments sent with the message */ - attachments?: attachment[]; - /** the author of the message */ - author: message_author; - /** message content (can be markdown) */ - content?: string; - /** discord-style embeds */ - embeds?: embed[]; - /** the id of the message replied to */ - reply_id?: string[]; -} - -/** an author of a message */ -export interface message_author { - /** the nickname of the author */ - username: string; - /** the author's username */ - rawname: string; - /** a url pointing to the authors profile picture */ - profile?: string; - /** the author's id */ - id: string; - /** the color of an author */ - color?: string; -} diff --git a/packages/lightning/src/structures/mod.ts b/packages/lightning/src/structures/mod.ts deleted file mode 100644 index 9bf90267..00000000 --- a/packages/lightning/src/structures/mod.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './bridge.ts'; -export * from './cacher.ts'; -export * from './commands.ts'; -export * from './errors.ts'; -export * from './messages.ts'; -export * from './plugins.ts'; -export * from './validate.ts'; diff --git a/packages/lightning/src/structures/plugins.ts b/packages/lightning/src/structures/plugins.ts deleted file mode 100644 index ac998027..00000000 --- a/packages/lightning/src/structures/plugins.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { EventEmitter } from '@denosaurs/event'; -import type { bridge_message_opts } from './bridge.ts'; -import type { command, create_command } from './commands.ts'; -import type { deleted_message, message } from './messages.ts'; -import type { config_schema } from './validate.ts'; - -/** the events emitted by core/plugins */ -export type events = { - /** when a message is created */ - create_message: [message]; - /** when a message is edited */ - edit_message: [message]; - /** when a message is deleted */ - delete_message: [deleted_message]; - /** when a command is run */ - create_command: [create_command]; -}; - -/** a plugin for lightning */ -export interface plugin { - /** setup user-facing commands, if available */ - set_commands?(commands: command[]): Promise | void; -} - -/** a plugin for lightning */ -export abstract class plugin extends EventEmitter { - /** the name of your plugin */ - abstract name: string; - /** setup a channel to be used in a bridge */ - abstract setup_channel(channel: string): Promise | unknown; - /** send a message to a given channel */ - abstract create_message( - message: message, - opts?: bridge_message_opts, - ): Promise; - /** edit a message in a given channel */ - abstract edit_message( - message: message, - opts: bridge_message_opts & { edit_ids: string[] }, - ): Promise; - /** delete messages in a given channel */ - abstract delete_messages( - messages: deleted_message[], - ): Promise; -} - -/** the type core uses to load a module */ -export interface plugin_module { - /** the plugin constructor */ - default?: { new (cfg: unknown): plugin }; - /** the config to validate use */ - schema?: config_schema; -} diff --git a/packages/lightning/src/structures/validate.ts b/packages/lightning/src/structures/validate.ts deleted file mode 100644 index ee373184..00000000 --- a/packages/lightning/src/structures/validate.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { log_error } from './errors.ts'; - -/** A config schema */ -export interface config_schema { - name: string; - keys: Record; -} - -/** Validate an item based on a schema */ -export function validate_config(config: unknown, schema: config_schema): T { - if (typeof config !== 'object' || config === null) { - log_error(`[${schema.name}] config is not an object`, { - without_cause: true, - }); - } - - for (const [key, { type, required }] of Object.entries(schema.keys)) { - const value = (config as Record)[key]; - - if (required && value === undefined) { - log_error(`[${schema.name}] missing required config key '${key}'`, { - without_cause: true, - }); - } else if (value !== undefined && typeof value !== type) { - log_error(`[${schema.name}] config key '${key}' must be a ${type}`, { - without_cause: true, - }); - } - } - - return config as T; -} diff --git a/packages/revolt/README.md b/packages/revolt/README.md deleted file mode 100644 index ea0efddc..00000000 --- a/packages/revolt/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# @lightning/revolt - -[![JSR](https://jsr.io/badges/@lightning/revolt)](https://jsr.io/@lightning/revolt) - -@lightning/telegram adds support for Revolt. To use it, you'll need to create a -Revolt bot first. After that, you need to add the following to your config file: - -```toml -[[plugins]] -plugin = "jsr:@lightning/revolt@0.8.0-alpha.5" -config.token = "your_bot_token" -config.user_id = "your_bot_user_id" -``` diff --git a/packages/revolt/deno.json b/packages/revolt/deno.json deleted file mode 100644 index b136815e..00000000 --- a/packages/revolt/deno.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@lightning/revolt", - "version": "0.8.0-alpha.5", - "license": "MIT", - "exports": "./src/mod.ts", - "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", - "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.12", - "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.8", - "@std/ulid": "jsr:@std/ulid@^1.0.0" - } -} diff --git a/packages/revolt/src/cache.ts b/packages/revolt/src/cache.ts deleted file mode 100644 index f5241b35..00000000 --- a/packages/revolt/src/cache.ts +++ /dev/null @@ -1,196 +0,0 @@ -import type { - Channel, - Emoji, - Masquerade, - Member, - Message, - Role, - Server, - User, -} from '@jersey/revolt-api-types'; -import type { Client } from '@jersey/rvapi'; -import { cacher, type message_author } from '@lightning/lightning'; - -const authors = new cacher<`${string}/${string}`, message_author>(); -const channels = new cacher(); -const emojis = new cacher(); -const members = new cacher<`${string}/${string}`, Member>(); -const messages = new cacher<`${string}/${string}`, Message>(); -const roles = new cacher<`${string}/${string}`, Role>(); -const servers = new cacher(); -const users = new cacher(); - -export async function fetch_author( - api: Client, - authorID: string, - channelID: string, - masquerade?: Masquerade, -): Promise { - try { - const cached = authors.get(`${authorID}/${channelID}`); - - if (cached) return cached; - - const channel = await fetch_channel(api, channelID); - const author = await fetch_user(api, authorID); - - const data = { - id: authorID, - rawname: author.username, - username: masquerade?.name ?? author.username, - color: masquerade?.colour ?? '#FF4654', - profile: masquerade?.avatar ?? - (author.avatar - ? `https://cdn.revoltusercontent.com/avatars/${author.avatar._id}` - : undefined), - }; - - if (channel.channel_type !== 'TextChannel') return data; - - try { - const member = await fetch_member(api, channel.server, authorID); - - return authors.set(`${authorID}/${channelID}`, { - ...data, - username: masquerade?.name ?? member.nickname ?? data.username, - profile: masquerade?.avatar ?? - (member.avatar - ? `https://cdn.revoltusercontent.com/avatars/${member.avatar._id}` - : data.profile), - }); - } catch { - return authors.set(`${authorID}/${channelID}`, data); - } - } catch { - return { - id: authorID, - rawname: masquerade?.name ?? 'RevoltUser', - username: masquerade?.name ?? 'Revolt User', - profile: masquerade?.avatar ?? undefined, - color: masquerade?.colour ?? '#FF4654', - }; - } -} - -export async function fetch_channel( - api: Client, - channelID: string, -): Promise { - const cached = channels.get(channelID); - - if (cached) return cached; - - const channel = await api.request( - 'get', - `/channels/${channelID}`, - undefined, - ) as Channel; - - return channels.set(channelID, channel); -} - -export async function fetch_emoji( - api: Client, - emoji_id: string, -): Promise { - const cached = emojis.get(emoji_id); - - if (cached) return cached; - - return emojis.set( - emoji_id, - await api.request( - 'get', - `/custom/emoji/${emoji_id}`, - undefined, - ), - ); -} - -export async function fetch_member( - client: Client, - serverID: string, - userID: string, -): Promise { - const member = members.get(`${serverID}/${userID}`); - - if (member) return member; - - const response = await client.request( - 'get', - `/servers/${serverID}/members/${userID}`, - { roles: false }, - ) as Member; - - return members.set(`${serverID}/${userID}`, response); -} - -export async function fetch_message( - client: Client, - channelID: string, - messageID: string, -): Promise { - const message = messages.get(`${channelID}/${messageID}`); - - if (message) return message; - - const response = await client.request( - 'get', - `/channels/${channelID}/messages/${messageID}`, - undefined, - ) as Message; - - return messages.set(`${channelID}/${messageID}`, response); -} - -export async function fetch_role( - client: Client, - serverID: string, - roleID: string, -): Promise { - const role = roles.get(`${serverID}/${roleID}`); - - if (role) return role; - - const response = await client.request( - 'get', - `/servers/${serverID}/roles/${roleID}`, - undefined, - ) as Role; - - return roles.set(`${serverID}/${roleID}`, response); -} - -export async function fetch_server( - client: Client, - serverID: string, -): Promise { - const server = servers.get(serverID); - - if (server) return server; - - const response = await client.request( - 'get', - `/servers/${serverID}`, - undefined, - ) as Server; - - return servers.set(serverID, response); -} - -export async function fetch_user( - api: Client, - userID: string, -): Promise { - const cached = users.get(userID); - - if (cached) return cached; - - const user = await api.request( - 'get', - `/users/${userID}`, - undefined, - ) as User; - - return users.set(userID, user); -} diff --git a/packages/revolt/src/errors.ts b/packages/revolt/src/errors.ts deleted file mode 100644 index 1f2e5854..00000000 --- a/packages/revolt/src/errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { RequestError } from '@jersey/revolt-api-types'; -import { MediaError } from '@jersey/rvapi'; -import { log_error } from '@lightning/lightning'; - -const errors = [ - [403, 'Insufficient permissions. Please check them', false, true], - [404, 'Resource not found', false, true], - [0, 'Unknown Revolt RequestError', false, false], -] as const; - -export function handle_error(err: unknown, edit?: boolean): never[] { - if (err instanceof MediaError) { - log_error(err); - } else if (err instanceof RequestError) { - if (err.cause.status === 404 && edit) return []; - - const [, message, read, write] = errors.find((e) => - e[0] === err.cause.status - ) ?? errors[errors.length - 1]; - - log_error(err, { message, disable: { read, write } }); - } else { - log_error(err, { message: 'unknown revolt error' }); - } -} diff --git a/packages/revolt/src/incoming.ts b/packages/revolt/src/incoming.ts deleted file mode 100644 index 78e82ede..00000000 --- a/packages/revolt/src/incoming.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { Message } from '@jersey/revolt-api-types'; -import type { Client } from '@jersey/rvapi'; -import type { embed, message } from '@lightning/lightning'; -import { decodeTime } from '@std/ulid'; -import { fetch_author, fetch_channel, fetch_emoji } from './cache.ts'; - -async function get_content( - api: Client, - channel_id: string, - content?: string | null, -) { - if (!content) return; - - for ( - const match of content.matchAll(/:([0-7][0-9A-HJKMNP-TV-Z]{25}):/g) - ) { - try { - content = content.replace( - match[0], - `:${(await fetch_emoji(api, match[1])).name}:`, - ); - } catch { - content = content.replace(match[0], `:${match[1]}:`); - } - } - - for ( - const match of content.matchAll(/<@([0-7][0-9A-HJKMNP-TV-Z]{25})>/g) - ) { - try { - content = content.replace( - match[0], - `@${(await fetch_author(api, match[1], channel_id)).username}`, - ); - } catch { - content = content.replace(match[0], `@${match[1]}`); - } - } - - for ( - const match of content.matchAll(/<#([0-7][0-9A-HJKMNP-TV-Z]{25})>/g) - ) { - try { - const channel = await fetch_channel(api, match[1]); - content = content.replace( - match[0], - `#${'name' in channel ? channel.name : `DM${channel._id}`}`, - ); - } catch { - content = content.replace(match[0], `#${match[1]}`); - } - } - - return content; -} - -export async function get_incoming( - message: Message, - api: Client, -): Promise { - return { - attachments: message.attachments?.map((i) => { - return { - file: - `https://cdn.revoltusercontent.com/attachments/${i._id}/${i.filename}`, - name: i.filename, - size: i.size / 1048576, - }; - }), - author: await fetch_author(api, message.author, message.channel), - channel_id: message.channel, - content: await get_content(api, message.channel, message.content), - embeds: message.embeds?.map((i) => { - return { - color: 'colour' in i && i.colour - ? parseInt(i.colour.replace('#', ''), 16) - : undefined, - ...i, - } as embed; - }), - message_id: message._id, - plugin: 'bolt-revolt', - timestamp: message.edited - ? Temporal.Instant.from(message.edited) - : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), - reply_id: message.replies ?? undefined, - }; -} diff --git a/packages/revolt/src/mod.ts b/packages/revolt/src/mod.ts deleted file mode 100644 index ba320ee5..00000000 --- a/packages/revolt/src/mod.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Message } from '@jersey/revolt-api-types'; -import { Bonfire, type Client, createClient } from '@jersey/rvapi'; -import { - type bridge_message_opts, - type config_schema, - type deleted_message, - type message, - plugin, -} from '@lightning/lightning'; -import { fetch_message } from './cache.ts'; -import { handle_error } from './errors.ts'; -import { get_incoming } from './incoming.ts'; -import { get_outgoing } from './outgoing.ts'; -import { check_permissions } from './permissions.ts'; - -/** the config for the revolt bot */ -export interface revolt_config { - /** the token for the revolt bot */ - token: string; - /** the user id for the bot */ - user_id: string; -} - -/** the config schema for the revolt plugin */ -export const schema: config_schema = { - name: 'bolt-revolt', - keys: { - token: { type: 'string', required: true }, - user_id: { type: 'string', required: true }, - }, -}; - -/** revolt support for lightning */ -export default class revolt extends plugin { - name = 'bolt-revolt'; - private client: Client; - private user_id: string; - - /** setup revolt using these options */ - constructor(opts: revolt_config) { - super(); - this.client = createClient({ token: opts.token }); - this.user_id = opts.user_id; - this.setup_events(opts); - } - - private setup_events(opts: revolt_config) { - this.client.bonfire.on('Message', async (data) => { - const msg = await get_incoming(data, this.client); - if (msg) this.emit('create_message', msg); - }).on('MessageDelete', (data) => { - this.emit('delete_message', { - channel_id: data.channel, - message_id: data.id, - plugin: 'bolt-revolt', - timestamp: Temporal.Now.instant(), - }); - }).on('MessageUpdate', async (data) => { - try { - const msg = await get_incoming({ - ...await fetch_message(this.client, data.channel, data.id), - ...data, - }, this.client); - - if (msg) this.emit('edit_message', msg); - } catch { - return; - } - }).on('Ready', (data) => { - console.log( - `[revolt] ready as ${ - data.users.find((i) => i._id === this.user_id)?.username - } in ${data.servers.length} servers`, - `\n[revolt] invite me at https://app.revolt.chat/bot/${this.user_id}`, - ); - }).on('socket_close', () => { - this.client.bonfire = new Bonfire({ - token: opts.token, - url: 'wss://ws.revolt.chat', - }); - this.setup_events(opts); - }); - } - - /** ensure masquerading will work in that channel */ - async setup_channel(channel_id: string): Promise { - return await check_permissions(channel_id, this.user_id, this.client); - } - - /** send a message to a channel */ - async create_message( - message: message, - data?: bridge_message_opts, - ): Promise { - try { - return [ - (await this.client.request( - 'post', - `/channels/${message.channel_id}/messages`, - await get_outgoing(this.client, message, data !== undefined), - ) as Message)._id, - ]; - } catch (e) { - return handle_error(e); - } - } - - /** edit a message in a channel */ - async edit_message( - message: message, - data: bridge_message_opts & { edit_ids: string[] }, - ): Promise { - try { - return [ - (await this.client.request( - 'patch', - `/channels/${message.channel_id}/messages/${data.edit_ids[0]}`, - await get_outgoing(this.client, message, true), - ) as Message)._id, - ]; - } catch (e) { - return handle_error(e, true); - } - } - - /** delete messages in a channel */ - async delete_messages(messages: deleted_message[]): Promise { - return await Promise.all( - messages.map(async (msg) => { - try { - await this.client.request( - 'delete', - `/channels/${msg.channel_id}/messages/${msg.message_id}`, - undefined, - ); - return msg.message_id; - } catch (e) { - handle_error(e, true); - return msg.message_id; - } - }), - ); - } -} diff --git a/packages/revolt/src/outgoing.ts b/packages/revolt/src/outgoing.ts deleted file mode 100644 index 43e213c4..00000000 --- a/packages/revolt/src/outgoing.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; -import type { Client } from '@jersey/rvapi'; -import { LightningError, type message } from '@lightning/lightning'; - -export async function get_outgoing( - api: Client, - message: message, - masquerade = true, -): Promise { - const attachments = (await Promise.all( - message.attachments?.map(async (attachment) => { - try { - const file = await (await fetch(attachment.file)).blob(); - if (file.size < 1) return; - return await api.media.upload_file('attachments', file); - } catch (e) { - new LightningError(e, { - message: 'Failed to upload attachment', - extra: { original: e }, - }); - - return; - } - }) ?? [], - )).filter((i) => i !== undefined); - - if ( - (!message.content || message.content.length < 1) && - (!message.embeds || message.embeds.length < 1) && - (!attachments || attachments.length < 1) - ) { - message.content = '*empty message*'; - } - - return { - attachments, - content: (message.content?.length ?? 0) > 2000 - ? `${message.content?.substring(0, 1997)}...` - : message.content, - embeds: message.embeds?.map((embed) => { - const data: SendableEmbed = { - icon_url: embed.author?.icon_url, - url: embed.url, - title: embed.title, - description: embed.description ?? '', - media: embed.image?.url, - colour: embed.color - ? `#${embed.color.toString(16).padStart(6, '0')}` - : undefined, - }; - - for (const field of embed.fields ?? []) { - data.description += `\n\n**${field.name}**\n${field.value}`; - } - - if (data.description?.length === 0) data.description = undefined; - - return data; - }), - replies: message.reply_id?.map((reply) => ({ - id: reply, - mention: false, - fail_if_not_exists: false, - })), - masquerade: masquerade - ? { - name: message.author.username, - avatar: message.author.profile, - colour: message.author.color, - } - : undefined, - }; -} diff --git a/packages/revolt/src/permissions.ts b/packages/revolt/src/permissions.ts deleted file mode 100644 index 26b5a112..00000000 --- a/packages/revolt/src/permissions.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { Client } from '@jersey/rvapi'; -import { log_error } from '@lightning/lightning'; -import { - fetch_channel, - fetch_member, - fetch_role, - fetch_server, -} from './cache.ts'; -import { handle_error } from './errors.ts'; - -const needed_permissions = 485495808; -const error_message = - 'missing ChangeNickname, ChangeAvatar, ReadMessageHistory, \ -SendMessage, ManageMessages, SendEmbeds, UploadFiles, and/or Masquerade permissions \ -please add them to a role, assign that role to the bot, and rejoin the bridge'; - -export async function check_permissions( - channel_id: string, - bot_id: string, - client: Client, -) { - try { - const channel = await fetch_channel(client, channel_id); - - if (channel.channel_type === 'Group') { - if ( - !(channel.permissions && (channel.permissions & needed_permissions)) - ) log_error(error_message); - } else if (channel.channel_type === 'TextChannel') { - const server = await fetch_server(client, channel.server); - const member = await fetch_member(client, channel.server, bot_id); - - // check server permissions - let currentPermissions = server.default_permissions; - - for (const role of (member.roles ?? [])) { - const { permissions: role_permissions } = await fetch_role( - client, - server._id, - role, - ); - - currentPermissions |= role_permissions.a || 0; - currentPermissions &= ~role_permissions.d || 0; - } - - // apply default allow/denies - if (channel.default_permissions) { - currentPermissions |= channel.default_permissions.a; - currentPermissions &= ~channel.default_permissions.d; - } - - // apply role permissions - if (channel.role_permissions) { - for (const role of (member.roles ?? [])) { - currentPermissions |= channel.role_permissions[role]?.a || 0; - currentPermissions &= ~channel.role_permissions[role]?.d || 0; - } - } - - if (!(currentPermissions & needed_permissions)) log_error(error_message); - } else { - log_error(`unsupported channel type: ${channel.channel_type}`); - } - - return channel_id; - } catch (e) { - handle_error(e); - } -} diff --git a/packages/telegram/README.md b/packages/telegram/README.md deleted file mode 100644 index 097648e4..00000000 --- a/packages/telegram/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# @lightning/telegram - -[![JSR](https://jsr.io/badges/@lightning/telegram)](https://jsr.io/@lightning/telegram) - -@lightning/telegram adds support for Telegram. Before using it, you'll need to -talk with @BotFather to create a bot. After that, you need to add the following -to your config: - -```toml -[[plugins]] -plugin = "jsr:@lightning/telegram@0.8.0-alpha.5" -config.token = "your_bot_token" -config.proxy_port = 9090 -config.proxy_url = "https://example.com:9090" -``` - -Additionally, you will need to expose the port provided at the URL provided for -attachments sent from Telegram to work properly diff --git a/packages/telegram/deno.json b/packages/telegram/deno.json deleted file mode 100644 index 2fa850c8..00000000 --- a/packages/telegram/deno.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@lightning/telegram", - "version": "0.8.0-alpha.5", - "license": "MIT", - "exports": "./src/mod.ts", - "imports": { - "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", - "grammy": "npm:grammy@^1.36.3", - "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" - } -} diff --git a/packages/telegram/src/incoming.ts b/packages/telegram/src/incoming.ts deleted file mode 100644 index ecca69c2..00000000 --- a/packages/telegram/src/incoming.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { command, create_command, message } from '@lightning/lightning'; -import type { CommandContext, Context } from 'grammy'; -import { get_outgoing } from './outgoing.ts'; - -const types = [ - 'text', - 'dice', - 'location', - 'document', - 'animation', - 'audio', - 'photo', - 'sticker', - 'video', - 'video_note', - 'voice', -] as const; - -export async function get_incoming( - ctx: Context, - proxy: string, -): Promise { - const msg = ctx.editedMessage ?? ctx.msg; - if (!msg) return; - const author = await ctx.getAuthor(); - const profile = await ctx.getUserProfilePhotos({ limit: 1 }); - const type = types.find((type) => type in msg) ?? 'unsupported'; - const base: message = { - author: { - username: author.user.last_name - ? `${author.user.first_name} ${author.user.last_name}` - : author.user.first_name, - rawname: author.user.username ?? author.user.first_name, - color: '#24A1DE', - profile: profile.total_count - ? `${proxy}/${ - (await ctx.api.getFile(profile.photos[0][0].file_id)).file_path - }` - : undefined, - id: `${author.user.id}`, - }, - channel_id: `${msg.chat.id}`, - message_id: `${msg.message_id}`, - timestamp: Temporal.Instant.fromEpochMilliseconds( - (msg.edit_date ?? msg.date) * 1000, - ), - plugin: 'bolt-telegram', - reply_id: msg.reply_to_message - ? [`${msg.reply_to_message.message_id}`] - : undefined, - }; - - if (type === 'unsupported') return; - if (type === 'text') return { ...base, content: msg.text }; - if (type === 'dice') { - return { - ...base, - content: `${msg.dice!.emoji} ${msg.dice!.value}`, - }; - } - if (type === 'location') { - return { - ...base, - content: `https://www.openstreetmap.com/#map=18/${ - msg.location!.latitude - }/${msg.location!.longitude}`, - }; - } - - const file = await ctx.api.getFile( - (type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!).file_id, - ); - - if (!file.file_path) return; - - return { - ...base, - attachments: [{ - file: `${proxy}/${file.file_path}`, - name: file.file_path, - size: (file.file_size ?? 0) / 1048576, - }], - }; -} - -export function get_command( - ctx: CommandContext, - cmd: command, -): create_command { - return { - channel_id: `${ctx.chat.id}`, - command: cmd.name, - message_id: `${ctx.msgId}`, - timestamp: Temporal.Instant.fromEpochMilliseconds(ctx.msg.date * 1000), - plugin: 'bolt-telegram', - prefix: '/', - args: {}, - rest: cmd.subcommands - ? ctx.match.split(' ').slice(1) - : ctx.match.split(' '), - subcommand: cmd.subcommands ? ctx.match.split(' ')[0] : undefined, - reply: async (message: message) => { - for (const msg of get_outgoing(message, false)) { - await ctx.api[msg.function]( - ctx.chat.id, - msg.value, - { - reply_parameters: { message_id: ctx.msgId }, - parse_mode: 'MarkdownV2', - }, - ); - } - }, - }; -} diff --git a/packages/telegram/src/mod.ts b/packages/telegram/src/mod.ts deleted file mode 100644 index 43bcf8fb..00000000 --- a/packages/telegram/src/mod.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { - type bridge_message_opts, - type command, - type config_schema, - type deleted_message, - type message, - plugin, -} from '@lightning/lightning'; -import { Bot, type Composer, type Context, GrammyError } from 'grammy'; -import { get_command, get_incoming } from './incoming.ts'; -import { get_outgoing } from './outgoing.ts'; - -/** options for telegram */ -export type telegram_config = { - /** the token for the bot */ - token: string; - /** the port the file proxy will run on */ - proxy_port: number; - /** the publicly accessible url of the file proxy */ - proxy_url: string; -}; - -/** the config schema for the plugin */ -export const schema: config_schema = { - name: 'bolt-telegram', - keys: { - token: { type: 'string', required: true }, - proxy_port: { type: 'number', required: true }, - proxy_url: { type: 'string', required: true }, - }, -}; - -/** telegram support for lightning */ -export default class telegram extends plugin { - name = 'bolt-telegram'; - private bot: Bot; - private composer: Composer; - - /** setup telegram and its file proxy */ - constructor(opts: telegram_config) { - super(); - this.bot = new Bot(opts.token); - this.composer = this.bot.on('message') as Composer; - this.bot.start(); - - this.bot.on(['message', 'edited_message'], async (ctx) => { - const msg = await get_incoming(ctx, opts.proxy_url); - if (msg) this.emit('create_message', msg); - }); - - const handler = async ({ url }: { url: string }) => - await fetch( - `https://api.telegram.org/file/bot${opts.token}/${ - (new URL(url)).pathname.replace('/telegram', '/') - }`, - ); - - if ('Deno' in globalThis) { - Deno.serve({ port: opts.proxy_port }, handler); - } else if ('Bun' in globalThis) { - // @ts-ignore: Bun.serve is not typed - Bun.serve({ - fetch: handler, - port: opts.proxy_port, - }); - } else if ('process' in globalThis) { - // deno-lint-ignore no-process-global - process.getBuiltinModule('node:http').createServer(async (req, res) => { - const resp = await handler(req as { url: string }); - res.writeHead(resp.status, Array.from(resp.headers.entries())); - res.write(new Uint8Array(await resp.arrayBuffer())); - res.end(); - }); - } else { - throw new Error('Unsupported environment for file proxy!'); - } - - console.log( - `[telegram] proxy available at localhost:${opts.proxy_port} or ${opts.proxy_url}`, - ); - } - - /** handle commands */ - override async set_commands(commands: command[]): Promise { - await this.bot.api.setMyCommands(commands.map((cmd) => ({ - command: cmd.name, - description: cmd.description, - }))); - - for (const cmd of commands) { - const name = cmd.name === 'help' ? ['help', 'start'] : cmd.name; - this.composer.command(name, (ctx) => { - this.emit('create_command', get_command(ctx, cmd)); - }); - } - } - - /** stub for setup_channel */ - setup_channel(channel: string): unknown { - return channel; - } - - /** send a message in a channel */ - async create_message( - message: message, - data: bridge_message_opts, - ): Promise { - const messages = []; - - for (const msg of get_outgoing(message, data !== undefined)) { - const result = await this.bot.api[msg.function]( - message.channel_id, - msg.value, - { - reply_parameters: message.reply_id && message.reply_id.length > 0 - ? { - message_id: parseInt(message.reply_id[0]), - } - : undefined, - parse_mode: 'MarkdownV2', - }, - ); - - messages.push(`${result.message_id}`); - } - - return messages; - } - - /** edit a message in a channel */ - async edit_message( - message: message, - opts: bridge_message_opts & { edit_ids: string[] }, - ): Promise { - try { - await this.bot.api.editMessageText( - opts.channel.id, - parseInt(opts.edit_ids[0]), - get_outgoing(message, true)[0].value, - { parse_mode: 'MarkdownV2' }, - ); - } catch (e) { - if (!(e instanceof GrammyError && e.error_code === 400)) { - throw e; - } - } - - return opts.edit_ids; - } - - /** delete messages in a channel */ - async delete_messages(messages: deleted_message[]): Promise { - return await Promise.all( - messages.map(async (msg) => { - await this.bot.api.deleteMessage( - msg.channel_id, - parseInt(msg.message_id), - ); - return msg.message_id; - }), - ); - } -} diff --git a/packages/telegram/src/outgoing.ts b/packages/telegram/src/outgoing.ts deleted file mode 100644 index 843b87a8..00000000 --- a/packages/telegram/src/outgoing.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { message } from '@lightning/lightning'; -import convert_markdown from 'telegramify-markdown'; - -export function get_outgoing( - msg: message, - bridged: boolean, -): { function: 'sendMessage' | 'sendDocument'; value: string }[] { - let content = bridged - ? `${msg.author.username} ยป ${msg.content || '_no content_'}` - : msg.content ?? '_no content_'; - - if ((msg.embeds?.length ?? 0) > 0) { - content += '\n_this message has embeds_'; - } - - const messages: { - function: 'sendMessage' | 'sendDocument'; - value: string; - }[] = [{ - function: 'sendMessage', - value: convert_markdown(content, 'escape'), - }]; - - for (const attachment of (msg.attachments ?? [])) { - messages.push({ - function: 'sendDocument', - value: attachment.file, - }); - } - - return messages; -} diff --git a/readme.md b/readme.md index 59d48f32..acd60b0f 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ # lightning - a chatbot > [!NOTE] -> This branch contains the next version of lightning, currently `0.8.0-alpha.5`, +> This branch contains the next version of lightning, currently `0.8.0-alpha.6`, > and reflects active development. To see the latest stable version, go to the > `main` branch. @@ -11,8 +11,7 @@ - **Extensible**: support for messaging apps provided by plugins which can be enabled/disabled by the user - **Easy to run**: able to run in Docker with multiple database options -- **Based on TypeScript**: uses the flexibility of JavaScript along with the - safety provided by typing and Deno +- **Based on Go**: uses the strong typing, performance and simplicity of Go ## documentation @@ -54,11 +53,10 @@ to platform limitations. ### matrix notes The Matrix Specification is really difficult to correctly handle, especially -with the current state of JavaScript libraries. Solutions that work without a -reliance on `matrix-appservice-bridge` but still use JavaScript and are -_consistently reliable_ aren't easy to implement, and currently I don't have -time to work on implementing this. If you would like to implement Matrix -support, please take a look at #66 for a prior attempt of mine. +with the current state of the various Matrix libraries. Solutions that work +well and are _consistently reliable_ aren't easy to implement, and currently +I don't have time to work on implementing this. If you would like to implement +Matrix support, please take a look at #66 for a prior attempt of mine. ### requesting another platform @@ -67,7 +65,7 @@ to add support for more platforms, though there are a few requirements they should fulfil: 1. having a pre-existing substantial user base -2. having JavaScript libraries with decent code quality +2. having Go libraries with decent code quality 3. having rich-messaging support of some kind ## licensing diff --git a/revolt/errors.go b/revolt/errors.go new file mode 100644 index 00000000..03d5f880 --- /dev/null +++ b/revolt/errors.go @@ -0,0 +1,37 @@ +package revolt + +import ( + "strconv" + "strings" + + "github.com/williamhorning/lightning" +) + +func extractStatusAndBody(err error) (int, string) { + msg := err.Error() + + if !strings.HasPrefix(msg, "bad status code ") { + return 0, "" + } + + msg = msg[16:] + statusCode, _ := strconv.Atoi(msg[:3]) + return statusCode, msg[5:] +} + +func getRevoltError(err error, extra map[string]any, message string, edit bool) error { + statusCode, body := extractStatusAndBody(err) + + extra["status_code"] = statusCode + extra["body"] = body + + if statusCode == 403 { + return lightning.LogError(err, "insufficient permissions, please check them", extra, lightning.ReadWriteDisabled{Read: false, Write: true}) + } else if statusCode == 404 && edit { + return nil + } else if statusCode == 404 { + return lightning.LogError(err, "resource not found", extra, lightning.ReadWriteDisabled{Read: false, Write: true}) + } else { + return lightning.LogError(err, message, extra, lightning.ReadWriteDisabled{Read: false, Write: true}) + } +} diff --git a/revolt/go.mod b/revolt/go.mod new file mode 100644 index 00000000..8063033a --- /dev/null +++ b/revolt/go.mod @@ -0,0 +1,14 @@ +module github.com/williamhorning/lightning/revolt + +go 1.24.4 + +require github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d + +require github.com/oklog/ulid/v2 v2.1.1 + +require ( + github.com/dolthub/maphash v0.1.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lxzan/gws v1.8.9 // indirect +) diff --git a/revolt/go.sum b/revolt/go.sum new file mode 100644 index 00000000..6222f455 --- /dev/null +++ b/revolt/go.sum @@ -0,0 +1,13 @@ +github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= +github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM= +github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d h1:DOQdvM3y/egSSpW2sM1bfY4S9gCsKvCwjqPoRUXrZwY= +github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d/go.mod h1:NZZh2iADP8/9NBnlea1b22idZn3fNm74QXAH4uFqgJE= diff --git a/revolt/incoming.go b/revolt/incoming.go new file mode 100644 index 00000000..e6765057 --- /dev/null +++ b/revolt/incoming.go @@ -0,0 +1,262 @@ +package revolt + +import ( + "regexp" + "strconv" + "strings" + "time" + + "github.com/oklog/ulid/v2" + "github.com/sentinelb51/revoltgo" + "github.com/williamhorning/lightning" +) + +func getLightningMessage(s *revoltgo.Session, m revoltgo.Message) *lightning.Message { + timestamp, err := getLightningTime(m) + if err != nil { + timestamp = time.Now() + } + + return &lightning.Message{ + BaseMessage: lightning.BaseMessage{ + EventID: m.ID, + ChannelID: m.Channel, + Plugin: "bolt-revolt", + Time: timestamp, + }, + Attachments: getLightningAttachment(m.Attachments), + Author: getLightningAuthor(s, m.Author, m.Channel, m.Masquerade), + Content: getLightningContent(s, m.Channel, m.Content), + Embeds: getLightningEmbeds(m.Embeds), + RepliedTo: m.Replies, + } +} + +func getLightningTime(m revoltgo.Message) (time.Time, error) { + if !m.Edited.IsZero() { + return m.Edited, nil + } + + id, err := ulid.Parse(m.ID) + if err != nil { + return time.Time{}, lightning.LogError( + err, + "Failed to parse ULID from Revolt message ID", + map[string]any{"message_id": m.ID}, + lightning.ReadWriteDisabled{}, + ) + } + + return time.UnixMilli(int64(id.Time())), nil +} + +func getLightningAttachment(attachments []*revoltgo.Attachment) []lightning.Attachment { + result := make([]lightning.Attachment, len(attachments)) + for i, att := range attachments { + result[i] = lightning.Attachment{ + URL: getURL(att), + Name: att.Filename, + Size: float64(att.Size) / 1048576, + } + } + return result +} + +func getUser(s *revoltgo.Session, id string) *revoltgo.User { + if user := s.State.User(id); user != nil { + return user + } + user, _ := s.User(id) + return user +} + +func getChannel(s *revoltgo.Session, id string) *revoltgo.Channel { + if channel := s.State.Channel(id); channel != nil { + return channel + } + channel, _ := s.Channel(id) + return channel +} + +func getMember(s *revoltgo.Session, serverID, userID string) *revoltgo.ServerMember { + if member := s.State.Member(serverID, userID); member != nil { + return member + } + member, _ := s.ServerMember(serverID, userID) + return member +} + +func getLightningAuthor(s *revoltgo.Session, id string, chID string, masquerade *revoltgo.MessageMasquerade) lightning.MessageAuthor { + author := lightning.MessageAuthor{ + ID: id, + Username: "RevoltUser", + Nickname: "Revolt User", + Color: "#FF4654", + } + + user := getUser(s, id) + if user == nil { + return applyMasquerade(author, masquerade) + } + + author.Username = user.Username + author.Nickname = user.Username + if user.Avatar != nil { + profilePic := getURL(user.Avatar) + author.ProfilePicture = &profilePic + } + + channel := getChannel(s, chID) + if channel != nil && channel.ChannelType == "TextChannel" && channel.Server != "" { + if member := getMember(s, channel.Server, id); member != nil { + if member.Nickname != nil { + author.Nickname = *member.Nickname + } + if member.Avatar != nil { + memberAvatar := getURL(member.Avatar) + author.ProfilePicture = &memberAvatar + } + } + } + + return applyMasquerade(author, masquerade) +} + +func getURL(file *revoltgo.Attachment) string { + return "https://cdn.revoltusercontent.com/" + file.Tag + "/" + file.ID +} + +func applyMasquerade(author lightning.MessageAuthor, masquerade *revoltgo.MessageMasquerade) lightning.MessageAuthor { + if masquerade == nil { + return author + } + + if masquerade.Name != "" { + author.Nickname = masquerade.Name + } + if masquerade.Colour != "" { + author.Color = masquerade.Colour + } + if masquerade.Avatar != "" { + author.ProfilePicture = &masquerade.Avatar + } + + return author +} + +var ( + emojiRegex = regexp.MustCompile(":([0-7][0-9A-HJKMNP-TV-Z]{25}):") + mentionRegex = regexp.MustCompile("<@([0-7][0-9A-HJKMNP-TV-Z]{25})>") + channelRegex = regexp.MustCompile("<#([0-7][0-9A-HJKMNP-TV-Z]{25})>") +) + +func getLightningContent(s *revoltgo.Session, channelID string, content string) string { + content = emojiRegex.ReplaceAllStringFunc(content, func(match string) string { + if emojiID := extractID(match, emojiRegex); emojiID != "" { + if emoji := s.State.Emoji(emojiID); emoji != nil { + return ":" + emoji.Name + ":" + } + return ":" + emojiID + ":" + } + return match + }) + + content = mentionRegex.ReplaceAllStringFunc(content, func(match string) string { + userID := extractID(match, mentionRegex) + if userID == "" { + return match + } + + user := getUser(s, userID) + if user == nil { + return "@" + userID + } + + channel := getChannel(s, channelID) + if channel != nil && channel.Server != "" { + if member := getMember(s, channel.Server, userID); member != nil && member.Nickname != nil { + return "@" + *member.Nickname + } + } + return "@" + user.Username + }) + + content = channelRegex.ReplaceAllStringFunc(content, func(match string) string { + chanID := extractID(match, channelRegex) + if chanID == "" { + return match + } + + channel := getChannel(s, chanID) + if channel == nil { + return "#" + chanID + } + + if channel.ChannelType == "DirectMessage" || channel.ChannelType == "GroupDM" { + return "#DM" + chanID + } + return "#" + channel.Name + }) + + return content +} + +func extractID(match string, re *regexp.Regexp) string { + matches := re.FindStringSubmatch(match) + if len(matches) < 2 { + return "" + } + return matches[1] +} + +func getLightningEmbeds(embeds []*revoltgo.MessageEmbed) []lightning.Embed { + if len(embeds) == 0 { + return nil + } + + result := make([]lightning.Embed, 0, len(embeds)) + for _, e := range embeds { + embed := lightning.Embed{} + + if e.Title != "" { + embed.Title = &e.Title + } + if e.Description != "" { + embed.Description = &e.Description + } + if e.URL != "" { + embed.URL = &e.URL + } + + if e.Colour != "" { + if colorInt, err := strconv.ParseInt(strings.TrimPrefix(e.Colour, "#"), 16, 32); err == nil { + colorVal := int(colorInt) + embed.Color = &colorVal + } + } + + if e.Image != nil && e.Image.URL != "" { + embed.Image = &lightning.Media{ + URL: e.Image.URL, + Width: e.Image.Width, + Height: e.Image.Height, + } + } + + if e.Video != nil && e.Video.URL != "" { + embed.Video = &lightning.Media{ + URL: e.Video.URL, + Width: e.Video.Width, + Height: e.Video.Height, + } + } + + if e.IconURL != "" { + embed.Thumbnail = &lightning.Media{URL: e.IconURL} + } + + result = append(result, embed) + } + + return result +} diff --git a/revolt/outgoing.go b/revolt/outgoing.go new file mode 100644 index 00000000..8ab8d4ff --- /dev/null +++ b/revolt/outgoing.go @@ -0,0 +1,185 @@ +package revolt + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + "github.com/sentinelb51/revoltgo" + "github.com/williamhorning/lightning" +) + +func toEdit(message revoltgo.MessageSend) revoltgo.MessageEditData { + return revoltgo.MessageEditData{ + Content: message.Content, + Embeds: message.Embeds, + } +} + +func getOutgoingMessage(s *revoltgo.Session, message lightning.Message, skipfiles bool, skipmasq bool) revoltgo.MessageSend { + content := message.Content + + if content == "" && len(message.Embeds) == 0 && len(message.Attachments) == 0 { + content = "*empty message*" + } else if len([]rune(content)) > 2000 { + content = string([]rune(content)[:1997]) + "..." + } + + return revoltgo.MessageSend{ + Attachments: getOutgoingAttachments(s, message.Attachments, skipfiles), + Content: content, + Embeds: getOutgoingEmbeds(message.Embeds), + Replies: getOutgoingReplies(message.RepliedTo), + Masquerade: getOutgoingMasquerade(message.Author, skipmasq), + Interactions: nil, + } +} + +func getOutgoingAttachments(s *revoltgo.Session, attachments []lightning.Attachment, skip bool) []string { + if skip || len(attachments) == 0 { + return nil + } + + attachmentIDs := make([]string, 0, len(attachments)) + + for _, attachment := range attachments { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + req, err := http.NewRequestWithContext(ctx, "GET", attachment.URL, nil) + if err != nil { + cancel() + continue + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + cancel() + continue + } + + file, err := s.AttachmentUpload(&revoltgo.File{ + Name: attachment.Name, + Reader: &cancelableReadCloser{resp.Body, cancel}, + }) + + if err != nil { + cancel() + continue + } + + attachmentIDs = append(attachmentIDs, file.ID) + } + + return attachmentIDs +} + +type cancelableReadCloser struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (c *cancelableReadCloser) Close() error { + err := c.ReadCloser.Close() + c.cancel() + return err +} + +func getOutgoingEmbeds(embeds []lightning.Embed) []*revoltgo.MessageEmbed { + if len(embeds) == 0 { + return nil + } + + result := make([]*revoltgo.MessageEmbed, 0, len(embeds)) + + for _, embed := range embeds { + revoltEmbed := &revoltgo.MessageEmbed{} + + if embed.Title != nil { + revoltEmbed.Title = *embed.Title + } + + description := "" + if embed.Description != nil { + description = *embed.Description + } + + if len(embed.Fields) > 0 { + for _, field := range embed.Fields { + if description != "" { + description += "\n\n" + } + description += fmt.Sprintf("**%s**\n%s", field.Name, field.Value) + } + } + + if description != "" { + revoltEmbed.Description = description + } + + if embed.URL != nil { + revoltEmbed.URL = *embed.URL + } + + if embed.Color != nil { + revoltEmbed.Colour = fmt.Sprintf("#%06x", *embed.Color) + } + + if embed.Image != nil { + revoltEmbed.Image = &revoltgo.MessageEmbedImage{ + URL: embed.Image.URL, + Width: embed.Image.Width, + Height: embed.Image.Height, + } + } + + if embed.Video != nil { + revoltEmbed.Video = &revoltgo.MessageEmbedVideo{ + URL: embed.Video.URL, + Width: embed.Video.Width, + Height: embed.Video.Height, + } + } + + if embed.Thumbnail != nil { + revoltEmbed.IconURL = embed.Thumbnail.URL + } + + result = append(result, revoltEmbed) + } + + return result +} + +func getOutgoingReplies(replyIDs []string) []*revoltgo.MessageReplies { + if len(replyIDs) == 0 { + return nil + } + + replies := make([]*revoltgo.MessageReplies, len(replyIDs)) + for i, id := range replyIDs { + replies[i] = &revoltgo.MessageReplies{ + ID: id, + Mention: false, + } + } + + return replies +} + +func getOutgoingMasquerade(author lightning.MessageAuthor, skipmasq bool) *revoltgo.MessageMasquerade { + if skipmasq { + return nil + } + + avatar := "" + if author.ProfilePicture != nil { + avatar = *author.ProfilePicture + } + + return &revoltgo.MessageMasquerade{ + Colour: author.Color, + Name: author.Nickname, + Avatar: avatar, + } +} diff --git a/revolt/plugin.go b/revolt/plugin.go new file mode 100644 index 00000000..da3c3b7a --- /dev/null +++ b/revolt/plugin.go @@ -0,0 +1,191 @@ +package revolt + +import ( + "errors" + "log" + "strings" + "time" + + "github.com/sentinelb51/revoltgo" + "github.com/williamhorning/lightning" +) + +func init() { + lightning.RegisterPluginType("revolt", newRevoltPlugin) +} + +type zerologAdapter struct{} + +func (z *zerologAdapter) Write(p []byte) (n int, err error) { + message := strings.TrimSpace(string(p)) + lightning.Log.Debug(). + Str("plugin", "revolt"). + Str("type", "revoltgo"). + Msg(message) + return len(p), nil +} + +func newRevoltPlugin(config any) (lightning.Plugin, error) { + if cfg, ok := config.(map[string]any); !ok { + return nil, lightning.LogError( + lightning.ErrPluginConfigInvalid, + "Invalid config for Revolt plugin", + nil, + lightning.ReadWriteDisabled{}, + ) + } else { + revolt := revoltgo.New(cfg["token"].(string)) + + log.SetFlags(0) + log.SetOutput(&zerologAdapter{}) + + err := revolt.Open() + + if err != nil { + return nil, lightning.LogError( + err, + "Failed to open Revolt session", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + revolt.AddHandler(func(s *revoltgo.Session, m *revoltgo.EventReady) { + lightning.Log.Info().Str("plugin", "revolt").Str("username", s.State.Self().Username).Int("servers", len(m.Servers)).Msg("ready!") + lightning.Log.Info().Str("plugin", "revolt").Msg("invite me at https://revolt.chat/invite/" + s.State.Self().ID) + }) + + return &revoltPlugin{cfg, revolt}, nil + } +} + +type revoltPlugin struct { + config map[string]any + revolt *revoltgo.Session +} + +func (p *revoltPlugin) Name() string { + return "bolt-revolt" +} + +const requiredPermissions = revoltgo.PermissionChangeNickname | + revoltgo.PermissionChangeAvatar | + revoltgo.PermissionReadMessageHistory | + revoltgo.PermissionSendMessage | + revoltgo.PermissionManageMessages | + revoltgo.PermissionSendEmbeds | + revoltgo.PermissionUploadFiles + +func (p *revoltPlugin) SetupChannel(channel string) (any, error) { + permissions, err := p.revolt.State.ChannelPermissions(p.revolt.State.Self(), p.revolt.State.Channel(channel)) + + if err != nil { + return nil, lightning.LogError( + err, + "Failed to get channel permissions in Revolt", + map[string]any{"channel": channel}, + lightning.ReadWriteDisabled{}, + ) + } + + if (permissions & requiredPermissions) != requiredPermissions { + return nil, lightning.LogError( + errors.New("insufficient permissions in Revolt channel"), + "missing ChangeNickname, ChangeAvatar, ReadMessageHistory, SendMessage, ManageMessages, SendEmbeds, UploadFiles, and/or Masquerade permissions please add them to a role, assign that role to the bot, and rejoin the bridge", + map[string]any{"channel": channel, "permissions": permissions}, + lightning.ReadWriteDisabled{}, + ) + } + + return channel, nil +} + +func (p *revoltPlugin) SendMessage(message lightning.Message, opts *lightning.BridgeMessageOptions) ([]string, error) { + canMasquerade := opts != nil + + if opts == nil { + chPermissions, err := p.revolt.State.ChannelPermissions(p.revolt.State.Self(), p.revolt.State.Channel(message.ChannelID)) + + if err == nil { + canMasquerade = chPermissions&revoltgo.PermissionMasquerade == revoltgo.PermissionMasquerade + } + } + + msg := getOutgoingMessage(p.revolt, message, false, canMasquerade) + res, err := p.revolt.ChannelMessageSend(message.ChannelID, msg) + + if err != nil { + return nil, getRevoltError(err, map[string]any{"msg": msg}, "Failed to send message to Revolt", false) + } + + return []string{res.ID}, nil +} + +func (p *revoltPlugin) EditMessage(message lightning.Message, ids []string, opts *lightning.BridgeMessageOptions) error { + _, err := p.revolt.ChannelMessageEdit(opts.Channel.ID, ids[0], toEdit(getOutgoingMessage(p.revolt, message, true, false))) + + if err != nil { + return getRevoltError(err, map[string]any{"ids": ids}, "Failed to edit message on Revolt", true) + } + + return nil +} + +func (p *revoltPlugin) DeleteMessage(ids []string, opts *lightning.BridgeMessageOptions) error { + for _, id := range ids { + err := p.revolt.ChannelMessageDelete(opts.Channel.ID, id) + + if err != nil { + return getRevoltError(err, map[string]any{"ids": ids}, "Failed to delete message on Revolt", true) + } + } + + return nil +} + +func (p *revoltPlugin) SetupCommands(command []lightning.Command) error { + return nil +} + +func (p *revoltPlugin) ListenMessages() <-chan lightning.Message { + ch := make(chan lightning.Message, 100) + + p.revolt.AddHandler(func(s *revoltgo.Session, m *revoltgo.EventMessage) { + if msg := getLightningMessage(s, m.Message); msg != nil { + ch <- *msg + } + }) + + return ch +} + +func (p *revoltPlugin) ListenEdits() <-chan lightning.Message { + ch := make(chan lightning.Message, 100) + + p.revolt.AddHandler(func(s *revoltgo.Session, m *revoltgo.EventMessageUpdate) { + if msg := getLightningMessage(s, m.Data); msg != nil { + ch <- *msg + } + }) + + return ch +} + +func (p *revoltPlugin) ListenDeletes() <-chan lightning.BaseMessage { + ch := make(chan lightning.BaseMessage, 100) + + p.revolt.AddHandler(func(s *revoltgo.Session, m *revoltgo.EventMessageDelete) { + ch <- lightning.BaseMessage{ + EventID: m.ID, + ChannelID: m.Channel, + Plugin: p.Name(), + Time: time.Now(), + } + }) + + return ch +} + +func (p *revoltPlugin) ListenCommands() <-chan lightning.CommandEvent { + return make(chan lightning.CommandEvent) +} diff --git a/telegram/go.mod b/telegram/go.mod new file mode 100644 index 00000000..1450c2e5 --- /dev/null +++ b/telegram/go.mod @@ -0,0 +1,5 @@ +module github.com/williamhorning/lightning/telegram + +go 1.24.4 + +require github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32 diff --git a/telegram/incoming.go b/telegram/incoming.go new file mode 100644 index 00000000..14ebe936 --- /dev/null +++ b/telegram/incoming.go @@ -0,0 +1,162 @@ +package telegram + +import ( + "fmt" + "mime" + "strconv" + "strings" + "time" + + "github.com/PaulSonOfLars/gotgbot/v2" + "github.com/PaulSonOfLars/gotgbot/v2/ext" + "github.com/williamhorning/lightning" +) + +func getCommand(cmdName string, b *gotgbot.Bot, ctx *ext.Context) lightning.CommandEvent { + if cmdName == "start" { + cmdName = "help" + } + + fullText := ctx.EffectiveMessage.Text + args := []string{} + + if spaceIndex := strings.Index(fullText, " "); spaceIndex != -1 { + args = strings.Fields(fullText[spaceIndex+1:]) + } + + return lightning.CommandEvent{ + CommandOptions: lightning.CommandOptions{ + Channel: strconv.FormatInt(ctx.EffectiveChat.Id, 10), + Plugin: "bolt-telegram", + Prefix: "/", + Time: time.UnixMilli(ctx.EffectiveMessage.GetDate()), + }, + Command: cmdName, + Options: &args, + EventID: strconv.FormatInt(ctx.EffectiveMessage.GetMessageId(), 10), + Reply: func(message string) error { + _, err := ctx.EffectiveMessage.Reply(b, telegramifyMarkdown(message), &gotgbot.SendMessageOpts{ + ParseMode: gotgbot.ParseModeMarkdownV2, + }) + return err + }, + } +} + +func getMessage(b *gotgbot.Bot, ctx *ext.Context, proxyPath string) lightning.Message { + msg := lightning.Message{ + BaseMessage: lightning.BaseMessage{ + EventID: strconv.FormatInt(ctx.EffectiveMessage.GetMessageId(), 10), + ChannelID: strconv.FormatInt(ctx.EffectiveChat.Id, 10), + Plugin: "bolt-telegram", + Time: time.UnixMilli(ctx.EffectiveMessage.GetDate() * 1000), + }, + Attachments: []lightning.Attachment{}, + Author: getLightningAuthor(b, ctx, proxyPath), + Embeds: []lightning.Embed{}, + RepliedTo: getLightningReply(ctx), + } + + if text := ctx.EffectiveMessage.Text; text != "" { + msg.Content = text + return msg + } + + if dice := ctx.EffectiveMessage.Dice; dice != nil { + msg.Content = dice.Emoji + " " + strconv.FormatInt(dice.Value, 10) + return msg + } + + if location := ctx.EffectiveMessage.Location; location != nil { + msg.Content = fmt.Sprintf("https://www.openstreetmap.org/#map=18/%f/%f", location.Latitude, location.Longitude) + return msg + } + + msg.Content = ctx.EffectiveMessage.Caption + + addAttachment(b, ctx, &msg, proxyPath) + + return msg +} + +func addAttachment(b *gotgbot.Bot, ctx *ext.Context, msg *lightning.Message, proxyPath string) { + m := ctx.EffectiveMessage + + if doc := m.Document; doc != nil { + handleAttachment(b, doc.FileId, doc.FileName, doc.FileSize, doc.MimeType, msg, proxyPath) + } else if anim := m.Animation; anim != nil { + handleAttachment(b, anim.FileId, anim.FileName, anim.FileSize, anim.MimeType, msg, proxyPath) + } else if audio := m.Audio; audio != nil { + handleAttachment(b, audio.FileId, audio.FileName, audio.FileSize, audio.MimeType, msg, proxyPath) + } else if photos := m.Photo; len(photos) > 0 { + handleAttachment(b, photos[0].FileId, photos[0].FileId+".jpg", photos[0].FileSize, "image/jpeg", msg, proxyPath) + } else if sticker := m.Sticker; sticker != nil { + ext := ".webp" + if sticker.IsAnimated { + ext = ".tgs" + } else if sticker.IsVideo { + ext = ".webm" + } + handleAttachment(b, sticker.FileId, sticker.SetName+ext, sticker.FileSize, "", msg, proxyPath) + } else if video := m.Video; video != nil { + handleAttachment(b, video.FileId, video.FileName, video.FileSize, video.MimeType, msg, proxyPath) + } else if vnote := m.VideoNote; vnote != nil { + handleAttachment(b, vnote.FileId, "video_note.mp4", vnote.FileSize, "video/mp4", msg, proxyPath) + } else if voice := m.Voice; voice != nil { + handleAttachment(b, voice.FileId, "voice.ogg", voice.FileSize, "audio/ogg", msg, proxyPath) + } +} + +func handleAttachment(b *gotgbot.Bot, fileID, name string, size int64, mimeType string, msg *lightning.Message, proxyPath string) { + ext := "" + if mimeType != "" { + if exts, err := mime.ExtensionsByType(mimeType); err == nil && len(exts) > 0 { + ext = exts[0] + } + } + + if f, err := b.GetFile(fileID, nil); err == nil { + msg.Attachments = append(msg.Attachments, lightning.Attachment{ + URL: proxyPath + f.FilePath + ext, + Name: name, + Size: float64(size) / 1048576, + }) + } +} + +func getLightningAuthor(b *gotgbot.Bot, ctx *ext.Context, proxyPath string) lightning.MessageAuthor { + author := lightning.MessageAuthor{ + ID: strconv.FormatInt(ctx.EffectiveSender.Id(), 10), + Nickname: ctx.EffectiveSender.Name(), + Username: ctx.EffectiveSender.Username(), + ProfilePicture: nil, + Color: "#24A1DE", + } + + if pics, err := ctx.EffectiveUser.GetProfilePhotos(b, nil); err == nil && pics.TotalCount > 0 { + var bestPhoto *gotgbot.PhotoSize + + for i := range pics.Photos[0] { + photo := &pics.Photos[0][i] + if bestPhoto == nil || photo.Width > bestPhoto.Width { + bestPhoto = photo + } + } + + if bestPhoto != nil { + if f, err := b.GetFile(bestPhoto.FileId, nil); err == nil { + url := proxyPath + f.FilePath + author.ProfilePicture = &url + } + } + } + + return author +} + +func getLightningReply(ctx *ext.Context) []string { + if ctx.EffectiveMessage.ReplyToMessage == nil { + return nil + } + return []string{strconv.FormatInt(ctx.EffectiveMessage.ReplyToMessage.GetMessageId(), 10)} +} diff --git a/telegram/outgoing.go b/telegram/outgoing.go new file mode 100644 index 00000000..4e96f99d --- /dev/null +++ b/telegram/outgoing.go @@ -0,0 +1,214 @@ +package telegram + +import ( + "regexp" + "strings" + + "slices" + + "github.com/williamhorning/lightning" +) + +var telegramSpecialCharacters = []string{ + "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!", +} + +var headingPattern = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) + +func parseContent(message lightning.Message, opts *lightning.BridgeMessageOptions) string { + bridged := opts != nil + + content := "" + + if bridged { + content += message.Author.Nickname + " ยป " + } + + content += message.Content + + if len(message.Content) == 0 { + content += "_no content_" + } + + if len(message.Embeds) > 0 { + content += "\n_this message has embeds_" + } + + return telegramifyMarkdown(content) +} + +func telegramifyMarkdown(input string) string { + lines := strings.Split(input, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if headingMatches := headingPattern.FindStringSubmatch(trimmed); len(headingMatches) == 3 { + headingText := headingMatches[2] + lines[i] = "*" + escapeMarkdownV2(headingText) + "*" + } else { + lines[i] = processInlineMarkdown(line) + } + } + + return strings.Join(lines, "\n") +} + +func escapeMarkdownV2(text string) string { + for _, char := range telegramSpecialCharacters { + text = strings.ReplaceAll(text, char, "\\"+char) + } + return text +} + +func processInlineMarkdown(input string) string { + output := strings.Builder{} + chars := []rune(input) + + i := 0 + for i < len(chars) { + switch { + case checkPrefix(chars, i, "**") && findClosing(chars, i+2, "**") != -1: + closing := findClosing(chars, i+2, "**") + innerText := string(chars[i+2 : closing]) + output.WriteString("*") + output.WriteString(processInlineMarkdown(innerText)) + output.WriteString("*") + i = closing + 2 + + case checkPrefix(chars, i, "*") && findClosing(chars, i+1, "*") != -1: + closing := findClosing(chars, i+1, "*") + innerText := string(chars[i+1 : closing]) + output.WriteString("_") + output.WriteString(processInlineMarkdown(innerText)) + output.WriteString("_") + i = closing + 1 + + case checkPrefix(chars, i, "_") && findClosing(chars, i+1, "_") != -1: + closing := findClosing(chars, i+1, "_") + innerText := string(chars[i+1 : closing]) + output.WriteString("_") + output.WriteString(processInlineMarkdown(innerText)) + output.WriteString("_") + i = closing + 1 + + case checkPrefix(chars, i, "~~") && findClosing(chars, i+2, "~~") != -1: + closing := findClosing(chars, i+2, "~~") + innerText := string(chars[i+2 : closing]) + output.WriteString("~") + output.WriteString(processInlineMarkdown(innerText)) + output.WriteString("~") + i = closing + 2 + + case checkPrefix(chars, i, "```") && findClosing(chars, i+3, "```") != -1: + closing := findClosing(chars, i+3, "```") + innerText := string(chars[i+3 : closing]) + output.WriteString("```") + output.WriteString(innerText) + output.WriteString("```") + i = closing + 3 + + case checkPrefix(chars, i, "`") && findClosing(chars, i+1, "`") != -1: + closing := findClosing(chars, i+1, "`") + innerText := string(chars[i+1 : closing]) + output.WriteString("`") + output.WriteString(innerText) + output.WriteString("`") + i = closing + 1 + + case checkPrefix(chars, i, "[") && findClosingLink(chars, i) != -1: + closingBracket := findClosing(chars, i+1, "]") + openingParen := closingBracket + 1 + closingParen := findClosing(chars, openingParen+1, ")") + + if closingBracket != -1 && openingParen < len(chars) && string(chars[openingParen]) == "(" && closingParen != -1 { + linkText := string(chars[i+1 : closingBracket]) + linkURL := string(chars[openingParen+1 : closingParen]) + + output.WriteString("[") + output.WriteString(escapeMarkdownV2(linkText)) + output.WriteString("](") + output.WriteString(escapeMarkdownV2(linkURL)) + output.WriteString(")") + + i = closingParen + 1 + } else { + output.WriteString("\\[") + i++ + } + + default: + if i < len(chars) { + needsEscape := slices.Contains(telegramSpecialCharacters, string(chars[i])) + + if needsEscape { + output.WriteString("\\") + } + output.WriteRune(chars[i]) + i++ + } + } + } + + return output.String() +} + +func checkPrefix(chars []rune, pos int, prefix string) bool { + if pos+len(prefix) > len(chars) { + return false + } + + for i, r := range []rune(prefix) { + if chars[pos+i] != r { + return false + } + } + return true +} + +func findClosing(chars []rune, start int, delimiter string) int { + delim := []rune(delimiter) + i := start + + for i < len(chars) { + if i > 0 && chars[i-1] == '\\' { + i++ + continue + } + + if i+len(delimiter) <= len(chars) { + match := true + for j, r := range delim { + if chars[i+j] != r { + match = false + break + } + } + if match { + return i + } + } + i++ + } + return -1 +} + +func findClosingLink(chars []rune, start int) int { + if start >= len(chars) || chars[start] != '[' { + return -1 + } + + closingBracket := findClosing(chars, start+1, "]") + if closingBracket == -1 || closingBracket+1 >= len(chars) { + return -1 + } + + if chars[closingBracket+1] != '(' { + return -1 + } + + closingParen := findClosing(chars, closingBracket+2, ")") + if closingParen == -1 { + return -1 + } + + return closingParen +} diff --git a/telegram/plugin.go b/telegram/plugin.go new file mode 100644 index 00000000..54e98d37 --- /dev/null +++ b/telegram/plugin.go @@ -0,0 +1,297 @@ +package telegram + +import ( + "strconv" + + "github.com/PaulSonOfLars/gotgbot/v2" + "github.com/PaulSonOfLars/gotgbot/v2/ext" + "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" + "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message" + "github.com/williamhorning/lightning" +) + +func init() { + lightning.RegisterPluginType("telegram", newTelegramPlugin) +} + +func newTelegramPlugin(config any) (lightning.Plugin, error) { + cfg, ok := config.(map[string]any) + if !ok { + return nil, lightning.LogError( + lightning.ErrPluginConfigInvalid, + "Invalid config for Telegram plugin", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + token, ok := cfg["token"].(string) + if !ok || token == "" { + return nil, lightning.LogError( + lightning.ErrPluginConfigInvalid, + "Missing or invalid token in Telegram plugin config", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + proxyPort, ok := cfg["proxy_port"].(int64) + if !ok || proxyPort < 0 { + return nil, lightning.LogError( + lightning.ErrPluginConfigInvalid, + "Missing or invalid proxy port in Telegram plugin config", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + proxyURL, ok := cfg["proxy_url"].(string) + if !ok || proxyURL == "" { + return nil, lightning.LogError( + lightning.ErrPluginConfigInvalid, + "Missing or invalid proxy URL in Telegram plugin config", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + telegram, err := gotgbot.NewBot(token, nil) + if err != nil { + return nil, lightning.LogError( + err, + "Failed to setup Telegram bot", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + commandChannel := make(chan lightning.CommandEvent, 100) + messageChannel := make(chan lightning.Message, 100) + editChannel := make(chan lightning.Message, 100) + + dispatch := ext.NewDispatcher(&ext.DispatcherOpts{ + Error: func(b *gotgbot.Bot, ctx *ext.Context, err error) ext.DispatcherAction { + lightning.LogError(err, "Error in Telegram plugin", nil, lightning.ReadWriteDisabled{}) + return ext.DispatcherActionNoop + }, + MaxRoutines: ext.DefaultMaxRoutines, + }) + + dispatch.AddHandler(handlers.Message{ + AllowEdited: true, + AllowChannel: true, + AllowBusiness: true, + Filter: message.All, + Response: func(b *gotgbot.Bot, ctx *ext.Context) error { + msg := getMessage(b, ctx, proxyURL) + if ctx.EditedMessage != nil { + editChannel <- msg + } else { + messageChannel <- msg + } + return nil + }, + }) + + updater := ext.NewUpdater(dispatch, nil) + if err := updater.StartPolling(telegram, &ext.PollingOpts{ + DropPendingUpdates: true, + }); err != nil { + return nil, lightning.LogError( + err, + "Failed to start Telegram updater", + nil, + lightning.ReadWriteDisabled{}, + ) + } + + lightning.Log.Info().Str("plugin", "telegram").Str("username", telegram.Username).Msg("ready!") + lightning.Log.Info().Str("plugin", "telegram").Msg("invite me at https://t.me/" + telegram.Username) + + plugin := &telegramPlugin{commandChannel, messageChannel, editChannel, dispatch, proxyURL, proxyPort, telegram, updater} + + go plugin.startProxy() + + return plugin, nil +} + +type telegramPlugin struct { + commandChannel chan lightning.CommandEvent + messageChannel chan lightning.Message + editChannel chan lightning.Message + dispatch *ext.Dispatcher + proxyURL string + proxyPort int64 + telegram *gotgbot.Bot + updater *ext.Updater +} + +func (p *telegramPlugin) Name() string { + return "bolt-telegram" +} + +func (p *telegramPlugin) SetupChannel(channel string) (any, error) { + return channel, nil +} + +func (p *telegramPlugin) SendMessage(message lightning.Message, opts *lightning.BridgeMessageOptions) ([]string, error) { + channel, err := strconv.ParseInt(message.ChannelID, 10, 64) + if err != nil { + return []string{}, lightning.LogError( + err, + "Failed to parse channel ID", + map[string]any{"channel_id": message.ChannelID}, + lightning.ReadWriteDisabled{Read: false, Write: true}, + ) + } + + content := parseContent(message, opts) + + // Setup message options with proper reply handling + sendOpts := &gotgbot.SendMessageOpts{ + ParseMode: gotgbot.ParseModeMarkdownV2, + } + + if len(message.RepliedTo) > 0 { + replyID, err := strconv.ParseInt(message.RepliedTo[0], 10, 64) + if err == nil { + sendOpts.ReplyParameters = &gotgbot.ReplyParameters{ + MessageId: replyID, + AllowSendingWithoutReply: true, + } + } + } + + msg, err := p.telegram.SendMessage(channel, content, sendOpts) + + if err != nil { + return []string{}, lightning.LogError( + err, + "Failed to send message to Telegram", + map[string]any{"channel_id": opts.Channel.ID, "content": content}, + lightning.ReadWriteDisabled{}, + ) + } + + ids := []string{strconv.FormatInt(msg.MessageId, 10)} + + docOpts := &gotgbot.SendDocumentOpts{} + if len(message.RepliedTo) > 0 { + replyID, err := strconv.ParseInt(message.RepliedTo[0], 10, 64) + if err == nil { + docOpts.ReplyParameters = &gotgbot.ReplyParameters{ + MessageId: replyID, + AllowSendingWithoutReply: true, + } + } + } + + for _, attachment := range message.Attachments { + if msg, err := p.telegram.SendDocument(channel, gotgbot.InputFileByURL(attachment.URL), docOpts); err == nil { + ids = append(ids, strconv.FormatInt(msg.MessageId, 10)) + } + } + + return ids, nil +} + +func (p *telegramPlugin) EditMessage(message lightning.Message, ids []string, opts *lightning.BridgeMessageOptions) error { + channel, err := strconv.ParseInt(opts.Channel.ID, 10, 64) + if err != nil { + return lightning.LogError( + err, + "Failed to parse channel ID", + map[string]any{"channel_id": opts.Channel.ID}, + lightning.ReadWriteDisabled{Read: false, Write: true}, + ) + } + + msgID, err := strconv.ParseInt(ids[0], 10, 64) + if err != nil { + return lightning.LogError( + err, + "Failed to parse message ID", + map[string]any{"id": ids[0]}, + lightning.ReadWriteDisabled{}, + ) + } + + content := parseContent(message, opts) + _, _, err = p.telegram.EditMessageText(content, &gotgbot.EditMessageTextOpts{ + ChatId: channel, + MessageId: msgID, + ParseMode: gotgbot.ParseModeMarkdownV2, + }) + + return err +} + +func (p *telegramPlugin) DeleteMessage(ids []string, opts *lightning.BridgeMessageOptions) error { + channel, err := strconv.ParseInt(opts.Channel.ID, 10, 64) + if err != nil { + return lightning.LogError( + err, + "Failed to parse channel ID", + map[string]any{"channel_id": opts.Channel.ID}, + lightning.ReadWriteDisabled{Read: false, Write: true}, + ) + } + + messageIDs := make([]int64, 0, len(ids)) + for _, id := range ids { + if msgID, err := strconv.ParseInt(id, 10, 64); err == nil { + messageIDs = append(messageIDs, msgID) + } else { + return lightning.LogError( + err, + "Failed to parse message ID", + map[string]any{"id": id}, + lightning.ReadWriteDisabled{}, + ) + } + } + + _, err = p.telegram.DeleteMessages(channel, messageIDs, nil) + return err +} + +func (p *telegramPlugin) SetupCommands(commands []lightning.Command) error { + start := lightning.HelpCommand() + start.Name = "start" + commands = append(commands, start) + + cmds := make([]gotgbot.BotCommand, 0, len(commands)) + + for _, cmd := range commands { + cmds = append(cmds, gotgbot.BotCommand{ + Command: cmd.Name, + Description: cmd.Description, + }) + + handler := handlers.NewCommand(cmd.Name, func(b *gotgbot.Bot, ctx *ext.Context) error { + p.commandChannel <- getCommand(cmd.Name, b, ctx) + return nil + }) + handler.SetAllowChannel(true) + p.dispatch.AddHandler(handler) + } + + _, err := p.telegram.SetMyCommands(cmds, nil) + return err +} + +func (p *telegramPlugin) ListenMessages() <-chan lightning.Message { + return p.messageChannel +} + +func (p *telegramPlugin) ListenEdits() <-chan lightning.Message { + return p.editChannel +} + +func (p *telegramPlugin) ListenDeletes() <-chan lightning.BaseMessage { + return make(chan lightning.BaseMessage) +} + +func (p *telegramPlugin) ListenCommands() <-chan lightning.CommandEvent { + return p.commandChannel +} diff --git a/telegram/proxy.go b/telegram/proxy.go new file mode 100644 index 00000000..f57093d5 --- /dev/null +++ b/telegram/proxy.go @@ -0,0 +1,51 @@ +package telegram + +import ( + "io" + "maps" + "net/http" + "strconv" + "strings" + + "github.com/williamhorning/lightning" +) + +func (p *telegramPlugin) startProxy() { + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/telegram") + url := p.telegram.FileURL(p.telegram.Token, path, nil) + req, err := http.NewRequestWithContext(r.Context(), r.Method, url, nil) + if err != nil { + http.Error(w, "Failed to create request", http.StatusInternalServerError) + lightning.Log.Error().Str("plugin", "telegram").Err(err).Msg("Failed to create request for Telegram file proxy") + return + } + + req.Header = r.Header.Clone() + resp, err := http.DefaultClient.Do(req) + if err != nil { + http.Error(w, "Failed to fetch file from Telegram", http.StatusInternalServerError) + lightning.Log.Error().Str("plugin", "telegram").Err(err).Msg("Failed to fetch file from Telegram") + return + } + + defer resp.Body.Close() + maps.Copy(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + if _, err := io.CopyBuffer(w, resp.Body, make([]byte, 64*1024)); err != nil { + http.Error(w, "Failed to write response", http.StatusInternalServerError) + lightning.Log.Error().Str("plugin", "telegram").Err(err).Msg("Failed to write response from Telegram file proxy") + return + } + }) + + if err := http.ListenAndServe("0.0.0.0:"+strconv.FormatInt(p.proxyPort, 10), nil); err != nil { + lightning.Log.Panic().Str("plugin", "telegram").Err(err).Msg("Failed to start Telegram file proxy") + } + + lightning.Log.Info(). + Str("plugin", "telegram"). + Int64("port", p.proxyPort). + Str("url", p.proxyURL). + Msg("Telegram file proxy available") +} From 32b7995996e166e0c142b76688e4358348915a87 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 19 Jun 2025 21:26:15 -0400 Subject: [PATCH 91/97] fix ca certs --- containerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/containerfile b/containerfile index 507e1b15..ff9e5415 100644 --- a/containerfile +++ b/containerfile @@ -21,6 +21,7 @@ LABEL org.opencontainers.image.licenses="MIT" # copy stuff over USER 1001:1001 COPY --from=builder /app/lightning /lightning +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt VOLUME [ "/data" ] WORKDIR /data From ff510d5771cefaa0f71cd2a254268388965e8cdc Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 19 Jun 2025 21:45:00 -0400 Subject: [PATCH 92/97] actually fix the bot --- .gitignore | 3 ++- core/bridge.go | 1 - core/postgres.go | 24 +++++++++++++++++------- discord/plugin.go | 2 +- guilded/plugin.go | 2 +- revolt/plugin.go | 2 +- telegram/plugin.go | 2 +- 7 files changed, 23 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 84d2fc03..a177f990 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /.env -/lightning.toml \ No newline at end of file +/lightning.toml +/lightning.toml.* \ No newline at end of file diff --git a/core/bridge.go b/core/bridge.go index 595e3fe2..4c847e80 100644 --- a/core/bridge.go +++ b/core/bridge.go @@ -130,7 +130,6 @@ func handleBridgeMessage(db Database, event string, data any) error { Log.Trace().Str("event_id", base.EventID).Msg("Looking up prior message IDs") bridgeMsg, _ := db.getMessage(base.EventID) for _, msg := range bridgeMsg.Messages { - println(msg.ID[0], base.EventID, msg.Plugin, channel.Plugin) if msg.Channel == channel.ID && msg.Plugin == channel.Plugin { priorMessageIDs = msg.ID Log.Trace().Strs("prior_ids", priorMessageIDs).Msg("Found prior message IDs") diff --git a/core/postgres.go b/core/postgres.go index e0a3e5e5..6363fe3b 100644 --- a/core/postgres.go +++ b/core/postgres.go @@ -102,9 +102,11 @@ func (p *postgresDatabase) getBridge(id string) (Bridge, error) { func (p *postgresDatabase) getBridgeByChannel(channelID string) (Bridge, error) { row := p.conn.QueryRow(p.ctx, ` - SELECT * FROM bridges - WHERE channels @> jsonb_build_array(jsonb_build_object('id', $1)) - `, channelID) + SELECT id, name, channels, settings FROM bridges + WHERE EXISTS ( + SELECT 1 FROM jsonb_array_elements(channels) AS ch + WHERE ch->>'id' = $1 + )`, channelID) return handleBridgeRow(row) } @@ -135,8 +137,12 @@ func (p *postgresDatabase) deleteMessage(id string) error { func (p *postgresDatabase) getMessage(id string) (BridgeMessageCollection, error) { row := p.conn.QueryRow(p.ctx, ` - SELECT * FROM bridge_messages - WHERE id = $1 OR jsonb_path_exists(messages, '$[*].id ? (@ == $1)', $1) + SELECT id, name, bridge_id, channels, messages, settings FROM bridge_messages + WHERE id = $1 OR EXISTS ( + SELECT 1 FROM jsonb_array_elements(messages) AS msg + CROSS JOIN jsonb_array_elements_text(msg->'id') AS id_element + WHERE id_element = $1 + ) `, id) return handleMessageRow(row) } @@ -235,8 +241,10 @@ func handleBridgeRow(row pgx.Row) (Bridge, error) { var bridge Bridge var channelsJSON, settingsJSON []byte - if err := row.Scan(&bridge.ID, &bridge.Name, &channelsJSON, &settingsJSON); err != nil { + if err := row.Scan(&bridge.ID, &bridge.Name, &channelsJSON, &settingsJSON); err != nil && err != pgx.ErrNoRows { return Bridge{}, err + } else if err == pgx.ErrNoRows { + return Bridge{}, nil } if err := json.Unmarshal(channelsJSON, &bridge.Channels); err != nil { @@ -254,8 +262,10 @@ func handleMessageRow(row pgx.Row) (BridgeMessageCollection, error) { var message BridgeMessageCollection var channelsJSON, messagesJSON, settingsJSON []byte - if err := row.Scan(&message.ID, &message.Name, &message.BridgeID, &channelsJSON, &messagesJSON, &settingsJSON); err != nil { + if err := row.Scan(&message.ID, &message.Name, &message.BridgeID, &channelsJSON, &messagesJSON, &settingsJSON); err != nil && err != pgx.ErrNoRows { return BridgeMessageCollection{}, err + } else if err == pgx.ErrNoRows { + return BridgeMessageCollection{}, nil } if err := json.Unmarshal(channelsJSON, &message.Channels); err != nil { diff --git a/discord/plugin.go b/discord/plugin.go index a84685a6..62192b38 100644 --- a/discord/plugin.go +++ b/discord/plugin.go @@ -6,7 +6,7 @@ import ( ) func init() { - lightning.RegisterPluginType("discord", newDiscordPlugin) + lightning.RegisterPluginType("bolt-discord", newDiscordPlugin) } func newDiscordPlugin(config any) (lightning.Plugin, error) { diff --git a/guilded/plugin.go b/guilded/plugin.go index dcbd438e..dcf43bda 100644 --- a/guilded/plugin.go +++ b/guilded/plugin.go @@ -5,7 +5,7 @@ import ( ) func init() { - lightning.RegisterPluginType("guilded", newGuildedPlugin) + lightning.RegisterPluginType("bolt-guilded", newGuildedPlugin) } func newGuildedPlugin(config any) (lightning.Plugin, error) { diff --git a/revolt/plugin.go b/revolt/plugin.go index da3c3b7a..24137634 100644 --- a/revolt/plugin.go +++ b/revolt/plugin.go @@ -11,7 +11,7 @@ import ( ) func init() { - lightning.RegisterPluginType("revolt", newRevoltPlugin) + lightning.RegisterPluginType("bolt-revolt", newRevoltPlugin) } type zerologAdapter struct{} diff --git a/telegram/plugin.go b/telegram/plugin.go index 54e98d37..94c33850 100644 --- a/telegram/plugin.go +++ b/telegram/plugin.go @@ -11,7 +11,7 @@ import ( ) func init() { - lightning.RegisterPluginType("telegram", newTelegramPlugin) + lightning.RegisterPluginType("bolt-telegram", newTelegramPlugin) } func newTelegramPlugin(config any) (lightning.Plugin, error) { From 560672aa56232a17b772fcd9bbcf8d2f689a25a1 Mon Sep 17 00:00:00 2001 From: Jersey Date: Thu, 19 Jun 2025 22:35:09 -0400 Subject: [PATCH 93/97] alpha.7 - fix guilded --- cli/main.go | 2 +- containerfile | 4 +- core/commands.go | 2 +- core/plugin.go | 2 +- guilded/api.go | 248 +++++++++++++++++----------------------------- guilded/plugin.go | 33 +++--- readme.md | 2 +- 7 files changed, 116 insertions(+), 177 deletions(-) diff --git a/cli/main.go b/cli/main.go index 947a81b4..a97294a5 100644 --- a/cli/main.go +++ b/cli/main.go @@ -20,7 +20,7 @@ func main() { (&cli.Command{ Name: "lightning", Usage: "extensible chatbot connecting communities", - Version: "0.8.0-alpha.6", + Version: "0.8.0-alpha.7", DefaultCommand: "help", EnableShellCompletion: true, Authors: []any{"William Horning", "Lightning contributors"}, diff --git a/containerfile b/containerfile index ff9e5415..8c454a48 100644 --- a/containerfile +++ b/containerfile @@ -10,11 +10,11 @@ FROM scratch # metadata LABEL maintainer="William Horning" -LABEL version="0.8.0-alpha.6" +LABEL version="0.8.0-alpha.7" LABEL description="Lightning" LABEL org.opencontainers.image.title="Lightning" LABEL org.opencontainers.image.description="extensible chatbot connecting communities" -LABEL org.opencontainers.image.version="0.8.0-alpha.6" +LABEL org.opencontainers.image.version="0.8.0-alpha.7" LABEL org.opencontainers.image.source="https://github.com/williamhorning/lightning" LABEL org.opencontainers.image.licenses="MIT" diff --git a/core/commands.go b/core/commands.go index 465172d2..8d127850 100644 --- a/core/commands.go +++ b/core/commands.go @@ -71,7 +71,7 @@ func HelpCommand() Command { Arguments: []CommandArgument{}, Subcommands: []Command{}, Executor: func(options CommandOptions) (string, error) { - return "hi! i'm lightning v0.8.0-alpha.6.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help!", nil + return "hi! i'm lightning v0.8.0-alpha.7.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help!", nil }, } } diff --git a/core/plugin.go b/core/plugin.go index 33e00b53..c4fb9af2 100644 --- a/core/plugin.go +++ b/core/plugin.go @@ -63,7 +63,7 @@ func distributeEvents[T any](ev string, plugin Plugin, source <-chan T, destinat for event := range source { key := getEventKey(event) + "-" + ev - time.Sleep(100 * time.Millisecond) + time.Sleep(150 * time.Millisecond) if _, exists := handledEvents[key]; exists { Log.Trace().Str("plugin", plugin.Name()).Str("event", key).Msg("Event already handled, skipping") diff --git a/guilded/api.go b/guilded/api.go index 34bf8ddf..7011cc1c 100644 --- a/guilded/api.go +++ b/guilded/api.go @@ -2,15 +2,14 @@ package guilded import ( "encoding/json" - "errors" "fmt" "io" - "math" "net/http" "sync" "time" "github.com/gorilla/websocket" + "github.com/williamhorning/lightning" ) func guildedMakeRequest(token, method, endpoint string, body *io.Reader) (*http.Response, error) { @@ -35,67 +34,51 @@ func guildedMakeRequest(token, method, endpoint string, body *io.Reader) (*http. return http.DefaultClient.Do(req) } -type guildedCloseInfo struct { - Code int - Reason string -} - type guildedSocketManager struct { - conn *websocket.Conn - Alive bool - LastMessageID string - ReconnectCount int - Token string - listeners map[string][]func(...any) - mu sync.RWMutex - done chan struct{} + conn *websocket.Conn + Alive bool + Token string + mu sync.RWMutex + done chan struct{} + readyHandler func(*guildedWelcomeMessage) + messageCreatedHandler func(*guildedChatMessageCreated) + messageUpdatedHandler func(*guildedChatMessageUpdated) + messageDeletedHandler func(*guildedChatMessageDeleted) } func guildedNewSocketManager(token string) *guildedSocketManager { return &guildedSocketManager{ - Token: token, - listeners: make(map[string][]func(...any)), - done: make(chan struct{}), + Token: token, + done: make(chan struct{}), } } -func (s *guildedSocketManager) On(event string, handler any) { +func (s *guildedSocketManager) OnReady(handler func(*guildedWelcomeMessage)) { s.mu.Lock() defer s.mu.Unlock() + s.readyHandler = handler + lightning.Log.Trace().Str("plugin", "guilded").Msg("Registered ready handler") +} - // Convert handler to func(...any) - var fn func(...any) - switch h := handler.(type) { - case func(...any): - fn = h - case func(): - fn = func(...any) { h() } - case func(any): - fn = func(args ...any) { - if len(args) > 0 { - h(args[0]) - } - } - default: - fn = func(args ...any) { - if len(args) > 0 { - if f, ok := handler.(func(any)); ok { - f(args[0]) - } - } - } - } - s.listeners[event] = append(s.listeners[event], fn) +func (s *guildedSocketManager) OnMessageCreated(handler func(*guildedChatMessageCreated)) { + s.mu.Lock() + defer s.mu.Unlock() + s.messageCreatedHandler = handler + lightning.Log.Trace().Str("plugin", "guilded").Msg("Registered message created handler") } -func (s *guildedSocketManager) Emit(event string, args ...any) { - s.mu.RLock() - handlers := s.listeners[event] - s.mu.RUnlock() +func (s *guildedSocketManager) OnMessageUpdated(handler func(*guildedChatMessageUpdated)) { + s.mu.Lock() + defer s.mu.Unlock() + s.messageUpdatedHandler = handler + lightning.Log.Trace().Str("plugin", "guilded").Msg("Registered message updated handler") +} - for _, handler := range handlers { - handler(args...) - } +func (s *guildedSocketManager) OnMessageDeleted(handler func(*guildedChatMessageDeleted)) { + s.mu.Lock() + defer s.mu.Unlock() + s.messageDeletedHandler = handler + lightning.Log.Trace().Str("plugin", "guilded").Msg("Registered message deleted handler") } func (s *guildedSocketManager) Connect() error { @@ -111,6 +94,7 @@ func (s *guildedSocketManager) Connect() error { if err != nil { return err } + s.Alive = true s.done = make(chan struct{}) @@ -120,139 +104,87 @@ func (s *guildedSocketManager) Connect() error { func (s *guildedSocketManager) readMessages() { defer func() { - s.conn.Close() s.Alive = false close(s.done) + if s.conn != nil { + s.conn.Close() + } }() - for { + for s.Alive { _, message, err := s.conn.ReadMessage() if err != nil { - s.Emit("debug", fmt.Sprintf("Error reading from socket: %v", err)) - closeInfo := guildedCloseInfo{Code: websocket.CloseNormalClosure} - if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { - if ce, ok := err.(*websocket.CloseError); ok { - closeInfo.Code = ce.Code - closeInfo.Reason = ce.Text - } + if !websocket.IsCloseError(err, websocket.CloseNormalClosure) { + lightning.Log.Error().Err(err).Msg("Error reading from WebSocket") } - s.Emit("close", closeInfo) - s.handleReconnect() - return + break } - s.Emit("debug", fmt.Sprintf("received packet: %s", message)) - s.handleMessage(message) - } -} - -func (s *guildedSocketManager) handleMessage(message []byte) { - var data guildedSocketEventEnvelope - if err := json.Unmarshal(message, &data); err != nil { - s.Emit("debug", "received invalid packet") - return - } - - if data.S != nil { - s.LastMessageID = *data.S - } + var data guildedSocketEventEnvelope + if err := json.Unmarshal(message, &data); err != nil { + lightning.Log.Error().Err(err).Msg("Error parsing WebSocket message") + continue + } - switch data.Op { - case guildedSocketOPSuccess: s.handleEvent(data) - case guildedSocketOPWelcome: - s.handleWelcome(data) - case guildedSocketOPResume: - s.Emit("debug", "received resume packet") - s.LastMessageID = "" - case guildedSocketOPError: - s.handleError(data) - case guildedSocketOPPing: - s.handlePing() - default: - s.Emit("debug", "received unknown opcode") } } func (s *guildedSocketManager) handleEvent(data guildedSocketEventEnvelope) { if data.T == nil { + lightning.Log.Trace().Msg("Received event with nil type") return } + eventType := *data.T - eventJSON, _ := json.Marshal(data.D) + lightning.Log.Trace(). + Str("plugin", "guilded"). + Str("event_type", eventType). + Msg("Processing socket event") - var evt any - var err error switch eventType { + case "ready": + if s.readyHandler != nil { + var welcome guildedWelcomeMessage + welcomeJSON, _ := json.Marshal(data.D) + if err := json.Unmarshal(welcomeJSON, &welcome); err != nil { + lightning.Log.Error().Err(err).Msg("Failed to parse ready event") + return + } + go s.readyHandler(&welcome) + } case "ChatMessageCreated": - evt = &guildedChatMessageCreated{} + if s.messageCreatedHandler != nil { + var msg guildedChatMessageCreated + msgJSON, _ := json.Marshal(data.D) + if err := json.Unmarshal(msgJSON, &msg); err != nil { + lightning.Log.Error().Err(err).Msg("Failed to parse message created event") + return + } + lightning.Log.Trace().Str("plugin", "guilded").Msg("Calling message created handler") + go s.messageCreatedHandler(&msg) + } case "ChatMessageUpdated": - evt = &guildedChatMessageUpdated{} + if s.messageUpdatedHandler != nil { + var msg guildedChatMessageUpdated + msgJSON, _ := json.Marshal(data.D) + if err := json.Unmarshal(msgJSON, &msg); err != nil { + lightning.Log.Error().Err(err).Msg("Failed to parse message updated event") + return + } + go s.messageUpdatedHandler(&msg) + } case "ChatMessageDeleted": - evt = &guildedChatMessageDeleted{} + if s.messageDeletedHandler != nil { + var msg guildedChatMessageDeleted + msgJSON, _ := json.Marshal(data.D) + if err := json.Unmarshal(msgJSON, &msg); err != nil { + lightning.Log.Error().Err(err).Msg("Failed to parse message deleted event") + return + } + go s.messageDeletedHandler(&msg) + } default: - s.Emit(eventType, data.D) - return - } - - if err = json.Unmarshal(eventJSON, evt); err != nil { - s.Emit("debug", fmt.Sprintf("Failed to parse %s: %v", eventType, err)) - return - } - s.Emit(eventType, evt) -} - -func (s *guildedSocketManager) handleWelcome(data guildedSocketEventEnvelope) { - var welcome guildedWelcomeMessage - welcomeJSON, _ := json.Marshal(data.D) - if err := json.Unmarshal(welcomeJSON, &welcome); err != nil { - s.Emit("debug", "received invalid welcome packet") - return - } - s.Emit("ready", &welcome) -} - -func (s *guildedSocketManager) handleError(data guildedSocketEventEnvelope) { - s.Emit("debug", "received error packet") - var errorData struct { - Message string `json:"message"` - } - errJSON, _ := json.Marshal(data.D) - if err := json.Unmarshal(errJSON, &errorData); err == nil { - s.Emit("error", errors.New(errorData.Message), data) - } - s.LastMessageID = "" - s.conn.Close() -} - -func (s *guildedSocketManager) handlePing() { - s.Emit("debug", "received ping packet, sending pong") - pong := map[string]any{"op": guildedSocketOPPong} - if pongData, err := json.Marshal(pong); err == nil { - s.conn.WriteMessage(websocket.TextMessage, pongData) - } -} - -func (s *guildedSocketManager) handleReconnect() { - s.Emit("debug", "disconnecting due to close") - s.Emit("debug", "reconnecting to Guilded") - s.Emit("reconnect") - s.ReconnectCount++ - - backoff := time.Duration(math.Min(float64(s.ReconnectCount*s.ReconnectCount), 30)) * time.Second - time.Sleep(backoff) - s.Connect() -} - -func (s *guildedSocketManager) Close() error { - if s.conn == nil { - return nil - } - - err := s.conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) - if err != nil { - return err + lightning.Log.Trace().Str("event", eventType).Msg("Unhandled event type") } - <-s.done - return nil } diff --git a/guilded/plugin.go b/guilded/plugin.go index dcf43bda..1520a2da 100644 --- a/guilded/plugin.go +++ b/guilded/plugin.go @@ -20,6 +20,11 @@ func newGuildedPlugin(config any) (lightning.Plugin, error) { token := cfg["token"].(string) socket := guildedNewSocketManager(token) + plugin := &guildedPlugin{token, socket} + + socket.OnReady(func(msg *guildedWelcomeMessage) { + lightning.Log.Info().Str("plugin", "guilded").Str("username", msg.User.Name).Msg("ready!") + }) if err := socket.Connect(); err != nil { return nil, lightning.LogError( @@ -30,11 +35,7 @@ func newGuildedPlugin(config any) (lightning.Plugin, error) { ) } - socket.On("ready", func(msg *guildedWelcomeMessage) { - lightning.Log.Info().Str("plugin", "guilded").Str("username", msg.User.Name).Msg("ready!") - }) - - return &guildedPlugin{token, socket}, nil + return plugin, nil } } @@ -73,29 +74,35 @@ func (p *guildedPlugin) SetupCommands(command []lightning.Command) error { } func (p *guildedPlugin) ListenMessages() <-chan lightning.Message { - ch := make(chan lightning.Message) + ch := make(chan lightning.Message, 100) - p.socket.On("ChatMessageCreated", func(msg *guildedChatMessageCreated) { - ch <- *getIncomingMessage(p.token, &msg.Message) + p.socket.OnMessageCreated(func(msg *guildedChatMessageCreated) { + message := getIncomingMessage(p.token, &msg.Message) + if message != nil { + ch <- *message + } }) return ch } func (p *guildedPlugin) ListenEdits() <-chan lightning.Message { - ch := make(chan lightning.Message) + ch := make(chan lightning.Message, 100) - p.socket.On("ChatMessageUpdated", func(msg *guildedChatMessageUpdated) { - ch <- *getIncomingMessage(p.token, &msg.Message) + p.socket.OnMessageUpdated(func(msg *guildedChatMessageUpdated) { + message := getIncomingMessage(p.token, &msg.Message) + if message != nil { + ch <- *message + } }) return ch } func (p *guildedPlugin) ListenDeletes() <-chan lightning.BaseMessage { - ch := make(chan lightning.BaseMessage) + ch := make(chan lightning.BaseMessage, 100) - p.socket.On("ChatMessageDeleted", func(msg *guildedChatMessageDeleted) { + p.socket.OnMessageDeleted(func(msg *guildedChatMessageDeleted) { ch <- lightning.BaseMessage{ EventID: msg.Message.Id, ChannelID: msg.Message.ChannelId, diff --git a/readme.md b/readme.md index acd60b0f..2832176e 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ # lightning - a chatbot > [!NOTE] -> This branch contains the next version of lightning, currently `0.8.0-alpha.6`, +> This branch contains the next version of lightning, currently `0.8.0-alpha.7`, > and reflects active development. To see the latest stable version, go to the > `main` branch. From 224d5d134d1cddac2bb0346404f632b6cb5bf0df Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 20 Jun 2025 21:30:58 -0400 Subject: [PATCH 94/97] new plugin registry thing, support changing the bridge delay, and fix telegram issues --- core/bridge.go | 10 +- core/bridge_commands.go | 32 +++--- core/commands.go | 22 ++--- core/config.go | 8 +- core/plugin.go | 213 ++++++++++++++++++++-------------------- discord/command.go | 12 ++- discord/errors.go | 2 +- discord/incoming.go | 1 - discord/plugin.go | 2 +- guilded/plugin.go | 2 +- revolt/errors.go | 2 +- revolt/plugin.go | 2 +- telegram/incoming.go | 23 ++--- telegram/outgoing.go | 13 ++- telegram/plugin.go | 23 ++--- 15 files changed, 182 insertions(+), 185 deletions(-) diff --git a/core/bridge.go b/core/bridge.go index 4c847e80..323167c9 100644 --- a/core/bridge.go +++ b/core/bridge.go @@ -11,7 +11,7 @@ func SetupBridge(db Database) { RegisterCommand(bridgeCommand(db)) go func() { - for event := range ListenMessages() { + for event := range Plugins.ListenMessages() { Log.Trace().Str("event_id", event.EventID).Str("channel", event.ChannelID).Msg("Received message creation event") if err := handleBridgeMessage(db, "create_message", event); err != nil { LogError(err, "Failed to handle bridge message creation", nil, ReadWriteDisabled{}) @@ -20,7 +20,7 @@ func SetupBridge(db Database) { }() go func() { - for event := range ListenEdits() { + for event := range Plugins.ListenEdits() { Log.Trace().Str("event_id", event.EventID).Str("channel", event.ChannelID).Msg("Received message edit event") if err := handleBridgeMessage(db, "edit_message", event); err != nil { LogError(err, "Failed to handle bridge message edit", nil, ReadWriteDisabled{}) @@ -29,7 +29,7 @@ func SetupBridge(db Database) { }() go func() { - for event := range ListenDeletes() { + for event := range Plugins.ListenDeletes() { Log.Trace().Str("event_id", event.EventID).Str("channel", event.ChannelID).Msg("Received message deletion event") if err := handleBridgeMessage(db, "delete_message", event); err != nil { LogError(err, "Failed to handle bridge message deletion", nil, ReadWriteDisabled{}) @@ -149,7 +149,7 @@ func handleBridgeMessage(db Database, event string, data any) error { } Log.Trace().Str("plugin", channel.Plugin).Msg("Getting plugin") - plugin, ok := GetPlugin(channel.Plugin) + plugin, ok := Plugins.Get(channel.Plugin) if !ok { Log.Debug().Str("plugin", channel.Plugin).Msg("Plugin not found, skipping channel") continue @@ -270,7 +270,7 @@ func handleBridgeMessage(db Database, event string, data any) error { } for _, id := range resultIDs { - setHandled(channel.Plugin, id, strings.Replace(event, "_message", "", 1)) + Plugins.setHandled(channel.Plugin, id, strings.Replace(event, "_message", "", 1)) Log.Trace().Str("plugin", channel.Plugin).Str("message_id", id).Msg("Marked message as handled") } diff --git a/core/bridge_commands.go b/core/bridge_commands.go index 6d8a4936..8b459e5e 100644 --- a/core/bridge_commands.go +++ b/core/bridge_commands.go @@ -67,26 +67,26 @@ func bridgeCommand(db Database) Command { } func prepareChannelForBridge(db Database, opts CommandOptions) (BridgeChannel, string) { - Log.Trace().Str("channel", opts.Channel).Str("plugin", opts.Plugin).Msg("Adding channel to bridge") + Log.Trace().Str("channel", opts.ChannelID).Str("plugin", opts.Plugin).Msg("Adding channel to bridge") - if br, err := db.getBridgeByChannel(opts.Channel); br.ID != "" || err != nil { + if br, err := db.getBridgeByChannel(opts.ChannelID); br.ID != "" || err != nil { return BridgeChannel{}, "This channel is already part of a bridge. Please leave the bridge first." } - plugin, ok := GetPlugin(opts.Plugin) + plugin, ok := Plugins.Get(opts.Plugin) if !ok { return BridgeChannel{}, LogError(ErrPluginNotFound, "Failed to add channel to bridge using plugin", - map[string]any{"plugin": opts.Plugin, "channel": opts.Channel}, ReadWriteDisabled{}).Error() + map[string]any{"plugin": opts.Plugin, "channel": opts.ChannelID}, ReadWriteDisabled{}).Error() } - data, err := plugin.SetupChannel(opts.Channel) + data, err := plugin.SetupChannel(opts.ChannelID) if err != nil { return BridgeChannel{}, LogError(err, "Failed to setup channel for bridge", - map[string]any{"plugin": plugin.Name(), "channel": opts.Channel}, ReadWriteDisabled{}).Error() + map[string]any{"plugin": plugin.Name(), "channel": opts.ChannelID}, ReadWriteDisabled{}).Error() } return BridgeChannel{ - ID: opts.Channel, + ID: opts.ChannelID, Data: data, Plugin: plugin.Name(), Disabled: ReadWriteDisabled{false, false}, @@ -111,7 +111,7 @@ func createCommand(db Database, opts CommandOptions) (string, error) { map[string]any{"bridge": bridge}, ReadWriteDisabled{}).Error(), nil } - Log.Debug().Str("bridge_id", bridge.ID).Str("channel", opts.Channel).Msg("Bridge created successfully") + Log.Debug().Str("bridge_id", bridge.ID).Str("channel", opts.ChannelID).Msg("Bridge created successfully") return "Bridge created successfully! You can now join it using `" + opts.Prefix + "bridge join " + bridge.ID + "`.", nil } @@ -139,17 +139,17 @@ func joinCommand(db Database, opts CommandOptions, subscribe bool) (string, erro map[string]any{"bridge": br}, ReadWriteDisabled{}).Error(), nil } - Log.Debug().Str("bridge_id", br.ID).Str("channel", opts.Channel).Msg("Channel joined bridge successfully") + Log.Debug().Str("bridge_id", br.ID).Str("channel", opts.ChannelID).Msg("Channel joined bridge successfully") return "Bridge joined successfully!", nil } func leaveCommand(db Database, opts CommandOptions) (string, error) { id := opts.Arguments["id"] - br, err := db.getBridgeByChannel(opts.Channel) + br, err := db.getBridgeByChannel(opts.ChannelID) if err != nil { return LogError(err, "Failed to get bridge from database", - map[string]any{"channel": opts.Channel}, ReadWriteDisabled{}).Error(), nil + map[string]any{"channel": opts.ChannelID}, ReadWriteDisabled{}).Error(), nil } else if br.ID == "" { return "You are not in a bridge.", nil } @@ -159,7 +159,7 @@ func leaveCommand(db Database, opts CommandOptions) (string, error) { } for i, channel := range br.Channels { - if channel.ID == opts.Channel { + if channel.ID == opts.ChannelID { br.Channels = slices.Delete(br.Channels, i, i+1) break } @@ -176,10 +176,10 @@ func leaveCommand(db Database, opts CommandOptions) (string, error) { func toggleCommand(db Database, opts CommandOptions) (string, error) { setting := opts.Arguments["setting"] - br, err := db.getBridgeByChannel(opts.Channel) + br, err := db.getBridgeByChannel(opts.ChannelID) if err != nil { return LogError(err, "Failed to get bridge from database", - map[string]any{"channel": opts.Channel}, ReadWriteDisabled{}).Error(), nil + map[string]any{"channel": opts.ChannelID}, ReadWriteDisabled{}).Error(), nil } else if br.ID == "" { return "You are not in a bridge.", nil } @@ -199,10 +199,10 @@ func toggleCommand(db Database, opts CommandOptions) (string, error) { } func statusCommand(db Database, opts CommandOptions) (string, error) { - br, err := db.getBridgeByChannel(opts.Channel) + br, err := db.getBridgeByChannel(opts.ChannelID) if err != nil { return LogError(err, "Failed to get bridge from database", - map[string]any{"channel": opts.Channel}, ReadWriteDisabled{}).Error(), nil + map[string]any{"channel": opts.ChannelID}, ReadWriteDisabled{}).Error(), nil } else if br.ID == "" { return "You are not in a bridge.", nil } diff --git a/core/commands.go b/core/commands.go index 8d127850..1f68c34a 100644 --- a/core/commands.go +++ b/core/commands.go @@ -19,7 +19,7 @@ func RegisterCommand(command Command) { commands = append(commands, cmd) } - for _, plugin := range pluginRegistry { + for _, plugin := range Plugins.Plugins { if err := plugin.SetupCommands(commands); err != nil { LogError(err, "Failed to setup commands for plugin", map[string]any{ "plugin": plugin.Name(), @@ -40,11 +40,9 @@ type CommandArgument struct { } type CommandOptions struct { + BaseMessage Arguments map[string]string - Channel string - Plugin string Prefix string - Time time.Time } type Command struct { @@ -60,7 +58,6 @@ type CommandEvent struct { Command string Subcommand *string Options *[]string - EventID string Reply func(message string) error } @@ -95,13 +92,13 @@ func SetupCommands(prefix string) { RegisterCommand(PingCommand()) go func() { - for event := range ListenCommands() { + for event := range Plugins.ListenCommands() { handleCommandEvent(event) } }() go func() { - for event := range ListenMessages() { + for event := range Plugins.ListenMessages() { handleMessageCommand(event, prefix) } }() @@ -125,17 +122,14 @@ func handleMessageCommand(event Message, prefix string) { handleCommandEvent(CommandEvent{ CommandOptions: CommandOptions{ - Arguments: make(map[string]string), - Channel: event.ChannelID, - Plugin: event.Plugin, - Prefix: prefix, - Time: event.Time, + Arguments: make(map[string]string), + BaseMessage: event.BaseMessage, + Prefix: prefix, }, Command: commandName, Options: &options, - EventID: event.EventID, Reply: func(message string) error { - plugin, exists := GetPlugin(event.Plugin) + plugin, exists := Plugins.Get(event.Plugin) if !exists { return LogError(ErrPluginNotFound, "Plugin not found for command reply", map[string]any{ "plugin": event.Plugin, diff --git a/core/config.go b/core/config.go index 9dd1fa0a..6ef7d2e7 100644 --- a/core/config.go +++ b/core/config.go @@ -2,12 +2,14 @@ package lightning import ( "os" + "time" "github.com/BurntSushi/toml" "github.com/rs/zerolog" ) type Config struct { + BridgeDelay *int64 `toml:"bridge_delay,omitempty"` CommandPrefix string `toml:"prefix,omitempty"` DatabaseConfig DatabaseConfig `toml:"database"` ErrorURL string `toml:"error_url"` @@ -37,10 +39,14 @@ func LoadConfig(path string) (Config, error) { return Config{}, err } + if config.BridgeDelay != nil { + Plugins.eventDelay = time.Duration(*config.BridgeDelay) * time.Millisecond + } + SetupCommands(config.CommandPrefix) for plugin, cfg := range config.Plugins { - registerPlugin(plugin, cfg) + Plugins.registerPlugin(plugin, cfg) } return config, nil diff --git a/core/plugin.go b/core/plugin.go index c4fb9af2..10028905 100644 --- a/core/plugin.go +++ b/core/plugin.go @@ -9,19 +9,19 @@ import ( var ( ErrPluginNotFound = errors.New("plugin not found internally: this is a bug or misconfiguration") ErrPluginConfigInvalid = errors.New("plugin config is invalid") - - pluginConstructors = make(map[string]PluginConstructor) - pluginRegistry = make(map[string]Plugin) - handledEvents = make(map[string]struct{}) - - constructorsLock sync.RWMutex - pluginRegistryLock sync.RWMutex - - messages []chan Message - edits []chan Message - deletes []chan BaseMessage - commands []chan CommandEvent - mutex sync.RWMutex + Plugins = &PluginRegistry{ + make(map[string]Plugin), + sync.RWMutex{}, + make(map[string]PluginConstructor), + sync.RWMutex{}, + make(map[string]struct{}), + []chan Message{}, + []chan Message{}, + []chan BaseMessage{}, + []chan CommandEvent{}, + sync.RWMutex{}, + 200 * time.Millisecond, + } ) type PluginConstructor func(config any) (Plugin, error) @@ -39,133 +39,136 @@ type Plugin interface { ListenCommands() <-chan CommandEvent } -func RegisterPluginType(name string, constructor PluginConstructor) { - constructorsLock.Lock() - defer constructorsLock.Unlock() +type PluginRegistry struct { + Plugins map[string]Plugin + pluginsLock sync.RWMutex + pluginTypes map[string]PluginConstructor + pluginTypesLock sync.RWMutex + handledEvents map[string]struct{} + messages []chan Message + edits []chan Message + deletes []chan BaseMessage + commands []chan CommandEvent + eventMutex sync.RWMutex + eventDelay time.Duration +} + +func (pr *PluginRegistry) RegisterType(name string, constructor PluginConstructor) { + pr.pluginTypesLock.Lock() + defer pr.pluginTypesLock.Unlock() Log.Debug().Str("plugin", name).Msg("Registering plugin type") - if _, exists := pluginConstructors[name]; exists { + if _, exists := pr.pluginTypes[name]; exists { Log.Panic().Str("plugin", name).Msg("Plugin type already registered") } - pluginConstructors[name] = constructor + pr.pluginTypes[name] = constructor } -func GetPlugin(name string) (Plugin, bool) { - pluginRegistryLock.RLock() - defer pluginRegistryLock.RUnlock() - plugin, exists := pluginRegistry[name] +func (pr *PluginRegistry) Get(name string) (Plugin, bool) { + pr.pluginsLock.RLock() + defer pr.pluginsLock.RUnlock() + plugin, exists := pr.Plugins[name] return plugin, exists } -func distributeEvents[T any](ev string, plugin Plugin, source <-chan T, destinations *[]chan T) { - for event := range source { - key := getEventKey(event) + "-" + ev +func (pr *PluginRegistry) registerPlugin(name string, config any) error { + pr.pluginTypesLock.RLock() + pr.pluginsLock.Lock() + defer pr.pluginTypesLock.RUnlock() + defer pr.pluginsLock.Unlock() - time.Sleep(150 * time.Millisecond) + Log.Debug().Str("plugin", name).Msg("Registering plugin") - if _, exists := handledEvents[key]; exists { - Log.Trace().Str("plugin", plugin.Name()).Str("event", key).Msg("Event already handled, skipping") - continue - } - - mutex.RLock() - for _, ch := range *destinations { - select { - case ch <- event: - Log.Trace().Str("plugin", plugin.Name()).Str("event", key).Msg("Event distributed") - default: - Log.Warn().Str("plugin", plugin.Name()).Msg("Skipped event - channel full or closed") - } - } - mutex.RUnlock() - } -} - -func getEventKey(event any) string { - switch e := event.(type) { - case Message: - return e.Plugin + "-" + e.EventID - case BaseMessage: - return e.Plugin + "-" + e.EventID - case CommandEvent: - return e.Plugin + "-" + e.EventID - default: - return "-" + if _, exists := pr.Plugins[name]; exists { + Log.Panic().Str("plugin", name).Msg("Plugin already registered") } -} - -func registerPlugin(plugin string, config any) { - pluginRegistryLock.Lock() - defer pluginRegistryLock.Unlock() - - Log.Debug().Str("plugin", plugin).Msg("Registering plugin") - - if _, exists := pluginRegistry[plugin]; exists { - Log.Panic().Str("plugin", plugin).Msg("Plugin already registered") - } - - constructorsLock.RLock() - constructor, exists := pluginConstructors[plugin] - constructorsLock.RUnlock() + constructor, exists := pr.pluginTypes[name] if !exists { - Log.Panic().Str("plugin", plugin).Msg("Plugin type not found") + return ErrPluginNotFound } instance, err := constructor(config) if err != nil { - Log.Panic().Str("plugin", plugin).Err(err).Msg("Failed to setup plugin") + return err } - commands_list := make([]Command, 0, len(commandRegistry)) - for _, cmd := range commandRegistry { - commands_list = append(commands_list, cmd) - } + pr.Plugins[instance.Name()] = instance - if err := instance.SetupCommands(commands_list); err != nil { - Log.Warn().Str("plugin", plugin).Err(err).Msg("Failed to setup commands for plugin") - } - - pluginRegistry[plugin] = instance - go distributeEvents("create", instance, instance.ListenMessages(), &messages) - go distributeEvents("edit", instance, instance.ListenEdits(), &edits) - go distributeEvents("delete", instance, instance.ListenDeletes(), &deletes) - go distributeEvents("command", instance, instance.ListenCommands(), &commands) + go distributeEvents(pr, "create", instance, instance.ListenMessages(), &pr.messages) + go distributeEvents(pr, "edit", instance, instance.ListenEdits(), &pr.edits) + go distributeEvents(pr, "delete", instance, instance.ListenDeletes(), &pr.deletes) + go distributeEvents(pr, "command", instance, instance.ListenCommands(), &pr.commands) - Log.Debug().Str("plugin", plugin).Msg("Plugin registered and listening!") + Log.Debug().Str("plugin", instance.Name()).Msg("Plugin registered and listening!") + return nil } -func setHandled(plugin string, event string, ev string) { +func (pr *PluginRegistry) setHandled(plugin string, event string, ev string) { + pr.eventMutex.Lock() + defer pr.eventMutex.Unlock() Log.Trace().Str("plugin", plugin).Str("event", event).Str("ev", ev).Msg("Setting handled event") - handledEvents[plugin+"-"+event+"-"+ev] = struct{}{} + pr.handledEvents[ev+"-"+plugin+"-"+event] = struct{}{} } -func createEventChannel[T any](bufferSize int, channelList *[]chan T) <-chan T { - ch := make(chan T, bufferSize) - mutex.Lock() - *channelList = append(*channelList, ch) - mutex.Unlock() - return ch +func (pr *PluginRegistry) ListenMessages() <-chan Message { + return createEventChannel(pr, 100, &pr.messages) +} + +func (pr *PluginRegistry) ListenEdits() <-chan Message { + return createEventChannel(pr, 100, &pr.edits) } -func ListenMessages() <-chan Message { - Log.Trace().Msg("Creating message event channel") - return createEventChannel(100, &messages) +func (pr *PluginRegistry) ListenDeletes() <-chan BaseMessage { + return createEventChannel(pr, 100, &pr.deletes) } -func ListenEdits() <-chan Message { - Log.Trace().Msg("Creating edit event channel") - return createEventChannel(100, &edits) +func (pr *PluginRegistry) ListenCommands() <-chan CommandEvent { + return createEventChannel(pr, 100, &pr.commands) } -func ListenDeletes() <-chan BaseMessage { - Log.Trace().Msg("Creating delete event channel") - return createEventChannel(100, &deletes) +func distributeEvents[T any](pr *PluginRegistry, ev string, plugin Plugin, source <-chan T, destinations *[]chan T) { + for event := range source { + key := ev + "-" + plugin.Name() + "-" + + switch v := any(event).(type) { + case Message: + key += v.EventID + case BaseMessage: + key += v.EventID + case CommandEvent: + key += v.EventID + } + + time.Sleep(pr.eventDelay) + + if _, exists := pr.handledEvents[key]; exists { + Log.Trace().Str("plugin", plugin.Name()).Str("event", key).Msg("Event already handled, skipping") + continue + } + + pr.setHandled(plugin.Name(), ev, key) + + pr.eventMutex.RLock() + for _, ch := range *destinations { + select { + case ch <- event: + Log.Trace().Str("plugin", plugin.Name()).Str("event", key).Msg("Event distributed") + default: + Log.Warn().Str("plugin", plugin.Name()).Msg("Skipped event - channel full or closed") + } + } + pr.eventMutex.RUnlock() + } } -func ListenCommands() <-chan CommandEvent { - Log.Trace().Msg("Creating command event channel") - return createEventChannel(100, &commands) +func createEventChannel[T any](pr *PluginRegistry, bufferSize int, channelList *[]chan T) <-chan T { + Log.Trace().Msg("Creating event channel") + ch := make(chan T, bufferSize) + pr.eventMutex.Lock() + defer pr.eventMutex.Unlock() + *channelList = append(*channelList, ch) + return ch } diff --git a/discord/command.go b/discord/command.go index 11aad215..cb029bb1 100644 --- a/discord/command.go +++ b/discord/command.go @@ -85,14 +85,16 @@ func getLightningCommand(session *discordgo.Session, interaction *discordgo.Inte return &lightning.CommandEvent{ CommandOptions: lightning.CommandOptions{ Arguments: args, - Channel: interaction.ChannelID, - Plugin: "bolt-discord", - Prefix: "/", - Time: timestamp, + BaseMessage: lightning.BaseMessage{ + EventID: interaction.ID, + ChannelID: interaction.ChannelID, + Plugin: "bolt-discord", + Time: timestamp, + }, + Prefix: "/", }, Command: data.Name, Subcommand: subcommand, - EventID: interaction.ID, Reply: func(message string) error { return session.InteractionRespond(interaction.Interaction, &discordgo.InteractionResponse{ Type: discordgo.InteractionResponseChannelMessageWithSource, diff --git a/discord/errors.go b/discord/errors.go index 0a528dac..b79d3690 100644 --- a/discord/errors.go +++ b/discord/errors.go @@ -21,7 +21,7 @@ var discordErrors = map[int]ErrorConfig{ 10003: {10003, "unknown channel, disabling channel", true, true}, 10015: {10015, "unknown message, disabling channel", false, true}, 50027: {50027, "invalid webhook token, disabling channel", false, true}, - 0: {0, "unknown RESTError, not disabling channel", false, false}, // Default case + 0: {0, "unknown RESTError, not disabling channel", false, false}, } func getError(err error, extra map[string]any, message string) error { diff --git a/discord/incoming.go b/discord/incoming.go index dc559b3c..e4de1235 100644 --- a/discord/incoming.go +++ b/discord/incoming.go @@ -48,7 +48,6 @@ func getLightningAttachments(attachments []*discordgo.MessageAttachment, sticker for _, sticker := range stickers { stickerURL := "" - // Handle different sticker formats switch sticker.FormatType { case discordgo.StickerFormatTypePNG, discordgo.StickerFormatTypeAPNG: stickerURL = "https://cdn.discordapp.com/stickers/" + sticker.ID + ".png" diff --git a/discord/plugin.go b/discord/plugin.go index 62192b38..d34facf2 100644 --- a/discord/plugin.go +++ b/discord/plugin.go @@ -6,7 +6,7 @@ import ( ) func init() { - lightning.RegisterPluginType("bolt-discord", newDiscordPlugin) + lightning.Plugins.RegisterType("discord", newDiscordPlugin) } func newDiscordPlugin(config any) (lightning.Plugin, error) { diff --git a/guilded/plugin.go b/guilded/plugin.go index 1520a2da..e7ec2ec7 100644 --- a/guilded/plugin.go +++ b/guilded/plugin.go @@ -5,7 +5,7 @@ import ( ) func init() { - lightning.RegisterPluginType("bolt-guilded", newGuildedPlugin) + lightning.Plugins.RegisterType("guilded", newGuildedPlugin) } func newGuildedPlugin(config any) (lightning.Plugin, error) { diff --git a/revolt/errors.go b/revolt/errors.go index 03d5f880..56cf89b3 100644 --- a/revolt/errors.go +++ b/revolt/errors.go @@ -32,6 +32,6 @@ func getRevoltError(err error, extra map[string]any, message string, edit bool) } else if statusCode == 404 { return lightning.LogError(err, "resource not found", extra, lightning.ReadWriteDisabled{Read: false, Write: true}) } else { - return lightning.LogError(err, message, extra, lightning.ReadWriteDisabled{Read: false, Write: true}) + return lightning.LogError(err, message, extra, lightning.ReadWriteDisabled{Read: false, Write: false}) } } diff --git a/revolt/plugin.go b/revolt/plugin.go index 24137634..664e3a28 100644 --- a/revolt/plugin.go +++ b/revolt/plugin.go @@ -11,7 +11,7 @@ import ( ) func init() { - lightning.RegisterPluginType("bolt-revolt", newRevoltPlugin) + lightning.Plugins.RegisterType("revolt", newRevoltPlugin) } type zerologAdapter struct{} diff --git a/telegram/incoming.go b/telegram/incoming.go index 14ebe936..6bd88d34 100644 --- a/telegram/incoming.go +++ b/telegram/incoming.go @@ -12,6 +12,15 @@ import ( "github.com/williamhorning/lightning" ) +func getBase(ctx *ext.Context) lightning.BaseMessage { + return lightning.BaseMessage{ + EventID: strconv.FormatInt(ctx.EffectiveMessage.GetMessageId(), 10), + ChannelID: strconv.FormatInt(ctx.EffectiveChat.Id, 10), + Plugin: "bolt-telegram", + Time: time.UnixMilli(ctx.EffectiveMessage.GetDate() * 1000), + } +} + func getCommand(cmdName string, b *gotgbot.Bot, ctx *ext.Context) lightning.CommandEvent { if cmdName == "start" { cmdName = "help" @@ -26,14 +35,11 @@ func getCommand(cmdName string, b *gotgbot.Bot, ctx *ext.Context) lightning.Comm return lightning.CommandEvent{ CommandOptions: lightning.CommandOptions{ - Channel: strconv.FormatInt(ctx.EffectiveChat.Id, 10), - Plugin: "bolt-telegram", - Prefix: "/", - Time: time.UnixMilli(ctx.EffectiveMessage.GetDate()), + BaseMessage: getBase(ctx), + Prefix: "/", }, Command: cmdName, Options: &args, - EventID: strconv.FormatInt(ctx.EffectiveMessage.GetMessageId(), 10), Reply: func(message string) error { _, err := ctx.EffectiveMessage.Reply(b, telegramifyMarkdown(message), &gotgbot.SendMessageOpts{ ParseMode: gotgbot.ParseModeMarkdownV2, @@ -45,12 +51,7 @@ func getCommand(cmdName string, b *gotgbot.Bot, ctx *ext.Context) lightning.Comm func getMessage(b *gotgbot.Bot, ctx *ext.Context, proxyPath string) lightning.Message { msg := lightning.Message{ - BaseMessage: lightning.BaseMessage{ - EventID: strconv.FormatInt(ctx.EffectiveMessage.GetMessageId(), 10), - ChannelID: strconv.FormatInt(ctx.EffectiveChat.Id, 10), - Plugin: "bolt-telegram", - Time: time.UnixMilli(ctx.EffectiveMessage.GetDate() * 1000), - }, + BaseMessage: getBase(ctx), Attachments: []lightning.Attachment{}, Author: getLightningAuthor(b, ctx, proxyPath), Embeds: []lightning.Embed{}, diff --git a/telegram/outgoing.go b/telegram/outgoing.go index 4e96f99d..2c4f93af 100644 --- a/telegram/outgoing.go +++ b/telegram/outgoing.go @@ -2,9 +2,8 @@ package telegram import ( "regexp" - "strings" - "slices" + "strings" "github.com/williamhorning/lightning" ) @@ -21,7 +20,7 @@ func parseContent(message lightning.Message, opts *lightning.BridgeMessageOption content := "" if bridged { - content += message.Author.Nickname + " ยป " + content += escapeMarkdownV2(message.Author.Nickname) + " ยป " } content += message.Content @@ -70,7 +69,7 @@ func processInlineMarkdown(input string) string { closing := findClosing(chars, i+2, "**") innerText := string(chars[i+2 : closing]) output.WriteString("*") - output.WriteString(processInlineMarkdown(innerText)) + output.WriteString(escapeMarkdownV2(innerText)) output.WriteString("*") i = closing + 2 @@ -78,7 +77,7 @@ func processInlineMarkdown(input string) string { closing := findClosing(chars, i+1, "*") innerText := string(chars[i+1 : closing]) output.WriteString("_") - output.WriteString(processInlineMarkdown(innerText)) + output.WriteString(escapeMarkdownV2(innerText)) output.WriteString("_") i = closing + 1 @@ -86,7 +85,7 @@ func processInlineMarkdown(input string) string { closing := findClosing(chars, i+1, "_") innerText := string(chars[i+1 : closing]) output.WriteString("_") - output.WriteString(processInlineMarkdown(innerText)) + output.WriteString(escapeMarkdownV2(innerText)) output.WriteString("_") i = closing + 1 @@ -94,7 +93,7 @@ func processInlineMarkdown(input string) string { closing := findClosing(chars, i+2, "~~") innerText := string(chars[i+2 : closing]) output.WriteString("~") - output.WriteString(processInlineMarkdown(innerText)) + output.WriteString(escapeMarkdownV2(innerText)) output.WriteString("~") i = closing + 2 diff --git a/telegram/plugin.go b/telegram/plugin.go index 94c33850..5b6f9469 100644 --- a/telegram/plugin.go +++ b/telegram/plugin.go @@ -2,6 +2,7 @@ package telegram import ( "strconv" + "strings" "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" @@ -11,7 +12,7 @@ import ( ) func init() { - lightning.RegisterPluginType("bolt-telegram", newTelegramPlugin) + lightning.Plugins.RegisterType("telegram", newTelegramPlugin) } func newTelegramPlugin(config any) (lightning.Plugin, error) { @@ -147,14 +148,13 @@ func (p *telegramPlugin) SendMessage(message lightning.Message, opts *lightning. content := parseContent(message, opts) - // Setup message options with proper reply handling sendOpts := &gotgbot.SendMessageOpts{ ParseMode: gotgbot.ParseModeMarkdownV2, } if len(message.RepliedTo) > 0 { replyID, err := strconv.ParseInt(message.RepliedTo[0], 10, 64) - if err == nil { + if err == nil && replyID > 0 { sendOpts.ReplyParameters = &gotgbot.ReplyParameters{ MessageId: replyID, AllowSendingWithoutReply: true, @@ -175,19 +175,8 @@ func (p *telegramPlugin) SendMessage(message lightning.Message, opts *lightning. ids := []string{strconv.FormatInt(msg.MessageId, 10)} - docOpts := &gotgbot.SendDocumentOpts{} - if len(message.RepliedTo) > 0 { - replyID, err := strconv.ParseInt(message.RepliedTo[0], 10, 64) - if err == nil { - docOpts.ReplyParameters = &gotgbot.ReplyParameters{ - MessageId: replyID, - AllowSendingWithoutReply: true, - } - } - } - for _, attachment := range message.Attachments { - if msg, err := p.telegram.SendDocument(channel, gotgbot.InputFileByURL(attachment.URL), docOpts); err == nil { + if msg, err := p.telegram.SendDocument(channel, gotgbot.InputFileByURL(attachment.URL), nil); err == nil { ids = append(ids, strconv.FormatInt(msg.MessageId, 10)) } } @@ -223,6 +212,10 @@ func (p *telegramPlugin) EditMessage(message lightning.Message, ids []string, op ParseMode: gotgbot.ParseModeMarkdownV2, }) + if err != nil && strings.Contains("message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message", err.Error()) { + return nil + } + return err } From 3764cee1e8157cedb247c9cfa46077b7a7dd4b5e Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 20 Jun 2025 21:50:34 -0400 Subject: [PATCH 95/97] fix revolt permissions and bump version --- cli/main.go | 2 +- containerfile | 4 ++-- core/commands.go | 2 +- readme.md | 2 +- revolt/plugin.go | 29 +++++++++++++++++++---------- 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/cli/main.go b/cli/main.go index a97294a5..0b7c8436 100644 --- a/cli/main.go +++ b/cli/main.go @@ -20,7 +20,7 @@ func main() { (&cli.Command{ Name: "lightning", Usage: "extensible chatbot connecting communities", - Version: "0.8.0-alpha.7", + Version: "0.8.0-alpha.8", DefaultCommand: "help", EnableShellCompletion: true, Authors: []any{"William Horning", "Lightning contributors"}, diff --git a/containerfile b/containerfile index 8c454a48..15192a82 100644 --- a/containerfile +++ b/containerfile @@ -10,11 +10,11 @@ FROM scratch # metadata LABEL maintainer="William Horning" -LABEL version="0.8.0-alpha.7" +LABEL version="0.8.0-alpha.8" LABEL description="Lightning" LABEL org.opencontainers.image.title="Lightning" LABEL org.opencontainers.image.description="extensible chatbot connecting communities" -LABEL org.opencontainers.image.version="0.8.0-alpha.7" +LABEL org.opencontainers.image.version="0.8.0-alpha.8" LABEL org.opencontainers.image.source="https://github.com/williamhorning/lightning" LABEL org.opencontainers.image.licenses="MIT" diff --git a/core/commands.go b/core/commands.go index 1f68c34a..bfdc24ff 100644 --- a/core/commands.go +++ b/core/commands.go @@ -68,7 +68,7 @@ func HelpCommand() Command { Arguments: []CommandArgument{}, Subcommands: []Command{}, Executor: func(options CommandOptions) (string, error) { - return "hi! i'm lightning v0.8.0-alpha.7.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help!", nil + return "hi! i'm lightning v0.8.0-alpha.8.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help!", nil }, } } diff --git a/readme.md b/readme.md index 2832176e..95514931 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ # lightning - a chatbot > [!NOTE] -> This branch contains the next version of lightning, currently `0.8.0-alpha.7`, +> This branch contains the next version of lightning, currently `0.8.0-alpha.8`, > and reflects active development. To see the latest stable version, go to the > `main` branch. diff --git a/revolt/plugin.go b/revolt/plugin.go index 664e3a28..91dc7524 100644 --- a/revolt/plugin.go +++ b/revolt/plugin.go @@ -55,6 +55,8 @@ func newRevoltPlugin(config any) (lightning.Plugin, error) { lightning.Log.Info().Str("plugin", "revolt").Msg("invite me at https://revolt.chat/invite/" + s.State.Self().ID) }) + revolt.ReconnectInterval = 100 * time.Millisecond + return &revoltPlugin{cfg, revolt}, nil } } @@ -68,13 +70,9 @@ func (p *revoltPlugin) Name() string { return "bolt-revolt" } -const requiredPermissions = revoltgo.PermissionChangeNickname | - revoltgo.PermissionChangeAvatar | - revoltgo.PermissionReadMessageHistory | - revoltgo.PermissionSendMessage | - revoltgo.PermissionManageMessages | - revoltgo.PermissionSendEmbeds | - revoltgo.PermissionUploadFiles +const ( + CorrectPermissionValue = uint(485495808) +) func (p *revoltPlugin) SetupChannel(channel string) (any, error) { permissions, err := p.revolt.State.ChannelPermissions(p.revolt.State.Self(), p.revolt.State.Channel(channel)) @@ -88,11 +86,22 @@ func (p *revoltPlugin) SetupChannel(channel string) (any, error) { ) } - if (permissions & requiredPermissions) != requiredPermissions { + lightning.Log.Debug(). + Str("plugin", "revolt"). + Uint64("actual_permissions", uint64(permissions)). + Uint64("expected_permissions", uint64(CorrectPermissionValue)). + Bool("permissions_sufficient", (permissions&CorrectPermissionValue) == CorrectPermissionValue). + Msg("Revolt permissions check") + + if (permissions & CorrectPermissionValue) != CorrectPermissionValue { return nil, lightning.LogError( errors.New("insufficient permissions in Revolt channel"), - "missing ChangeNickname, ChangeAvatar, ReadMessageHistory, SendMessage, ManageMessages, SendEmbeds, UploadFiles, and/or Masquerade permissions please add them to a role, assign that role to the bot, and rejoin the bridge", - map[string]any{"channel": channel, "permissions": permissions}, + "Missing required permissions. Please add all permissions to a role, assign that role to the bot, and rejoin the bridge", + map[string]any{ + "channel": channel, + "current_permissions": permissions, + "expected_permissions": CorrectPermissionValue, + }, lightning.ReadWriteDisabled{}, ) } From b31a16a87e8a7cc782c4c732918fc1da55585455 Mon Sep 17 00:00:00 2001 From: Jersey Date: Fri, 20 Jun 2025 22:02:42 -0400 Subject: [PATCH 96/97] fix revolt masquerade issue --- revolt/plugin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/revolt/plugin.go b/revolt/plugin.go index 91dc7524..b6e92ae2 100644 --- a/revolt/plugin.go +++ b/revolt/plugin.go @@ -120,7 +120,7 @@ func (p *revoltPlugin) SendMessage(message lightning.Message, opts *lightning.Br } } - msg := getOutgoingMessage(p.revolt, message, false, canMasquerade) + msg := getOutgoingMessage(p.revolt, message, false, !canMasquerade) res, err := p.revolt.ChannelMessageSend(message.ChannelID, msg) if err != nil { From 32ae519644bb07147795eee041ad776420ca3c6c Mon Sep 17 00:00:00 2001 From: Jersey Date: Wed, 25 Jun 2025 14:47:34 -0400 Subject: [PATCH 97/97] 0.8.0-alpha.9 core: - add log handler for stdlib log - add spoiler for bridge id - bump version to 0.8.0-alpha.9 - ensure messages aren't modified by the non-sender plugin - fix command argument missing responses - fix subcommand handling - try to better handle message duplication? discord: - handle forwarded messages guilded: - automatically reconnect if disconnected - clean up api types revolt - don't check for channel permissions on every command - ensure masquerade username and avatar_url aren't too long telegram - use external package for telegram markdown handling --- cli/go.mod | 5 - cli/go.sum | 2 - {cli => cmd/lightning}/main.go | 12 +- containerfile | 6 +- core/go.mod | 31 --- discord/go.mod | 11 - discord/go.sum | 15 -- go.mod | 38 ++++ core/go.sum => go.sum | 65 ++++-- go.work | 10 - go.work.sum | 21 -- guilded/go.mod | 5 - guilded/go.sum | 2 - {core => pkg/lightning}/bridge.go | 10 + {core => pkg/lightning}/bridge_commands.go | 2 +- {core => pkg/lightning}/bridge_types.go | 0 {core => pkg/lightning}/commands.go | 15 +- {core => pkg/lightning}/config.go | 4 + {core => pkg/lightning}/database.go | 0 {core => pkg/lightning}/errors.go | 9 + {core => pkg/lightning}/messages.go | 0 {core => pkg/lightning}/plugin.go | 0 {core => pkg/lightning}/postgres.go | 0 {core => pkg/lightning}/redis.go | 0 {discord => pkg/platforms/discord}/command.go | 2 +- {discord => pkg/platforms/discord}/errors.go | 2 +- .../platforms/discord}/incoming.go | 20 +- .../platforms/discord}/outgoing.go | 2 +- {discord => pkg/platforms/discord}/plugin.go | 4 +- {guilded => pkg/platforms/guilded}/api.go | 80 ++++++- {guilded => pkg/platforms/guilded}/cache.go | 2 +- .../platforms/guilded}/guilded.gen.go | 35 +-- .../platforms/guilded}/incoming.go | 2 +- .../platforms/guilded}/outgoing.go | 2 +- {guilded => pkg/platforms/guilded}/plugin.go | 2 +- {guilded => pkg/platforms/guilded}/send.go | 2 +- {guilded => pkg/platforms/guilded}/setup.go | 2 +- {revolt => pkg/platforms/revolt}/errors.go | 2 +- {revolt => pkg/platforms/revolt}/incoming.go | 2 +- {revolt => pkg/platforms/revolt}/outgoing.go | 26 ++- {revolt => pkg/platforms/revolt}/plugin.go | 42 +--- .../platforms/telegram}/incoming.go | 4 +- pkg/platforms/telegram/outgoing.go | 55 +++++ .../platforms/telegram}/plugin.go | 4 +- {telegram => pkg/platforms/telegram}/proxy.go | 2 +- readme.md | 2 +- revolt/go.mod | 14 -- revolt/go.sum | 13 -- telegram/go.mod | 5 - telegram/outgoing.go | 213 ------------------ todo.md | 10 + 51 files changed, 339 insertions(+), 475 deletions(-) delete mode 100644 cli/go.mod delete mode 100644 cli/go.sum rename {cli => cmd/lightning}/main.go (91%) delete mode 100644 core/go.mod delete mode 100644 discord/go.mod delete mode 100644 discord/go.sum create mode 100644 go.mod rename core/go.sum => go.sum (51%) delete mode 100644 go.work delete mode 100644 go.work.sum delete mode 100644 guilded/go.mod delete mode 100644 guilded/go.sum rename {core => pkg/lightning}/bridge.go (96%) rename {core => pkg/lightning}/bridge_commands.go (98%) rename {core => pkg/lightning}/bridge_types.go (100%) rename {core => pkg/lightning}/commands.go (94%) rename {core => pkg/lightning}/config.go (95%) rename {core => pkg/lightning}/database.go (100%) rename {core => pkg/lightning}/errors.go (92%) rename {core => pkg/lightning}/messages.go (100%) rename {core => pkg/lightning}/plugin.go (100%) rename {core => pkg/lightning}/postgres.go (100%) rename {core => pkg/lightning}/redis.go (100%) rename {discord => pkg/platforms/discord}/command.go (98%) rename {discord => pkg/platforms/discord}/errors.go (96%) rename {discord => pkg/platforms/discord}/incoming.go (90%) rename {discord => pkg/platforms/discord}/outgoing.go (99%) rename {discord => pkg/platforms/discord}/plugin.go (97%) rename {guilded => pkg/platforms/guilded}/api.go (79%) rename {guilded => pkg/platforms/guilded}/cache.go (97%) rename {guilded => pkg/platforms/guilded}/guilded.gen.go (81%) rename {guilded => pkg/platforms/guilded}/incoming.go (99%) rename {guilded => pkg/platforms/guilded}/outgoing.go (98%) rename {guilded => pkg/platforms/guilded}/plugin.go (98%) rename {guilded => pkg/platforms/guilded}/send.go (98%) rename {guilded => pkg/platforms/guilded}/setup.go (98%) rename {revolt => pkg/platforms/revolt}/errors.go (94%) rename {revolt => pkg/platforms/revolt}/incoming.go (99%) rename {revolt => pkg/platforms/revolt}/outgoing.go (84%) rename {revolt => pkg/platforms/revolt}/plugin.go (83%) rename {telegram => pkg/platforms/telegram}/incoming.go (97%) create mode 100644 pkg/platforms/telegram/outgoing.go rename {telegram => pkg/platforms/telegram}/plugin.go (96%) rename {telegram => pkg/platforms/telegram}/proxy.go (96%) delete mode 100644 revolt/go.mod delete mode 100644 revolt/go.sum delete mode 100644 telegram/go.mod delete mode 100644 telegram/outgoing.go create mode 100644 todo.md diff --git a/cli/go.mod b/cli/go.mod deleted file mode 100644 index ce8bc410..00000000 --- a/cli/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/williamhorning/lightning/cli - -go 1.24.4 - -require github.com/urfave/cli/v3 v3.3.8 diff --git a/cli/go.sum b/cli/go.sum deleted file mode 100644 index d6bf8819..00000000 --- a/cli/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E= -github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= diff --git a/cli/main.go b/cmd/lightning/main.go similarity index 91% rename from cli/main.go rename to cmd/lightning/main.go index 0b7c8436..90152b47 100644 --- a/cli/main.go +++ b/cmd/lightning/main.go @@ -9,18 +9,18 @@ import ( "syscall" "github.com/urfave/cli/v3" - "github.com/williamhorning/lightning" - _ "github.com/williamhorning/lightning/discord" - _ "github.com/williamhorning/lightning/guilded" - _ "github.com/williamhorning/lightning/revolt" - _ "github.com/williamhorning/lightning/telegram" + "github.com/williamhorning/lightning/pkg/lightning" + _ "github.com/williamhorning/lightning/pkg/platforms/discord" + _ "github.com/williamhorning/lightning/pkg/platforms/guilded" + _ "github.com/williamhorning/lightning/pkg/platforms/revolt" + _ "github.com/williamhorning/lightning/pkg/platforms/telegram" ) func main() { (&cli.Command{ Name: "lightning", Usage: "extensible chatbot connecting communities", - Version: "0.8.0-alpha.8", + Version: "0.8.0-alpha.9", DefaultCommand: "help", EnableShellCompletion: true, Authors: []any{"William Horning", "Lightning contributors"}, diff --git a/containerfile b/containerfile index 15192a82..9950440d 100644 --- a/containerfile +++ b/containerfile @@ -3,18 +3,18 @@ FROM golang:1.24-alpine AS builder WORKDIR /app COPY . . -RUN go build -o lightning ./cli/main.go +RUN go build -o lightning ./cmd/lightning/main.go # build the final image FROM scratch # metadata LABEL maintainer="William Horning" -LABEL version="0.8.0-alpha.8" +LABEL version="0.8.0-alpha.9" LABEL description="Lightning" LABEL org.opencontainers.image.title="Lightning" LABEL org.opencontainers.image.description="extensible chatbot connecting communities" -LABEL org.opencontainers.image.version="0.8.0-alpha.8" +LABEL org.opencontainers.image.version="0.8.0-alpha.9" LABEL org.opencontainers.image.source="https://github.com/williamhorning/lightning" LABEL org.opencontainers.image.licenses="MIT" diff --git a/core/go.mod b/core/go.mod deleted file mode 100644 index a66e3472..00000000 --- a/core/go.mod +++ /dev/null @@ -1,31 +0,0 @@ -module github.com/williamhorning/lightning - -go 1.24.4 - -require github.com/jackc/pgx/v5 v5.7.5 - -require github.com/BurntSushi/toml v1.5.0 - -require github.com/briandowns/spinner v1.23.2 - -require github.com/oklog/ulid/v2 v2.1.1 - -require github.com/redis/go-redis/v9 v9.10.0 - -require github.com/rs/zerolog v1.34.0 - -require ( - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect - github.com/fatih/color v1.7.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect - golang.org/x/crypto v0.37.0 // indirect - golang.org/x/sync v0.13.0 // indirect - golang.org/x/sys v0.32.0 // indirect - golang.org/x/term v0.31.0 // indirect - golang.org/x/text v0.24.0 // indirect -) diff --git a/discord/go.mod b/discord/go.mod deleted file mode 100644 index abcf5647..00000000 --- a/discord/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module github.com/williamhorning/lightning/discord - -go 1.24.4 - -require github.com/bwmarrin/discordgo v0.29.0 - -require ( - github.com/gorilla/websocket v1.5.3 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/sys v0.33.0 // indirect -) diff --git a/discord/go.sum b/discord/go.sum deleted file mode 100644 index 4bb2f46a..00000000 --- a/discord/go.sum +++ /dev/null @@ -1,15 +0,0 @@ -github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= -github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= -github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..217cde89 --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module github.com/williamhorning/lightning + +go 1.24.4 + +require ( + github.com/BurntSushi/toml v1.5.0 + github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32 + github.com/briandowns/spinner v1.23.2 + github.com/bwmarrin/discordgo v0.29.0 + github.com/gorilla/websocket v1.5.3 + github.com/jackc/pgx/v5 v5.7.5 + github.com/oklog/ulid/v2 v2.1.1 + github.com/redis/go-redis/v9 v9.11.0 + github.com/rs/zerolog v1.34.0 + github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d + github.com/sshturbo/GoTeleMD v1.0.10 + github.com/urfave/cli/v3 v3.3.8 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dolthub/maphash v0.1.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lxzan/gws v1.8.9 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + golang.org/x/crypto v0.39.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/term v0.32.0 // indirect + golang.org/x/text v0.26.0 // indirect +) diff --git a/core/go.sum b/go.sum similarity index 51% rename from core/go.sum rename to go.sum index 7e502c79..24663d6b 100644 --- a/core/go.sum +++ b/go.sum @@ -1,11 +1,15 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32 h1:+YzI72wzNTcaPUDVcSxeYQdHfvEk8mPGZh/yTk5kkRg= +github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32/go.mod h1:BSzsfjlE0wakLw2/U1FtO8rdVt+Z+4VyoGo/YcGD9QQ= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -14,9 +18,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= -github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= +github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -25,40 +36,58 @@ github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM= +github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs= -github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs= +github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d h1:DOQdvM3y/egSSpW2sM1bfY4S9gCsKvCwjqPoRUXrZwY= +github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d/go.mod h1:NZZh2iADP8/9NBnlea1b22idZn3fNm74QXAH4uFqgJE= +github.com/sshturbo/GoTeleMD v1.0.10 h1:7XEg2CYBGlFh/Vib7PBtx8XrAoELkncxoyRVnkLM42k= +github.com/sshturbo/GoTeleMD v1.0.10/go.mod h1:9TgL1fbDy5Tx1mOypY+payEkVftzo4TmYPD2xk/SKl4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= -golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= -golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli/v3 v3.3.8 h1:BzolUExliMdet9NlJ/u4m5vHSotJ3PzEqSAZ1oPMa/E= +github.com/urfave/cli/v3 v3.3.8/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= +golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= -golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= -golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= +golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/go.work b/go.work deleted file mode 100644 index 7150ce92..00000000 --- a/go.work +++ /dev/null @@ -1,10 +0,0 @@ -go 1.24.4 - -use ( - ./cli - ./core - ./discord - ./guilded - ./revolt - ./telegram -) \ No newline at end of file diff --git a/go.work.sum b/go.work.sum deleted file mode 100644 index 29fc1d12..00000000 --- a/go.work.sum +++ /dev/null @@ -1,21 +0,0 @@ -github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32 h1:+YzI72wzNTcaPUDVcSxeYQdHfvEk8mPGZh/yTk5kkRg= -github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32/go.mod h1:BSzsfjlE0wakLw2/U1FtO8rdVt+Z+4VyoGo/YcGD9QQ= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/telemetry v0.0.0-20240521205824-bda55230c457/go.mod h1:pRgIJT+bRLFKnoM1ldnzKoxTIn14Yxz928LQRYYgIN0= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/guilded/go.mod b/guilded/go.mod deleted file mode 100644 index 16215e27..00000000 --- a/guilded/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/williamhorning/lightning/guilded - -go 1.24.4 - -require github.com/gorilla/websocket v1.5.3 diff --git a/guilded/go.sum b/guilded/go.sum deleted file mode 100644 index 25a9fc4b..00000000 --- a/guilded/go.sum +++ /dev/null @@ -1,2 +0,0 @@ -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/core/bridge.go b/pkg/lightning/bridge.go similarity index 96% rename from core/bridge.go rename to pkg/lightning/bridge.go index 323167c9..6d1e70f5 100644 --- a/core/bridge.go +++ b/pkg/lightning/bridge.go @@ -60,6 +60,12 @@ func handleBridgeMessage(db Database, event string, data any) error { if event == "create_message" { Log.Trace().Str("channel", base.ChannelID).Str("event", event).Msg("Getting bridge by channel for new message") + + if _, err := db.getMessage(base.EventID); err == nil { + Log.Trace().Str("channel", base.ChannelID).Str("message", base.EventID).Msg("Skipping duplicate message") + return nil + } + bridge, err = db.getBridgeByChannel(base.ChannelID) } else { Log.Trace().Str("event_id", base.EventID).Str("event", event).Msg("Getting bridge message collection for existing message") @@ -73,6 +79,10 @@ func handleBridgeMessage(db Database, event string, data any) error { Settings: bridgeMsg.Settings, } Log.Trace().Str("bridge_id", bridge.ID).Str("event_id", base.EventID).Msg("Retrieved bridge information") + if bridgeMsg.ID != base.EventID && event == "edit_message" { + Log.Trace().Str("event_id", base.EventID).Msg("Message is not the original, skipping bridge edit handling") + return nil + } } } diff --git a/core/bridge_commands.go b/pkg/lightning/bridge_commands.go similarity index 98% rename from core/bridge_commands.go rename to pkg/lightning/bridge_commands.go index 8b459e5e..573a596c 100644 --- a/core/bridge_commands.go +++ b/pkg/lightning/bridge_commands.go @@ -112,7 +112,7 @@ func createCommand(db Database, opts CommandOptions) (string, error) { } Log.Debug().Str("bridge_id", bridge.ID).Str("channel", opts.ChannelID).Msg("Bridge created successfully") - return "Bridge created successfully! You can now join it using `" + opts.Prefix + "bridge join " + bridge.ID + "`.", nil + return "Bridge created successfully! You can now join it using ||`" + opts.Prefix + "bridge join " + bridge.ID + "`||. Keep this command secret!", nil } func joinCommand(db Database, opts CommandOptions, subscribe bool) (string, error) { diff --git a/core/bridge_types.go b/pkg/lightning/bridge_types.go similarity index 100% rename from core/bridge_types.go rename to pkg/lightning/bridge_types.go diff --git a/core/commands.go b/pkg/lightning/commands.go similarity index 94% rename from core/commands.go rename to pkg/lightning/commands.go index bfdc24ff..065817b1 100644 --- a/core/commands.go +++ b/pkg/lightning/commands.go @@ -68,7 +68,7 @@ func HelpCommand() Command { Arguments: []CommandArgument{}, Subcommands: []Command{}, Executor: func(options CommandOptions) (string, error) { - return "hi! i'm lightning v0.8.0-alpha.8.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help!", nil + return "hi! i'm lightning v0.8.0-alpha.9.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help!", nil }, } } @@ -157,12 +157,12 @@ func handleCommandEvent(event CommandEvent) error { if event.Options != nil && len(*event.Options) > 0 { event.Subcommand = &(*event.Options)[0] *event.Options = (*event.Options)[1:] + } - for _, subcommand := range command.Subcommands { - if subcommand.Name == *event.Subcommand { - command = subcommand - break - } + for _, subcommand := range command.Subcommands { + if event.Subcommand != nil && subcommand.Name == *event.Subcommand { + command = subcommand + break } } @@ -174,7 +174,7 @@ func handleCommandEvent(event CommandEvent) error { if arg.Required && event.CommandOptions.Arguments[arg.Name] == "" { Log.Trace().Str("argument", arg.Name).Msg("Required argument missing") - err := event.Reply("Please provide the " + arg.Name + " argument. Try using the " + event.Prefix + "help command.") + err := event.Reply("Please provide the " + arg.Name + " argument. Try using the `" + event.Prefix + "help` command.") if err != nil { return LogError(err, "Error sending missing argument response", map[string]any{ "argument": arg.Name, @@ -182,6 +182,7 @@ func handleCommandEvent(event CommandEvent) error { "event": event.EventID, }, ReadWriteDisabled{}) } + return nil } } diff --git a/core/config.go b/pkg/lightning/config.go similarity index 95% rename from core/config.go rename to pkg/lightning/config.go index 6ef7d2e7..cc4ee8d4 100644 --- a/core/config.go +++ b/pkg/lightning/config.go @@ -1,6 +1,7 @@ package lightning import ( + "log" "os" "time" @@ -43,6 +44,9 @@ func LoadConfig(path string) (Config, error) { Plugins.eventDelay = time.Duration(*config.BridgeDelay) * time.Millisecond } + log.SetFlags(0) + log.SetOutput(&zerologAdapter{}) + SetupCommands(config.CommandPrefix) for plugin, cfg := range config.Plugins { diff --git a/core/database.go b/pkg/lightning/database.go similarity index 100% rename from core/database.go rename to pkg/lightning/database.go diff --git a/core/errors.go b/pkg/lightning/errors.go similarity index 92% rename from core/errors.go rename to pkg/lightning/errors.go index 1a4486eb..5b47bedd 100644 --- a/core/errors.go +++ b/pkg/lightning/errors.go @@ -18,6 +18,15 @@ var ( ErrLogErrorNilError = errors.New("LogError called with nil error. Please provide a valid error") ) +type zerologAdapter struct{} + +func (z *zerologAdapter) Write(p []byte) (n int, err error) { + Log.Debug(). + Str("type", "log package log"). + Msg(string(p)) + return len(p), nil +} + type LightningError struct { Disable ReadWriteDisabled Message string diff --git a/core/messages.go b/pkg/lightning/messages.go similarity index 100% rename from core/messages.go rename to pkg/lightning/messages.go diff --git a/core/plugin.go b/pkg/lightning/plugin.go similarity index 100% rename from core/plugin.go rename to pkg/lightning/plugin.go diff --git a/core/postgres.go b/pkg/lightning/postgres.go similarity index 100% rename from core/postgres.go rename to pkg/lightning/postgres.go diff --git a/core/redis.go b/pkg/lightning/redis.go similarity index 100% rename from core/redis.go rename to pkg/lightning/redis.go diff --git a/discord/command.go b/pkg/platforms/discord/command.go similarity index 98% rename from discord/command.go rename to pkg/platforms/discord/command.go index cb029bb1..1a2a2ab0 100644 --- a/discord/command.go +++ b/pkg/platforms/discord/command.go @@ -4,7 +4,7 @@ import ( "time" "github.com/bwmarrin/discordgo" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func getDiscordCommandOptions(arguments lightning.Command) []*discordgo.ApplicationCommandOption { diff --git a/discord/errors.go b/pkg/platforms/discord/errors.go similarity index 96% rename from discord/errors.go rename to pkg/platforms/discord/errors.go index b79d3690..51f1696d 100644 --- a/discord/errors.go +++ b/pkg/platforms/discord/errors.go @@ -4,7 +4,7 @@ import ( "fmt" "github.com/bwmarrin/discordgo" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) type ErrorConfig struct { diff --git a/discord/incoming.go b/pkg/platforms/discord/incoming.go similarity index 90% rename from discord/incoming.go rename to pkg/platforms/discord/incoming.go index e4de1235..7184d709 100644 --- a/discord/incoming.go +++ b/pkg/platforms/discord/incoming.go @@ -4,9 +4,10 @@ import ( "regexp" "slices" "strconv" + "strings" "github.com/bwmarrin/discordgo" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) var allowedTypes = []discordgo.MessageType{0, 7, 19, 20, 23} @@ -25,7 +26,7 @@ func getLightningMessage(s *discordgo.Session, m *discordgo.Message) *lightning. }, Attachments: getLightningAttachments(m.Attachments, m.StickerItems), Author: getLightningAuthor(s, m), - Content: getLightningContent(s, m), + Content: getLightningForward(s, m) + getLightningContent(s, m), Embeds: getLightningEmbeds(m.Embeds), RepliedTo: getLightningReplies(m), } @@ -227,3 +228,18 @@ func getLightningReplies(m *discordgo.Message) []string { } return []string{m.MessageReference.MessageID} } + +func getLightningForward(s *discordgo.Session, m *discordgo.Message) string { + if m.MessageReference == nil || m.MessageReference.MessageID == "" || + m.MessageReference.Type != discordgo.MessageReferenceTypeForward || m.MessageSnapshots == nil || len(m.MessageSnapshots) == 0 { + return "" + } + + snapshot := "" + + for _, snap := range m.MessageSnapshots { + snapshot += "> *Forwarded:*\n> " + strings.ReplaceAll(getLightningContent(s, snap.Message), "\n", "\n> ") + } + + return snapshot +} diff --git a/discord/outgoing.go b/pkg/platforms/discord/outgoing.go similarity index 99% rename from discord/outgoing.go rename to pkg/platforms/discord/outgoing.go index 145480af..a8340f44 100644 --- a/discord/outgoing.go +++ b/pkg/platforms/discord/outgoing.go @@ -10,7 +10,7 @@ import ( "time" "github.com/bwmarrin/discordgo" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) const ( diff --git a/discord/plugin.go b/pkg/platforms/discord/plugin.go similarity index 97% rename from discord/plugin.go rename to pkg/platforms/discord/plugin.go index d34facf2..e69ac280 100644 --- a/discord/plugin.go +++ b/pkg/platforms/discord/plugin.go @@ -2,7 +2,7 @@ package discord import ( "github.com/bwmarrin/discordgo" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func init() { @@ -32,6 +32,8 @@ func newDiscordPlugin(config any) (lightning.Plugin, error) { discord.Identify.Intents = 16813601 discord.StateEnabled = true + discord.ShouldReconnectOnError = true + discord.LogLevel = 2 if err != nil { return nil, lightning.LogError( diff --git a/guilded/api.go b/pkg/platforms/guilded/api.go similarity index 79% rename from guilded/api.go rename to pkg/platforms/guilded/api.go index 7011cc1c..662a7313 100644 --- a/guilded/api.go +++ b/pkg/platforms/guilded/api.go @@ -9,7 +9,7 @@ import ( "time" "github.com/gorilla/websocket" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func guildedMakeRequest(token, method, endpoint string, body *io.Reader) (*http.Response, error) { @@ -44,6 +44,7 @@ type guildedSocketManager struct { messageCreatedHandler func(*guildedChatMessageCreated) messageUpdatedHandler func(*guildedChatMessageUpdated) messageDeletedHandler func(*guildedChatMessageDeleted) + reconnecting bool } func guildedNewSocketManager(token string) *guildedSocketManager { @@ -82,6 +83,24 @@ func (s *guildedSocketManager) OnMessageDeleted(handler func(*guildedChatMessage } func (s *guildedSocketManager) Connect() error { + s.mu.Lock() + if s.Alive || s.reconnecting { + s.mu.Unlock() + return nil + } + s.reconnecting = true + s.mu.Unlock() + + err := s.connectWebsocket() + + s.mu.Lock() + s.reconnecting = false + s.mu.Unlock() + + return err +} + +func (s *guildedSocketManager) connectWebsocket() error { header := http.Header{} header.Set("Authorization", "Bearer "+s.Token) header.Set("User-Agent", "guildapi/0.0.5") @@ -95,8 +114,10 @@ func (s *guildedSocketManager) Connect() error { return err } + s.mu.Lock() s.Alive = true s.done = make(chan struct{}) + s.mu.Unlock() go s.readMessages() return nil @@ -104,20 +125,39 @@ func (s *guildedSocketManager) Connect() error { func (s *guildedSocketManager) readMessages() { defer func() { + s.mu.Lock() s.Alive = false - close(s.done) if s.conn != nil { s.conn.Close() + s.conn = nil } + close(s.done) + s.mu.Unlock() + + go s.handleReconnect() }() - for s.Alive { - _, message, err := s.conn.ReadMessage() + for { + s.mu.RLock() + if !s.Alive { + s.mu.RUnlock() + return + } + conn := s.conn + s.mu.RUnlock() + + if conn == nil { + return + } + + _, message, err := conn.ReadMessage() if err != nil { if !websocket.IsCloseError(err, websocket.CloseNormalClosure) { lightning.Log.Error().Err(err).Msg("Error reading from WebSocket") + } else { + lightning.Log.Info().Msg("WebSocket closed") } - break + return } var data guildedSocketEventEnvelope @@ -130,6 +170,36 @@ func (s *guildedSocketManager) readMessages() { } } +func (s *guildedSocketManager) handleReconnect() { + attempts := 0 + backoff := 100 * time.Millisecond + maxBackoff := 2 * time.Second + + for { + attempts++ + + lightning.Log.Info(). + Int("attempt", attempts). + Dur("backoff", backoff). + Msg("Attempting to reconnect to Guilded WebSocket") + + time.Sleep(backoff) + + err := s.Connect() + if err == nil { + lightning.Log.Info().Msg("Guilded WebSocket reconnection successful") + return + } + + lightning.Log.Error(). + Err(err). + Int("attempt", attempts). + Msg("Failed to reconnect to Guilded WebSocket") + + backoff = min(time.Duration(float64(backoff)*1.5), maxBackoff) + } +} + func (s *guildedSocketManager) handleEvent(data guildedSocketEventEnvelope) { if data.T == nil { lightning.Log.Trace().Msg("Received event with nil type") diff --git a/guilded/cache.go b/pkg/platforms/guilded/cache.go similarity index 97% rename from guilded/cache.go rename to pkg/platforms/guilded/cache.go index 8e9de4bb..206abf0e 100644 --- a/guilded/cache.go +++ b/pkg/platforms/guilded/cache.go @@ -4,7 +4,7 @@ import ( "sync" "time" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) const ( diff --git a/guilded/guilded.gen.go b/pkg/platforms/guilded/guilded.gen.go similarity index 81% rename from guilded/guilded.gen.go rename to pkg/platforms/guilded/guilded.gen.go index 661cc3b9..f077bbb2 100644 --- a/guilded/guilded.gen.go +++ b/pkg/platforms/guilded/guilded.gen.go @@ -117,23 +117,12 @@ type guildedServerMember struct { } type guildedSocketEventEnvelope struct { - D *map[string]interface{} `json:"d,omitempty"` - Op guildedSocketEventEnvelopeOp `json:"op"` - S *string `json:"s,omitempty"` - T *string `json:"t,omitempty"` + D *map[string]any `json:"d,omitempty"` + Op int `json:"op"` + S *string `json:"s,omitempty"` + T *string `json:"t,omitempty"` } -const ( - guildedSocketOPSuccess guildedSocketEventEnvelopeOp = 0 - guildedSocketOPWelcome guildedSocketEventEnvelopeOp = 1 - guildedSocketOPResume guildedSocketEventEnvelopeOp = 2 - guildedSocketOPError guildedSocketEventEnvelopeOp = 8 - guildedSocketOPPing guildedSocketEventEnvelopeOp = 9 - guildedSocketOPPong guildedSocketEventEnvelopeOp = 10 -) - -type guildedSocketEventEnvelopeOp int - type guildedUrlSignature struct { RetryAfter *int `json:"retryAfter,omitempty"` Signature *string `json:"signature,omitempty"` @@ -168,16 +157,8 @@ type guildedWebhook struct { } type guildedWelcomeMessage struct { - BotId string `json:"botId"` - HeartbeatIntervalMs int `json:"heartbeatIntervalMs"` - LastMessageId string `json:"lastMessageId"` - User struct { - Avatar *string `json:"avatar,omitempty"` - Banner *string `json:"banner,omitempty"` - CreatedAt time.Time `json:"createdAt"` - Id string `json:"id"` - Name string `json:"name"` - Status *guildedUserStatus `json:"status,omitempty"` - Type *string `json:"type,omitempty"` - } `json:"user"` + BotId string `json:"botId"` + HeartbeatIntervalMs int `json:"heartbeatIntervalMs"` + LastMessageId string `json:"lastMessageId"` + User guildedUser `json:"user"` } diff --git a/guilded/incoming.go b/pkg/platforms/guilded/incoming.go similarity index 99% rename from guilded/incoming.go rename to pkg/platforms/guilded/incoming.go index 0aa24642..4c89007d 100644 --- a/guilded/incoming.go +++ b/pkg/platforms/guilded/incoming.go @@ -11,7 +11,7 @@ import ( "strconv" "strings" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) var attachmentRegex = regexp.MustCompile(`!\[.*?\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)`) diff --git a/guilded/outgoing.go b/pkg/platforms/guilded/outgoing.go similarity index 98% rename from guilded/outgoing.go rename to pkg/platforms/guilded/outgoing.go index fa4341a9..e87ed6d7 100644 --- a/guilded/outgoing.go +++ b/pkg/platforms/guilded/outgoing.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) type guildedPayload struct { diff --git a/guilded/plugin.go b/pkg/platforms/guilded/plugin.go similarity index 98% rename from guilded/plugin.go rename to pkg/platforms/guilded/plugin.go index e7ec2ec7..d7103893 100644 --- a/guilded/plugin.go +++ b/pkg/platforms/guilded/plugin.go @@ -1,7 +1,7 @@ package guilded import ( - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func init() { diff --git a/guilded/send.go b/pkg/platforms/guilded/send.go similarity index 98% rename from guilded/send.go rename to pkg/platforms/guilded/send.go index b632a044..d5cf105e 100644 --- a/guilded/send.go +++ b/pkg/platforms/guilded/send.go @@ -8,7 +8,7 @@ import ( "io" "net/http" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func (p *guildedPlugin) SendMessage(message lightning.Message, opts *lightning.BridgeMessageOptions) ([]string, error) { diff --git a/guilded/setup.go b/pkg/platforms/guilded/setup.go similarity index 98% rename from guilded/setup.go rename to pkg/platforms/guilded/setup.go index 61b4f2d3..e967cdb8 100644 --- a/guilded/setup.go +++ b/pkg/platforms/guilded/setup.go @@ -7,7 +7,7 @@ import ( "io" "strconv" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func (p *guildedPlugin) SetupChannel(channel string) (any, error) { diff --git a/revolt/errors.go b/pkg/platforms/revolt/errors.go similarity index 94% rename from revolt/errors.go rename to pkg/platforms/revolt/errors.go index 56cf89b3..09ef7043 100644 --- a/revolt/errors.go +++ b/pkg/platforms/revolt/errors.go @@ -4,7 +4,7 @@ import ( "strconv" "strings" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func extractStatusAndBody(err error) (int, string) { diff --git a/revolt/incoming.go b/pkg/platforms/revolt/incoming.go similarity index 99% rename from revolt/incoming.go rename to pkg/platforms/revolt/incoming.go index e6765057..2aee16b4 100644 --- a/revolt/incoming.go +++ b/pkg/platforms/revolt/incoming.go @@ -8,7 +8,7 @@ import ( "github.com/oklog/ulid/v2" "github.com/sentinelb51/revoltgo" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func getLightningMessage(s *revoltgo.Session, m revoltgo.Message) *lightning.Message { diff --git a/revolt/outgoing.go b/pkg/platforms/revolt/outgoing.go similarity index 84% rename from revolt/outgoing.go rename to pkg/platforms/revolt/outgoing.go index 8ab8d4ff..4bcdc1da 100644 --- a/revolt/outgoing.go +++ b/pkg/platforms/revolt/outgoing.go @@ -8,7 +8,7 @@ import ( "time" "github.com/sentinelb51/revoltgo" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func toEdit(message revoltgo.MessageSend) revoltgo.MessageEditData { @@ -18,7 +18,7 @@ func toEdit(message revoltgo.MessageSend) revoltgo.MessageEditData { } } -func getOutgoingMessage(s *revoltgo.Session, message lightning.Message, skipfiles bool, skipmasq bool) revoltgo.MessageSend { +func getOutgoingMessage(s *revoltgo.Session, message lightning.Message, skipFiles bool, useMasquerade bool) revoltgo.MessageSend { content := message.Content if content == "" && len(message.Embeds) == 0 && len(message.Attachments) == 0 { @@ -28,11 +28,11 @@ func getOutgoingMessage(s *revoltgo.Session, message lightning.Message, skipfile } return revoltgo.MessageSend{ - Attachments: getOutgoingAttachments(s, message.Attachments, skipfiles), + Attachments: getOutgoingAttachments(s, message.Attachments, skipFiles), Content: content, Embeds: getOutgoingEmbeds(message.Embeds), Replies: getOutgoingReplies(message.RepliedTo), - Masquerade: getOutgoingMasquerade(message.Author, skipmasq), + Masquerade: getOutgoingMasquerade(message.Author, useMasquerade), Interactions: nil, } } @@ -167,19 +167,29 @@ func getOutgoingReplies(replyIDs []string) []*revoltgo.MessageReplies { return replies } -func getOutgoingMasquerade(author lightning.MessageAuthor, skipmasq bool) *revoltgo.MessageMasquerade { - if skipmasq { +func getOutgoingMasquerade(author lightning.MessageAuthor, useMasquerade bool) *revoltgo.MessageMasquerade { + if !useMasquerade { return nil } avatar := "" - if author.ProfilePicture != nil { + if author.ProfilePicture != nil && len([]rune(*author.ProfilePicture)) > 1 && len([]rune(*author.ProfilePicture)) <= 128 { avatar = *author.ProfilePicture } return &revoltgo.MessageMasquerade{ Colour: author.Color, - Name: author.Nickname, + Name: getMasqueradeUsername(author.Nickname), Avatar: avatar, } } + +func getMasqueradeUsername(name string) string { + runes := []rune(name) + + if len(runes) > 32 { + return string(runes[:32]) + } + + return name +} diff --git a/revolt/plugin.go b/pkg/platforms/revolt/plugin.go similarity index 83% rename from revolt/plugin.go rename to pkg/platforms/revolt/plugin.go index b6e92ae2..886db70c 100644 --- a/revolt/plugin.go +++ b/pkg/platforms/revolt/plugin.go @@ -2,29 +2,16 @@ package revolt import ( "errors" - "log" - "strings" "time" "github.com/sentinelb51/revoltgo" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func init() { lightning.Plugins.RegisterType("revolt", newRevoltPlugin) } -type zerologAdapter struct{} - -func (z *zerologAdapter) Write(p []byte) (n int, err error) { - message := strings.TrimSpace(string(p)) - lightning.Log.Debug(). - Str("plugin", "revolt"). - Str("type", "revoltgo"). - Msg(message) - return len(p), nil -} - func newRevoltPlugin(config any) (lightning.Plugin, error) { if cfg, ok := config.(map[string]any); !ok { return nil, lightning.LogError( @@ -36,12 +23,7 @@ func newRevoltPlugin(config any) (lightning.Plugin, error) { } else { revolt := revoltgo.New(cfg["token"].(string)) - log.SetFlags(0) - log.SetOutput(&zerologAdapter{}) - - err := revolt.Open() - - if err != nil { + if err := revolt.Open(); err != nil { return nil, lightning.LogError( err, "Failed to open Revolt session", @@ -55,6 +37,10 @@ func newRevoltPlugin(config any) (lightning.Plugin, error) { lightning.Log.Info().Str("plugin", "revolt").Msg("invite me at https://revolt.chat/invite/" + s.State.Self().ID) }) + revolt.AddHandler(func(s *revoltgo.Session, m *revoltgo.EventError) { + lightning.Log.Error().Str("plugin", "revolt").Str("type", string(m.Error)).Msg("revolt webhook error") + }) + revolt.ReconnectInterval = 100 * time.Millisecond return &revoltPlugin{cfg, revolt}, nil @@ -91,7 +77,7 @@ func (p *revoltPlugin) SetupChannel(channel string) (any, error) { Uint64("actual_permissions", uint64(permissions)). Uint64("expected_permissions", uint64(CorrectPermissionValue)). Bool("permissions_sufficient", (permissions&CorrectPermissionValue) == CorrectPermissionValue). - Msg("Revolt permissions check") + Msg("revolt permissions check") if (permissions & CorrectPermissionValue) != CorrectPermissionValue { return nil, lightning.LogError( @@ -110,17 +96,7 @@ func (p *revoltPlugin) SetupChannel(channel string) (any, error) { } func (p *revoltPlugin) SendMessage(message lightning.Message, opts *lightning.BridgeMessageOptions) ([]string, error) { - canMasquerade := opts != nil - - if opts == nil { - chPermissions, err := p.revolt.State.ChannelPermissions(p.revolt.State.Self(), p.revolt.State.Channel(message.ChannelID)) - - if err == nil { - canMasquerade = chPermissions&revoltgo.PermissionMasquerade == revoltgo.PermissionMasquerade - } - } - - msg := getOutgoingMessage(p.revolt, message, false, !canMasquerade) + msg := getOutgoingMessage(p.revolt, message, false, opts != nil) res, err := p.revolt.ChannelMessageSend(message.ChannelID, msg) if err != nil { @@ -131,7 +107,7 @@ func (p *revoltPlugin) SendMessage(message lightning.Message, opts *lightning.Br } func (p *revoltPlugin) EditMessage(message lightning.Message, ids []string, opts *lightning.BridgeMessageOptions) error { - _, err := p.revolt.ChannelMessageEdit(opts.Channel.ID, ids[0], toEdit(getOutgoingMessage(p.revolt, message, true, false))) + _, err := p.revolt.ChannelMessageEdit(opts.Channel.ID, ids[0], toEdit(getOutgoingMessage(p.revolt, message, true, true))) if err != nil { return getRevoltError(err, map[string]any{"ids": ids}, "Failed to edit message on Revolt", true) diff --git a/telegram/incoming.go b/pkg/platforms/telegram/incoming.go similarity index 97% rename from telegram/incoming.go rename to pkg/platforms/telegram/incoming.go index 6bd88d34..3d958599 100644 --- a/telegram/incoming.go +++ b/pkg/platforms/telegram/incoming.go @@ -9,7 +9,7 @@ import ( "github.com/PaulSonOfLars/gotgbot/v2" "github.com/PaulSonOfLars/gotgbot/v2/ext" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func getBase(ctx *ext.Context) lightning.BaseMessage { @@ -41,7 +41,7 @@ func getCommand(cmdName string, b *gotgbot.Bot, ctx *ext.Context) lightning.Comm Command: cmdName, Options: &args, Reply: func(message string) error { - _, err := ctx.EffectiveMessage.Reply(b, telegramifyMarkdown(message), &gotgbot.SendMessageOpts{ + _, err := ctx.EffectiveMessage.Reply(b, getMarkdownV2(message), &gotgbot.SendMessageOpts{ ParseMode: gotgbot.ParseModeMarkdownV2, }) return err diff --git a/pkg/platforms/telegram/outgoing.go b/pkg/platforms/telegram/outgoing.go new file mode 100644 index 00000000..a1c1c9aa --- /dev/null +++ b/pkg/platforms/telegram/outgoing.go @@ -0,0 +1,55 @@ +package telegram + +import ( + "math" + "slices" + + "github.com/sshturbo/GoTeleMD/pkg/formatter" + "github.com/sshturbo/GoTeleMD/pkg/types" + "github.com/williamhorning/lightning/pkg/lightning" +) + +var specialChars = []string{"[", "]", "(", ")", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!", "\\."} + +func parseContent(message lightning.Message, opts *lightning.BridgeMessageOptions) string { + content := "" + + if opts != nil { + content += message.Author.Nickname + " ยป " + } + + mdV2 := getMarkdownV2(message.Content) + + if len(mdV2) > 0 && slices.Contains(specialChars, mdV2[:1]) { + content += "\n" + } + + content += mdV2 + + if len(message.Embeds) > 0 { + content += "\n_this message has embeds_" + } + + println(content) + + return content +} + +var config = &types.Config{ + SafetyLevel: 1, + AlignTableColumns: true, + IgnoreTableSeparator: false, + MaxMessageLength: math.MaxInt, + EnableDebugLogs: false, + PreserveEmptyLines: false, + StrictLineBreaks: true, + NumWorkers: 4, + WorkerQueueSize: 32, + MaxConcurrentParts: 8, +} + +func getMarkdownV2(str string) string { + resp, _ := formatter.ConvertMarkdown(str, config) + + return resp +} diff --git a/telegram/plugin.go b/pkg/platforms/telegram/plugin.go similarity index 96% rename from telegram/plugin.go rename to pkg/platforms/telegram/plugin.go index 5b6f9469..017aade2 100644 --- a/telegram/plugin.go +++ b/pkg/platforms/telegram/plugin.go @@ -8,7 +8,7 @@ import ( "github.com/PaulSonOfLars/gotgbot/v2/ext" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers" "github.com/PaulSonOfLars/gotgbot/v2/ext/handlers/filters/message" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func init() { @@ -212,7 +212,7 @@ func (p *telegramPlugin) EditMessage(message lightning.Message, ids []string, op ParseMode: gotgbot.ParseModeMarkdownV2, }) - if err != nil && strings.Contains("message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message", err.Error()) { + if err != nil && strings.Contains("message is not modified", err.Error()) { return nil } diff --git a/telegram/proxy.go b/pkg/platforms/telegram/proxy.go similarity index 96% rename from telegram/proxy.go rename to pkg/platforms/telegram/proxy.go index f57093d5..01125ae5 100644 --- a/telegram/proxy.go +++ b/pkg/platforms/telegram/proxy.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "github.com/williamhorning/lightning" + "github.com/williamhorning/lightning/pkg/lightning" ) func (p *telegramPlugin) startProxy() { diff --git a/readme.md b/readme.md index 95514931..8df1c307 100644 --- a/readme.md +++ b/readme.md @@ -3,7 +3,7 @@ # lightning - a chatbot > [!NOTE] -> This branch contains the next version of lightning, currently `0.8.0-alpha.8`, +> This branch contains the next version of lightning, currently `0.8.0-alpha.9`, > and reflects active development. To see the latest stable version, go to the > `main` branch. diff --git a/revolt/go.mod b/revolt/go.mod deleted file mode 100644 index 8063033a..00000000 --- a/revolt/go.mod +++ /dev/null @@ -1,14 +0,0 @@ -module github.com/williamhorning/lightning/revolt - -go 1.24.4 - -require github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d - -require github.com/oklog/ulid/v2 v2.1.1 - -require ( - github.com/dolthub/maphash v0.1.0 // indirect - github.com/goccy/go-json v0.10.5 // indirect - github.com/klauspost/compress v1.18.0 // indirect - github.com/lxzan/gws v1.8.9 // indirect -) diff --git a/revolt/go.sum b/revolt/go.sum deleted file mode 100644 index 6222f455..00000000 --- a/revolt/go.sum +++ /dev/null @@ -1,13 +0,0 @@ -github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ= -github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= -github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM= -github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y= -github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= -github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= -github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d h1:DOQdvM3y/egSSpW2sM1bfY4S9gCsKvCwjqPoRUXrZwY= -github.com/sentinelb51/revoltgo v0.0.0-20250522112229-346cb7fb458d/go.mod h1:NZZh2iADP8/9NBnlea1b22idZn3fNm74QXAH4uFqgJE= diff --git a/telegram/go.mod b/telegram/go.mod deleted file mode 100644 index 1450c2e5..00000000 --- a/telegram/go.mod +++ /dev/null @@ -1,5 +0,0 @@ -module github.com/williamhorning/lightning/telegram - -go 1.24.4 - -require github.com/PaulSonOfLars/gotgbot/v2 v2.0.0-rc.32 diff --git a/telegram/outgoing.go b/telegram/outgoing.go deleted file mode 100644 index 2c4f93af..00000000 --- a/telegram/outgoing.go +++ /dev/null @@ -1,213 +0,0 @@ -package telegram - -import ( - "regexp" - "slices" - "strings" - - "github.com/williamhorning/lightning" -) - -var telegramSpecialCharacters = []string{ - "_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!", -} - -var headingPattern = regexp.MustCompile(`^(#{1,6})\s+(.+)$`) - -func parseContent(message lightning.Message, opts *lightning.BridgeMessageOptions) string { - bridged := opts != nil - - content := "" - - if bridged { - content += escapeMarkdownV2(message.Author.Nickname) + " ยป " - } - - content += message.Content - - if len(message.Content) == 0 { - content += "_no content_" - } - - if len(message.Embeds) > 0 { - content += "\n_this message has embeds_" - } - - return telegramifyMarkdown(content) -} - -func telegramifyMarkdown(input string) string { - lines := strings.Split(input, "\n") - for i, line := range lines { - trimmed := strings.TrimSpace(line) - if headingMatches := headingPattern.FindStringSubmatch(trimmed); len(headingMatches) == 3 { - headingText := headingMatches[2] - lines[i] = "*" + escapeMarkdownV2(headingText) + "*" - } else { - lines[i] = processInlineMarkdown(line) - } - } - - return strings.Join(lines, "\n") -} - -func escapeMarkdownV2(text string) string { - for _, char := range telegramSpecialCharacters { - text = strings.ReplaceAll(text, char, "\\"+char) - } - return text -} - -func processInlineMarkdown(input string) string { - output := strings.Builder{} - chars := []rune(input) - - i := 0 - for i < len(chars) { - switch { - case checkPrefix(chars, i, "**") && findClosing(chars, i+2, "**") != -1: - closing := findClosing(chars, i+2, "**") - innerText := string(chars[i+2 : closing]) - output.WriteString("*") - output.WriteString(escapeMarkdownV2(innerText)) - output.WriteString("*") - i = closing + 2 - - case checkPrefix(chars, i, "*") && findClosing(chars, i+1, "*") != -1: - closing := findClosing(chars, i+1, "*") - innerText := string(chars[i+1 : closing]) - output.WriteString("_") - output.WriteString(escapeMarkdownV2(innerText)) - output.WriteString("_") - i = closing + 1 - - case checkPrefix(chars, i, "_") && findClosing(chars, i+1, "_") != -1: - closing := findClosing(chars, i+1, "_") - innerText := string(chars[i+1 : closing]) - output.WriteString("_") - output.WriteString(escapeMarkdownV2(innerText)) - output.WriteString("_") - i = closing + 1 - - case checkPrefix(chars, i, "~~") && findClosing(chars, i+2, "~~") != -1: - closing := findClosing(chars, i+2, "~~") - innerText := string(chars[i+2 : closing]) - output.WriteString("~") - output.WriteString(escapeMarkdownV2(innerText)) - output.WriteString("~") - i = closing + 2 - - case checkPrefix(chars, i, "```") && findClosing(chars, i+3, "```") != -1: - closing := findClosing(chars, i+3, "```") - innerText := string(chars[i+3 : closing]) - output.WriteString("```") - output.WriteString(innerText) - output.WriteString("```") - i = closing + 3 - - case checkPrefix(chars, i, "`") && findClosing(chars, i+1, "`") != -1: - closing := findClosing(chars, i+1, "`") - innerText := string(chars[i+1 : closing]) - output.WriteString("`") - output.WriteString(innerText) - output.WriteString("`") - i = closing + 1 - - case checkPrefix(chars, i, "[") && findClosingLink(chars, i) != -1: - closingBracket := findClosing(chars, i+1, "]") - openingParen := closingBracket + 1 - closingParen := findClosing(chars, openingParen+1, ")") - - if closingBracket != -1 && openingParen < len(chars) && string(chars[openingParen]) == "(" && closingParen != -1 { - linkText := string(chars[i+1 : closingBracket]) - linkURL := string(chars[openingParen+1 : closingParen]) - - output.WriteString("[") - output.WriteString(escapeMarkdownV2(linkText)) - output.WriteString("](") - output.WriteString(escapeMarkdownV2(linkURL)) - output.WriteString(")") - - i = closingParen + 1 - } else { - output.WriteString("\\[") - i++ - } - - default: - if i < len(chars) { - needsEscape := slices.Contains(telegramSpecialCharacters, string(chars[i])) - - if needsEscape { - output.WriteString("\\") - } - output.WriteRune(chars[i]) - i++ - } - } - } - - return output.String() -} - -func checkPrefix(chars []rune, pos int, prefix string) bool { - if pos+len(prefix) > len(chars) { - return false - } - - for i, r := range []rune(prefix) { - if chars[pos+i] != r { - return false - } - } - return true -} - -func findClosing(chars []rune, start int, delimiter string) int { - delim := []rune(delimiter) - i := start - - for i < len(chars) { - if i > 0 && chars[i-1] == '\\' { - i++ - continue - } - - if i+len(delimiter) <= len(chars) { - match := true - for j, r := range delim { - if chars[i+j] != r { - match = false - break - } - } - if match { - return i - } - } - i++ - } - return -1 -} - -func findClosingLink(chars []rune, start int) int { - if start >= len(chars) || chars[start] != '[' { - return -1 - } - - closingBracket := findClosing(chars, start+1, "]") - if closingBracket == -1 || closingBracket+1 >= len(chars) { - return -1 - } - - if chars[closingBracket+1] != '(' { - return -1 - } - - closingParen := findClosing(chars, closingBracket+2, ")") - if closingParen == -1 { - return -1 - } - - return closingParen -} diff --git a/todo.md b/todo.md new file mode 100644 index 00000000..6ea0e58b --- /dev/null +++ b/todo.md @@ -0,0 +1,10 @@ +# todo + +- move the database and bridge stuff over to internal packages (in the internal folder) + - figure out how to turn opts into something that applies to normal bot replies and bridge messages? + - `AllowEveryone` can transfer over just fine, but how do we handle the BridgeChannel info? +- add testing +- add example implementations +- add documentation +- clean up the cli? +- add a debug web gui thing? \ No newline at end of file