├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .mailmap ├── containerfile ├── deno.jsonc ├── license ├── logo.svg ├── packages ├── discord │ ├── README.md │ ├── deno.json │ └── src │ │ ├── commands.ts │ │ ├── errors.ts │ │ ├── incoming.ts │ │ ├── mod.ts │ │ └── outgoing.ts ├── guilded │ ├── README.md │ ├── deno.json │ └── src │ │ ├── errors.ts │ │ ├── incoming.ts │ │ ├── mod.ts │ │ └── outgoing.ts ├── lightning │ ├── README.md │ ├── deno.json │ └── src │ │ ├── bridge │ │ ├── commands.ts │ │ ├── handler.ts │ │ └── setup.ts │ │ ├── cli.ts │ │ ├── cli_config.ts │ │ ├── core.ts │ │ ├── database │ │ ├── mod.ts │ │ ├── postgres.ts │ │ └── redis.ts │ │ ├── mod.ts │ │ └── structures │ │ ├── bridge.ts │ │ ├── cacher.ts │ │ ├── commands.ts │ │ ├── cross.ts │ │ ├── errors.ts │ │ ├── messages.ts │ │ ├── mod.ts │ │ ├── plugins.ts │ │ └── validate.ts ├── revolt │ ├── README.md │ ├── deno.json │ └── src │ │ ├── cache.ts │ │ ├── errors.ts │ │ ├── incoming.ts │ │ ├── mod.ts │ │ ├── outgoing.ts │ │ └── permissions.ts └── telegram │ ├── README.md │ ├── deno.json │ └── src │ ├── incoming.ts │ ├── mod.ts │ └── outgoing.ts └── readme.md /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | permissions: 9 | contents: read 10 | packages: write 11 | id-token: write 12 | 13 | jobs: 14 | publish: 15 | name: publish to jsr and ghcr 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: checkout 19 | uses: actions/checkout@v4 20 | - name: setup deno 21 | uses: denoland/setup-deno@v2 22 | with: 23 | deno-version: v2.3.5 24 | - name: publish to jsr 25 | run: deno publish 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v3 28 | - name: login to ghcr 29 | uses: redhat-actions/podman-login@v1 30 | with: 31 | registry: ghcr.io 32 | username: ${{github.actor}} 33 | password: ${{secrets.GITHUB_TOKEN}} 34 | - name: build image with podman 35 | id: build-image 36 | uses: redhat-actions/buildah-build@v2 37 | with: 38 | image: ghcr.io/williamhorning/lightning 39 | archs: amd64, arm64 40 | tags: latest ${{github.ref_name}} 41 | containerfiles: ./containerfile 42 | - name: push to ghcr.io 43 | uses: redhat-actions/push-to-registry@v2 44 | with: 45 | image: ${{ steps.build-image.outputs.image }} 46 | tags: ${{ steps.build-image.outputs.tags }} 47 | registry: ghcr.io 48 | username: ${{github.actor}} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /lightning.toml -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Jersey William Horning 2 | Jersey William Horning 3 | -------------------------------------------------------------------------------- /containerfile: -------------------------------------------------------------------------------- 1 | FROM denoland/deno:alpine-2.3.5 2 | 3 | # make a deno cache directory 4 | RUN ["mkdir", "/deno_dir"] 5 | ENV DENO_DIR=/deno_dir 6 | 7 | # install lightning 8 | RUN ["deno", "install", "-gA", "--unstable-temporal", "--unstable-net", "jsr:@lightning/lightning@0.8.0-alpha.5"] 9 | RUN ["chown", "--recursive", "1001:1001", "/deno_dir"] 10 | 11 | # run as user instead of root 12 | USER 1001:1001 13 | 14 | # the volume containing your lightning.toml file 15 | VOLUME [ "/data" ] 16 | WORKDIR /data 17 | 18 | # this is the lightning command line 19 | ENTRYPOINT [ "lightning" ] 20 | 21 | # run the bot using the user-provided lightning.toml file 22 | CMD [ "run" ] 23 | -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "lineWidth": 80, 4 | "proseWrap": "always", 5 | "semiColons": true, 6 | "useTabs": true, 7 | "singleQuote": true 8 | }, 9 | "lint": { 10 | "rules": { 11 | "include": [ 12 | "ban-untagged-todo", 13 | "default-param-last", 14 | "eqeqeq", 15 | "no-eval", 16 | "no-external-import", 17 | "triple-slash-reference", 18 | "verbatim-module-syntax" 19 | ] 20 | } 21 | }, 22 | "workspace": ["./packages/*"], 23 | "lock": false, 24 | "unstable": ["net", "temporal"] 25 | } 26 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | Copyright (c) William Horning and contributors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the “Software”), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /packages/discord/README.md: -------------------------------------------------------------------------------- 1 | # @lightning/discord 2 | 3 | [![JSR](https://jsr.io/badges/@lightning/discord)](https://jsr.io/@lightning/discord) 4 | 5 | @lightning/discord adds support for Discord to Lightning. To use it, you'll 6 | first need to create a Discord bot at the 7 | [Discord Developer Portal](https://discord.com/developers/applications). After 8 | you do that, you will need to add the following to your `lightning.toml` file: 9 | 10 | ```toml 11 | [[plugins]] 12 | plugin = "jsr:@lightning/discord@0.8.0-alpha.5" 13 | config.token = "your_bot_token" 14 | ``` 15 | -------------------------------------------------------------------------------- /packages/discord/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lightning/discord", 3 | "version": "0.8.0-alpha.5", 4 | "license": "MIT", 5 | "exports": "./src/mod.ts", 6 | "imports": { 7 | "@discordjs/core": "npm:@discordjs/core@^2.1.0", 8 | "@discordjs/rest": "npm:@discordjs/rest@^2.5.0", 9 | "@discordjs/ws": "npm:@discordjs/ws@^2.0.2", 10 | "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/discord/src/commands.ts: -------------------------------------------------------------------------------- 1 | import type { API } from '@discordjs/core'; 2 | import type { command } from '@lightning/lightning'; 3 | 4 | export async function setup_commands( 5 | api: API, 6 | commands: command[], 7 | ): Promise { 8 | const format_arguments = (args: command['arguments']) => 9 | args?.map((arg) => ({ 10 | name: arg.name, 11 | description: arg.description, 12 | type: 3, 13 | required: arg.required, 14 | })) ?? []; 15 | 16 | const format_subcommands = (subcommands: command['subcommands']) => 17 | subcommands?.map((subcommand) => ({ 18 | name: subcommand.name, 19 | description: subcommand.description, 20 | type: 1, 21 | options: format_arguments(subcommand.arguments), 22 | })) ?? []; 23 | 24 | await api.applicationCommands.bulkOverwriteGlobalCommands( 25 | (await api.applications.getCurrent()).id, 26 | commands.map((cmd) => ({ 27 | name: cmd.name, 28 | type: 1, 29 | description: cmd.description, 30 | options: [ 31 | ...format_arguments(cmd.arguments), 32 | ...format_subcommands(cmd.subcommands), 33 | ], 34 | })), 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /packages/discord/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { DiscordAPIError } from '@discordjs/rest'; 2 | import { log_error } from '@lightning/lightning'; 3 | 4 | const errors = [ 5 | [30007, 'Too many webhooks in channel, try deleting some', false, true], 6 | [30058, 'Too many webhooks in guild, try deleting some', false, true], 7 | [50013, 'Missing permissions to make webhook', false, true], 8 | [10003, 'Unknown channel, disabling channel', true, true], 9 | [10015, 'Unknown message, disabling channel', false, true], 10 | [50027, 'Invalid webhook token, disabling channel', false, true], 11 | [0, 'Unknown DiscordAPIError, not disabling channel', false, false], 12 | ] as const; 13 | 14 | export function handle_error( 15 | err: unknown, 16 | channel: string, 17 | edit?: boolean, 18 | ) { 19 | if (err instanceof DiscordAPIError) { 20 | if (edit && err.code === 10008) return []; // message already deleted or non-existent 21 | 22 | const extra = { channel, code: err.code }; 23 | const [, message, read, write] = errors.find((e) => e[0] === err.code) ?? 24 | errors[errors.length - 1]; 25 | 26 | log_error(err, { disable: { read, write }, message, extra }); 27 | } else { 28 | log_error(err, { 29 | message: `unknown discord error`, 30 | extra: { channel }, 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/discord/src/incoming.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | API, 3 | APIInteraction, 4 | APIStickerItem, 5 | GatewayMessageDeleteDispatchData, 6 | GatewayMessageUpdateDispatchData, 7 | ToEventProps, 8 | } from '@discordjs/core'; 9 | import type { 10 | attachment, 11 | create_command, 12 | deleted_message, 13 | message, 14 | } from '@lightning/lightning'; 15 | import { get_outgoing_message } from './outgoing.ts'; 16 | 17 | export function get_deleted_message( 18 | data: GatewayMessageDeleteDispatchData, 19 | ): deleted_message { 20 | return { 21 | message_id: data.id, 22 | channel_id: data.channel_id, 23 | plugin: 'bolt-discord', 24 | timestamp: Temporal.Now.instant(), 25 | }; 26 | } 27 | 28 | async function fetch_author(api: API, data: GatewayMessageUpdateDispatchData) { 29 | let profile = data.author.avatar 30 | ? `https://cdn.discordapp.com/avatars/${data.author.id}/${data.author.avatar}.png` 31 | : `https://cdn.discordapp.com/embed/avatars/${ 32 | Number(BigInt(data.author.id) >> 22n) % 6 33 | }.png`; 34 | 35 | let username = data.author.global_name ?? data.author.username; 36 | 37 | if (data.guild_id) { 38 | try { 39 | const member = data.member ?? await api.guilds.getMember( 40 | data.guild_id, 41 | data.author.id, 42 | ); 43 | 44 | if (member.avatar) { 45 | profile = 46 | `https://cdn.discordapp.com/guilds/${data.guild_id}/users/${data.author.id}/avatars/${member.avatar}.png`; 47 | } 48 | 49 | if (member.nick) username = member.nick; 50 | } catch { 51 | // safe to ignore, we already have a name and avatar 52 | } 53 | } 54 | 55 | return { profile, username }; 56 | } 57 | 58 | async function fetch_stickers( 59 | stickers: APIStickerItem[], 60 | ): Promise { 61 | return (await Promise.allSettled(stickers.map(async (sticker) => { 62 | let type; 63 | 64 | if (sticker.format_type === 1) type = 'png'; 65 | if (sticker.format_type === 2) type = 'apng'; 66 | if (sticker.format_type === 3) type = 'lottie'; 67 | if (sticker.format_type === 4) type = 'gif'; 68 | 69 | const url = `https://media.discordapp.net/stickers/${sticker.id}.${type}`; 70 | 71 | const request = await fetch(url, { method: 'HEAD' }); 72 | 73 | return { 74 | file: url, 75 | alt: sticker.name, 76 | name: `${sticker.name}.${type}`, 77 | size: parseInt(request.headers.get('Content-Length') ?? '0', 10) / 78 | 1048576, 79 | }; 80 | }))).flatMap((i) => i.status === 'fulfilled' ? i.value : []); 81 | } 82 | 83 | async function handle_content( 84 | content: string, 85 | api: API, 86 | guild_id?: string, 87 | ): Promise { 88 | // handle user mentions 89 | for (const match of content.matchAll(/<@!?(\d+)>/g)) { 90 | try { 91 | const user = guild_id 92 | ? await api.guilds.getMember(guild_id, match[1]) 93 | : await api.users.get(match[1]); 94 | content = content.replace( 95 | match[0], 96 | `@${ 97 | 'nickname' in user 98 | ? user.nickname 99 | : 'username' in user 100 | ? user.global_name ?? user.username 101 | : user.user.global_name ?? user.user.username 102 | }`, 103 | ); 104 | } catch { 105 | // safe to ignore, we already have content here as a fallback 106 | } 107 | } 108 | 109 | // handle channel mentions 110 | for (const match of content.matchAll(/<#(\d+)>/g)) { 111 | try { 112 | content = content.replace( 113 | match[0], 114 | `#${(await api.channels.get(match[1])).name}`, 115 | ); 116 | } catch { 117 | // safe to ignore, we already have content here as a fallback 118 | } 119 | } 120 | 121 | // handle role mentions 122 | if (guild_id) { 123 | for (const match of content.matchAll(/<@&(\d+)>/g)) { 124 | try { 125 | content = content.replace( 126 | match[0], 127 | `@${(await api.guilds.getRole(guild_id!, match[1])).name}`, 128 | ); 129 | } catch { 130 | // safe to ignore, we already have content here as a fallback 131 | } 132 | } 133 | } 134 | 135 | // handle emojis 136 | return content.replaceAll(/<(a?)?(:\w+:)\d+>/g, (_, _2, emoji) => emoji); 137 | } 138 | 139 | export async function get_incoming_message( 140 | { api, data }: { api: API; data: GatewayMessageUpdateDispatchData }, 141 | ): Promise { 142 | // normal messages, replies, and user joins 143 | if (![0, 7, 19, 20, 23].includes(data.type)) return; 144 | 145 | return { 146 | attachments: [ 147 | ...data.attachments?.map( 148 | (i: typeof data['attachments'][0]) => { 149 | return { 150 | file: i.url, 151 | alt: i.description, 152 | name: i.filename, 153 | size: i.size / 1048576, // bytes -> MiB 154 | }; 155 | }, 156 | ), 157 | ...data.sticker_items ? await fetch_stickers(data.sticker_items) : [], 158 | ], 159 | author: { 160 | rawname: data.author.username, 161 | id: data.author.id, 162 | color: '#5865F2', 163 | ...await fetch_author(api, data), 164 | }, 165 | channel_id: data.channel_id, 166 | content: data.type === 7 167 | ? '*joined on discord*' 168 | : (data.flags ?? 0) & 128 169 | ? '*loading...*' 170 | : await handle_content(data.content, api, data.guild_id), 171 | embeds: data.embeds.map((i) => ({ 172 | ...i, 173 | timestamp: i.timestamp ? Number(i.timestamp) : undefined, 174 | video: i.video ? { ...i.video, url: i.video.url ?? '' } : undefined, 175 | })), 176 | message_id: data.id, 177 | plugin: 'bolt-discord', 178 | reply_id: data.message_reference && 179 | data.message_reference.type === 0 180 | ? data.message_reference.message_id 181 | : undefined, 182 | timestamp: Temporal.Instant.fromEpochMilliseconds( 183 | Number(BigInt(data.id) >> 22n) + 1420070400000, 184 | ), 185 | }; 186 | } 187 | 188 | export function get_incoming_command( 189 | interaction: ToEventProps, 190 | ): create_command | undefined { 191 | if (interaction.data.type !== 2 || interaction.data.data.type !== 1) return; 192 | 193 | const args: Record = {}; 194 | let subcommand: string | undefined; 195 | 196 | for (const option of interaction.data.data.options ?? []) { 197 | if (option.type === 1) { 198 | subcommand = option.name; 199 | for (const suboption of option.options ?? []) { 200 | if (suboption.type === 3) { 201 | args[suboption.name] = suboption.value; 202 | } 203 | } 204 | } else if (option.type === 3) { 205 | args[option.name] = option.value; 206 | } 207 | } 208 | 209 | return { 210 | args, 211 | channel_id: interaction.data.channel.id, 212 | command: interaction.data.data.name, 213 | message_id: interaction.data.id, 214 | prefix: '/', 215 | plugin: 'bolt-discord', 216 | reply: async (msg) => 217 | await interaction.api.interactions.reply( 218 | interaction.data.id, 219 | interaction.data.token, 220 | await get_outgoing_message(msg, interaction.api, false, false), 221 | ), 222 | subcommand, 223 | timestamp: Temporal.Instant.fromEpochMilliseconds( 224 | Number(BigInt(interaction.data.id) >> 22n) + 1420070400000, 225 | ), 226 | }; 227 | } 228 | -------------------------------------------------------------------------------- /packages/discord/src/mod.ts: -------------------------------------------------------------------------------- 1 | import { Client, GatewayDispatchEvents } from '@discordjs/core'; 2 | import { REST, type RESTOptions } from '@discordjs/rest'; 3 | import { WebSocketManager } from '@discordjs/ws'; 4 | import { 5 | type bridge_message_opts, 6 | type command, 7 | type config_schema, 8 | type deleted_message, 9 | type message, 10 | plugin, 11 | } from '@lightning/lightning'; 12 | import { setup_commands } from './commands.ts'; 13 | import { handle_error } from './errors.ts'; 14 | import { 15 | get_deleted_message, 16 | get_incoming_command, 17 | get_incoming_message, 18 | } from './incoming.ts'; 19 | import { get_outgoing_message } from './outgoing.ts'; 20 | 21 | /** options for the discord bot */ 22 | export type discord_config = { 23 | /** the token for your bot */ 24 | token: string; 25 | }; 26 | 27 | /** the config schema for the class */ 28 | export const schema: config_schema = { 29 | name: 'bolt-discord', 30 | keys: { token: { type: 'string', required: true } }, 31 | }; 32 | 33 | /** discord support for lightning */ 34 | export default class discord extends plugin { 35 | name = 'bolt-discord'; 36 | private client: Client; 37 | private received_messages = new Set(); 38 | 39 | /** create the plugin */ 40 | constructor(cfg: discord_config) { 41 | super(); 42 | 43 | const rest = new REST({ 44 | makeRequest: fetch as RESTOptions['makeRequest'], 45 | version: '10', 46 | }).setToken(cfg.token); 47 | 48 | const gateway = new WebSocketManager({ 49 | token: cfg.token, 50 | intents: 0 | 16813601, 51 | rest, 52 | }); 53 | 54 | this.client = new Client({ gateway, rest }); 55 | this.setup_events(); 56 | gateway.connect(); 57 | } 58 | 59 | private setup_events() { 60 | this.client.on(GatewayDispatchEvents.MessageCreate, async (data) => { 61 | if (this.received_messages.has(data.data.id)) { 62 | return this.received_messages.delete(data.data.id); 63 | } else this.received_messages.add(data.data.id); 64 | 65 | const msg = await get_incoming_message(data); 66 | if (msg) this.emit('create_message', msg); 67 | }).on(GatewayDispatchEvents.MessageDelete, ({ data }) => { 68 | this.emit('delete_message', get_deleted_message(data)); 69 | }).on(GatewayDispatchEvents.MessageDeleteBulk, ({ data }) => { 70 | for (const id of data.ids) { 71 | this.emit('delete_message', get_deleted_message({ id, ...data })); 72 | } 73 | }).on(GatewayDispatchEvents.MessageUpdate, async (data) => { 74 | const msg = await get_incoming_message(data); 75 | if (msg) this.emit('edit_message', msg); 76 | }).on(GatewayDispatchEvents.InteractionCreate, (data) => { 77 | const cmd = get_incoming_command(data); 78 | if (cmd) this.emit('create_command', cmd); 79 | }).on(GatewayDispatchEvents.Ready, async ({ data }) => { 80 | console.log( 81 | `[discord] ready as ${data.user.username}#${data.user.discriminator} in ${data.guilds.length} servers`, 82 | `\n[discord] invite me at https://discord.com/oauth2/authorize?client_id=${ 83 | (await this.client.api.applications.getCurrent()).id 84 | }&scope=bot&permissions=8`, 85 | ); 86 | }); 87 | } 88 | 89 | /** setup slash commands */ 90 | override async set_commands(commands: command[]): Promise { 91 | await setup_commands(this.client.api, commands); 92 | } 93 | 94 | /** create a webhook */ 95 | async setup_channel(channelID: string): Promise { 96 | try { 97 | const { id, token } = await this.client.api.channels.createWebhook( 98 | channelID, 99 | { name: 'lightning bridge' }, 100 | ); 101 | 102 | return { id, token }; 103 | } catch (e) { 104 | return handle_error(e, channelID); 105 | } 106 | } 107 | 108 | /** send a message using the bot itself or a webhook */ 109 | async create_message( 110 | message: message, 111 | data?: bridge_message_opts, 112 | ): Promise { 113 | try { 114 | const msg = await get_outgoing_message( 115 | message, 116 | this.client.api, 117 | data !== undefined, 118 | data?.settings?.allow_everyone ?? false, 119 | ); 120 | 121 | if (data) { 122 | const webhook = data.channel.data as { id: string; token: string }; 123 | return [ 124 | (await this.client.api.webhooks.execute( 125 | webhook.id, 126 | webhook.token, 127 | msg, 128 | )).id, 129 | ]; 130 | } else { 131 | return [ 132 | (await this.client.api.channels.createMessage( 133 | message.channel_id, 134 | msg, 135 | )) 136 | .id, 137 | ]; 138 | } 139 | } catch (e) { 140 | return handle_error(e, message.channel_id); 141 | } 142 | } 143 | 144 | /** edit a message sent by webhook */ 145 | async edit_message( 146 | message: message, 147 | data: bridge_message_opts & { edit_ids: string[] }, 148 | ): Promise { 149 | try { 150 | const webhook = data.channel.data as { id: string; token: string }; 151 | 152 | await this.client.api.webhooks.editMessage( 153 | webhook.id, 154 | webhook.token, 155 | data.edit_ids[0], 156 | await get_outgoing_message( 157 | message, 158 | this.client.api, 159 | true, 160 | data?.settings?.allow_everyone ?? false, 161 | ), 162 | ); 163 | return data.edit_ids; 164 | } catch (e) { 165 | return handle_error(e, data.channel.id, true); 166 | } 167 | } 168 | 169 | /** delete messages */ 170 | async delete_messages(msgs: deleted_message[]): Promise { 171 | return await Promise.all( 172 | msgs.map(async (msg) => { 173 | try { 174 | await this.client.api.channels.deleteMessage( 175 | msg.channel_id, 176 | msg.message_id, 177 | ); 178 | return msg.message_id; 179 | } catch (e) { 180 | // if this doesn't throw, it's fine 181 | handle_error(e, msg.channel_id, true); 182 | return msg.message_id; 183 | } 184 | }), 185 | ); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /packages/discord/src/outgoing.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AllowedMentionsTypes, 3 | type API, 4 | type APIEmbed, 5 | type DescriptiveRawFile, 6 | type RESTPostAPIWebhookWithTokenJSONBody, 7 | type RESTPostAPIWebhookWithTokenQuery, 8 | } from '@discordjs/core'; 9 | import type { attachment, message } from '@lightning/lightning'; 10 | 11 | export interface discord_payload 12 | extends 13 | RESTPostAPIWebhookWithTokenJSONBody, 14 | RESTPostAPIWebhookWithTokenQuery { 15 | embeds: APIEmbed[]; 16 | files?: DescriptiveRawFile[]; 17 | message_reference?: { type: number; channel_id: string; message_id: string }; 18 | wait: true; 19 | } 20 | 21 | async function fetch_reply( 22 | channelID: string, 23 | replyID?: string, 24 | api?: API, 25 | ) { 26 | try { 27 | if (!replyID || !api) return; 28 | 29 | const channel = await api.channels.get(channelID); 30 | const channelPath = 'guild_id' in channel 31 | ? `${channel.guild_id}/${channelID}` 32 | : `@me/${channelID}`; 33 | const msg = await api.channels.getMessage(channelID, replyID); 34 | 35 | return [{ 36 | type: 1 as const, 37 | components: [{ 38 | type: 2 as const, 39 | style: 5 as const, 40 | label: `reply to ${msg.author.username}`, 41 | url: `https://discord.com/channels/${channelPath}/${replyID}`, 42 | }], 43 | }]; 44 | } catch { 45 | return; 46 | } 47 | } 48 | 49 | async function fetch_files( 50 | attachments: attachment[] | undefined, 51 | ): Promise { 52 | if (!attachments) return; 53 | 54 | let totalSize = 0; 55 | 56 | return (await Promise.all( 57 | attachments.map(async (attachment) => { 58 | try { 59 | if (attachment.size >= 25) return; 60 | if (totalSize + attachment.size >= 25) return; 61 | 62 | const data = new Uint8Array( 63 | await (await fetch(attachment.file, { 64 | signal: AbortSignal.timeout(5000), 65 | })).arrayBuffer(), 66 | ); 67 | 68 | const name = attachment.name ?? attachment.file?.split('/').pop()!; 69 | 70 | totalSize += attachment.size; 71 | 72 | return { data, name }; 73 | } catch { 74 | return; 75 | } 76 | }), 77 | )).filter((i) => i !== undefined); 78 | } 79 | 80 | export async function get_outgoing_message( 81 | msg: message, 82 | api: API, 83 | button_reply: boolean, 84 | limit_mentions: boolean, 85 | ): Promise { 86 | const payload: discord_payload = { 87 | allowed_mentions: limit_mentions 88 | ? { parse: [AllowedMentionsTypes.Role, AllowedMentionsTypes.User] } 89 | : undefined, 90 | avatar_url: msg.author.profile, 91 | // TODO(jersey): since telegram forced multiple message support, split the message into two? 92 | content: (msg.content?.length || 0) > 2000 93 | ? `${msg.content?.substring(0, 1997)}...` 94 | : msg.content, 95 | components: button_reply 96 | ? await fetch_reply(msg.channel_id, msg.reply_id, api) 97 | : undefined, 98 | embeds: (msg.embeds ?? []).map((e) => ({ 99 | ...e, 100 | timestamp: e.timestamp?.toString(), 101 | })), 102 | files: await fetch_files(msg.attachments), 103 | message_reference: !button_reply && msg.reply_id 104 | ? { type: 0, channel_id: msg.channel_id, message_id: msg.reply_id } 105 | : undefined, 106 | username: msg.author.username, 107 | wait: true, 108 | }; 109 | 110 | if (!payload.content && (!payload.embeds || payload.embeds.length === 0)) { 111 | // this acts like a blank message and renders nothing 112 | payload.content = '_ _'; 113 | } 114 | 115 | return payload; 116 | } 117 | -------------------------------------------------------------------------------- /packages/guilded/README.md: -------------------------------------------------------------------------------- 1 | # @lightning/guilded 2 | 3 | [![JSR](https://jsr.io/badges/@lightning/guilded)](https://jsr.io/@lightning/guilded) 4 | 5 | @lightning/guilded adds support for Guilded. To use it, you'll first need to 6 | create a Guilded bot. After you do that, you'll need to add the following to 7 | your `lightning.toml` file: 8 | 9 | ```toml 10 | [[plugins]] 11 | plugin = "jsr:@lightning/guilded@0.8.0-alpha.5" 12 | config.token = "your_bot_token" 13 | ``` 14 | -------------------------------------------------------------------------------- /packages/guilded/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lightning/guilded", 3 | "version": "0.8.0-alpha.5", 4 | "license": "MIT", 5 | "exports": "./src/mod.ts", 6 | "imports": { 7 | "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", 8 | "@jersey/guildapi": "jsr:@jersey/guildapi@^0.0.6", 9 | "@jersey/guilded-api-types": "jsr:@jersey/guilded-api-types@^0.0.3" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/guilded/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { RequestError } from '@jersey/guilded-api-types'; 2 | import { log_error } from '@lightning/lightning'; 3 | 4 | const errors = [ 5 | [403, 'The bot lacks some permissions, please check them', false, true], 6 | [404, 'Not found! This might be a Guilded problem', false, true], 7 | [0, 'Unknown Guilded error, not disabling channel', false, false], 8 | ] as const; 9 | 10 | export function handle_error(err: unknown, channel: string): never { 11 | if (err instanceof RequestError) { 12 | const [, message, read, write] = errors.find((e) => 13 | e[0] === err.cause.status 14 | ) ?? 15 | errors[errors.length - 1]; 16 | 17 | log_error(err, { 18 | disable: { read, write }, 19 | extra: { channel_id: channel, response: err.cause }, 20 | message, 21 | }); 22 | } else { 23 | log_error(err, { 24 | message: `unknown error`, 25 | extra: { channel_id: channel }, 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/guilded/src/incoming.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@jersey/guildapi'; 2 | import type { 3 | ChatMessage, 4 | ServerMember, 5 | Webhook, 6 | } from '@jersey/guilded-api-types'; 7 | import { type attachment, cacher, type message } from '@lightning/lightning'; 8 | 9 | const member_cache = new cacher<`${string}/${string}`, ServerMember>(); 10 | const webhook_cache = new cacher<`${string}/${string}`, Webhook>(); 11 | const asset_cache = new cacher(86400000); 12 | 13 | export async function fetch_author(msg: ChatMessage, client: Client) { 14 | try { 15 | if (!msg.createdByWebhookId) { 16 | const key = `${msg.serverId}/${msg.createdBy}` as const; 17 | const author = member_cache.get(key) ?? member_cache.set( 18 | key, 19 | (await client.request( 20 | 'get', 21 | `/servers/${msg.serverId}/members/${msg.createdBy}`, 22 | undefined, 23 | ) as { member: ServerMember }).member, 24 | ); 25 | 26 | return { 27 | username: author.nickname ?? author.user.name, 28 | rawname: author.user.name, 29 | id: msg.createdBy, 30 | profile: author.user.avatar, 31 | }; 32 | } else { 33 | const key = `${msg.serverId}/${msg.createdByWebhookId}` as const; 34 | const webhook = webhook_cache.get(key) ?? webhook_cache.set( 35 | key, 36 | (await client.request( 37 | 'get', 38 | `/servers/${msg.serverId}/webhooks/${msg.createdByWebhookId}`, 39 | undefined, 40 | )).webhook, 41 | ); 42 | 43 | return { 44 | username: webhook.name, 45 | rawname: webhook.name, 46 | id: webhook.id, 47 | profile: webhook.avatar, 48 | }; 49 | } 50 | } catch { 51 | return { 52 | username: 'Guilded User', 53 | rawname: 'GuildedUser', 54 | id: msg.createdByWebhookId ?? msg.createdBy, 55 | }; 56 | } 57 | } 58 | 59 | async function fetch_attachments(markdown: string[], client: Client) { 60 | const urls = markdown.map( 61 | (url) => (url.split('(').pop())?.split(')')[0], 62 | ).filter((i) => i !== undefined); 63 | 64 | const attachments: attachment[] = []; 65 | 66 | for (const url of urls) { 67 | const cached = asset_cache.get(url); 68 | 69 | if (cached) { 70 | attachments.push(cached); 71 | } else { 72 | try { 73 | const signed = (await client.request('post', '/url-signatures', { 74 | urls: [url], 75 | })).urlSignatures[0]; 76 | 77 | if (signed.retryAfter || !signed.signature) continue; 78 | 79 | attachments.push(asset_cache.set(signed.url, { 80 | name: signed.signature.split('/').pop()?.split('?')[0] ?? 'unknown', 81 | file: signed.signature, 82 | size: parseInt( 83 | (await fetch(signed.signature, { 84 | method: 'HEAD', 85 | })).headers.get('Content-Length') ?? '0', 86 | ) / 1048576, 87 | })); 88 | } catch { 89 | continue; 90 | } 91 | } 92 | } 93 | 94 | return attachments; 95 | } 96 | 97 | export async function get_incoming( 98 | msg: ChatMessage, 99 | client: Client, 100 | ): Promise { 101 | if (!msg.serverId) return; 102 | 103 | let content = msg.content?.replaceAll('\n```\n```\n', '\n'); 104 | 105 | const urls = content?.match( 106 | /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, 107 | ) ?? []; 108 | 109 | content = content?.replaceAll( 110 | /!\[.*\]\(https:\/\/cdn\.gldcdn\.com\/ContentMediaGenericFiles\/.*\)/gm, 111 | '', 112 | )?.replaceAll(/<(:\w+:)\d+>/g, (_, emoji) => emoji); 113 | 114 | return { 115 | attachments: await fetch_attachments(urls, client), 116 | author: { 117 | ...await fetch_author(msg, client), 118 | color: '#F5C400', 119 | }, 120 | channel_id: msg.channelId, 121 | content, 122 | embeds: msg.embeds?.map((embed) => ({ 123 | ...embed, 124 | author: embed.author 125 | ? { 126 | ...embed.author, 127 | name: embed.author.name ?? '', 128 | } 129 | : undefined, 130 | image: embed.image 131 | ? { 132 | ...embed.image, 133 | url: embed.image.url ?? '', 134 | } 135 | : undefined, 136 | thumbnail: embed.thumbnail 137 | ? { 138 | ...embed.thumbnail, 139 | url: embed.thumbnail.url ?? '', 140 | } 141 | : undefined, 142 | timestamp: embed.timestamp ? Number(embed.timestamp) : undefined, 143 | })), 144 | message_id: msg.id, 145 | plugin: 'bolt-guilded', 146 | reply_id: msg.replyMessageIds && msg.replyMessageIds.length > 0 147 | ? msg.replyMessageIds[0] 148 | : undefined, 149 | timestamp: Temporal.Instant.from( 150 | msg.createdAt, 151 | ), 152 | }; 153 | } 154 | -------------------------------------------------------------------------------- /packages/guilded/src/mod.ts: -------------------------------------------------------------------------------- 1 | import { type Client, createClient } from '@jersey/guildapi'; 2 | import type { ServerChannel } from '@jersey/guilded-api-types'; 3 | import { 4 | type bridge_message_opts, 5 | type config_schema, 6 | type deleted_message, 7 | type message, 8 | plugin, 9 | } from '@lightning/lightning'; 10 | import { handle_error } from './errors.ts'; 11 | import { get_incoming } from './incoming.ts'; 12 | import { get_outgoing } from './outgoing.ts'; 13 | 14 | /** options for the guilded bot */ 15 | export interface guilded_config { 16 | /** enable debug logging */ 17 | debug?: boolean; 18 | /** the token to use */ 19 | token: string; 20 | } 21 | 22 | /** the config schema for the plugin */ 23 | export const schema: config_schema = { 24 | name: 'bolt-guilded', 25 | keys: { 26 | debug: { type: 'boolean', required: false }, 27 | token: { type: 'string', required: true }, 28 | }, 29 | }; 30 | 31 | /** guilded support for lightning */ 32 | export default class guilded extends plugin { 33 | name = 'bolt-guilded'; 34 | private client: Client; 35 | private token: string; 36 | 37 | constructor(opts: guilded_config) { 38 | super(); 39 | this.client = createClient(opts.token); 40 | this.token = opts.token; 41 | this.setup_events(opts.debug); 42 | this.client.socket.connect(); 43 | } 44 | 45 | private setup_events(debug?: boolean) { 46 | this.client.socket.on('ChatMessageCreated', async (data) => { 47 | const msg = await get_incoming(data.d.message, this.client); 48 | if (msg) this.emit('create_message', msg); 49 | }).on('ChatMessageDeleted', ({ d }) => { 50 | this.emit('delete_message', { 51 | channel_id: d.message.channelId, 52 | message_id: d.message.id, 53 | plugin: 'bolt-guilded', 54 | timestamp: Temporal.Instant.from(d.deletedAt), 55 | }); 56 | }).on('ChatMessageUpdated', async (data) => { 57 | const msg = await get_incoming(data.d.message, this.client); 58 | if (msg) this.emit('edit_message', msg); 59 | }).on('ready', (data) => { 60 | console.log(`[guilded] ready as ${data.name} (${data.id})`); 61 | }).on('reconnect', () => { 62 | console.log(`[guilded] reconnected`); 63 | }).on( 64 | 'debug', 65 | (data) => debug && console.log(`[guilded] guildapi debug:`, data), 66 | ); 67 | } 68 | 69 | /** create a webhook in a channel */ 70 | async setup_channel(channel_id: string): Promise { 71 | try { 72 | const { channel: { serverId } } = await this.client.request( 73 | 'get', 74 | `/channels/${channel_id}`, 75 | undefined, 76 | ) as { channel: ServerChannel }; 77 | 78 | const { webhook } = await this.client.request( 79 | 'post', 80 | `/servers/${serverId}/webhooks`, 81 | { 82 | channelId: channel_id, 83 | name: 'Lightning Bridges', 84 | }, 85 | ); 86 | 87 | if (!webhook.id || !webhook.token) { 88 | throw 'failed to create webhook: missing id or token'; 89 | } 90 | 91 | return { id: webhook.id, token: webhook.token }; 92 | } catch (e) { 93 | return handle_error(e, channel_id); 94 | } 95 | } 96 | 97 | /** send a message either as the bot or using a webhook */ 98 | async create_message( 99 | message: message, 100 | data?: bridge_message_opts, 101 | ): Promise { 102 | try { 103 | const msg = await get_outgoing( 104 | message, 105 | this.client, 106 | data?.settings?.allow_everyone ?? false, 107 | ); 108 | 109 | if (data) { 110 | const webhook = data.channel.data as { id: string; token: string }; 111 | 112 | const res = await (await fetch( 113 | `https://media.guilded.gg/webhooks/${webhook.id}/${webhook.token}`, 114 | { 115 | method: 'POST', 116 | headers: { 'Content-Type': 'application/json' }, 117 | body: JSON.stringify(msg), 118 | }, 119 | )).json(); 120 | 121 | return [res.id]; 122 | } else { 123 | const resp = await this.client.request( 124 | 'post', 125 | `/channels/${message.channel_id}/messages`, 126 | msg, 127 | ); 128 | 129 | return [resp.message.id]; 130 | } 131 | } catch (e) { 132 | return handle_error(e, message.channel_id); 133 | } 134 | } 135 | 136 | /** edit stub function */ 137 | // deno-lint-ignore require-await 138 | async edit_message( 139 | _message: message, 140 | data: bridge_message_opts & { edit_ids: string[] }, 141 | ): Promise { 142 | return data.edit_ids; 143 | } 144 | 145 | /** delete messages from guilded */ 146 | async delete_messages(messages: deleted_message[]): Promise { 147 | return await Promise.all(messages.map(async (msg) => { 148 | try { 149 | await fetch( 150 | `https://www.guilded.gg/api/v1/channels/${msg.channel_id}/messages/${msg.message_id}`, 151 | { 152 | method: 'DELETE', 153 | headers: { Authorization: `Bearer ${this.token}` }, 154 | }, 155 | ); 156 | return msg.message_id; 157 | } catch { 158 | return msg.message_id; 159 | } 160 | })); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /packages/guilded/src/outgoing.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@jersey/guildapi'; 2 | import type { ChatEmbed } from '@jersey/guilded-api-types'; 3 | import type { message } from '@lightning/lightning'; 4 | import { fetch_author } from './incoming.ts'; 5 | 6 | type guilded_payload = { 7 | content?: string; 8 | embeds?: ChatEmbed[]; 9 | replyMessageIds?: string[]; 10 | avatar_url?: string; 11 | username?: string; 12 | }; 13 | 14 | const username = /^[a-zA-Z0-9_ ()-]{1,25}$/ms; 15 | 16 | function get_name(msg: message): string { 17 | if (username.test(msg.author.username)) { 18 | return msg.author.username; 19 | } else if (username.test(msg.author.rawname)) { 20 | return msg.author.rawname; 21 | } else { 22 | return `${msg.author.id}`; 23 | } 24 | } 25 | 26 | async function fetch_reply( 27 | msg: message, 28 | client: Client, 29 | ): Promise { 30 | if (!msg.reply_id) return; 31 | 32 | try { 33 | const { message } = await client.request( 34 | 'get', 35 | `/channels/${msg.channel_id}/messages/${msg.reply_id}`, 36 | undefined, 37 | ); 38 | 39 | const { profile, username } = await fetch_author(message, client); 40 | 41 | return { 42 | author: { name: `reply to ${username}`, icon_url: profile }, 43 | description: message.content, 44 | }; 45 | } catch { 46 | return; 47 | } 48 | } 49 | 50 | export async function get_outgoing( 51 | msg: message, 52 | client: Client, 53 | limitMentions?: boolean, 54 | ): Promise { 55 | const message: guilded_payload = { 56 | content: msg.content, 57 | avatar_url: msg.author.profile, 58 | username: get_name(msg), 59 | embeds: msg.embeds?.map((i) => { 60 | return { 61 | ...i, 62 | fields: i.fields?.map((j) => ({ ...j, inline: j.inline ?? false })), 63 | timestamp: i.timestamp?.toString(), 64 | }; 65 | }), 66 | }; 67 | 68 | const embed = await fetch_reply(msg, client); 69 | 70 | if (embed) { 71 | if (!message.embeds) message.embeds = []; 72 | message.embeds.push(embed); 73 | } 74 | 75 | if (msg.attachments?.length) { 76 | if (!message.embeds) message.embeds = []; 77 | message.embeds.push({ 78 | title: 'attachments', 79 | description: msg.attachments 80 | .slice(0, 5) 81 | .map((a) => { 82 | return `![${a.alt ?? a.name}](${a.file})`; 83 | }) 84 | .join('\n'), 85 | }); 86 | } 87 | 88 | if (!message.content && !message.embeds) message.content = '\u2800'; 89 | 90 | if (limitMentions && message.content) { 91 | message.content = message.content.replace(/@everyone/gi, '(a)everyone'); 92 | message.content = message.content.replace(/@here/gi, '(a)here'); 93 | } 94 | 95 | return message; 96 | } 97 | -------------------------------------------------------------------------------- /packages/lightning/README.md: -------------------------------------------------------------------------------- 1 | ![lightning](https://raw.githubusercontent.com/williamhorning/lightning/refs/heads/develop/logo.svg) 2 | 3 | # @lightning/lightning 4 | 5 | lightning is a typescript-based chatbot that supports bridging multiple chat 6 | apps via plugins 7 | 8 | ## [docs](https://williamhorning.eu.org/lightning) 9 | 10 | ## `lightning.toml` example 11 | 12 | ```toml 13 | prefix = "!" 14 | 15 | [database] 16 | type = "postgres" 17 | config = "postgresql://server:password@postgres:5432/lightning" 18 | 19 | [[plugins]] 20 | plugin = "jsr:@lightning/discord@0.8.0-alpha.5" 21 | config.token = "your_token" 22 | 23 | [[plugins]] 24 | plugin = "jsr:@lightning/revolt@0.8.0-alpha.5" 25 | config.token = "your_token" 26 | config.user_id = "your_bot_user_id" 27 | ``` 28 | -------------------------------------------------------------------------------- /packages/lightning/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lightning/lightning", 3 | "version": "0.8.0-alpha.5", 4 | "license": "MIT", 5 | "exports": { 6 | ".": "./src/mod.ts", 7 | "./cli": "./src/cli.ts" 8 | }, 9 | "imports": { 10 | "@db/postgres": "jsr:@db/postgres@^0.19.5", 11 | "@denosaurs/event": "jsr:@denosaurs/event@^2.0.2", 12 | "@iuioiua/redis": "jsr:@iuioiua/redis@^1.1.8", 13 | "@std/cli": "jsr:@std/cli@^1.0.19", 14 | "@std/fs": "jsr:@std/fs@^1.0.18", 15 | "@std/toml": "jsr:@std/toml@^1.0.7" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/lightning/src/bridge/commands.ts: -------------------------------------------------------------------------------- 1 | import type { bridge_data } from '../database/mod.ts'; 2 | import { bridge_settings_list } from '../structures/bridge.ts'; 3 | import { log_error } from '../structures/errors.ts'; 4 | import type { bridge_channel, command_opts } from '../structures/mod.ts'; 5 | 6 | export async function create( 7 | db: bridge_data, 8 | opts: command_opts, 9 | ): Promise { 10 | const result = await _add(db, opts); 11 | 12 | if (typeof result === 'string') return result; 13 | 14 | const data = { 15 | name: opts.args.name!, 16 | channels: [result], 17 | settings: { allow_everyone: false }, 18 | }; 19 | 20 | try { 21 | const { id } = await db.create_bridge(data); 22 | 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.`; 23 | } catch (e) { 24 | log_error(e, { 25 | message: 'Failed to insert bridge into database', 26 | extra: data, 27 | }); 28 | } 29 | } 30 | 31 | export async function join( 32 | db: bridge_data, 33 | opts: command_opts, 34 | ): Promise { 35 | const result = await _add(db, opts); 36 | 37 | if (typeof result === 'string') return result; 38 | 39 | const target_bridge = await db.get_bridge_by_id( 40 | opts.args.id!, 41 | ); 42 | 43 | if (!target_bridge) { 44 | return `Bridge with id \`${opts.args.id}\` not found. Make sure you have the correct id.`; 45 | } 46 | 47 | target_bridge.channels.push(result); 48 | 49 | try { 50 | await db.edit_bridge(target_bridge); 51 | 52 | return `Bridge joined successfully!`; 53 | } catch (e) { 54 | log_error(e, { 55 | message: 'Failed to update bridge in database', 56 | extra: { target_bridge }, 57 | }); 58 | } 59 | } 60 | 61 | export async function subscribe( 62 | db: bridge_data, 63 | opts: command_opts, 64 | ): Promise { 65 | const result = await _add(db, opts); 66 | 67 | if (typeof result === 'string') return result; 68 | 69 | const target_bridge = await db.get_bridge_by_id( 70 | opts.args.id!, 71 | ); 72 | 73 | if (!target_bridge) { 74 | return `Bridge with id \`${opts.args.id}\` not found. Make sure you have the correct id.`; 75 | } 76 | 77 | target_bridge.channels.push({ 78 | ...result, 79 | disabled: { read: true, write: false }, 80 | }); 81 | 82 | try { 83 | await db.edit_bridge(target_bridge); 84 | 85 | return `Bridge subscribed successfully! You will not receive messages from this channel, but you can still send messages to it.`; 86 | } catch (e) { 87 | log_error(e, { 88 | message: 'Failed to update bridge in database', 89 | extra: { target_bridge }, 90 | }); 91 | } 92 | } 93 | 94 | async function _add( 95 | db: bridge_data, 96 | opts: command_opts, 97 | ): Promise { 98 | const existing_bridge = await db.get_bridge_by_channel( 99 | opts.channel_id, 100 | ); 101 | 102 | if (existing_bridge) { 103 | 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.`; 104 | } 105 | 106 | try { 107 | return { 108 | id: opts.channel_id, 109 | data: await opts.plugin.setup_channel(opts.channel_id), 110 | disabled: { read: false, write: false }, 111 | plugin: opts.plugin.name, 112 | }; 113 | } catch (e) { 114 | log_error(e, { 115 | message: 'Failed to create bridge using plugin', 116 | extra: { channel: opts.channel_id, plugin_name: opts.plugin }, 117 | }); 118 | } 119 | } 120 | 121 | export async function leave( 122 | db: bridge_data, 123 | opts: command_opts, 124 | ): Promise { 125 | const bridge = await db.get_bridge_by_channel( 126 | opts.channel_id, 127 | ); 128 | 129 | if (!bridge) return `You are not in a bridge`; 130 | 131 | if (opts.args.id !== bridge.id) { 132 | return `You must provide the bridge id in order to leave this bridge`; 133 | } 134 | 135 | bridge.channels = bridge.channels.filter(( 136 | ch, 137 | ) => ch.id !== opts.channel_id); 138 | 139 | try { 140 | await db.edit_bridge( 141 | bridge, 142 | ); 143 | return `Bridge left successfully`; 144 | } catch (e) { 145 | log_error(e, { 146 | message: 'Failed to update bridge in database', 147 | extra: { bridge }, 148 | }); 149 | } 150 | } 151 | 152 | export async function status( 153 | db: bridge_data, 154 | opts: command_opts, 155 | ): Promise { 156 | const bridge = await db.get_bridge_by_channel( 157 | opts.channel_id, 158 | ); 159 | 160 | if (!bridge) return `You are not in a bridge`; 161 | 162 | let str = `Name: \`${bridge.name}\`\n\nChannels:\n`; 163 | 164 | for (const [i, value] of bridge.channels.entries()) { 165 | str += `${i + 1}. \`${value.id}\` on \`${value.plugin}\``; 166 | 167 | if (typeof value.disabled === 'object') { 168 | if (value.disabled.read) str += ` (subscribed)`; 169 | if (value.disabled.write) str += ` (write disabled)`; 170 | } else if (value.disabled === true) { 171 | str += ` (disabled)`; 172 | } 173 | 174 | str += `\n`; 175 | } 176 | 177 | str += `\nSettings:\n`; 178 | 179 | for ( 180 | const [key, value] of Object.entries(bridge.settings).filter(([key]) => 181 | bridge_settings_list.includes(key) 182 | ) 183 | ) { 184 | str += `- \`${key}\` ${value ? '✔' : '❌'}\n`; 185 | } 186 | 187 | return str; 188 | } 189 | 190 | export async function toggle( 191 | db: bridge_data, 192 | opts: command_opts, 193 | ): Promise { 194 | const bridge = await db.get_bridge_by_channel( 195 | opts.channel_id, 196 | ); 197 | 198 | if (!bridge) return `You are not in a bridge`; 199 | 200 | if (!bridge_settings_list.includes(opts.args.setting!)) { 201 | return `That setting does not exist`; 202 | } 203 | 204 | const key = opts.args.setting as keyof typeof bridge.settings; 205 | 206 | bridge.settings[key] = !bridge.settings[key]; 207 | 208 | try { 209 | await db.edit_bridge( 210 | bridge, 211 | ); 212 | return `Bridge settings updated successfully`; 213 | } catch (e) { 214 | log_error(e, { 215 | message: 'Failed to update bridge in database', 216 | extra: { bridge }, 217 | }); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /packages/lightning/src/bridge/handler.ts: -------------------------------------------------------------------------------- 1 | import type { core } from '../core.ts'; 2 | import type { bridge_data } from '../database/mod.ts'; 3 | import type { bridge_message, bridged_message } from '../structures/bridge.ts'; 4 | import { LightningError } from '../structures/errors.ts'; 5 | import type { deleted_message, message } from '../structures/messages.ts'; 6 | 7 | export async function bridge_message( 8 | core: core, 9 | bridge_data: bridge_data, 10 | event: 'create_message' | 'edit_message' | 'delete_message', 11 | data: message | deleted_message, 12 | ) { 13 | const bridge = event === 'create_message' 14 | ? await bridge_data.get_bridge_by_channel(data.channel_id) 15 | : await bridge_data.get_message(data.message_id); 16 | 17 | if (!bridge) return; 18 | 19 | // if the channel is disabled, return 20 | if ( 21 | bridge.channels.some( 22 | (channel) => 23 | channel.id === data.channel_id && 24 | channel.plugin === data.plugin && 25 | (channel.disabled === true || 26 | typeof channel.disabled === 'object' && 27 | channel.disabled.read === true), 28 | ) 29 | ) return; 30 | 31 | // remove ourselves & disabled channels 32 | const channels = bridge.channels.filter((channel) => 33 | (channel.id !== data.channel_id || channel.plugin !== data.plugin) && 34 | (!(channel.disabled === true || typeof channel.disabled === 'object' && 35 | channel.disabled.write === true) || !channel.data) 36 | ); 37 | 38 | // if there aren't any left, return 39 | if (channels.length < 1) return; 40 | 41 | const messages: bridged_message[] = []; 42 | 43 | for (const channel of channels) { 44 | const prior_bridged_ids = event === 'create_message' 45 | ? undefined 46 | : (bridge as bridge_message).messages.find((i) => 47 | i.channel === channel.id && i.plugin === channel.plugin 48 | ); 49 | 50 | if (event !== 'create_message' && !prior_bridged_ids) continue; 51 | 52 | const plugin = core.get_plugin(channel.plugin)!; 53 | 54 | let reply_id: string | undefined; 55 | 56 | if ('reply_id' in data && data.reply_id) { 57 | try { 58 | const bridged = await bridge_data.get_message(data.reply_id); 59 | 60 | reply_id = bridged?.messages?.find((message) => 61 | message.channel === channel.id && message.plugin === channel.plugin 62 | )?.id[0] ?? bridged?.id; 63 | } catch { 64 | reply_id = undefined; 65 | } 66 | } 67 | 68 | try { 69 | let result_ids: string[]; 70 | 71 | switch (event) { 72 | case 'create_message': 73 | case 'edit_message': 74 | result_ids = await plugin[event]( 75 | { 76 | ...(data as message), 77 | reply_id, 78 | channel_id: channel.id, 79 | message_id: prior_bridged_ids?.id[0] ?? '', 80 | }, 81 | { 82 | channel, 83 | settings: bridge.settings, 84 | edit_ids: prior_bridged_ids?.id as string[], 85 | }, 86 | ); 87 | break; 88 | case 'delete_message': 89 | result_ids = await plugin.delete_messages( 90 | prior_bridged_ids!.id.map((id) => ({ 91 | ...(data as deleted_message), 92 | message_id: id, 93 | channel_id: channel.id, 94 | })), 95 | ); 96 | } 97 | 98 | result_ids.forEach((id) => core.set_handled(channel.plugin, id)); 99 | 100 | messages.push({ 101 | id: result_ids, 102 | channel: channel.id, 103 | plugin: channel.plugin, 104 | }); 105 | } catch (e) { 106 | const err = new LightningError(e, { 107 | message: `An error occurred while handling a message in the bridge`, 108 | }); 109 | 110 | if (err.disable) { 111 | new LightningError( 112 | `disabling channel ${channel.id} in bridge ${bridge.id}`, 113 | { 114 | extra: { original_error: err.id, disable: err.disable }, 115 | }, 116 | ); 117 | 118 | await bridge_data.edit_bridge({ 119 | ...bridge, 120 | channels: bridge.channels.map((ch) => 121 | ch.id === channel.id && ch.plugin === channel.plugin 122 | ? { ...ch, disabled: err.disable! } 123 | : ch 124 | ), 125 | }); 126 | } 127 | } 128 | } 129 | 130 | await bridge_data[event]({ 131 | ...bridge, 132 | id: data.message_id, 133 | messages, 134 | bridge_id: bridge.id, 135 | }); 136 | } 137 | -------------------------------------------------------------------------------- /packages/lightning/src/bridge/setup.ts: -------------------------------------------------------------------------------- 1 | import type { core } from '../core.ts'; 2 | import { create_database, type database_config } from '../database/mod.ts'; 3 | import { create, join, leave, status, subscribe, toggle } from './commands.ts'; 4 | import { bridge_message } from './handler.ts'; 5 | 6 | export async function setup_bridge(core: core, config: database_config) { 7 | const database = await create_database(config); 8 | 9 | core.on( 10 | 'create_message', 11 | (msg) => bridge_message(core, database, 'create_message', msg), 12 | ); 13 | core.on( 14 | 'edit_message', 15 | (msg) => bridge_message(core, database, 'edit_message', msg), 16 | ); 17 | core.on( 18 | 'delete_message', 19 | (msg) => bridge_message(core, database, 'delete_message', msg), 20 | ); 21 | 22 | core.set_command({ 23 | name: 'bridge', 24 | description: 'bridge commands', 25 | execute: () => 'take a look at the subcommands of this command', 26 | subcommands: [ 27 | { 28 | name: 'create', 29 | description: 'create a new bridge', 30 | arguments: [{ 31 | name: 'name', 32 | description: 'name of the bridge', 33 | required: true, 34 | }], 35 | execute: (o) => create(database, o), 36 | }, 37 | { 38 | name: 'join', 39 | description: 'join an existing bridge', 40 | arguments: [{ 41 | name: 'id', 42 | description: 'id of the bridge', 43 | required: true, 44 | }], 45 | execute: (o) => join(database, o), 46 | }, 47 | { 48 | name: 'subscribe', 49 | description: 'subscribe to a bridge', 50 | arguments: [{ 51 | name: 'id', 52 | description: 'id of the bridge', 53 | required: true, 54 | }], 55 | execute: (o) => subscribe(database, o), 56 | }, 57 | { 58 | name: 'leave', 59 | description: 'leave the current bridge', 60 | arguments: [{ 61 | name: 'id', 62 | description: 'id of the current bridge', 63 | required: true, 64 | }], 65 | execute: (o) => leave(database, o), 66 | }, 67 | { 68 | name: 'toggle', 69 | description: 'toggle a setting on the current bridge', 70 | arguments: [{ 71 | name: 'setting', 72 | description: 'setting to toggle', 73 | required: true, 74 | }], 75 | execute: (o) => toggle(database, o), 76 | }, 77 | { 78 | name: 'status', 79 | description: 'get the status of the current bridge', 80 | execute: (o) => status(database, o), 81 | }, 82 | ], 83 | }); 84 | } 85 | -------------------------------------------------------------------------------- /packages/lightning/src/cli.ts: -------------------------------------------------------------------------------- 1 | import { setup_bridge } from './bridge/setup.ts'; 2 | import { parse_config } from './cli_config.ts'; 3 | import { core } from './core.ts'; 4 | import { handle_migration } from './database/mod.ts'; 5 | import { cwd, exit, get_args } from './structures/cross.ts'; 6 | import { LightningError } from './structures/errors.ts'; 7 | 8 | /** 9 | * This module provides the Lightning CLI, which you can use to run the bot 10 | * @module 11 | */ 12 | 13 | const args = get_args(); 14 | 15 | if (args[0] === 'migrate') { 16 | handle_migration(); 17 | } else if (args[0] === 'run') { 18 | try { 19 | const config = await parse_config( 20 | new URL(args[1] ?? 'lightning.toml', `file://${cwd()}/`), 21 | ); 22 | const lightning = new core(config); 23 | await setup_bridge(lightning, config.database); 24 | } catch (e) { 25 | await new LightningError(e, { 26 | extra: { type: 'global class error' }, 27 | without_cause: true, 28 | }).log(); 29 | 30 | exit(1); 31 | } 32 | } else if (args[0] === 'version') { 33 | console.log('0.8.0-alpha.5'); 34 | } else { 35 | console.log( 36 | `lightning v0.8.0-alpha.5 - extensible chatbot connecting communities`, 37 | ); 38 | console.log(' Usage: lightning [subcommand]'); 39 | console.log(' Subcommands:'); 40 | console.log(' run : run a lightning instance'); 41 | console.log(' migrate: migrate databases'); 42 | console.log(' version: display the version number'); 43 | console.log(' help: display this help message'); 44 | console.log(' Environment Variables:'); 45 | console.log(' LIGHTNING_ERROR_WEBHOOK: the webhook to send errors to'); 46 | console.log(' LIGHTNING_MIGRATE_CONFIRM: confirm migration on startup'); 47 | } 48 | -------------------------------------------------------------------------------- /packages/lightning/src/cli_config.ts: -------------------------------------------------------------------------------- 1 | import { readTextFile } from '@std/fs/unstable-read-text-file'; 2 | import { parse as parse_toml } from '@std/toml'; 3 | import type { core_config } from './core.ts'; 4 | import type { database_config } from './database/mod.ts'; 5 | import { set_env } from './structures/cross.ts'; 6 | import { log_error } from './structures/errors.ts'; 7 | import { validate_config } from './structures/validate.ts'; 8 | 9 | interface cli_plugin { 10 | plugin: string; 11 | config: Record; 12 | } 13 | 14 | interface config extends core_config { 15 | database: database_config; 16 | error_url?: string; 17 | } 18 | 19 | export async function parse_config(path: URL): Promise { 20 | try { 21 | const file = await readTextFile(path); 22 | const raw = parse_toml(file) as Record; 23 | 24 | const validated = validate_config(raw, { 25 | name: 'lightning', 26 | keys: { 27 | error_url: { type: 'string', required: false }, 28 | prefix: { type: 'string', required: false }, 29 | }, 30 | }) as Omit & { plugins: cli_plugin[] }; 31 | 32 | if ( 33 | !('type' in validated.database) || 34 | typeof validated.database.type !== 'string' || 35 | !('config' in validated.database) || 36 | validated.database.config === null || 37 | (validated.database.type === 'postgres' && 38 | typeof validated.database.config !== 'string') || 39 | (validated.database.type === 'redis' && 40 | (typeof validated.database.config !== 'object' || 41 | validated.database.config === null)) 42 | ) { 43 | return log_error('your config has an invalid `database` field', { 44 | without_cause: true, 45 | }); 46 | } 47 | 48 | if ( 49 | !validated.plugins.every( 50 | (p): p is cli_plugin => 51 | typeof p.plugin === 'string' && 52 | typeof p.config === 'object' && 53 | p.config !== null, 54 | ) 55 | ) { 56 | return log_error('your config has an invalid `plugins` field', { 57 | without_cause: true, 58 | }); 59 | } 60 | 61 | const plugins = []; 62 | 63 | for (const plugin of validated.plugins) { 64 | plugins.push({ 65 | module: await import(plugin.plugin), 66 | config: plugin.config, 67 | }); 68 | } 69 | 70 | set_env('LIGHTNING_ERROR_WEBHOOK', validated.error_url ?? ''); 71 | 72 | return { ...validated, plugins }; 73 | } catch (e) { 74 | log_error(e, { 75 | message: 76 | `could not open or parse your \`lightning.toml\` file at ${path}`, 77 | without_cause: true, 78 | }); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/lightning/src/core.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '@denosaurs/event'; 2 | import type { 3 | command, 4 | command_opts, 5 | create_command, 6 | } from './structures/commands.ts'; 7 | import { LightningError, log_error } from './structures/errors.ts'; 8 | import { create_message, type message } from './structures/messages.ts'; 9 | import type { events, plugin, plugin_module } from './structures/plugins.ts'; 10 | import { validate_config } from './structures/validate.ts'; 11 | 12 | export interface core_config { 13 | prefix?: string; 14 | plugins: { 15 | module: plugin_module; 16 | config: Record; 17 | }[]; 18 | } 19 | 20 | export class core extends EventEmitter { 21 | private commands = new Map([ 22 | ['help', { 23 | name: 'help', 24 | description: 'get help with the bot', 25 | execute: () => 26 | "hi! i'm lightning v0.8.0-alpha.5.\ncheck out [the docs](https://williamhorning.eu.org/lightning/) for help.", 27 | }], 28 | ['ping', { 29 | name: 'ping', 30 | description: 'check if the bot is alive', 31 | execute: ({ timestamp }: command_opts) => 32 | `Pong! 🏓 ${ 33 | Temporal.Now.instant().since(timestamp).round('millisecond') 34 | .total('milliseconds') 35 | }ms`, 36 | }], 37 | ]); 38 | private plugins = new Map(); 39 | private handled = new Set(); 40 | private prefix: string; 41 | 42 | constructor(cfg: core_config) { 43 | super(); 44 | this.prefix = cfg.prefix || '!'; 45 | 46 | for (const { module, config } of cfg.plugins) { 47 | if (!module.default || !module.schema) { 48 | log_error({ ...module }, { 49 | message: `one or more of you plugins isn't actually a plugin!`, 50 | without_cause: true, 51 | }); 52 | } 53 | 54 | const instance = new module.default( 55 | validate_config(config, module.schema), 56 | ); 57 | 58 | this.plugins.set(instance.name, instance); 59 | this.handle_events(instance); 60 | } 61 | } 62 | 63 | set_handled(plugin: string, message_id: string): void { 64 | this.handled.add(`${plugin}-${message_id}`); 65 | } 66 | 67 | set_command(opts: command): void { 68 | this.commands.set(opts.name, opts); 69 | 70 | for (const [, plugin] of this.plugins) { 71 | if (plugin.set_commands) { 72 | plugin.set_commands(this.commands.values().toArray()); 73 | } 74 | } 75 | } 76 | 77 | get_plugin(name: string): plugin | undefined { 78 | return this.plugins.get(name); 79 | } 80 | 81 | private async handle_events(plugin: plugin): Promise { 82 | for await (const { name, value } of plugin) { 83 | await new Promise((res) => setTimeout(res, 200)); 84 | 85 | if (this.handled.has(`${value[0].plugin}-${value[0].message_id}`)) { 86 | this.handled.delete(`${value[0].plugin}-${value[0].message_id}`); 87 | continue; 88 | } 89 | 90 | if (name === 'create_command') { 91 | this.handle_command(value[0] as create_command, plugin); 92 | } 93 | 94 | if (name === 'create_message') { 95 | const msg = value[0] as message; 96 | 97 | if (msg.content?.startsWith(this.prefix)) { 98 | const [command, ...rest] = msg.content.replace(this.prefix, '').split( 99 | ' ', 100 | ); 101 | 102 | this.handle_command({ 103 | ...msg, 104 | args: {}, 105 | command, 106 | prefix: this.prefix, 107 | reply: async (message: message) => { 108 | await plugin.create_message({ 109 | ...message, 110 | channel_id: msg.channel_id, 111 | reply_id: msg.message_id, 112 | }); 113 | }, 114 | rest, 115 | }, plugin); 116 | } 117 | } 118 | 119 | this.emit(name, ...value); 120 | } 121 | } 122 | 123 | private async handle_command( 124 | opts: create_command, 125 | plugin: plugin, 126 | ): Promise { 127 | let command = this.commands.get(opts.command) ?? this.commands.get('help')!; 128 | const subcommand_name = opts.subcommand ?? opts.rest?.shift(); 129 | 130 | if (command.subcommands && subcommand_name) { 131 | const subcommand = command.subcommands.find((i) => 132 | i.name === subcommand_name 133 | ); 134 | 135 | if (subcommand) command = subcommand; 136 | } 137 | 138 | for (const arg of (command.arguments ?? [])) { 139 | if (!opts.args[arg.name]) { 140 | opts.args[arg.name] = opts.rest?.shift(); 141 | } 142 | 143 | if (!opts.args[arg.name]) { 144 | return opts.reply( 145 | create_message( 146 | `Please provide the \`${arg.name}\` argument. Try using the \`${opts.prefix}help\` command.`, 147 | ), 148 | ); 149 | } 150 | } 151 | 152 | let resp: string | LightningError; 153 | 154 | try { 155 | resp = await command.execute({ ...opts, plugin }); 156 | } catch (e) { 157 | resp = new LightningError(e, { 158 | message: 'An error occurred while executing the command', 159 | extra: { command: command.name }, 160 | }); 161 | } 162 | 163 | try { 164 | await opts.reply( 165 | resp instanceof LightningError ? resp.msg : create_message(resp), 166 | ); 167 | } catch (e) { 168 | new LightningError(e, { 169 | message: 'An error occurred while sending the command response', 170 | extra: { command: command.name }, 171 | }); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /packages/lightning/src/database/mod.ts: -------------------------------------------------------------------------------- 1 | import type { bridge, bridge_message } from '../structures/bridge.ts'; 2 | import { postgres } from './postgres.ts'; 3 | import { redis, type redis_config } from './redis.ts'; 4 | 5 | export interface bridge_data { 6 | create_bridge(br: Omit): Promise; 7 | edit_bridge(br: bridge): Promise; 8 | get_bridge_by_id(id: string): Promise; 9 | get_bridge_by_channel(ch: string): Promise; 10 | create_message(msg: bridge_message): Promise; 11 | edit_message(msg: bridge_message): Promise; 12 | delete_message(msg: bridge_message): Promise; 13 | get_message(id: string): Promise; 14 | migration_get_bridges(): Promise; 15 | migration_get_messages(): Promise; 16 | migration_set_bridges(bridges: bridge[]): Promise; 17 | migration_set_messages(messages: bridge_message[]): Promise; 18 | } 19 | 20 | export type database_config = { 21 | type: 'postgres'; 22 | config: string; 23 | } | { 24 | type: 'redis'; 25 | config: redis_config; 26 | }; 27 | 28 | export async function create_database( 29 | config: database_config, 30 | ): Promise { 31 | if (config.type === 'postgres') return await postgres.create(config.config); 32 | if (config.type === 'redis') return await redis.create(config.config); 33 | throw new Error('invalid database type', { cause: config }); 34 | } 35 | 36 | function get_database(message: string): typeof postgres | typeof redis { 37 | const type = prompt(`${message} (redis,postgres)`); 38 | 39 | if (type === 'postgres') return postgres; 40 | if (type === 'redis') return redis; 41 | throw new Error('invalid database type!'); 42 | } 43 | 44 | export async function handle_migration() { 45 | const start = await get_database('Please enter your starting database type: ') 46 | .migration_get_instance(); 47 | 48 | const end = await get_database('Please enter your ending database type: ') 49 | .migration_get_instance(); 50 | 51 | console.log('Downloading bridges...'); 52 | let bridges = await start.migration_get_bridges(); 53 | 54 | console.log(`Copying ${bridges.length} bridges...`); 55 | await end.migration_set_bridges(bridges); 56 | bridges = []; 57 | 58 | console.log('Downloading messages...'); 59 | let messages = await start.migration_get_messages(); 60 | 61 | console.log(`Copying ${messages.length} messages...`); 62 | await end.migration_set_messages(messages); 63 | messages = []; 64 | 65 | console.log('Migration complete!'); 66 | } 67 | -------------------------------------------------------------------------------- /packages/lightning/src/database/postgres.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@db/postgres'; 2 | import { 3 | ProgressBar, 4 | type ProgressBarFormatter, 5 | } from '@std/cli/unstable-progress-bar'; 6 | import { Spinner } from '@std/cli/unstable-spinner'; 7 | import type { bridge, bridge_message } from '../structures/bridge.ts'; 8 | import { get_env, stdout } from '../structures/cross.ts'; 9 | import type { bridge_data } from './mod.ts'; 10 | 11 | const fmt = (fmt: ProgressBarFormatter) => 12 | `[postgres] ${fmt.progressBar} ${fmt.styledTime} [${fmt.value}/${fmt.max}]\n`; 13 | 14 | export class postgres implements bridge_data { 15 | private pg: Client; 16 | 17 | static async create(pg_url: string): Promise { 18 | const { Client } = await import('@db/postgres'); 19 | const pg = new Client(pg_url); 20 | 21 | await pg.connect(); 22 | await postgres.setup_schema(pg); 23 | 24 | return new this(pg); 25 | } 26 | 27 | private static async setup_schema(pg: Client) { 28 | await pg.queryArray` 29 | CREATE TABLE IF NOT EXISTS lightning ( 30 | prop TEXT PRIMARY KEY, 31 | value TEXT NOT NULL 32 | ); 33 | 34 | INSERT INTO lightning (prop, value) 35 | VALUES ('db_data_version', '0.8.0') 36 | /* the database shouldn't have been created before 0.8.0 so this is safe */ 37 | ON CONFLICT (prop) DO NOTHING; 38 | 39 | CREATE TABLE IF NOT EXISTS bridges ( 40 | id TEXT PRIMARY KEY, 41 | name TEXT NOT NULL, 42 | channels JSONB NOT NULL, 43 | settings JSONB NOT NULL 44 | ); 45 | 46 | CREATE TABLE IF NOT EXISTS bridge_messages ( 47 | id TEXT PRIMARY KEY, 48 | name TEXT NOT NULL, 49 | bridge_id TEXT NOT NULL, 50 | channels JSONB NOT NULL, 51 | messages JSONB NOT NULL, 52 | settings JSONB NOT NULL 53 | ); 54 | `; 55 | } 56 | 57 | private constructor(pg: Client) { 58 | this.pg = pg; 59 | } 60 | 61 | async create_bridge(br: Omit): Promise { 62 | const id = crypto.randomUUID(); 63 | 64 | await this.pg.queryArray` 65 | INSERT INTO bridges (id, name, channels, settings) 66 | VALUES (${id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ 67 | JSON.stringify(br.settings) 68 | }) 69 | `; 70 | 71 | return { id, ...br }; 72 | } 73 | 74 | async edit_bridge(br: bridge): Promise { 75 | await this.pg.queryArray` 76 | UPDATE bridges 77 | SET channels = ${JSON.stringify(br.channels)}, 78 | settings = ${JSON.stringify(br.settings)} 79 | WHERE id = ${br.id} 80 | `; 81 | } 82 | 83 | async get_bridge_by_id(id: string): Promise { 84 | const res = await this.pg.queryObject` 85 | SELECT * FROM bridges WHERE id = ${id} 86 | `; 87 | 88 | return res.rows[0]; 89 | } 90 | 91 | async get_bridge_by_channel(ch: string): Promise { 92 | const res = await this.pg.queryObject(` 93 | SELECT * FROM bridges WHERE EXISTS ( 94 | SELECT 1 FROM jsonb_array_elements(channels) AS ch 95 | WHERE ch->>'id' = '${ch}' 96 | ) 97 | `); 98 | 99 | return res.rows[0]; 100 | } 101 | 102 | async create_message(msg: bridge_message): Promise { 103 | await this.pg.queryArray`INSERT INTO bridge_messages 104 | (id, name, bridge_id, channels, messages, settings) VALUES 105 | (${msg.id}, ${msg.name}, ${msg.bridge_id}, ${ 106 | JSON.stringify(msg.channels) 107 | }, ${JSON.stringify(msg.messages)}, ${JSON.stringify(msg.settings)})`; 108 | } 109 | 110 | async edit_message(msg: bridge_message): Promise { 111 | await this.pg.queryArray` 112 | UPDATE bridge_messages 113 | SET messages = ${JSON.stringify(msg.messages)}, 114 | channels = ${JSON.stringify(msg.channels)}, 115 | settings = ${JSON.stringify(msg.settings)} 116 | WHERE id = ${msg.id} 117 | `; 118 | } 119 | 120 | async delete_message({ id }: bridge_message): Promise { 121 | await this.pg.queryArray` 122 | DELETE FROM bridge_messages WHERE id = ${id} 123 | `; 124 | } 125 | 126 | async get_message(id: string): Promise { 127 | const res = await this.pg.queryObject(` 128 | SELECT * FROM bridge_messages 129 | WHERE id = '${id}' OR EXISTS ( 130 | SELECT 1 FROM jsonb_array_elements(messages) AS msg 131 | CROSS JOIN jsonb_array_elements_text(msg->'id') AS id_element 132 | WHERE id_element = '${id}' 133 | ) 134 | `); 135 | 136 | return res.rows[0]; 137 | } 138 | 139 | async migration_get_bridges(): Promise { 140 | const spinner = new Spinner({ message: 'getting bridges from postgres' }); 141 | 142 | spinner.start(); 143 | 144 | const res = await this.pg.queryObject(` 145 | SELECT * FROM bridges 146 | `); 147 | 148 | spinner.stop(); 149 | 150 | return res.rows; 151 | } 152 | 153 | async migration_get_messages(): Promise { 154 | const spinner = new Spinner({ message: 'getting messages from postgres' }); 155 | 156 | spinner.start(); 157 | 158 | const res = await this.pg.queryObject(` 159 | SELECT * FROM bridge_messages 160 | `); 161 | 162 | spinner.stop(); 163 | 164 | return res.rows; 165 | } 166 | 167 | async migration_set_messages(messages: bridge_message[]): Promise { 168 | const progress = new ProgressBar({ 169 | max: messages.length, 170 | fmt: fmt, 171 | writable: stdout(), 172 | }); 173 | 174 | for (const msg of messages) { 175 | progress.value++; 176 | 177 | try { 178 | await this.create_message(msg); 179 | } catch { 180 | console.warn(`failed to insert message ${msg.id}`); 181 | } 182 | } 183 | 184 | progress.stop(); 185 | } 186 | 187 | async migration_set_bridges(bridges: bridge[]): Promise { 188 | const progress = new ProgressBar({ 189 | max: bridges.length, 190 | fmt: fmt, 191 | writable: stdout(), 192 | }); 193 | 194 | for (const br of bridges) { 195 | progress.value++; 196 | 197 | await this.pg.queryArray` 198 | INSERT INTO bridges (id, name, channels, settings) 199 | VALUES (${br.id}, ${br.name}, ${JSON.stringify(br.channels)}, ${ 200 | JSON.stringify(br.settings) 201 | }) 202 | `; 203 | } 204 | 205 | progress.stop(); 206 | } 207 | 208 | static async migration_get_instance(): Promise { 209 | const default_url = `postgres://${ 210 | get_env('USER') ?? get_env('USERNAME') 211 | }@localhost/lightning`; 212 | 213 | const pg_url = prompt( 214 | `Please enter your Postgres connection string (${default_url}):`, 215 | ); 216 | 217 | return await postgres.create(pg_url || default_url); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /packages/lightning/src/database/redis.ts: -------------------------------------------------------------------------------- 1 | import { RedisClient } from '@iuioiua/redis'; 2 | import { 3 | ProgressBar, 4 | type ProgressBarFormatter, 5 | } from '@std/cli/unstable-progress-bar'; 6 | import { writeTextFile } from '@std/fs/unstable-write-text-file'; 7 | import type { 8 | bridge, 9 | bridge_channel, 10 | bridge_message, 11 | bridged_message, 12 | } from '../structures/bridge.ts'; 13 | import { get_env, stdout, tcp_connect } from '../structures/cross.ts'; 14 | import { log_error } from '../structures/errors.ts'; 15 | import type { bridge_data } from './mod.ts'; 16 | 17 | export interface redis_config { 18 | hostname: string; 19 | port: number; 20 | } 21 | 22 | const fmt = (fmt: ProgressBarFormatter) => 23 | `[redis] ${fmt.progressBar} ${fmt.styledTime} [${fmt.value}/${fmt.max}]\n`; 24 | 25 | export class redis implements bridge_data { 26 | private redis: RedisClient; 27 | private seven: boolean; 28 | 29 | static async create( 30 | rd_options: redis_config, 31 | _do_not_use = false, 32 | ): Promise { 33 | const rd = new RedisClient(await tcp_connect(rd_options)); 34 | 35 | let db_data_version = await rd.sendCommand([ 36 | 'GET', 37 | 'lightning-db-version', 38 | ]); 39 | 40 | if (db_data_version === null) { 41 | const number_keys = await rd.sendCommand(['DBSIZE']) as number; 42 | 43 | if (number_keys === 0) { 44 | await rd.sendCommand(['SET', 'lightning-db-version', '0.8.0']); 45 | db_data_version = '0.8.0'; 46 | } 47 | } 48 | 49 | if (db_data_version !== '0.8.0' && !_do_not_use) { 50 | console.warn( 51 | `[lightning-redis] migrating database from ${db_data_version} to 0.8.0`, 52 | ); 53 | 54 | const instance = new this(rd, true); 55 | 56 | console.log('[lightning-redis] getting bridges...'); 57 | 58 | const bridges = await instance.migration_get_bridges(); 59 | 60 | console.log('[lightning-redis] got bridges!'); 61 | 62 | await writeTextFile( 63 | 'lightning-redis-migration.json', 64 | JSON.stringify(bridges, null, 2), 65 | ); 66 | 67 | const write = confirm( 68 | '[lightning-redis] write the data to the database? see \`lightning-redis-migration.json\` for the data', 69 | ); 70 | const env_confirm = get_env('LIGHTNING_MIGRATE_CONFIRM'); 71 | 72 | if (write || env_confirm === 'true') { 73 | await instance.migration_set_bridges(bridges); 74 | 75 | const former_messages = await rd.sendCommand([ 76 | 'KEYS', 77 | 'lightning-bridged-*', 78 | ]) as string[]; 79 | 80 | for (const key of former_messages) { 81 | await rd.sendCommand(['DEL', key]); 82 | } 83 | 84 | console.warn('[lightning-redis] data written to database'); 85 | 86 | return instance; 87 | } else { 88 | log_error('[lightning-redis] data not written to database', { 89 | without_cause: true, 90 | }); 91 | } 92 | } else { 93 | return new this(rd, _do_not_use); 94 | } 95 | } 96 | 97 | private constructor( 98 | redis: RedisClient, 99 | seven = false, 100 | ) { 101 | this.redis = redis; 102 | this.seven = seven; 103 | } 104 | 105 | async get_json(key: string): Promise { 106 | const reply = await this.redis.sendCommand(['GET', key]); 107 | if (!reply || reply === 'OK') return; 108 | return JSON.parse(reply as string) as T; 109 | } 110 | 111 | async create_bridge(br: Omit): Promise { 112 | const id = crypto.randomUUID(); 113 | 114 | await this.edit_bridge({ id, ...br }); 115 | 116 | return { id, ...br }; 117 | } 118 | 119 | async edit_bridge(br: bridge): Promise { 120 | const old_bridge = await this.get_bridge_by_id(br.id); 121 | 122 | for (const channel of old_bridge?.channels ?? []) { 123 | await this.redis.sendCommand([ 124 | 'DEL', 125 | `lightning-bchannel-${channel.id}`, 126 | ]); 127 | } 128 | 129 | await this.redis.sendCommand([ 130 | 'SET', 131 | `lightning-bridge-${br.id}`, 132 | JSON.stringify(br), 133 | ]); 134 | 135 | for (const channel of br.channels) { 136 | await this.redis.sendCommand([ 137 | 'SET', 138 | `lightning-bchannel-${channel.id}`, 139 | br.id, 140 | ]); 141 | } 142 | } 143 | 144 | async get_bridge_by_id(id: string): Promise { 145 | return await this.get_json(`lightning-bridge-${id}`); 146 | } 147 | 148 | async get_bridge_by_channel(ch: string): Promise { 149 | const channel = await this.redis.sendCommand([ 150 | 'GET', 151 | `lightning-bchannel-${ch}`, 152 | ]); 153 | if (!channel || channel === 'OK') return; 154 | return await this.get_bridge_by_id(channel as string); 155 | } 156 | 157 | async create_message(msg: bridge_message): Promise { 158 | await this.redis.sendCommand([ 159 | 'SET', 160 | `lightning-message-${msg.id}`, 161 | JSON.stringify(msg), 162 | ]); 163 | 164 | for (const message of msg.messages) { 165 | await this.redis.sendCommand([ 166 | 'SET', 167 | `lightning-message-${message.id}`, 168 | JSON.stringify(msg), 169 | ]); 170 | } 171 | } 172 | 173 | async edit_message(msg: bridge_message): Promise { 174 | await this.create_message(msg); 175 | } 176 | 177 | async delete_message(msg: bridge_message): Promise { 178 | await this.redis.sendCommand(['DEL', `lightning-message-${msg.id}`]); 179 | } 180 | 181 | async get_message(id: string): Promise { 182 | return await this.get_json( 183 | `lightning-message-${id}`, 184 | ); 185 | } 186 | 187 | async migration_get_bridges(): Promise { 188 | const keys = await this.redis.sendCommand([ 189 | 'KEYS', 190 | 'lightning-bridge-*', 191 | ]) as string[]; 192 | 193 | const bridges = [] as bridge[]; 194 | 195 | const progress = new ProgressBar({ 196 | max: keys.length, 197 | fmt, 198 | writable: stdout(), 199 | }); 200 | 201 | for (const key of keys) { 202 | progress.value++; 203 | if (!this.seven) { 204 | const bridge = await this.get_bridge_by_id( 205 | key.replace('lightning-bridge-', ''), 206 | ); 207 | 208 | if (bridge) bridges.push(bridge); 209 | } else { 210 | // ignore UUIDs and ULIDs 211 | if ( 212 | key.replace('lightning-bridge-', '').match( 213 | /[0-7][0-9A-HJKMNP-TV-Z]{25}/gm, 214 | ) || 215 | key.replace('lightning-bridge-', '').match( 216 | /^[0-9A-F]{8}-[0-9A-F]{4}-[4][0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, 217 | ) 218 | ) { 219 | continue; 220 | } 221 | 222 | const bridge = await this.get_json<{ 223 | channels: bridge_channel[]; 224 | id: string; 225 | messages?: bridged_message[]; 226 | }>(key); 227 | 228 | if (bridge && bridge.channels) { 229 | bridges.push({ 230 | id: key.replace('lightning-bridge-', ''), 231 | name: bridge.id, 232 | channels: bridge.channels, 233 | settings: { 234 | allow_everyone: false, 235 | }, 236 | }); 237 | } 238 | } 239 | } 240 | 241 | progress.stop(); 242 | 243 | return bridges; 244 | } 245 | 246 | async migration_set_bridges(bridges: bridge[]): Promise { 247 | const progress = new ProgressBar({ 248 | max: bridges.length, 249 | fmt, 250 | writable: stdout(), 251 | }); 252 | 253 | for (const bridge of bridges) { 254 | progress.value++; 255 | 256 | await this.redis.sendCommand([ 257 | 'DEL', 258 | `lightning-bridge-${bridge.id}`, 259 | ]); 260 | 261 | for (const channel of bridge.channels) { 262 | await this.redis.sendCommand([ 263 | 'DEL', 264 | `lightning-bchannel-${channel.id}`, 265 | ]); 266 | } 267 | 268 | if (bridge.channels.length < 2) continue; 269 | 270 | await this.redis.sendCommand([ 271 | 'SET', 272 | `lightning-bridge-${bridge.id}`, 273 | JSON.stringify(bridge), 274 | ]); 275 | 276 | for (const channel of bridge.channels) { 277 | await this.redis.sendCommand([ 278 | 'SET', 279 | `lightning-bchannel-${channel.id}`, 280 | bridge.id, 281 | ]); 282 | } 283 | } 284 | 285 | progress.stop(); 286 | 287 | await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); 288 | } 289 | 290 | async migration_get_messages(): Promise { 291 | const keys = await this.redis.sendCommand([ 292 | 'KEYS', 293 | 'lightning-message-*', 294 | ]) as string[]; 295 | 296 | const messages = [] as bridge_message[]; 297 | 298 | const progress = new ProgressBar({ 299 | max: keys.length, 300 | fmt, 301 | writable: stdout(), 302 | }); 303 | 304 | for (const key of keys) { 305 | progress.value++; 306 | const message = await this.get_json(key); 307 | if (message) messages.push(message); 308 | } 309 | 310 | progress.stop(); 311 | 312 | return messages; 313 | } 314 | 315 | async migration_set_messages(messages: bridge_message[]): Promise { 316 | const progress = new ProgressBar({ 317 | max: messages.length, 318 | fmt, 319 | writable: stdout(), 320 | }); 321 | 322 | for (const message of messages) { 323 | progress.value++; 324 | await this.create_message(message); 325 | } 326 | 327 | progress.stop(); 328 | 329 | await this.redis.sendCommand(['SET', 'lightning-db-version', '0.8.0']); 330 | } 331 | 332 | static async migration_get_instance(): Promise { 333 | const hostname = prompt('Please enter your Redis hostname (localhost):') || 334 | 'localhost'; 335 | const port = prompt('Please enter your Redis port (6379):') || '6379'; 336 | 337 | return await redis.create({ 338 | hostname, 339 | port: parseInt(port), 340 | }, true); 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /packages/lightning/src/mod.ts: -------------------------------------------------------------------------------- 1 | if (import.meta.main) import('./cli.ts'); 2 | 3 | export * from './structures/mod.ts'; 4 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/bridge.ts: -------------------------------------------------------------------------------- 1 | /** representation of a bridge */ 2 | export interface bridge { 3 | /** primary key */ 4 | id: string; 5 | /** user-facing name of the bridge */ 6 | name: string; 7 | /** channels in the bridge */ 8 | channels: bridge_channel[]; 9 | /** settings for the bridge */ 10 | settings: bridge_settings; 11 | } 12 | 13 | /** a channel within a bridge */ 14 | export interface bridge_channel { 15 | /** the channel's canonical id */ 16 | id: string; 17 | /** data needed to bridge this channel */ 18 | data: unknown; 19 | /** whether the channel is disabled */ 20 | disabled: boolean | { read: boolean; write: boolean }; 21 | /** the plugin used to bridge this channel */ 22 | plugin: string; 23 | } 24 | 25 | /** possible settings for a bridge */ 26 | export interface bridge_settings { 27 | /** `@everyone/@here/@room` */ 28 | allow_everyone: boolean; 29 | } 30 | 31 | /** list of settings for a bridge */ 32 | export const bridge_settings_list = ['allow_everyone']; 33 | 34 | /** representation of a bridged message collection */ 35 | export interface bridge_message extends bridge { 36 | /** original bridge id */ 37 | bridge_id: string; 38 | /** messages bridged */ 39 | messages: bridged_message[]; 40 | } 41 | 42 | /** representation of an individual bridged message */ 43 | export interface bridged_message { 44 | /** ids of the message */ 45 | id: string[]; 46 | /** the channel id sent to */ 47 | channel: string; 48 | /** the plugin used */ 49 | plugin: string; 50 | } 51 | 52 | /** options for a message to be bridged */ 53 | export interface bridge_message_opts { 54 | /** the channel to use */ 55 | channel: bridge_channel; 56 | /** ids of messages to edit, if any */ 57 | edit_ids?: string[]; 58 | /** the settings to use */ 59 | settings: bridge_settings; 60 | } 61 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/cacher.ts: -------------------------------------------------------------------------------- 1 | /** a class that wraps map to cache keys */ 2 | export class cacher { 3 | /** the map used to internally store keys */ 4 | private map = new Map(); 5 | 6 | /** create a cacher with a ttl (defaults to 30000) */ 7 | constructor(private ttl: number = 30000) {} 8 | 9 | /** get a key from the map, returning undefined if expired or not found */ 10 | get(key: k): v | undefined { 11 | const time = Temporal.Now.instant().epochMilliseconds; 12 | const entry = this.map.get(key); 13 | 14 | if (entry && entry.expiry >= time) return entry.value; 15 | this.map.delete(key); 16 | return undefined; 17 | } 18 | 19 | /** set a key in the map along with its expiry */ 20 | set(key: k, val: v, customTtl?: number): v { 21 | const time = Temporal.Now.instant().epochMilliseconds; 22 | this.map.set(key, { 23 | value: val, 24 | expiry: time + (customTtl ?? this.ttl), 25 | }); 26 | return val; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/commands.ts: -------------------------------------------------------------------------------- 1 | import type { message } from './messages.ts'; 2 | import type { plugin } from './plugins.ts'; 3 | 4 | /** representation of a command */ 5 | export interface command { 6 | /** user-facing command name */ 7 | name: string; 8 | /** user-facing command description */ 9 | description: string; 10 | /** possible arguments */ 11 | arguments?: command_argument[]; 12 | /** possible subcommands (use `${prefix}${cmd} ${subcommand}` if run as text command) */ 13 | subcommands?: Omit[]; 14 | /** the functionality of the command, returning text */ 15 | execute: (opts: command_opts) => Promise | string; 16 | } 17 | 18 | /** argument for a command */ 19 | export interface command_argument { 20 | /** user-facing name for the argument */ 21 | name: string; 22 | /** description of the argument */ 23 | description: string; 24 | /** whether the argument is required */ 25 | required: boolean; 26 | } 27 | 28 | /** options given to a command */ 29 | export interface command_opts { 30 | /** arguments to use */ 31 | args: Record; 32 | /** the channel the command was run in */ 33 | channel_id: string; 34 | /** the plugin the command was run with */ 35 | plugin: plugin; 36 | /** the command prefix used */ 37 | prefix: string; 38 | /** the time the command was sent */ 39 | timestamp: Temporal.Instant; 40 | } 41 | 42 | /** options used for a command event */ 43 | export interface create_command extends Omit { 44 | /** the command to run */ 45 | command: string; 46 | /** id of the associated event */ 47 | message_id: string; 48 | /** the plugin id used to run this with */ 49 | plugin: string; 50 | /** other, additional, options */ 51 | rest?: string[]; 52 | /** event reply function */ 53 | reply: (message: message) => Promise; 54 | /** the subcommand, if any, to use */ 55 | subcommand?: string; 56 | } 57 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/cross.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-process-global 2 | // deno-lint-ignore triple-slash-reference 3 | /// 4 | 5 | const is_deno = 'Deno' in globalThis; 6 | 7 | /** Get environment variable */ 8 | export function get_env(key: string): string | undefined { 9 | return is_deno ? Deno.env.get(key) : process.env[key]; 10 | } 11 | 12 | /** Set environment variable */ 13 | export function set_env(key: string, value: string): void { 14 | if (is_deno) { 15 | Deno.env.set(key, value); 16 | } else { 17 | process.env[key] = value; 18 | } 19 | } 20 | 21 | /** Get current directory */ 22 | export function cwd(): string { 23 | return is_deno ? Deno.cwd() : process.cwd(); 24 | } 25 | 26 | /** Exit the process */ 27 | export function exit(code: number): never { 28 | return is_deno ? Deno.exit(code) : process.exit(code); 29 | } 30 | 31 | /** Get command-line arguments */ 32 | export function get_args(): string[] { 33 | return is_deno ? Deno.args : process.argv.slice(2); 34 | } 35 | 36 | /** Get stdout stream */ 37 | export function stdout(): WritableStream { 38 | return is_deno 39 | ? Deno.stdout.writable 40 | : process.getBuiltinModule('stream').Writable.toWeb( 41 | process.stdout, 42 | ) as WritableStream; 43 | } 44 | 45 | /** Get tcp connection streams */ 46 | export async function tcp_connect( 47 | opts: { hostname: string; port: number }, 48 | ): Promise< 49 | { readable: ReadableStream; writable: WritableStream } 50 | > { 51 | if (is_deno) return await Deno.connect(opts); 52 | 53 | const { createConnection } = process.getBuiltinModule('node:net'); 54 | const { Readable, Writable } = process.getBuiltinModule('node:stream'); 55 | const conn = createConnection({ 56 | host: opts.hostname, 57 | port: opts.port, 58 | }); 59 | return { 60 | readable: Readable.toWeb(conn) as ReadableStream, 61 | writable: Writable.toWeb(conn) as WritableStream, 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/errors.ts: -------------------------------------------------------------------------------- 1 | import { get_env } from './cross.ts'; 2 | import { create_message, type message } from './messages.ts'; 3 | 4 | /** options used to create an error */ 5 | export interface error_options { 6 | /** the user-facing message of the error */ 7 | message?: string; 8 | /** the extra data to log */ 9 | extra?: Record; 10 | /** whether to disable the associated channel (when bridging) */ 11 | disable?: { read: boolean; write: boolean }; 12 | /** whether this should be logged without the cause */ 13 | without_cause?: boolean; 14 | } 15 | 16 | /** logs an error */ 17 | export function log_error(e: unknown, options?: error_options): never { 18 | throw new LightningError(e, options); 19 | } 20 | 21 | /** lightning error */ 22 | export class LightningError extends Error { 23 | /** the id associated with the error */ 24 | id: string; 25 | /** the cause of the error */ 26 | private error_cause: Error; 27 | /** extra information associated with the error */ 28 | extra: Record; 29 | /** the user-facing error message */ 30 | msg: message; 31 | /** whether to disable the associated channel (when bridging) */ 32 | disable?: { read: boolean; write: boolean }; 33 | /** whether to show the cause or not */ 34 | without_cause?: boolean; 35 | 36 | /** create and log an error */ 37 | constructor(e: unknown, options?: error_options) { 38 | if (e instanceof LightningError) { 39 | super(e.message, { cause: e.cause }); 40 | this.id = e.id; 41 | this.error_cause = e.error_cause; 42 | this.extra = e.extra; 43 | this.msg = e.msg; 44 | this.disable = e.disable; 45 | this.without_cause = e.without_cause; 46 | return e; 47 | } 48 | 49 | const cause_err = e instanceof Error 50 | ? e 51 | : e instanceof Object 52 | ? new Error(JSON.stringify(e)) 53 | : new Error(String(e)); 54 | 55 | const id = crypto.randomUUID(); 56 | 57 | super(options?.message ?? cause_err.message, { cause: e }); 58 | 59 | this.name = 'LightningError'; 60 | this.id = id; 61 | this.error_cause = cause_err; 62 | this.extra = options?.extra ?? {}; 63 | this.disable = options?.disable; 64 | this.without_cause = options?.without_cause; 65 | this.msg = create_message( 66 | `Something went wrong! Take a look at [the docs](https://williamhorning.eu.org/lightning).\n\`\`\`\n${this.message}\n${this.id}\n\`\`\``, 67 | ); 68 | 69 | console.error(`%c[lightning] ${this.message} - ${this.id}`, 'color: red'); 70 | if (this.disable?.read) console.log(`[lightning] channel reads disabled`); 71 | if (this.disable?.write) console.log(`[lightning] channel writes disabled`); 72 | if (!this.without_cause) this.log(); 73 | } 74 | 75 | /** log the error, automatically called in most cases */ 76 | async log(): Promise { 77 | if (!this.without_cause) console.error(this.error_cause, this.extra); 78 | 79 | const webhook = get_env('LIGHTNING_ERROR_WEBHOOK'); 80 | 81 | if (webhook && webhook.length > 0) { 82 | await fetch(webhook, { 83 | method: 'POST', 84 | headers: { 'Content-Type': 'application/json' }, 85 | body: JSON.stringify({ 86 | content: `# ${this.error_cause.message}\n*${this.id}*`, 87 | }), 88 | }); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/messages.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * creates a message that can be sent using lightning 3 | * @param text the text of the message (can be markdown) 4 | */ 5 | export function create_message(text: string): message { 6 | return { 7 | author: { 8 | username: 'lightning', 9 | profile: 10 | 'https://williamhorning.eu.org/assets/lightning/logo_monocolor_dark.svg', 11 | rawname: 'lightning', 12 | id: 'lightning', 13 | }, 14 | content: text, 15 | channel_id: '', 16 | message_id: '', 17 | timestamp: Temporal.Now.instant(), 18 | plugin: 'lightning', 19 | }; 20 | } 21 | 22 | /** attachments within a message */ 23 | export interface attachment { 24 | /** alt text for images */ 25 | alt?: string; 26 | /** a URL pointing to the file */ 27 | file: string; 28 | /** the file's name */ 29 | name?: string; 30 | /** whether or not the file has a spoiler */ 31 | spoiler?: boolean; 32 | /** file size in MiB */ 33 | size: number; 34 | } 35 | 36 | /** a representation of a message that has been deleted */ 37 | export interface deleted_message { 38 | /** the message's id */ 39 | message_id: string; 40 | /** the channel the message was sent in */ 41 | channel_id: string; 42 | /** the plugin that received the message */ 43 | plugin: string; 44 | /** the time the message was sent/edited as a temporal instant */ 45 | timestamp: Temporal.Instant; 46 | } 47 | 48 | /** a discord-style embed */ 49 | export interface embed { 50 | /** the author of the embed */ 51 | author?: { 52 | /** the name of the author */ 53 | name: string; 54 | /** the url of the author */ 55 | url?: string; 56 | /** the icon of the author */ 57 | icon_url?: string; 58 | }; 59 | /** the color of the embed */ 60 | color?: number; 61 | /** the text in an embed */ 62 | description?: string; 63 | /** fields within the embed */ 64 | fields?: { 65 | /** the name of the field */ 66 | name: string; 67 | /** the value of the field */ 68 | value: string; 69 | /** whether or not the field is inline */ 70 | inline?: boolean; 71 | }[]; 72 | /** a footer shown in the embed */ 73 | footer?: { 74 | /** the footer text */ 75 | text: string; 76 | /** the icon of the footer */ 77 | icon_url?: string; 78 | }; 79 | /** an image shown in the embed */ 80 | image?: media; 81 | /** a thumbnail shown in the embed */ 82 | thumbnail?: media; 83 | /** the time (in epoch ms) shown in the embed */ 84 | timestamp?: number; 85 | /** the title of the embed */ 86 | title?: string; 87 | /** a site linked to by the embed */ 88 | url?: string; 89 | /** a video inside of the embed */ 90 | video?: media; 91 | } 92 | 93 | /** media inside of an embed */ 94 | export interface media { 95 | /** the height of the media */ 96 | height?: number; 97 | /** the url of the media */ 98 | url: string; 99 | /** the width of the media */ 100 | width?: number; 101 | } 102 | 103 | /** a message received by a plugin */ 104 | export interface message extends deleted_message { 105 | /** the attachments sent with the message */ 106 | attachments?: attachment[]; 107 | /** the author of the message */ 108 | author: message_author; 109 | /** message content (can be markdown) */ 110 | content?: string; 111 | /** discord-style embeds */ 112 | embeds?: embed[]; 113 | /** the id of the message replied to */ 114 | reply_id?: string; 115 | } 116 | 117 | /** an author of a message */ 118 | export interface message_author { 119 | /** the nickname of the author */ 120 | username: string; 121 | /** the author's username */ 122 | rawname: string; 123 | /** a url pointing to the authors profile picture */ 124 | profile?: string; 125 | /** the author's id */ 126 | id: string; 127 | /** the color of an author */ 128 | color?: string; 129 | } 130 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/mod.ts: -------------------------------------------------------------------------------- 1 | export * from './bridge.ts'; 2 | export * from './cacher.ts'; 3 | export * from './commands.ts'; 4 | export * from './errors.ts'; 5 | export * from './messages.ts'; 6 | export * from './plugins.ts'; 7 | export * from './validate.ts'; 8 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/plugins.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from '@denosaurs/event'; 2 | import type { bridge_message_opts } from './bridge.ts'; 3 | import type { command, create_command } from './commands.ts'; 4 | import type { deleted_message, message } from './messages.ts'; 5 | import type { config_schema } from './validate.ts'; 6 | 7 | /** the events emitted by core/plugins */ 8 | export type events = { 9 | /** when a message is created */ 10 | create_message: [message]; 11 | /** when a message is edited */ 12 | edit_message: [message]; 13 | /** when a message is deleted */ 14 | delete_message: [deleted_message]; 15 | /** when a command is run */ 16 | create_command: [create_command]; 17 | }; 18 | 19 | /** a plugin for lightning */ 20 | export interface plugin { 21 | /** setup user-facing commands, if available */ 22 | set_commands?(commands: command[]): Promise | void; 23 | } 24 | 25 | /** a plugin for lightning */ 26 | export abstract class plugin extends EventEmitter { 27 | /** the name of your plugin */ 28 | abstract name: string; 29 | /** setup a channel to be used in a bridge */ 30 | abstract setup_channel(channel: string): Promise | unknown; 31 | /** send a message to a given channel */ 32 | abstract create_message( 33 | message: message, 34 | opts?: bridge_message_opts, 35 | ): Promise; 36 | /** edit a message in a given channel */ 37 | abstract edit_message( 38 | message: message, 39 | opts: bridge_message_opts & { edit_ids: string[] }, 40 | ): Promise; 41 | /** delete messages in a given channel */ 42 | abstract delete_messages( 43 | messages: deleted_message[], 44 | ): Promise; 45 | } 46 | 47 | /** the type core uses to load a module */ 48 | export interface plugin_module { 49 | /** the plugin constructor */ 50 | default?: { new (cfg: unknown): plugin }; 51 | /** the config to validate use */ 52 | schema?: config_schema; 53 | } 54 | -------------------------------------------------------------------------------- /packages/lightning/src/structures/validate.ts: -------------------------------------------------------------------------------- 1 | import { log_error } from './errors.ts'; 2 | 3 | /** A config schema */ 4 | export interface config_schema { 5 | name: string; 6 | keys: Record; 10 | } 11 | 12 | /** Validate an item based on a schema */ 13 | export function validate_config(config: unknown, schema: config_schema): T { 14 | if (typeof config !== 'object' || config === null) { 15 | log_error(`[${schema.name}] config is not an object`, { 16 | without_cause: true, 17 | }); 18 | } 19 | 20 | for (const [key, { type, required }] of Object.entries(schema.keys)) { 21 | const value = (config as Record)[key]; 22 | 23 | if (required && value === undefined) { 24 | log_error(`[${schema.name}] missing required config key '${key}'`, { 25 | without_cause: true, 26 | }); 27 | } else if (value !== undefined && typeof value !== type) { 28 | log_error(`[${schema.name}] config key '${key}' must be a ${type}`, { 29 | without_cause: true, 30 | }); 31 | } 32 | } 33 | 34 | return config as T; 35 | } 36 | -------------------------------------------------------------------------------- /packages/revolt/README.md: -------------------------------------------------------------------------------- 1 | # @lightning/revolt 2 | 3 | [![JSR](https://jsr.io/badges/@lightning/revolt)](https://jsr.io/@lightning/revolt) 4 | 5 | @lightning/telegram adds support for Revolt. To use it, you'll need to create a 6 | Revolt bot first. After that, you need to add the following to your config file: 7 | 8 | ```toml 9 | [[plugins]] 10 | plugin = "jsr:@lightning/revolt@0.8.0-alpha.5" 11 | config.token = "your_bot_token" 12 | config.user_id = "your_bot_user_id" 13 | ``` 14 | -------------------------------------------------------------------------------- /packages/revolt/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lightning/revolt", 3 | "version": "0.8.0-alpha.5", 4 | "license": "MIT", 5 | "exports": "./src/mod.ts", 6 | "imports": { 7 | "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", 8 | "@jersey/rvapi": "jsr:@jersey/rvapi@^0.0.12", 9 | "@jersey/revolt-api-types": "jsr:@jersey/revolt-api-types@^0.8.8", 10 | "@std/ulid": "jsr:@std/ulid@^1.0.0" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/revolt/src/cache.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Channel, 3 | Emoji, 4 | Masquerade, 5 | Member, 6 | Message, 7 | Role, 8 | Server, 9 | User, 10 | } from '@jersey/revolt-api-types'; 11 | import type { Client } from '@jersey/rvapi'; 12 | import { cacher, type message_author } from '@lightning/lightning'; 13 | 14 | const authors = new cacher<`${string}/${string}`, message_author>(); 15 | const channels = new cacher(); 16 | const emojis = new cacher(); 17 | const members = new cacher<`${string}/${string}`, Member>(); 18 | const messages = new cacher<`${string}/${string}`, Message>(); 19 | const roles = new cacher<`${string}/${string}`, Role>(); 20 | const servers = new cacher(); 21 | const users = new cacher(); 22 | 23 | export async function fetch_author( 24 | api: Client, 25 | authorID: string, 26 | channelID: string, 27 | masquerade?: Masquerade, 28 | ): Promise { 29 | try { 30 | const cached = authors.get(`${authorID}/${channelID}`); 31 | 32 | if (cached) return cached; 33 | 34 | const channel = await fetch_channel(api, channelID); 35 | const author = await fetch_user(api, authorID); 36 | 37 | const data = { 38 | id: authorID, 39 | rawname: author.username, 40 | username: masquerade?.name ?? author.username, 41 | color: masquerade?.colour ?? '#FF4654', 42 | profile: masquerade?.avatar ?? 43 | (author.avatar 44 | ? `https://cdn.revoltusercontent.com/avatars/${author.avatar._id}` 45 | : undefined), 46 | }; 47 | 48 | if (channel.channel_type !== 'TextChannel') return data; 49 | 50 | try { 51 | const member = await fetch_member(api, channel.server, authorID); 52 | 53 | return authors.set(`${authorID}/${channelID}`, { 54 | ...data, 55 | username: masquerade?.name ?? member.nickname ?? data.username, 56 | profile: masquerade?.avatar ?? 57 | (member.avatar 58 | ? `https://cdn.revoltusercontent.com/avatars/${member.avatar._id}` 59 | : data.profile), 60 | }); 61 | } catch { 62 | return authors.set(`${authorID}/${channelID}`, data); 63 | } 64 | } catch { 65 | return { 66 | id: authorID, 67 | rawname: masquerade?.name ?? 'RevoltUser', 68 | username: masquerade?.name ?? 'Revolt User', 69 | profile: masquerade?.avatar ?? undefined, 70 | color: masquerade?.colour ?? '#FF4654', 71 | }; 72 | } 73 | } 74 | 75 | export async function fetch_channel( 76 | api: Client, 77 | channelID: string, 78 | ): Promise { 79 | const cached = channels.get(channelID); 80 | 81 | if (cached) return cached; 82 | 83 | const channel = await api.request( 84 | 'get', 85 | `/channels/${channelID}`, 86 | undefined, 87 | ) as Channel; 88 | 89 | return channels.set(channelID, channel); 90 | } 91 | 92 | export async function fetch_emoji( 93 | api: Client, 94 | emoji_id: string, 95 | ): Promise { 96 | const cached = emojis.get(emoji_id); 97 | 98 | if (cached) return cached; 99 | 100 | return emojis.set( 101 | emoji_id, 102 | await api.request( 103 | 'get', 104 | `/custom/emoji/${emoji_id}`, 105 | undefined, 106 | ), 107 | ); 108 | } 109 | 110 | export async function fetch_member( 111 | client: Client, 112 | serverID: string, 113 | userID: string, 114 | ): Promise { 115 | const member = members.get(`${serverID}/${userID}`); 116 | 117 | if (member) return member; 118 | 119 | const response = await client.request( 120 | 'get', 121 | `/servers/${serverID}/members/${userID}`, 122 | { roles: false }, 123 | ) as Member; 124 | 125 | return members.set(`${serverID}/${userID}`, response); 126 | } 127 | 128 | export async function fetch_message( 129 | client: Client, 130 | channelID: string, 131 | messageID: string, 132 | ): Promise { 133 | const message = messages.get(`${channelID}/${messageID}`); 134 | 135 | if (message) return message; 136 | 137 | const response = await client.request( 138 | 'get', 139 | `/channels/${channelID}/messages/${messageID}`, 140 | undefined, 141 | ) as Message; 142 | 143 | return messages.set(`${channelID}/${messageID}`, response); 144 | } 145 | 146 | export async function fetch_role( 147 | client: Client, 148 | serverID: string, 149 | roleID: string, 150 | ): Promise { 151 | const role = roles.get(`${serverID}/${roleID}`); 152 | 153 | if (role) return role; 154 | 155 | const response = await client.request( 156 | 'get', 157 | `/servers/${serverID}/roles/${roleID}`, 158 | undefined, 159 | ) as Role; 160 | 161 | return roles.set(`${serverID}/${roleID}`, response); 162 | } 163 | 164 | export async function fetch_server( 165 | client: Client, 166 | serverID: string, 167 | ): Promise { 168 | const server = servers.get(serverID); 169 | 170 | if (server) return server; 171 | 172 | const response = await client.request( 173 | 'get', 174 | `/servers/${serverID}`, 175 | undefined, 176 | ) as Server; 177 | 178 | return servers.set(serverID, response); 179 | } 180 | 181 | export async function fetch_user( 182 | api: Client, 183 | userID: string, 184 | ): Promise { 185 | const cached = users.get(userID); 186 | 187 | if (cached) return cached; 188 | 189 | const user = await api.request( 190 | 'get', 191 | `/users/${userID}`, 192 | undefined, 193 | ) as User; 194 | 195 | return users.set(userID, user); 196 | } 197 | -------------------------------------------------------------------------------- /packages/revolt/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { RequestError } from '@jersey/revolt-api-types'; 2 | import { MediaError } from '@jersey/rvapi'; 3 | import { log_error } from '@lightning/lightning'; 4 | 5 | const errors = [ 6 | [403, 'Insufficient permissions. Please check them', false, true], 7 | [404, 'Resource not found', false, true], 8 | [0, 'Unknown Revolt RequestError', false, false], 9 | ] as const; 10 | 11 | export function handle_error(err: unknown, edit?: boolean): never[] { 12 | if (err instanceof MediaError) { 13 | log_error(err); 14 | } else if (err instanceof RequestError) { 15 | if (err.cause.status === 404 && edit) return []; 16 | 17 | const [, message, read, write] = errors.find((e) => 18 | e[0] === err.cause.status 19 | ) ?? errors[errors.length - 1]; 20 | 21 | log_error(err, { message, disable: { read, write } }); 22 | } else { 23 | log_error(err, { message: 'unknown revolt error' }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /packages/revolt/src/incoming.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '@jersey/revolt-api-types'; 2 | import type { Client } from '@jersey/rvapi'; 3 | import type { embed, message } from '@lightning/lightning'; 4 | import { decodeTime } from '@std/ulid'; 5 | import { fetch_author, fetch_channel, fetch_emoji } from './cache.ts'; 6 | 7 | async function get_content( 8 | api: Client, 9 | channel_id: string, 10 | content?: string | null, 11 | ) { 12 | if (!content) return; 13 | 14 | for ( 15 | const match of content.matchAll(/:([0-7][0-9A-HJKMNP-TV-Z]{25}):/g) 16 | ) { 17 | try { 18 | content = content.replace( 19 | match[0], 20 | `:${(await fetch_emoji(api, match[1])).name}:`, 21 | ); 22 | } catch { 23 | content = content.replace(match[0], `:${match[1]}:`); 24 | } 25 | } 26 | 27 | for ( 28 | const match of content.matchAll(/<@([0-7][0-9A-HJKMNP-TV-Z]{25})>/g) 29 | ) { 30 | try { 31 | content = content.replace( 32 | match[0], 33 | `@${(await fetch_author(api, match[1], channel_id)).username}`, 34 | ); 35 | } catch { 36 | content = content.replace(match[0], `@${match[1]}`); 37 | } 38 | } 39 | 40 | for ( 41 | const match of content.matchAll(/<#([0-7][0-9A-HJKMNP-TV-Z]{25})>/g) 42 | ) { 43 | try { 44 | const channel = await fetch_channel(api, match[1]); 45 | content = content.replace( 46 | match[0], 47 | `#${'name' in channel ? channel.name : `DM${channel._id}`}`, 48 | ); 49 | } catch { 50 | content = content.replace(match[0], `#${match[1]}`); 51 | } 52 | } 53 | 54 | return content; 55 | } 56 | 57 | export async function get_incoming( 58 | message: Message, 59 | api: Client, 60 | ): Promise { 61 | return { 62 | attachments: message.attachments?.map((i) => { 63 | return { 64 | file: 65 | `https://cdn.revoltusercontent.com/attachments/${i._id}/${i.filename}`, 66 | name: i.filename, 67 | size: i.size / 1048576, 68 | }; 69 | }), 70 | author: await fetch_author(api, message.author, message.channel), 71 | channel_id: message.channel, 72 | content: await get_content(api, message.channel, message.content), 73 | embeds: message.embeds?.map((i) => { 74 | return { 75 | color: 'colour' in i && i.colour 76 | ? parseInt(i.colour.replace('#', ''), 16) 77 | : undefined, 78 | ...i, 79 | } as embed; 80 | }), 81 | message_id: message._id, 82 | plugin: 'bolt-revolt', 83 | timestamp: message.edited 84 | ? Temporal.Instant.from(message.edited) 85 | : Temporal.Instant.fromEpochMilliseconds(decodeTime(message._id)), 86 | reply_id: message.replies?.[0] ?? undefined, 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /packages/revolt/src/mod.ts: -------------------------------------------------------------------------------- 1 | import type { Message } from '@jersey/revolt-api-types'; 2 | import { Bonfire, type Client, createClient } from '@jersey/rvapi'; 3 | import { 4 | type bridge_message_opts, 5 | type config_schema, 6 | type deleted_message, 7 | type message, 8 | plugin, 9 | } from '@lightning/lightning'; 10 | import { fetch_message } from './cache.ts'; 11 | import { handle_error } from './errors.ts'; 12 | import { get_incoming } from './incoming.ts'; 13 | import { get_outgoing } from './outgoing.ts'; 14 | import { check_permissions } from './permissions.ts'; 15 | 16 | /** the config for the revolt bot */ 17 | export interface revolt_config { 18 | /** the token for the revolt bot */ 19 | token: string; 20 | /** the user id for the bot */ 21 | user_id: string; 22 | } 23 | 24 | /** the config schema for the revolt plugin */ 25 | export const schema: config_schema = { 26 | name: 'bolt-revolt', 27 | keys: { 28 | token: { type: 'string', required: true }, 29 | user_id: { type: 'string', required: true }, 30 | }, 31 | }; 32 | 33 | /** revolt support for lightning */ 34 | export default class revolt extends plugin { 35 | name = 'bolt-revolt'; 36 | private client: Client; 37 | private user_id: string; 38 | 39 | /** setup revolt using these options */ 40 | constructor(opts: revolt_config) { 41 | super(); 42 | this.client = createClient({ token: opts.token }); 43 | this.user_id = opts.user_id; 44 | this.setup_events(opts); 45 | } 46 | 47 | private setup_events(opts: revolt_config) { 48 | this.client.bonfire.on('Message', async (data) => { 49 | const msg = await get_incoming(data, this.client); 50 | if (msg) this.emit('create_message', msg); 51 | }).on('MessageDelete', (data) => { 52 | this.emit('delete_message', { 53 | channel_id: data.channel, 54 | message_id: data.id, 55 | plugin: 'bolt-revolt', 56 | timestamp: Temporal.Now.instant(), 57 | }); 58 | }).on('MessageUpdate', async (data) => { 59 | try { 60 | const msg = await get_incoming({ 61 | ...await fetch_message(this.client, data.channel, data.id), 62 | ...data, 63 | }, this.client); 64 | 65 | if (msg) this.emit('edit_message', msg); 66 | } catch { 67 | return; 68 | } 69 | }).on('Ready', (data) => { 70 | console.log( 71 | `[revolt] ready as ${ 72 | data.users.find((i) => i._id === this.user_id)?.username 73 | } in ${data.servers.length} servers`, 74 | `\n[revolt] invite me at https://app.revolt.chat/bot/${this.user_id}`, 75 | ); 76 | }).on('socket_close', () => { 77 | this.client.bonfire = new Bonfire({ 78 | token: opts.token, 79 | url: 'wss://ws.revolt.chat', 80 | }); 81 | this.setup_events(opts); 82 | }); 83 | } 84 | 85 | /** ensure masquerading will work in that channel */ 86 | async setup_channel(channel_id: string): Promise { 87 | return await check_permissions(channel_id, this.user_id, this.client); 88 | } 89 | 90 | /** send a message to a channel */ 91 | async create_message( 92 | message: message, 93 | data?: bridge_message_opts, 94 | ): Promise { 95 | try { 96 | return [ 97 | (await this.client.request( 98 | 'post', 99 | `/channels/${message.channel_id}/messages`, 100 | await get_outgoing(this.client, message, data !== undefined), 101 | ) as Message)._id, 102 | ]; 103 | } catch (e) { 104 | return handle_error(e); 105 | } 106 | } 107 | 108 | /** edit a message in a channel */ 109 | async edit_message( 110 | message: message, 111 | data: bridge_message_opts & { edit_ids: string[] }, 112 | ): Promise { 113 | try { 114 | return [ 115 | (await this.client.request( 116 | 'patch', 117 | `/channels/${message.channel_id}/messages/${data.edit_ids[0]}`, 118 | await get_outgoing(this.client, message, true), 119 | ) as Message)._id, 120 | ]; 121 | } catch (e) { 122 | return handle_error(e, true); 123 | } 124 | } 125 | 126 | /** delete messages in a channel */ 127 | async delete_messages(messages: deleted_message[]): Promise { 128 | return await Promise.all( 129 | messages.map(async (msg) => { 130 | try { 131 | await this.client.request( 132 | 'delete', 133 | `/channels/${msg.channel_id}/messages/${msg.message_id}`, 134 | undefined, 135 | ); 136 | return msg.message_id; 137 | } catch (e) { 138 | handle_error(e, true); 139 | return msg.message_id; 140 | } 141 | }), 142 | ); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /packages/revolt/src/outgoing.ts: -------------------------------------------------------------------------------- 1 | import type { DataMessageSend, SendableEmbed } from '@jersey/revolt-api-types'; 2 | import type { Client } from '@jersey/rvapi'; 3 | import { LightningError, type message } from '@lightning/lightning'; 4 | 5 | export async function get_outgoing( 6 | api: Client, 7 | message: message, 8 | masquerade = true, 9 | ): Promise { 10 | const attachments = (await Promise.all( 11 | message.attachments?.map(async (attachment) => { 12 | try { 13 | const file = await (await fetch(attachment.file)).blob(); 14 | if (file.size < 1) return; 15 | return await api.media.upload_file('attachments', file); 16 | } catch (e) { 17 | new LightningError(e, { 18 | message: 'Failed to upload attachment', 19 | extra: { original: e }, 20 | }); 21 | 22 | return; 23 | } 24 | }) ?? [], 25 | )).filter((i) => i !== undefined); 26 | 27 | if ( 28 | (!message.content || message.content.length < 1) && 29 | (!message.embeds || message.embeds.length < 1) && 30 | (!attachments || attachments.length < 1) 31 | ) { 32 | message.content = '*empty message*'; 33 | } 34 | 35 | return { 36 | attachments, 37 | content: (message.content?.length ?? 0) > 2000 38 | ? `${message.content?.substring(0, 1997)}...` 39 | : message.content, 40 | embeds: message.embeds?.map((embed) => { 41 | const data: SendableEmbed = { 42 | icon_url: embed.author?.icon_url, 43 | url: embed.url, 44 | title: embed.title, 45 | description: embed.description ?? '', 46 | media: embed.image?.url, 47 | colour: embed.color 48 | ? `#${embed.color.toString(16).padStart(6, '0')}` 49 | : undefined, 50 | }; 51 | 52 | for (const field of embed.fields ?? []) { 53 | data.description += `\n\n**${field.name}**\n${field.value}`; 54 | } 55 | 56 | if (data.description?.length === 0) data.description = undefined; 57 | 58 | return data; 59 | }), 60 | replies: message.reply_id 61 | ? [{ id: message.reply_id, mention: true }] 62 | : undefined, 63 | masquerade: masquerade 64 | ? { 65 | name: message.author.username, 66 | avatar: message.author.profile, 67 | colour: message.author.color, 68 | } 69 | : undefined, 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /packages/revolt/src/permissions.ts: -------------------------------------------------------------------------------- 1 | import type { Client } from '@jersey/rvapi'; 2 | import { log_error } from '@lightning/lightning'; 3 | import { 4 | fetch_channel, 5 | fetch_member, 6 | fetch_role, 7 | fetch_server, 8 | } from './cache.ts'; 9 | import { handle_error } from './errors.ts'; 10 | 11 | const needed_permissions = 485495808; 12 | const error_message = 13 | 'missing ChangeNickname, ChangeAvatar, ReadMessageHistory, \ 14 | SendMessage, ManageMessages, SendEmbeds, UploadFiles, and/or Masquerade permissions \ 15 | please add them to a role, assign that role to the bot, and rejoin the bridge'; 16 | 17 | export async function check_permissions( 18 | channel_id: string, 19 | bot_id: string, 20 | client: Client, 21 | ) { 22 | try { 23 | const channel = await fetch_channel(client, channel_id); 24 | 25 | if (channel.channel_type === 'Group') { 26 | if ( 27 | !(channel.permissions && (channel.permissions & needed_permissions)) 28 | ) log_error(error_message); 29 | } else if (channel.channel_type === 'TextChannel') { 30 | const server = await fetch_server(client, channel.server); 31 | const member = await fetch_member(client, channel.server, bot_id); 32 | 33 | // check server permissions 34 | let currentPermissions = server.default_permissions; 35 | 36 | for (const role of (member.roles ?? [])) { 37 | const { permissions: role_permissions } = await fetch_role( 38 | client, 39 | server._id, 40 | role, 41 | ); 42 | 43 | currentPermissions |= role_permissions.a || 0; 44 | currentPermissions &= ~role_permissions.d || 0; 45 | } 46 | 47 | // apply default allow/denies 48 | if (channel.default_permissions) { 49 | currentPermissions |= channel.default_permissions.a; 50 | currentPermissions &= ~channel.default_permissions.d; 51 | } 52 | 53 | // apply role permissions 54 | if (channel.role_permissions) { 55 | for (const role of (member.roles ?? [])) { 56 | currentPermissions |= channel.role_permissions[role]?.a || 0; 57 | currentPermissions &= ~channel.role_permissions[role]?.d || 0; 58 | } 59 | } 60 | 61 | if (!(currentPermissions & needed_permissions)) log_error(error_message); 62 | } else { 63 | log_error(`unsupported channel type: ${channel.channel_type}`); 64 | } 65 | 66 | return channel_id; 67 | } catch (e) { 68 | handle_error(e); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /packages/telegram/README.md: -------------------------------------------------------------------------------- 1 | # @lightning/telegram 2 | 3 | [![JSR](https://jsr.io/badges/@lightning/telegram)](https://jsr.io/@lightning/telegram) 4 | 5 | @lightning/telegram adds support for Telegram. Before using it, you'll need to 6 | talk with @BotFather to create a bot. After that, you need to add the following 7 | to your config: 8 | 9 | ```toml 10 | [[plugins]] 11 | plugin = "jsr:@lightning/telegram@0.8.0-alpha.5" 12 | config.token = "your_bot_token" 13 | config.proxy_port = 9090 14 | config.proxy_url = "https://example.com:9090" 15 | ``` 16 | 17 | Additionally, you will need to expose the port provided at the URL provided for 18 | attachments sent from Telegram to work properly 19 | -------------------------------------------------------------------------------- /packages/telegram/deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@lightning/telegram", 3 | "version": "0.8.0-alpha.5", 4 | "license": "MIT", 5 | "exports": "./src/mod.ts", 6 | "imports": { 7 | "@lightning/lightning": "jsr:@lightning/lightning@0.8.0-alpha.5", 8 | "grammy": "npm:grammy@^1.36.3", 9 | "telegramify-markdown": "npm:telegramify-markdown@^1.3.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/telegram/src/incoming.ts: -------------------------------------------------------------------------------- 1 | import type { command, create_command, message } from '@lightning/lightning'; 2 | import type { CommandContext, Context } from 'grammy'; 3 | import { get_outgoing } from './outgoing.ts'; 4 | 5 | const types = [ 6 | 'text', 7 | 'dice', 8 | 'location', 9 | 'document', 10 | 'animation', 11 | 'audio', 12 | 'photo', 13 | 'sticker', 14 | 'video', 15 | 'video_note', 16 | 'voice', 17 | ] as const; 18 | 19 | export async function get_incoming( 20 | ctx: Context, 21 | proxy: string, 22 | ): Promise { 23 | const msg = ctx.editedMessage ?? ctx.msg; 24 | if (!msg) return; 25 | const author = await ctx.getAuthor(); 26 | const profile = await ctx.getUserProfilePhotos({ limit: 1 }); 27 | const type = types.find((type) => type in msg) ?? 'unsupported'; 28 | const base: message = { 29 | author: { 30 | username: author.user.last_name 31 | ? `${author.user.first_name} ${author.user.last_name}` 32 | : author.user.first_name, 33 | rawname: author.user.username ?? author.user.first_name, 34 | color: '#24A1DE', 35 | profile: profile.total_count 36 | ? `${proxy}/${ 37 | (await ctx.api.getFile(profile.photos[0][0].file_id)).file_path 38 | }` 39 | : undefined, 40 | id: author.user.id.toString(), 41 | }, 42 | channel_id: msg.chat.id.toString(), 43 | message_id: msg.message_id.toString(), 44 | timestamp: Temporal.Instant.fromEpochMilliseconds( 45 | (msg.edit_date ?? msg.date) * 1000, 46 | ), 47 | plugin: 'bolt-telegram', 48 | reply_id: msg.reply_to_message 49 | ? msg.reply_to_message.message_id.toString() 50 | : undefined, 51 | }; 52 | 53 | if (type === 'unsupported') return; 54 | if (type === 'text') return { ...base, content: msg.text }; 55 | if (type === 'dice') { 56 | return { 57 | ...base, 58 | content: `${msg.dice!.emoji} ${msg.dice!.value}`, 59 | }; 60 | } 61 | if (type === 'location') { 62 | return { 63 | ...base, 64 | content: `https://www.openstreetmap.com/#map=18/${ 65 | msg.location!.latitude 66 | }/${msg.location!.longitude}`, 67 | }; 68 | } 69 | 70 | const file = await ctx.api.getFile( 71 | (type === 'photo' ? msg.photo!.slice(-1)[0] : msg[type]!).file_id, 72 | ); 73 | 74 | if (!file.file_path) return; 75 | 76 | return { 77 | ...base, 78 | attachments: [{ 79 | file: `${proxy}/${file.file_path}`, 80 | name: file.file_path, 81 | size: (file.file_size ?? 0) / 1048576, 82 | }], 83 | }; 84 | } 85 | 86 | export function get_command( 87 | ctx: CommandContext, 88 | cmd: command, 89 | ): create_command { 90 | return { 91 | channel_id: ctx.chat.id.toString(), 92 | command: cmd.name, 93 | message_id: ctx.msgId.toString(), 94 | timestamp: Temporal.Instant.fromEpochMilliseconds(ctx.msg.date * 1000), 95 | plugin: 'bolt-telegram', 96 | prefix: '/', 97 | args: {}, 98 | rest: cmd.subcommands 99 | ? ctx.match.split(' ').slice(1) 100 | : ctx.match.split(' '), 101 | subcommand: cmd.subcommands ? ctx.match.split(' ')[0] : undefined, 102 | reply: async (message: message) => { 103 | for (const msg of await get_outgoing(message, false)) { 104 | await ctx.api[msg.function]( 105 | ctx.chat.id, 106 | msg.value, 107 | { 108 | reply_parameters: { message_id: ctx.msgId }, 109 | parse_mode: 'MarkdownV2', 110 | }, 111 | ); 112 | } 113 | }, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /packages/telegram/src/mod.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type bridge_message_opts, 3 | type command, 4 | type config_schema, 5 | type deleted_message, 6 | type message, 7 | plugin, 8 | } from '@lightning/lightning'; 9 | import { Bot, type Composer, type Context } from 'grammy'; 10 | import { get_command, get_incoming } from './incoming.ts'; 11 | import { get_outgoing } from './outgoing.ts'; 12 | 13 | /** options for telegram */ 14 | export type telegram_config = { 15 | /** the token for the bot */ 16 | token: string; 17 | /** the port the file proxy will run on */ 18 | proxy_port: number; 19 | /** the publicly accessible url of the file proxy */ 20 | proxy_url: string; 21 | }; 22 | 23 | /** the config schema for the plugin */ 24 | export const schema: config_schema = { 25 | name: 'bolt-telegram', 26 | keys: { 27 | token: { type: 'string', required: true }, 28 | proxy_port: { type: 'number', required: true }, 29 | proxy_url: { type: 'string', required: true }, 30 | }, 31 | }; 32 | 33 | /** telegram support for lightning */ 34 | export default class telegram extends plugin { 35 | name = 'bolt-telegram'; 36 | private bot: Bot; 37 | private composer: Composer; 38 | 39 | /** setup telegram and its file proxy */ 40 | constructor(opts: telegram_config) { 41 | super(); 42 | this.bot = new Bot(opts.token); 43 | this.composer = this.bot.on('message') as Composer; 44 | this.bot.start(); 45 | 46 | this.bot.on(['message', 'edited_message'], async (ctx) => { 47 | const msg = await get_incoming(ctx, opts.proxy_url); 48 | if (msg) this.emit('create_message', msg); 49 | }); 50 | 51 | const handler = async ({ url }: { url: string }) => 52 | await fetch( 53 | `https://api.telegram.org/file/bot${opts.token}/${ 54 | url.replace('/telegram', '/') 55 | }`, 56 | ); 57 | 58 | if ('Deno' in globalThis) { 59 | Deno.serve({ port: opts.proxy_port }, handler); 60 | } else if ('Bun' in globalThis) { 61 | // @ts-ignore: Bun.serve is not typed 62 | Bun.serve({ 63 | fetch: handler, 64 | port: opts.proxy_port, 65 | }); 66 | } else if ('process' in globalThis) { 67 | // deno-lint-ignore no-process-global 68 | process.getBuiltinModule('node:http').createServer(async (req, res) => { 69 | const resp = await handler(req as { url: string }); 70 | res.writeHead(resp.status, Array.from(resp.headers.entries())); 71 | res.write(new Uint8Array(await resp.arrayBuffer())); 72 | res.end(); 73 | }); 74 | } else { 75 | throw new Error('Unsupported environment for file proxy!'); 76 | } 77 | 78 | console.log( 79 | `[telegram] proxy available at localhost:${opts.proxy_port} or ${opts.proxy_url}`, 80 | ); 81 | } 82 | 83 | /** handle commands */ 84 | override async set_commands(commands: command[]): Promise { 85 | await this.bot.api.setMyCommands(commands.map((cmd) => ({ 86 | command: cmd.name, 87 | description: cmd.description, 88 | }))); 89 | 90 | for (const cmd of commands) { 91 | const name = cmd.name === 'help' ? ['help', 'start'] : cmd.name; 92 | this.composer.command(name, (ctx) => { 93 | this.emit('create_command', get_command(ctx, cmd)); 94 | }); 95 | } 96 | } 97 | 98 | /** stub for setup_channel */ 99 | setup_channel(channel: string): unknown { 100 | return channel; 101 | } 102 | 103 | /** send a message in a channel */ 104 | async create_message( 105 | message: message, 106 | data: bridge_message_opts, 107 | ): Promise { 108 | const messages = []; 109 | 110 | for (const msg of get_outgoing(message, data !== undefined)) { 111 | const result = await this.bot.api[msg.function]( 112 | message.channel_id, 113 | msg.value, 114 | { 115 | reply_parameters: message.reply_id 116 | ? { 117 | message_id: Number(message.reply_id), 118 | } 119 | : undefined, 120 | parse_mode: 'MarkdownV2', 121 | }, 122 | ); 123 | 124 | messages.push(String(result.message_id)); 125 | } 126 | 127 | return messages; 128 | } 129 | 130 | /** edit a message in a channel */ 131 | async edit_message( 132 | message: message, 133 | opts: bridge_message_opts & { edit_ids: string[] }, 134 | ): Promise { 135 | await this.bot.api.editMessageText( 136 | opts.channel.id, 137 | Number(opts.edit_ids[0]), 138 | get_outgoing(message, true)[0].value, 139 | { 140 | parse_mode: 'MarkdownV2', 141 | }, 142 | ); 143 | 144 | return opts.edit_ids; 145 | } 146 | 147 | /** delete messages in a channel */ 148 | async delete_messages(messages: deleted_message[]): Promise { 149 | return await Promise.all( 150 | messages.map(async (msg) => { 151 | await this.bot.api.deleteMessage( 152 | msg.channel_id, 153 | Number(msg.message_id), 154 | ); 155 | return msg.message_id; 156 | }), 157 | ); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /packages/telegram/src/outgoing.ts: -------------------------------------------------------------------------------- 1 | import type { message } from '@lightning/lightning'; 2 | import convert_markdown from 'telegramify-markdown'; 3 | 4 | export function get_outgoing( 5 | msg: message, 6 | bridged: boolean, 7 | ): { function: 'sendMessage' | 'sendDocument'; value: string }[] { 8 | let content = bridged 9 | ? `${msg.author.username} » ${msg.content || '_no content_'}` 10 | : msg.content ?? '_no content_'; 11 | 12 | if ((msg.embeds?.length ?? 0) > 0) { 13 | content += '\n_this message has embeds_'; 14 | } 15 | 16 | const messages: { 17 | function: 'sendMessage' | 'sendDocument'; 18 | value: string; 19 | }[] = [{ 20 | function: 'sendMessage', 21 | value: convert_markdown(content, 'escape'), 22 | }]; 23 | 24 | for (const attachment of (msg.attachments ?? [])) { 25 | messages.push({ 26 | function: 'sendDocument', 27 | value: attachment.file, 28 | }); 29 | } 30 | 31 | return messages; 32 | } 33 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | ![lightning logo](./logo.svg) 2 | 3 | # lightning - a chatbot 4 | 5 | > [!NOTE] 6 | > This branch contains the next version of lightning, currently `0.8.0-alpha.5`, 7 | > and reflects active development. To see the latest stable version, go to the 8 | > `main` branch. 9 | 10 | - **Connecting Communities**: bridges many popular messaging apps 11 | - **Extensible**: support for messaging apps provided by plugins which can be 12 | enabled/disabled by the user 13 | - **Easy to run**: able to run in Docker with multiple database options 14 | - **Based on TypeScript**: uses the flexibility of JavaScript along with the 15 | safety provided by typing and Deno 16 | 17 | ## documentation 18 | 19 | - [_User Guide_](https://williamhorning.eu.org/lightning/users) 20 | - [_Hosting Docs_](https://williamhorning.eu.org/lightning/hosting) 21 | - [_Development Docs_](https://williamhorning.eu.org/lightning/developer) 22 | 23 | ## the problem - and solution 24 | 25 | If you've ever had a community, chances are you talk to them in many different 26 | places, whether that be on Discord, Revolt, Telegram, or Guilded. Over time, 27 | however, you end up with fragmentation as your community starts to grow and 28 | change. Many people end up using multiple messaging apps only for various 29 | versions of your community, people get upset about the differences between the 30 | messaging apps in your community, and it becomes a mess. 31 | 32 | Now, you could just say "_X is the only chat app we're using from now on_", but 33 | that risks alienating your community. 34 | 35 | What other options are there? Bridging! Everyone gets to use their preferred app 36 | of choice, gets the same messages, and is on the same page. 37 | 38 | ## prior art 39 | 40 | Many bridges have existed before the existence of lightning, however, many of 41 | these solutions have had issues. Some bridges didn't play well with others, 42 | others didn't handle attachments, others refused to handle embedded media, and 43 | it was a mess. With lightning, part of the goal was to solve these issues by 44 | bringing many platforms into one tool, having it become the handler of truth. 45 | 46 | ## supported platforms 47 | 48 | Currently, the following platforms are supported: Discord, Guilded, Revolt, and 49 | Telegram. Support for more platforms is possible to do, however, support for 50 | these platforms should be up to par with support for other platforms and 51 | messages should be presented as similarly to other messages as possible, subject 52 | to platform limitations. 53 | 54 | ### matrix notes 55 | 56 | The Matrix Specification is really difficult to correctly handle, especially 57 | with the current state of JavaScript libraries. Solutions that work without a 58 | reliance on `matrix-appservice-bridge` but still use JavaScript and are 59 | _consistently reliable_ aren't easy to implement, and currently I don't have 60 | time to work on implementing this. If you would like to implement Matrix 61 | support, please take a look at #66 for a prior attempt of mine. 62 | 63 | ### requesting another platform 64 | 65 | If you would like support for another platform, please open an issue! I'd love 66 | to add support for more platforms, though there are a few requirements they 67 | should fulfil: 68 | 69 | 1. having a pre-existing substantial user base 70 | 2. having JavaScript libraries with decent code quality 71 | 3. having rich-messaging support of some kind 72 | 73 | ## licensing 74 | 75 | lightning is available under the MIT license 76 | --------------------------------------------------------------------------------