diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..31ae967 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: 2024 Gnuxie +// +// SPDX-License-Identifier: CC0-1.0 + +module.exports = { + parser: '@typescript-eslint/parser', // Specifies the ESLint parser + plugins: ['@typescript-eslint', 'prettier'], + extends: [ + 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from the @typescript-eslint/eslint-plugin + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'prettier', + 'plugin:prettier/recommended', + ], + parserOptions: { + ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features + sourceType: 'module', // Allows for the use of imports + project: ['tsconfig.json'] + }, + rules: { + // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs + // e.g. "@typescript-eslint/explicit-function-return-type": "off", + 'prettier/prettier': 'error', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + // we implement a lot of interfaces that return promises with synchronous functions. + "require-await": "off", + "@typescript-eslint/require-await": "off", + // we need never because our code can be wrong! + "@typescript-eslint/restrict-template-expressions": ['error', { allowNever: true }], + }, +}; diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 3a3cce5..67654c2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,11 +1,24 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# SPDX-FileCopyrightText: 2024 Gnuxie +# +# SPDX-License-Identifier: CC0-1.0 version: 2 updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: 'npm' + directory: '/' schedule: - interval: "weekly" + interval: 'monthly' + groups: + development-dependencies: + dependency-type: 'development' + production-dependencies: + dependency-type: 'production' + + - package-ecosystem: 'github-actions' + directory: '/' + schedule: + interval: 'monthly' + groups: + github-actions: + patterns: + - '*' diff --git a/.gitignore b/.gitignore index 98ce04c..599903f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ db # ??? notepad.txt bot.json + +# build files +dist/ diff --git a/.reuse/dep5 b/.reuse/dep5 new file mode 100644 index 0000000..5202b83 --- /dev/null +++ b/.reuse/dep5 @@ -0,0 +1,29 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: matrix-protection-suite +Upstream-Contact: Gnuxie +Source: https://github.com/Gnuxie/matrix-protection-suite + +# Sample paragraph, commented out: +# +# Files: src/* +# Copyright: $YEAR $NAME <$CONTACT> +# License: ... + + +# These are files that can't be copyrighted but we need to add information to anyhow +Files: .husky/pre-commit +Copyright: Gnuxie +License: CC0-1.0 + +Files: tsconfig.json +Copyright: Gnuxie +License: CC0-1.0 + +Files: yarn.lock +Copyright: Gnuxie +License: CC0-1.0 + +Files: package.json +Copyright: Gnuxie +License: Apache-2.0 + diff --git a/index.js b/index.js deleted file mode 100644 index 96913bf..0000000 --- a/index.js +++ /dev/null @@ -1,277 +0,0 @@ -//Import dependencies -import { - AutojoinRoomsMixin, - MatrixClient, - SimpleFsStorageProvider, -} from "matrix-bot-sdk"; -import { readFileSync } from "node:fs"; -import { parse } from "yaml"; - -//Import modules -import { blacklist } from "./modules/blacklist.js"; -import { redaction } from "./modules/redaction.js"; -import { database } from "./modules/db.js"; -import { message } from "./modules/message.js"; -import { Reaction } from "./modules/reaction.js"; -import { BanlistReader } from "./modules/banlistReader.js"; - -//Parse YAML configuration file -const loginFile = readFileSync("./db/login.yaml", "utf8"); -const loginParsed = parse(loginFile); -const homeserver = loginParsed["homeserver-url"]; -const accessToken = loginParsed["login-token"]; -const logRoom = loginParsed["log-room"]; -const commandRoom = loginParsed["command-room"]; -const authorizedUsers = loginParsed["authorized-users"]; -const name = loginParsed.name; - -//the bot sync something idk bro it was here in the example so i dont touch it ;-; -const storage = new SimpleFsStorageProvider("bot.json"); - -//login to client -const client = new MatrixClient(homeserver, accessToken, storage); - -//currently testing without accepting invites -//AutojoinRoomsMixin.setupOnClient(client); - -//map to put the handlers for each event type in (i guess this is fine here) -const eventhandlers = new Map(); - -const config = new database(); // Database for the config -const nogoList = new blacklist(); // Blacklist object -eventhandlers.set( - "m.room.message", - new message(logRoom, commandRoom, config, authorizedUsers), -); // Event handler for m.room.message -eventhandlers.set("m.policy.rule.user", new BanlistReader(client)); -eventhandlers.set("m.reaction", new Reaction(logRoom)); -eventhandlers.set("m.room.redaction", new redaction(eventhandlers)); // Event handler for m.room.redaction - -//preallocate variables so they have a global scope -let mxid; -let server; - -const reactionQueue = new Map(); - -const filter = { - //dont expect any presence from m.org, but in the case presence shows up its irrelevant to this bot - presence: { senders: [] }, - room: { - //ephemeral events are never used in this bot, are mostly inconsequentail and irrelevant - ephemeral: { senders: [] }, - //we fetch state manually later, hopefully with better load balancing - state: { - senders: [], - not_types: [ - "im.vector.modular.widgets", - "im.ponies.room_emotes", - "m.room.pinned_events", - ], - lazy_load_members: true, - }, - //we will manually fetch events anyways, this is just limiting how much backfill bot gets as to not - //respond to events far out of view - timeline: { - limit: 20, - }, - }, -}; - -//Start Client -client.start(filter).then(async (filter) => { - console.log("Client started!"); - - //to remotely monitor how often the bot restarts, to spot issues - client.sendText(logRoom, "Started.").catch((e) => { - console.log("Error sending basic test message to log room."); - process.exit(1); - }); - - //get mxid - mxid = await client.getUserId(); - server = mxid.split(":")[1]; - - //fetch rooms the bot is in - const rooms = await client.getJoinedRooms(); - - //fetch avatar url so we dont overrite it - let avatar_url; - - try { - const userProfile = await client.getUserProfile(mxid); - avatar_url = userProfile.avatar_url; - } catch (error) { - console.error("Error fetching user profile:", error); - // Handle the error as needed (e.g., provide a default avatar URL) - avatar_url = ""; - } - - //slowly loop through rooms to avoid ratelimit - const i = setInterval(async () => { - //if theres no rooms left to work through, stop the loop - if (rooms.length < 1) { - clearInterval(i); - - return; - } - - //work through the next room on the list - const currentRoom = rooms.pop(); - - //debug - console.log(`Setting displayname for room ${currentRoom}`); - - //fetch the room list of members with their profile data - const mwp = await client - .getJoinedRoomMembersWithProfiles(currentRoom) - .catch(() => { - console.log(`error ${currentRoom}`); - }); - - //variable to store the current display name of the bot - let cdn = ""; - - //if was able to fetch member profiles (sometimes fails for certain rooms) then fetch the current display name - if (mwp) cdn = mwp[mxid].display_name; - - //fetch prefix for that room - let prefix = config.getConfig(currentRoom, "prefix"); - - //default prefix if none set - if (!prefix) prefix = "+"; - - //establish desired display name based on the prefix - const ddn = `${prefix} | ${name}`; - - //if the current display name isnt the desired one - if (cdn !== ddn) { - //send member state with the new displayname - client - .sendStateEvent(currentRoom, "m.room.member", mxid, { - avatar_url: avatar_url, - displayname: ddn, - membership: "join", - }) - .then(console.log(`done ${currentRoom}`)) - .catch((e) => console.error); - } - - // 3 second delay to avoid ratelimit - }, 3000); - - // displayname = (await client.getUserProfile(mxid))["displayname"] -}); - -//when the client recieves an event -client.on("room.event", async (roomId, event) => { - //ignore events sent by self, unless its a banlist policy update - if (event.sender === mxid && !(event.type === "m.policy.rule.user")) { - return; - } - - //check banlists - bancheck(roomId, event); - - //fetch the handler for that event type - const handler = eventhandlers.get(event.type); - - //if there is no handler for that event, exit. - if (!handler) return; - - //fetch the room list of members with their profile data - const mwp = await client - .getJoinedRoomMembersWithProfiles(roomId) - .catch(() => { - console.log(`error ${roomId}`); - }); - - //variable to store the current display name of the bot - let cdn = ""; - - //if was able to fetch member profiles (sometimes fails for certain rooms) then fetch the current display name - if (mwp) cdn = mwp[mxid].display_name; - else { - //fetch prefix for that room - let prefix = config.getConfig(roomId, "prefix"); - - //default prefix if none set - if (!prefix) prefix = "+"; - - //establish desired display name based on the prefix - cdn = `${prefix} | ${name}`; - } - - handler.run({ - client: client, - roomId: roomId, - event: event, - mxid: mxid, - server: server, - displayname: cdn, - blacklist: nogoList, - reactionQueue: reactionQueue, - banListReader: eventhandlers.get("m.policy.rule.user"), - config: config, - }); -}); - -client.on("room.leave", (roomId) => { - nogoList.add(roomId, "kicked"); -}); - -async function bancheck(roomId, event) { - //if the bot cant ban users in the room, theres no reason to waste resources and check if it should ban the user - if (!(await client.userHasPowerLevelForAction(mxid, roomId, "ban"))) { - return; - } - - //fetch banlists for room - let roomBanlists = config.getConfig(roomId, "banlists"); - - //if there is no config, create a temporary one with just the room id - if (!roomBanlists) { - roomBanlists = [roomId]; - } - - //if there is a config, set the room up to check its own banlist - else { - roomBanlists.push(roomId); - } - - //variable to store reason - let reason; - - //look through all banlists - for (let i = 0; i < roomBanlists.length; i++) { - const rm = roomBanlists[i]; - - //find recommendation - const recomend = await eventhandlers - .get("m.policy.rule.user") - .match(rm, event.sender)[0]; - - //if that room doesn't recommend a ban, go ahead and exit out - if (!recomend) { - continue; - } - - //fetch the set alias of the room - let mainRoomAlias = await client.getPublishedAlias(rm); - - //if there is no alias of the room - if (!mainRoomAlias) { - //dig through the state, find room name, and use that in place of the main room alias - mainRoomAlias = (await client.getRoomState(roomId)).find( - (state) => state.type === "m.room.name", - ).content.name; - } - - //format together a reason - reason = `${recomend.content.reason} (${mainRoomAlias})`; - } - - //if there is a reason to be had, then we can ban - if (reason) { - client.banUser(event.sender, roomId, reason).catch(() => {}); - } -} diff --git a/modules/banlistReader.js b/modules/banlistReader.js deleted file mode 100644 index 3f22720..0000000 --- a/modules/banlistReader.js +++ /dev/null @@ -1,196 +0,0 @@ -//see if the State Event `se` is intended to ban the mxid `matchMXID` -function matchBanlistEventToUser(se, mm) { - let matchMXID = mm; - - //if the ban was erased - if (!se.content) { - return false; - } - - //parce out the mxid from the key - const potentialMXID = se.state_key.substring(5); - - //if mismatch, its invalid - //mothet sidev - if ( - se.content.entity !== potentialMXID || - se.content.recommendation !== "org.matrix.mjolnir.ban" - ) { - return false; - } - - //if exact match, it is match - if (potentialMXID === matchMXID) { - return true; - } - - //if theres no wildcards, and not an exact match, its not a match - if (!potentialMXID.includes("*")) { - return false; - } - - //split around the wildcards - const p = potentialMXID.split("*"); - - //check before first wildcard - const firstpart = p.shift(); - if (!matchMXID.startsWith(firstpart)) { - return false; - } - - //check after last wildcard - const lastpart = p.pop(); - if (!matchMXID.endsWith(lastpart)) { - return false; - } - - //parce off the evaluated start and end - matchMXID = matchMXID.substring( - firstpart.length, - matchMXID.length - lastpart.length, - ); - - //loop until a condition to return arises - while (true) { - //if there is no more parts to match and everything else around wildcards matched - //it is match - if (p.length < 1) { - return true; - } - - //if one of the bits between wildcards doesnt exist, it cant be a match - if (!matchMXID.includes(p[0])) { - return false; - } - - //match the remaining parts in order starting with the first one left to match, then removing everything - //before it, and going back through the process untill theres nothing left to match - //have to use a substring of the original in case there was multiple matches of the next item to match - const nexttomatch = p.shift(); - const r = matchMXID.split(nexttomatch); - matchMXID = matchMXID.substring(nexttomatch.length + r[0]); - } -} - -class BanlistReader { - constructor(client) { - this.client = client; - - this.rooms = new Map(); - } - - //synchronize all the state events of the provided room - async syncRoom(roomId) { - let list = (await this.client.getRoomState(roomId)).filter( - (event) => event.type === "m.policy.rule.user", - ); - - //allow .find to be run - if (!list) list = []; - - //organize away that list for later - this.rooms.set(roomId, list); - } - - //event run loop - async run({ roomId, event, config }) { - //fetch room's list of banlist events - const roomEvents = this.rooms.get(roomId); - - //see comment below - await this.syncRoom(roomId); - - /* - if the run pipeline was called without an event for whatever reason dont proceed - - the run method will only be directly triggered on a banlist recomendation event - - the run method may be triggered on redaction events without an event fed in *if* - the event it is redacting is a banlist recomendation - */ - if (!event) { - return; - } - - //confirm that the bot updated its list with the new event - this.client.sendReadReceipt(roomId, event.event_id); - - //fetch the set alias of the room - let mainRoomAlias = await this.client.getPublishedAlias(roomId); - - //if there is no alias of the room - if (!mainRoomAlias) { - //dig through the state, find room name, and use that in place of the main room alias - mainRoomAlias = (await this.client.getRoomState(roomId)).find( - (state) => state.type === "m.room.name", - ).content.name; - } - - //all rooms the bot is in - const joinedrooms = await this.client.getJoinedRooms(); - - //look through all these rooms for any that may be following the banlist this was - //written to - // biome-ignore lint/complexity/noForEach: - joinedrooms.forEach(async (r) => { - //fetch banlists for room - let roomBanlists = config.getConfig(r, "banlists"); - - //if there is no config, create a temporary one with just the room id - if (!roomBanlists) { - roomBanlists = [r]; - } - - //if there is a config, set the room up to check its own banlist - else { - roomBanlists.push(r); - } - - //if the room isn't following the banlist that the recomendation was written to, - //we shouldn't continue - if (!roomBanlists.some((rm) => rm === roomId)) { - return; - } - - //find any joined users matching the ban rule - const matched = (await this.client.getJoinedRoomMembers(r)).filter((m) => - matchBanlistEventToUser(event, m), - ); - - //ban found users - // biome-ignore lint/complexity/noForEach: - matched.forEach(async (mm) => - this.client - .banUser(mm, r, `${event.content.reason} (${mainRoomAlias})`) - .catch(() => {}), - ); - }); - } - - // get all "org.matrix.mjolnir.ban" type state events for a room - async getRules(roomId) { - //fetch room's list of banlist events - //rooms is a map of just the "org.matrix.mjolnir.ban" events - let roomEvents = this.rooms.get(roomId); - - //if the room was never synced, sync it - if (!Array.isArray(roomEvents)) { - await this.syncRoom(roomId); - roomEvents = this.rooms.get(roomId); - } - - return roomEvents; - } - - //find if there is a rule recommending the ban of a provided mxid - async match(roomId, matchMXID) { - //look through all the state events - const match = (await this.getRules(roomId)).find((se) => - matchBanlistEventToUser(se, matchMXID), - ); - - return match; - } -} - -export { BanlistReader }; diff --git a/modules/blacklist.js b/modules/blacklist.js deleted file mode 100644 index e8ab0fc..0000000 --- a/modules/blacklist.js +++ /dev/null @@ -1,94 +0,0 @@ -import { openSync, readdirSync, readFileSync, writeFile } from "fs"; - -class blacklist { - constructor() { - this.filepath = "./db/blacklist.txt"; - - //make sure file exists - if (!readdirSync("./db/").some((d) => d === "blacklist.txt")) { - closeSync(openSync(this.filepath, "w")); - } - - //read file - this.blacklistTXT = readFileSync(this.filepath, "utf-8"); - - //split into entry lines - this.blacklistARRAY = this.blacklistTXT.split("\n"); - } - - //fetches file from disk again, perhaps in case of updated file - reload() { - //read file - this.blacklistTXT = readFileSync(this.filepath, "utf-8"); - - //split into entry lines - this.blacklistARRAY = this.blacklistTXT.split("\n"); - } - - //returns the reason for blacklisting if roomId is in blacklist, otherwise returns null - match(roomId) { - //check if match - const match = this.blacklistARRAY.find((entry) => - entry.split(" ")[0].includes(roomId), - ); - - if (match) { - //return the reason part of the entry - return match.substring(match.split(" ")[0].length + 1); - - //if no entry, return empty - } - } - - //add roomId to the blacklist with reason - async add(roomId, reason) { - //check if the room is already in the blacklist - if ( - this.blacklistARRAY.some((entry) => entry.split(" ")[0].includes(roomId)) - ) { - //return that as msg - return "Already added."; - } - - //add roomId and reason together to make entry line - const entry = `${roomId} ${reason}`; - - //add entry to array - this.blacklistARRAY.push(entry); - - //add entry to txt file - this.blacklistTXT = `${this.blacklistTXT}\n${entry}`; - - //var to store error - let er; - - //write blacklist text file - writeFile(this.filepath, this.blacklistTXT, null, (err) => { - er = err; - }); - - //return whatever error message was found - return er; - } - - async remove(roomId) { - let er; - - //filter to only include entries without that room id - this.blacklistARRAY = this.blacklistARRAY.filter( - (entry) => !entry.split(" ")[0].includes(roomId), - ); - - //join array into text file - this.blacklistTXT = this.blacklistARRAY.join("\n"); - - writeFile(this.filepath, this.blacklistTXT, null, (err) => { - er = err; - }); - - //return whatever error message was found - return er; - } -} - -export { blacklist }; diff --git a/modules/commands/banlist.js b/modules/commands/banlist.js deleted file mode 100644 index 11df052..0000000 --- a/modules/commands/banlist.js +++ /dev/null @@ -1,169 +0,0 @@ -class Banlist { - async run({ client, roomId, event }, { offset, contentByWords }) { - try { - //parce the room that the cmd is referring to - let banlist = contentByWords[2 + offset]; - - if (!banlist) { - client.sendNotice(roomId, "❌ | invalid usage."); - - return; - } - - //use here so people dont have to type the room alias on an asinine client - if (banlist.toLowerCase() === "here") banlist = roomId; - - //fetch spam police config - const stateconfigevent = (await client.getRoomState(roomId)).filter( - (event) => event.type === "agency.pain.anti-scam.config", - )[0]; - - if (stateconfigevent?.content?.children?.[banlist]) - banlist = stateconfigevent.content.children[banlist]; - - //resolve alias to an id for easier use - client - .resolveRoom(banlist) - .then(async (banlistid) => { - let blconfigevent; - let anonymous; - try { - //fetch spam police config - blconfigevent = (await client.getRoomState(banlistid)).filter( - (event) => event.type === "agency.pain.anti-scam.config", - )[0]; - - anonymous = - (blconfigevent?.content?.parent && - blconfigevent.content.parent === roomId) || - roomId === banlistid; - - //check pl - if ( - !( - (await client.userHasPowerLevelForAction( - event.sender, - banlistid, - "ban", - )) || - (await client.userHasPowerLevelFor( - event.sender, - banlistid, - "m.policy.rule.user", - true, - )) - ) - ) { - client.sendNotice( - roomId, - "❌ | You don't have sufficent permission in the banlist room.", - ); - - return; - } - } catch (e) { - client.sendNotice(roomId, JSON.stringify(e)); - return; - } - - //account for all the chars when spit by spaces - let reasonStart = offset + 3 + 1; - - //for every word before the reason starts, count the length of that word - //and add it to the offset - for (let i = 0; i < offset + 3 + 1; i++) { - reasonStart += contentByWords[i].length; - } - - //parce the reason using the offset - let reason = event.content.body.substring(reasonStart); - - if (!anonymous) reason += ` (by ${event.sender})`; - - //parce out banned user - const bannedUser = contentByWords[3 + offset]; - - const action = contentByWords[1 + offset].toLowerCase(); - - if (action === "add") { - //make banlist rule - client - .sendStateEvent( - banlistid, - "m.policy.rule.user", - `rule:${bannedUser}`, - { - entity: bannedUser, - reason: reason, - recommendation: "org.matrix.mjolnir.ban", - }, - ) - .then(() => - client - .sendNotice( - roomId, - "✅ | Successfully set ban recommendation.", - ) - .catch(() => {}), - ) - .catch((err) => - client - .sendHtmlNotice( - roomId, - `

🍃 | I unfortunately ran into the following error while trying to add that to the banlist:\n

${err}`, - ) - .catch(() => {}), - ); - } else if (action === "remove" || action === "delete") { - //make banlist rule - client - .sendStateEvent( - banlistid, - "m.policy.rule.user", - `rule:${bannedUser}`, - { - reason: reason, - }, - ) - .then(() => - client - .sendNotice( - roomId, - "✅ | Successfully removed ban recommendation.", - ) - .catch(() => {}), - ) - .catch((err) => - client - .sendHtmlNotice( - roomId, - `

🍃 | I unfortunately ran into the following error while trying to remove that banlist rule:\n

${err}`, - ) - .catch(() => {}), - ); - } else { - client - .sendHtmlNotice( - roomId, - `

❌ | Invalid action, ${action} != add or remove/delete.

`, - ) - .catch(() => {}); - } - }) - .catch((err) => - client - .sendHtmlNotice( - roomId, - `

🍃 | I unfortunately ran into the following error while trying to resolve that room:\n

${err}`, - ) - .catch(() => {}), - ); - - //this might fail, just catch it and move on - } catch (e) { - console.log(e); - } - } -} - -export { Banlist }; diff --git a/modules/commands/create-community.js b/modules/commands/create-community.js deleted file mode 100644 index fb5408d..0000000 --- a/modules/commands/create-community.js +++ /dev/null @@ -1,143 +0,0 @@ -class CC { - async run( - { client, roomId, event, mxid, blacklist, server }, - { offset, commandRoom }, - ) { - //create space - const newSpaceId = await client - .createRoom({ - creation_content: { - type: "m.space", - }, - initial_state: [ - { - content: { - join_rule: "public", - }, - state_key: "", - type: "m.room.join_rules", - }, - { - content: { - history_visibility: "joined", - }, - state_key: "", - type: "m.room.history_visibility", - }, - ], - power_level_content_override: { - ban: 50, - events: { - "m.room.avatar": 50, - "m.room.canonical_alias": 50, - "m.room.encryption": 100, - "m.room.history_visibility": 100, - "m.room.name": 50, - "m.room.power_levels": 100, - "m.room.server_acl": 100, - "m.room.tombstone": 100, - }, - events_default: 50, - invite: 50, - kick: 50, - notifications: { - room: 50, - }, - redact: 50, - state_default: 50, - users: { - [mxid]: 102, - [event.sender]: 103, - }, - users_default: 0, - }, - invite: [event.sender], - is_direct: false, - name: "Community Management Room", - room_version: "11", - }) - .catch(() => - client - .sendHtmlNotice( - roomId, - "❌ | I encountered an error attempting to create the space", - ) - .catch(() => {}), - ); - - const newRoomId = await client - .createRoom({ - initial_state: [ - { - content: { - join_rule: "invite", - }, - state_key: "", - type: "m.room.join_rules", - }, - { - content: { - history_visibility: "joined", - }, - state_key: "", - type: "m.room.history_visibility", - }, - { - content: { - canonical: true, - via: [server], - }, - state_key: newSpaceId, - type: "m.space.parent", - }, - ], - power_level_content_override: { - ban: 50, - events: { - "m.room.avatar": 50, - "m.room.canonical_alias": 50, - "m.room.encryption": 100, - "m.room.history_visibility": 100, - "m.room.name": 50, - "m.room.power_levels": 100, - "m.room.server_acl": 100, - "m.room.tombstone": 100, - }, - events_default: 0, - invite: 50, - kick: 50, - notifications: { - room: 50, - }, - redact: 50, - state_default: 50, - users: { - [mxid]: 102, - [event.sender]: 103, - }, - users_default: 0, - }, - invite: [event.sender], - is_direct: false, - name: "Community Management Room", - room_version: "11", - }) - .catch(() => - client - .sendHtmlNotice( - roomId, - "❌ | I encountered an error attempting to create the management room", - ) - .catch(() => {}), - ); - - client - .sendStateEvent(newSpaceId, "m.space.child", newRoomId, { - suggested: false, - via: [server], - }) - .catch(() => {}); - } -} - -export { CC }; diff --git a/modules/commands/followbanlist.js b/modules/commands/followbanlist.js deleted file mode 100644 index 0d32be3..0000000 --- a/modules/commands/followbanlist.js +++ /dev/null @@ -1,194 +0,0 @@ -class FollowBanList { - async run( - { client, roomId, event, mxid, blacklist }, - { offset, contentByWords, config }, - ) { - //check if the command even has the required feilds - if (contentByWords.length !== 3 + offset) { - client.sendNotice(roomId, "❌ | Malformed command.").catch(() => {}); - - return; - } - - //make sure the user has ban permissions before adding banlist - if ( - !(await client.userHasPowerLevelForAction(event.sender, roomId, "ban")) - ) { - client - .sendNotice( - roomId, - "❌ | You don't have sufficent permission. (need ban permission)", - ) - .catch(() => {}); - - return; - } - - //make sure the bot has ban permissions before adding banlist - if (!(await client.userHasPowerLevelForAction(mxid, roomId, "ban"))) { - client - .sendNotice( - roomId, - "❌ | I don't have sufficent permission. (need ban permission)", - ) - .catch(() => {}); - - return; - } - - //get already set banlists - let currentBanlists = config.getConfig(roomId, "banlists"); - - //if there is none, give it something to prevent erroring - if (!currentBanlists) { - currentBanlists = []; - } - - //if the user wants a list - if (contentByWords[offset + 1].toLowerCase() === "list") { - client.sendNotice(roomId, "banlist list here").catch(() => {}); - - return; - } - - //grep out the room indicated by the user - const joinroom = contentByWords[2 + offset]; - - //evaluate if its a valid alias - client - .resolveRoom(joinroom) - .then(async (joinroomid) => { - if (contentByWords[1 + offset] === "add") { - //if already following banlist, there is nothing to do - if (currentBanlists.includes(joinroomid)) { - client - .sendNotice(roomId, "♻️ | Already following this banlist.") - .catch(() => {}); - - return; - } - - //check blacklist for a blacklisted reason - const blReason = blacklist.match(joinroomid); - - //if there is a reason that means the room was blacklisted - if (blReason) { - //send error - client - .sendHtmlNotice( - roomId, - `❌ | The bot was blacklisted from this room for reason ${blReason}.`, - ) - .catch(() => {}); - - //dont continue trying to join - return; - } - - //if the bot is already joined to the banlist, no need to try to join - if ((await client.getJoinedRooms()).includes(joinroomid)) { - //add the new banlist to the banlists - currentBanlists.push(joinroomid); - - //set the config - config.setConfig(roomId, "banlists", currentBanlists, (err) => { - client.sendNotice(roomId, err).catch(() => {}); - }); - - return; - } - - //deduce possible servers with the required information to join into the room - const aliasServer = joinroom.split(":")[1]; - const senderServer = event.sender.split(":")[1]; - const botServer = mxid.split(":")[1]; - - //try to join - client - .joinRoom(joinroomid, [ - aliasServer, - senderServer, - botServer, - "matrix.org", - ]) - .then(() => { - //greeting message - const greeting = `Greetings! I am brought here by ${event.sender}, bot by @jjj333:pain.agency (pls dm for questions). I am joining this room purely to read from the banlist, and as such will default to muted mode, however this can be changed with [prefix]mute. For more information please visit https://github.com/jjj333-p/spam-police`; - - //add the new banlist to the banlists - currentBanlists.push(joinroomid); - - //set the config - config.setConfig(roomId, "banlists", currentBanlists, (err) => { - client.sendNotice(roomId, err); - }); - - //try to send the greeting - client - .sendNotice(joinroomid, greeting) - .finally(() => { - //confirm joined and can send messages - client - .sendNotice(roomId, "✅ | successfully joined banlist!") - .catch(() => {}); - }) - .catch((err) => {}); - }) - .catch((err) => { - //throw error about joining room - client - .sendHtmlNotice( - roomId, - `❌ | I ran into the following error while trying to join that room:
${JSON.stringify( - err.body, - null, - 2, - )}
`, - ) - .catch(() => {}); - }); - } else if (contentByWords[1 + offset] === "remove") { - //if not currently subscribed, there is nothing to remove - if (!currentBanlists.some((b) => b === joinroomid)) { - client - .sendHtmlNotice( - roomId, - `Not currently subscribed to any banlist with the RoomID ${joinroomid}.`, - ) - .catch(() => {}); - - return; - } - - config.setConfig( - roomId, - "banlists", - currentBanlists.filter((b) => b !== joinroomid), - (err) => { - client.sendNotice(roomId, err); - }, - ); - } else { - client - .sendHtmlNotice( - roomId, - `❌ | Unknown operation
${ - contentByWords[1 + offset] - }
, expected
add
or
remove.`, - ) - .catch(() => {}); - } - }) - .catch((err) => { - //throw error about invalid alias - client - .sendHtmlNotice( - roomId, - `❌ | I ran into the following error while trying to resolve that room ID:
${err.message}
`, - ) - .catch(() => {}); - }); - } -} - -export { FollowBanList }; diff --git a/modules/commands/join.js b/modules/commands/join.js deleted file mode 100644 index 135084c..0000000 --- a/modules/commands/join.js +++ /dev/null @@ -1,106 +0,0 @@ -class Join { - async run( - { client, roomId, event, mxid, blacklist }, - { offset, commandRoom }, - ) { - //if not run in the command room - if (roomId !== commandRoom) { - client - .sendNotice( - roomId, - `❌ | you must run +join commands in https://matrix.to/#/${commandRoom}?via=${ - mxid.split(":")[1] - }`, - ) - .catch(() => {}); - - return; - } - - //grep out the room indicated by the user - const joinroom = event.content.body.split(" ")[1 + offset]; - - //evaluate if its a valid alias - client - .resolveRoom(joinroom) - .then(async (joinroomid) => { - //check blacklist for a blacklisted reason - const blReason = blacklist.match(joinroomid); - - //if there is a reason that means the room was blacklisted - if (blReason) { - //send error - client - .sendHtmlNotice( - roomId, - `❌ | The bot was blacklisted from this room for reason ${blReason}.`, - ) - .catch(() => {}); - - //dont continue trying to join - return; - } - - //deduce possible servers with the required information to join into the room - const aliasServer = joinroom.split(":")[1]; - const senderServer = event.sender.split(":")[1]; - const botServer = mxid.split(":")[1]; - - //try to join - client - .joinRoom(joinroomid, [ - aliasServer, - senderServer, - botServer, - "matrix.org", - ]) - .then(() => { - //greeting message - const greeting = `Greetings! I am brought here by ${event.sender}, bot by @jjj333:pain.agency (pls dm for questions). - My MO is I warn people about telegram and whatsapp investment scams whenever they are posted in the room. If I am unwanted please just kick me. - For more information please visit https://github.com/jjj333-p/spam-police`; - - //try to send the greeting - client - .sendNotice(joinroomid, greeting) - .then(() => { - //confirm joined and can send messages - client.sendNotice(roomId, "✅ | successfully joined room!"); - }) - .catch((err) => { - //confirm could join, but show error that couldn't send messages - client - .sendNotice( - roomId, - "🍃 | I was able to join the provided room however I am unable to send messages, and therefore will only be able to react to messages with my warning.", - ) - .catch(() => {}); - }); - }) - .catch((err) => { - //throw error about joining room - client - .sendHtmlNotice( - roomId, - `❌ | I ran into the following error while trying to join that room:
${JSON.stringify( - err.body, - null, - 2, - )}
`, - ) - .catch(() => {}); - }); - }) - .catch((err) => { - //throw error about invalid alias - client - .sendHtmlNotice( - roomId, - `❌ | I ran into the following error while trying to resolve that room ID:
${err.message}
`, - ) - .catch(() => {}); - }); - } -} - -export { Join }; diff --git a/modules/commands/leave.js b/modules/commands/leave.js deleted file mode 100644 index b6d23c0..0000000 --- a/modules/commands/leave.js +++ /dev/null @@ -1,98 +0,0 @@ -class Leave { - async run({ client, roomId, event, blacklist }, { authorizedUsers, offset }) { - //verify is sent by an admin - if (authorizedUsers.some((u) => u === event.sender)) { - //parce out the possible room id - const leaveRoom = event.content.body.split(" ")[1 + offset]; - - //"+leave" as well as a space afterwards - let substringStart = 7; - - //if has the characters required for a room id or alias - if ( - (leaveRoom.includes("#") || leaveRoom.includes("!")) && - leaveRoom.includes(":") && - leaveRoom.includes(".") - ) { - //evaluate if its a valid alias - client - .resolveRoom(leaveRoom) - .then(async (leaveroomid) => { - //add room id or alias to start the reason at the right part of the string - substringStart = substringStart + leaveRoom.length + 1; - - //parce out the reason - let reason = event.content.body.substring(substringStart); - - //make sure reason is in the banlist - if (!reason) { - reason = ""; - } - - //add room to blacklist - blacklist.add(leaveroomid, reason); - - //let the room know why the bot is leaving - client - .sendHtmlNotice( - leaveroomid, - `Leaving room for reason ${reason}.`, - ) - .catch(() => {}) //doesnt matter if unable to send to the room - .finally(() => { - //attempt to leave the room - client - .leaveRoom(leaveroomid) - .then(() => { - //success message - client - .sendHtmlNotice( - roomId, - `✅ | left room ${leaveroomid} with reason ${reason}.`, - ) - .catch(() => {}); - }) - .catch((err) => { - //error message - client - .sendHtmlNotice( - roomId, - `❌ | I ran into the following error leaving the room: ${err}`, - ) - .catch(() => {}); - }); - }); - }) - .catch((err) => { - //throw error about invalid alias - client - .sendHtmlNotice( - roomId, - `❌ | I ran into the following error while trying to resolve that room ID:
${err.message}
`, - ) - .catch(() => {}); - }); - - //if cant possibly be a room alias, leave *this* room - } else { - //parce out reason - const reason = event.content.body.substring(substringStart); - - //add to blacklist - blacklist.add(roomId, reason); - - //leave room - client.leaveRoom(roomId).catch(() => {}); - } - } else { - client - .sendText( - roomId, - "Sorry, only my owner(s) can do that. If you are a moderator of the room please just kick me from the room, I will not join back unless someone tells me to (and I will disclose who told me to).", - ) - .catch(() => {}); - } - } -} - -export { Leave }; diff --git a/modules/commands/mute.js b/modules/commands/mute.js deleted file mode 100644 index 29c5ff9..0000000 --- a/modules/commands/mute.js +++ /dev/null @@ -1,51 +0,0 @@ -class Mute { - async run({ client, roomId, event }, { config }) { - //im equivicating muting the bot to redacting its messages right after they are sent. - if ( - !(await client.userHasPowerLevelForAction(event.sender, roomId, "redact")) - ) { - //"redact")){ - - //error msg - client - .sendNotice( - roomId, - "🍃 | This command requires you have a powerlevel high enough to redact other users messages.", - ) - .catch(() => {}); - - //dont run the cmd - return; - } - - //confirm got message, idk if this actually works lmao - client.sendReadReceipt(roomId, event.event_id).catch(() => {}); - - //grab the opposite of what is in the db - const mute = !config.getConfig(roomId, "muted"); - - if (mute) { - client - .sendNotice( - roomId, - "Putting the bot into mute mode for this channel...", - ) - .catch(() => {}); - } else { - client - .sendNotice( - roomId, - "Taking the bot out of mute mode for this channel...", - ) - .catch(() => {}); - } - - //set the new config - config.setConfig(roomId, "muted", mute, (response) => { - //send confirmation - client.sendNotice(roomId, response).catch(() => {}); - }); - } -} - -export { Mute }; diff --git a/modules/commands/restart.js b/modules/commands/restart.js deleted file mode 100644 index a5fa305..0000000 --- a/modules/commands/restart.js +++ /dev/null @@ -1,37 +0,0 @@ -class Restart { - async run({ client, event, roomId }, { authorizedUsers }) { - //only for authorized users - if (authorizedUsers.some((u) => u === event.sender)) { - client - .sendEvent(roomId, "m.reaction", { - "m.relates_to": { - event_id: event.event_id, - key: "✅♻️", - rel_type: "m.annotation", - }, - }) - - //catch the error to prevent crashing, however if it cant send theres not much to do - .catch(() => {}) - - //just exit, setup on vps is for systemd to restart the service on exit - .finally(() => { - process.exit(0); - }); - } else { - client - .sendEvent(roomId, "m.reaction", { - "m.relates_to": { - event_id: event.event_id, - key: "❌ | unauthorized", - rel_type: "m.annotation", - }, - }) - - //catch the error to prevent crashing, however if it cant send theres not much to do - .catch(() => {}); - } - } -} - -export { Restart }; diff --git a/modules/commands/rules.js b/modules/commands/rules.js deleted file mode 100644 index 814670a..0000000 --- a/modules/commands/rules.js +++ /dev/null @@ -1,64 +0,0 @@ -import fs from "node:fs"; - -class Rules { - async run( - { client, roomId, event, config, banListReader }, - { offset, contentByWords }, - ) { - //fetch banlists for room - let roomBanlists = config.getConfig(roomId, "banlists"); - - //if there is no config, create a temporary one with just the room id - if (!roomBanlists) { - roomBanlists = [roomId]; - } - - //if there is a config, set the room up to check its own banlist - else { - if (!roomBanlists.includes(roomId)) { - roomBanlists = [roomId, ...roomBanlists]; - } - } - - //object to write rules to - const rules = {}; - - //look through all banlists - for (let i = 0; i < roomBanlists.length; i++) { - const rm = roomBanlists[i]; - - //find recommendation - const rulesForRoom = await banListReader.getRules(rm); - - rules[rm] = rulesForRoom; - } - - //generate filename to write to - const filename = `UserBanRecommendations_${roomId}_${new Date().toISOString()}.json`; - - //convert json into binary buffer - const file = Buffer.from(JSON.stringify(rules, null, 2)); - - //upload the file buffer to the matrix homeserver, and grab mxc:// url - const linktofile = await client - .uploadContent(file, "application/json", filename) - .catch(() => { - client.sendNotice(roomId, "Error uploading file.").catch(() => {}); - }); - - //send file - client - .sendMessage(roomId, { - body: filename, - info: { - mimetype: "application/json", - size: file.byteLength, - }, - msgtype: "m.file", - url: linktofile, - }) - .catch(() => {}); - } -} - -export { Rules }; diff --git a/modules/commands/unblacklist.js b/modules/commands/unblacklist.js deleted file mode 100644 index dca89ba..0000000 --- a/modules/commands/unblacklist.js +++ /dev/null @@ -1,64 +0,0 @@ -class Unblacklist { - async run({ client, roomId, event, blacklist }, { authorizedUsers, offset }) { - //verify is sent by an admin - if (authorizedUsers.some((u) => u === event.sender)) { - //parce out the possible room id - const leaveRoom = event.content.body.split(" ")[1 + offset]; - - //if has the characters required for a room id or alias - if ( - (leaveRoom.includes("#") || leaveRoom.includes("!")) && - leaveRoom.includes(":") && - leaveRoom.includes(".") - ) { - //evaluate if its a valid alias - client - .resolveRoom(leaveRoom) - .then(async (leaveroomid) => { - //remove room to blacklist - blacklist.remove(leaveroomid).then(() => { - client - .sendEvent(roomId, "m.reaction", { - "m.relates_to": { - event_id: event.event_id, - key: "✅", - rel_type: "m.annotation", - }, - }) - .catch(() => {}); - }); - }) - .catch((err) => { - //throw error about invalid alias - client - .sendHtmlNotice( - roomId, - `❌ | I ran into the following error while trying to resolve that room ID:
${err.message}
`, - ) - .catch(() => {}); - }); - - //if cant possibly be a room alias - } else { - client - .sendEvent(roomId, "m.reaction", { - "m.relates_to": { - event_id: event.event_id, - key: "❌", - rel_type: "m.annotation", - }, - }) - .catch(() => {}); - } - } else { - client - .sendText( - roomId, - "Sorry, only my owner(s) can do that. If you are a moderator of the room please just kick me from the room, I will not join back unless someone tells me to (and I will disclose who told me to).", - ) - .catch(() => {}); - } - } -} - -export { Unblacklist }; diff --git a/modules/commands/uptime.js b/modules/commands/uptime.js deleted file mode 100644 index 476862f..0000000 --- a/modules/commands/uptime.js +++ /dev/null @@ -1,27 +0,0 @@ -class Uptime { - async run({ client, roomId, event }) { - //const user know that the bot is online even if the matrix room is being laggy and the message event isnt comming across - client.sendReadReceipt(roomId, event.event_id); - - //maths - const seconds = process.uptime(); - - const minutes = Math.floor(seconds / 60); - - const rSeconds = seconds - minutes * 60; - - const hours = Math.floor(minutes / 60); - - const rMinutes = minutes - hours * 60; - - //send the uptime to the room - client.sendHtmlText( - roomId, - `
\n

${seconds}

\n
\n

${hours} hours, ${rMinutes} minutes, and ${Math.floor( - rSeconds, - )} seconds.

`, - ); - } -} - -export { Uptime }; diff --git a/modules/db.js b/modules/db.js deleted file mode 100644 index d36faa9..0000000 --- a/modules/db.js +++ /dev/null @@ -1,109 +0,0 @@ -import { mkdirSync, readdirSync, readFileSync, writeFile } from "fs"; - -class database { - constructor() { - //check if the config part of the db is there - const a = readdirSync("./db"); - - if (!a.some((b) => b === "config")) { - //if not, make the folder for it - mkdirSync("./db/config"); - } - - //fetch the stored config files - const configfilelist = readdirSync("./db/config"); - - //set up cache for db so dont have to wait on disk every time - this.cache = new Map(); - - // go ahead and load configs so dont have to wait for disk. - // size should be small enough to cache it all without - // worrying about ram usage - for (const fileName of configfilelist) { - //filename is derived from the room id (map key) - const id = fileName.substring(0, fileName.length - 5); - - //map to shove data into - const configMap = new Map(); - - //read the config and parse it to add it to cache - const rawconfig = JSON.parse(readFileSync(`./db/config/${fileName}`)); - - //pull the individual configs into a uniform map format - // biome-ignore lint/complexity/noForEach: - Object.entries(rawconfig).forEach(([key, value]) => { - configMap.set(key, value); - }); - - this.cache.set(id, configMap); - } - } - - getConfig(roomId, config) { - //make sure there is a config for this room - const cache = this.cache.get(roomId); - - //if we have a config file for the room, return the requested config - if (cache) return cache.get(config); - - //if not return null (defaults to falsey value for config) - return null; - } - - setConfig(roomId, config, value, callback) { - //fetch the existing config - let cachedconfig = this.cache.get(roomId); - - //if no config exists, make one - if (!cachedconfig) cachedconfig = new Map(); - - //write setting to the config - cachedconfig.set(config, value); - - //write the config to the global cache - this.cache.set(roomId, cachedconfig); - - //write current config to disk - writeFile( - `./db/config/${roomId}.json`, - JSON.stringify(Object.fromEntries(cachedconfig), null, 2), - (err) => { - if (err) - callback( - `🍃 | I ran into the following error trying to write this config to disk. Please report this to @jjj333:pain.agency in #anti-scam-support:matrix.org\n\n${err}`, - ); - else callback("✅ | Successfully saved."); - }, - ); - } - - cloneRoom(fromId, toId, callback) { - //fetch the existing config - const from = this.cache.get(fromId); - - //make sure we have something to copy - if (!from) { - callback("🍃 | There is no customized config to copy."); - - return; - } - - //clone the config in memory - this.cache.set(toId, from); - - //write current config to disk - writeFile( - `./db/config/${toId}.json`, - JSON.stringify(Object.fromEntries(from), null, 2), - (err) => { - if (err) - callback( - `🍃 | I ran into the following error trying to write this config to disk. Please report this to @jjj333_p_1325:matrix.org in #anti-scam-support:matrix.org\n\n${err}`, - ); - else callback("✅ | Successfully copied config."); - }, - ); - } -} - -export { database }; diff --git a/modules/message.js b/modules/message.js deleted file mode 100644 index 286c107..0000000 --- a/modules/message.js +++ /dev/null @@ -1,403 +0,0 @@ -//misc imports -import { PowerLevelAction } from "matrix-bot-sdk"; -import { createRequire } from "node:module"; -const require = createRequire(import.meta.url); - -import { Sendjson } from "./sendjson.js"; - -// commands -import { Uptime } from "./commands/uptime.js"; -import { Join } from "./commands/join.js"; -import { Leave } from "./commands/leave.js"; //does blacklisting -import { Unblacklist } from "./commands/unblacklist.js"; -import { Restart } from "./commands/restart.js"; -import { Mute } from "./commands/mute.js"; -import { Banlist } from "./commands/banlist.js"; -import { FollowBanList } from "./commands/followbanlist.js"; -import { Rules } from "./commands/rules.js"; -import { CC } from "./commands/create-community.js"; - -const sendjson = new Sendjson(); - -class message { - constructor(logRoom, commandRoom, config, authorizedUsers) { - //map to relate scams and their responses (for deletion) - this.tgScamResponses = new Map(); - this.tgScamReactions = new Map(); - - //config thingys - this.logRoom = logRoom; - this.commandRoom = commandRoom; - this.config = config; - this.authorizedUsers = authorizedUsers; - - //fetch keywords - this.keywords = require("../keywords.json"); - - //create collection of different commands to run - this.commands = new Map(); - - this.commands.set("uptime", new Uptime()); - this.commands.set("join", new Join()); - this.commands.set("leave", new Leave()); - this.commands.set("unblacklist", new Unblacklist()); - this.commands.set("restart", new Restart()); - this.commands.set("mute", new Mute()); - this.commands.set("banlist", new Banlist()); - this.commands.set("followbanlist", new FollowBanList()); - this.commands.set("rules", new Rules()); - this.commands.set("create-community", new CC()); - } - - // async run ({client, roomId, event, mxid, displayname, blacklist}){ - async run(datapoints) { - //if no content in message - if (!datapoints.event.content) return; - - // Don't handle non-text events - if (datapoints.event.content.msgtype !== "m.text") return; - - //grab the content from the message, and put it to lowercase to prevent using caps to evade - const scannableContent = datapoints.event.content.body.toLowerCase(); - - // this.commands.run(datapoints, scannableContent) - - //scan for common scam words - if ( - includesWord(scannableContent, [ - this.keywords.scams.currencies, - this.keywords.scams.socials, - this.keywords.scams.verbs, - ]) - ) { - //if the scam is posted in the room deticated to posting tg scams - if (datapoints.roomId === this.logRoom) { - //confirm it matches the keywords - datapoints.client - .sendEvent(datapoints.roomId, "m.reaction", { - "m.relates_to": { - event_id: datapoints.event.event_id, - key: "Detected", - rel_type: "m.annotation", - }, - }) - // ??? - .catch(() => {}); - } else { - //custom function to handle the fetching and sending of the json file async as to not impact responsiveness - sendjson.send( - datapoints, - this.logRoom, - datapoints.banListReader, - this.tgScamReactions, - this.tgScamResponses, - ); - - //React to the message with a little warning so its obvious what msg im referring to - datapoints.client - .sendEvent(datapoints.roomId, "m.reaction", { - "m.relates_to": { - event_id: datapoints.event.event_id, - key: "🚨 scam! 🚨", - rel_type: "m.annotation", - }, - }) - - //if reaction is sent, associate it with the original scam for later redaction - .then((responseID) => { - this.tgScamReactions.set(datapoints.event.event_id, { - roomId: datapoints.roomId, - responseID: responseID, - }); - }) - - //catch the error to prevent crashing, however if it cant send theres not much to do - .catch(() => {}) - - //dont care if it was successful, carry on with the code - .finally(async () => { - //if the room is in mute mode, dont respond - if (this.config.getConfig(datapoints.roomId, "muted")) return; - - //send warning message - datapoints.client - .sendHtmlText(datapoints.roomId, this.keywords.scams.response) - - //if warning is sent, associate it with the original scam for later redaction - .then((responseID) => { - this.tgScamResponses.set(datapoints.event.event_id, { - roomId: datapoints.roomId, - responseID: responseID, - }); - }) - - //catch the error to prevent crashing, however if it cant send theres not much to do - .catch(() => {}) - - .finally(async () => { - //if the message is replying - const replyRelation = datapoints.event.content["m.relates_to"]; //["m.in_reply_to"].event_id - if (replyRelation) { - //pull the id of the event its replying to - if (replyRelation["m.in_reply_to"]) { - const replyID = replyRelation["m.in_reply_to"].event_id; - - //fetch the event from that id - const repliedEvent = await datapoints.client - .getEvent(datapoints.roomId, replyID) - .catch(() => {}); - - //make the content scanable - const scannableContent = - repliedEvent.content.body.toLowerCase(); - - //if the message is replying to a scam, it doesnt need to be acted upon - if ( - includesWord(scannableContent, [ - this.keywords.scams.currencies, - this.keywords.scams.socials, - this.keywords.scams.verbs, - ]) - ) { - return; - } - } - } - - const scamAction = this.config.getConfig( - datapoints.roomId, - "scamAction", - ); - - const reason = "Scam Likely"; - - try { - if (!scamAction) { - datapoints.client - .kickUser( - datapoints.event.sender, - datapoints.roomId, - reason, - ) - .catch(() => {}); - } else if (scamAction === -1) { - datapoints.client - .redactEvent( - datapoints.roomId, - datapoints.event.event_id, - reason, - ) - .catch(() => {}); - } else if (scamAction === 1) { - // userHasPowerLevelFor(userId: string, datapoints.roomId: string, eventType: string, isState: boolean): Promise; - // setUserPowerLevel(userId: string, roomId: string, newLevel: number): Promise; - // datapoints.client.setUserPowerLevel(user, roomId, newlevel) - // if ( await datapoints.client.userHasPowerLevelFor(mxid, roomId, "m.room.power_levels", true) ){ - // } - } - } catch (e) { - /*TODO*/ - } - }); - }); - } - - //check if can respond - } else if ( - !(await datapoints.client - .userHasPowerLevelFor( - datapoints.mxid, - datapoints.roomId, - "m.room.message", - false, - ) - .catch(() => {})) - ) { - return; - } else { - //greeting message - const greeting = - "Greetings! I am a bot by @jjj333:pain.agency (pls dm for questions). " + - "My MO is I warn people about telegram and whatsapp investment scams whenever they are posted in the room. If I am unwanted please just kick me. " + - "For more information please visit https://github.com/jjj333-p/spam-police"; - - //split into words, and filter out the empty strings because js is an actual meme language - const contentByWords = datapoints.event.content.body - .split(" ") - .filter((a) => a); - const displaynameByWords = datapoints.displayname - .split(" ") - .filter((a) => a); - - //if the user is trying to mention the bot - if ( - datapoints.event.content.body.includes(datapoints.mxid) || - datapoints.event.content.body.includes(datapoints.displayname) - ) { - //if someone starts the message with the mxid - if (contentByWords[0].includes(datapoints.mxid)) { - //help command - if (!contentByWords[1] || contentByWords[1] === "help") { - datapoints.client - .sendText(datapoints.roomId, greeting) - .catch(() => {}); - return; - } - - //definitely not a command - if ( - contentByWords[1].startsWith("+") || - contentByWords[1].startsWith("1") - ) - return; - - //if that is a command, run the command - const handler = this.commands.get(contentByWords[1]); - - //if no handler its not a valid command - if (!handler) { - await datapoints.client - .sendEvent(datapoints.roomId, "m.reaction", { - "m.relates_to": { - event_id: datapoints.event.event_id, - key: "❌ | invalid cmd", - rel_type: "m.annotation", - }, - }) - .catch(() => {}); - - return; - } - - client - .sendReadReceipt(datapoints.roomId, datapoints.event.event_id) - .catch(() => {}); - - //run the command - handler.run(datapoints, { - scannableContent: scannableContent, - contentByWords: contentByWords, - keywords: this.keywords, - logRoom: this.logRoom, - commandRoom: this.commandRoom, - config: this.config, - authorizedUsers: this.authorizedUsers, - offset: displaynameByWords.length, - }); - - //if someone starts the message with the display name - } else if ( - datapoints.event.content.body.startsWith(datapoints.displayname) && - contentByWords.length > displaynameByWords.length - ) { - //if that is a command, run the command - const handler = this.commands.get( - contentByWords[displaynameByWords.length], - ); - - //if no handler its not a valid command - if (!handler) { - await datapoints.client - .sendEvent(datapoints.roomId, "m.reaction", { - "m.relates_to": { - event_id: datapoints.event.event_id, - key: "❌ | invalid cmd", - rel_type: "m.annotation", - }, - }) - .catch(() => {}); - - return; - } - - datapoints.client - .sendReadReceipt(datapoints.roomId, datapoints.event.event_id) - .catch(() => {}); - - //run the command - handler.run(datapoints, { - scannableContent: scannableContent, - contentByWords: contentByWords, - keywords: this.keywords, - logRoom: this.logRoom, - commandRoom: this.commandRoom, - config: this.config, - authorizedUsers: this.authorizedUsers, - offset: displaynameByWords.length, - }); - } else { - datapoints.client - .sendText(datapoints.roomId, greeting) - .catch(() => {}); - } - } else { - //fetch prefix for that room - let prefix = this.config.getConfig(datapoints.roomId, "prefix"); - - //default prefix if none set - if (!prefix) prefix = "+"; - - if (!scannableContent.startsWith(prefix)) return; - - //parce out command - const command = contentByWords[0].substring(prefix.length); - - //not a command - if (!command || command.startsWith("+") || command.startsWith("1")) - return; - - //help - if (command === "help") { - datapoints.client - .sendText(datapoints.roomId, greeting) - .catch(() => {}); - return; - } - - //if that is a command, run the command - const handler = this.commands.get(command); - - //if no handler, than its not a valid command - if (!handler) { - await datapoints.client - .sendEvent(datapoints.roomId, "m.reaction", { - "m.relates_to": { - event_id: datapoints.event.event_id, - key: "❌ | invalid cmd", - rel_type: "m.annotation", - }, - }) - .catch(() => {}); - - return; - } - - //run the handler - handler.run(datapoints, { - scannableContent: scannableContent, - contentByWords: contentByWords, - keywords: this.keywords, - logRoom: this.logRoom, - commandRoom: this.commandRoom, - config: this.config, - authorizedUsers: this.authorizedUsers, - offset: 0, - }); - } - } - } -} - -//function to scan if it matches the keywords -function includesWord(str, catgs) { - //assume true if you dont have any missing - let result = true; - - for (const cat of catgs) { - if (!cat.some((word) => str.includes(word))) result = false; - } - - return result; -} - -export { message }; diff --git a/modules/reaction.js b/modules/reaction.js deleted file mode 100644 index 18eca7c..0000000 --- a/modules/reaction.js +++ /dev/null @@ -1,25 +0,0 @@ -class Reaction { - constructor(logRoom) { - this.logRoom = logRoom; - } - - async run({ client, roomId, event, mxid, reactionQueue }) { - //should never happen but aparently it does - //https://matrix.pain.agency/_matrix/media/v3/download/pain.agency/51cc6283f64310640f67daa84f284ae8e7a08a969bd2f7f57920a4d30aa83c00 - if (!event.content["m.relates_to"]?.event_id) return; - - if (roomId === this.logRoom) { - //get queued function - const qf = reactionQueue.get(event.content["m.relates_to"].event_id); - - //make sure the reaction was to a scam entry - if (!qf) { - return; - } - - qf(event); - } - } -} - -export { Reaction }; diff --git a/modules/redaction.js b/modules/redaction.js deleted file mode 100644 index 26f2826..0000000 --- a/modules/redaction.js +++ /dev/null @@ -1,53 +0,0 @@ -class redaction { - constructor(eventhandlers) { - this.eventhandlers = eventhandlers; - } - - async run({ client, roomId, event, config }) { - let redactedEvent; - try { - redactedEvent = await client.getEvent(roomId, event.redacts); - } catch (e) { - console.log( - `Attempted to fetch redacted event ${event.redacts}, but it does not exist?`, - e, - ); - return; //if there was no event redacted, that means theres inherently nothing to do - } - - //deleting a chat message - if (redactedEvent.type === "m.room.message") { - //fetch the bots response to the scam - const response = this.eventhandlers - .get("m.room.message") - .tgScamResponses.get(event.redacts); - const reaction = this.eventhandlers - .get("m.room.message") - .tgScamReactions.get(event.redacts); - - //if there is a response to the redacted message then redact the response - if (response) { - client - .redactEvent( - response.roomId, - response.responseID, - "The message that this message was replying to was deleted.", - ) - .catch(() => {}); - } - if (reaction) { - client - .redactEvent(reaction.roomId, reaction.responseID) - .catch(() => {}); - } - - //if deleting a banlist event just reprocess banlist - } else if (redactedEvent.type === "m.policy.rule.user") { - this.eventhandlers - .get("m.policy.rule.user") - .run({ roomId: roomId, config: config }); - } - } -} - -export { redaction }; diff --git a/modules/sendjson.js b/modules/sendjson.js deleted file mode 100644 index d801c2b..0000000 --- a/modules/sendjson.js +++ /dev/null @@ -1,349 +0,0 @@ -import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { createRequire } from "node:module"; -const require = createRequire(import.meta.url); - -class Sendjson { - constructor() { - //create array to store scams to help limit duplicates (when spammed) - this.tgScams = []; - - //fetch keywords - this.keywords = require("../keywords.json"); - } - - async send( - { client, roomId, event, mxid, reactionQueue }, - logchannel, - banlistReader, - reactions, - responses, - ) { - //if the message is replying - const replyRelation = event.content["m.relates_to"]; //["m.in_reply_to"].event_id - if (replyRelation) { - //pull the id of the event its replying to - if (replyRelation["m.in_reply_to"]) { - const replyID = replyRelation["m.in_reply_to"].event_id; - - //fetch the event from that id - let repliedEvent; - - try { - repliedEvent = await client.getEvent(roomId, replyID); - } catch (e) { - console.error("Error loading message that was replied to:", e); - return; - } - - //make the content scanable - const scannableContent = repliedEvent.content.body.toLowerCase(); - - //if the message is replying to a scam, it doesnt need to be logged - if ( - includesWord(scannableContent, [ - this.keywords.scams.currencies, - this.keywords.scams.socials, - this.keywords.scams.verbs, - ]) - ) { - return; - } - } - } - - //fetch the set alias of the room - let mainRoomAlias; - try { - mainRoomAlias = - (await client.getPublishedAlias(roomId)) ?? - (await client.getRoomState(roomId)).find( - (state) => state.type === "m.room.name", - ).content.name; - } catch (e) { - console.error( - "Error fetching alias and or room name in scam reporting\n", - e, - ); - mainRoomAlias = ""; - } - - //check if already on banlist - const entry = await banlistReader.match(logchannel, event.sender); - - if (entry) { - //get the reason its already on the banlist - const existingReason = entry.content.reason; - - //if the ban reason already includes that scam was sent in this room, theres nothing to add - if (existingReason.includes(mainRoomAlias)) { - return; - } - - //make banlist rule - client - .sendStateEvent( - logchannel, - "m.policy.rule.user", - `rule:${event.sender}`, - { - entity: event.sender, - reason: `${existingReason} ${mainRoomAlias}`, - recommendation: "org.matrix.mjolnir.ban", - }, - ) - //it literally does not matter if this fails - .catch(() => {}); - - //dont send a log if its already been reported - return; - } - - //limit duplicates - if ( - this.tgScams.some( - (scam) => - scam.event.content.body === event.content.body && - scam.roomId === roomId && - scam.event.sender === event.sender, - ) - ) { - return; - } - - this.tgScams.push({ event: event, roomId: roomId }); - - //filename - const filename = `${ - event.sender - }_${roomId}_@_${new Date().toISOString()}.json`; - - //convert json into binary buffer - const file = Buffer.from(JSON.stringify(event, null, 2)); - - //upload the file buffer to the matrix homeserver, and grab mxc:// url - let linktofile; - try { - linktofile = await client.uploadContent( - file, - "application/json", - filename, - ); - } catch (e) { - console.error("Unable to upload scam detection json file", e); - } - - //if the bot is in the room, that mean it's homeserver can be used for a via - const via = mxid.split(":")[1]; - - //escape html and '@' to avoid mentions - const escapedText = event.content.body - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll("@", "&64;"); - - //send log message - let logmsgid; - try { - logmsgid = await client.sendHtmlText( - logchannel, - `${event.sender} in ${mainRoomAlias}\n
${escapedText}
\nhttps://matrix.to/#/${roomId}/${event.event_id}?via=${via}`, - ); - } catch (e) { - console.error("Unable to send message to log room:", e); - //this is all pointless if we cant send a message - return; - } - - //send the file that was uploaded - let logfileid; - if (linktofile) { - try { - logfileid = await client.sendMessage(logchannel, { - body: filename, - info: { - mimetype: "application/json", - size: file.byteLength, - }, - msgtype: "m.file", - url: linktofile, - }); - - //makes no sense to happen if the earlier message succeeded - //if that somehow does happen, makes no sense to try to do anything - } catch (e) {} - } - - //easy reaction for moderators - const checkMessagePromise = client.sendEvent(logchannel, "m.reaction", { - "m.relates_to": { - event_id: logmsgid, - key: "✅ confirm", - rel_type: "m.annotation", - }, - }); - - //easy reaction for moderators - const xMessagePromise = client.sendEvent(logchannel, "m.reaction", { - "m.relates_to": { - event_id: logmsgid, - key: "❌ falsepos", - rel_type: "m.annotation", - }, - }); - - //add callback to the map to be called upon reaction event - reactionQueue.set(logmsgid, async (reactionEvent) => { - const senderpl = ( - await client.getRoomStateEvent(logchannel, "m.room.power_levels", null) - ).users[reactionEvent.sender]; - - if (senderpl === undefined || senderpl < 10) { - return; - } - - const userReactionId = reactionEvent.event_id; - - if (reactionEvent.content["m.relates_to"].key.includes("✅")) { - //only allow to run once - reactionQueue.delete(logmsgid); - - //generate reason - const reason = `telegram scam in ${mainRoomAlias}`; //+ " (see " + await client.getPublishedAlias(logchannel) + " )" - - //make banlist rule - client - .sendStateEvent( - logchannel, - "m.policy.rule.user", - `rule:${event.sender}`, - { - entity: event.sender, - reason: reason, - recommendation: "org.matrix.mjolnir.ban", - }, - ) - - .then(async () => { - //delete reactions to limit duplicate responses - //didnt await these earler for speed and performance, so need to await the promises now - client - .redactEvent( - logchannel, - await checkMessagePromise, - "related reaction", - ) - .catch(() => {}); - client - .redactEvent( - logchannel, - await xMessagePromise, - "related reaction", - ) - .catch(() => {}); - - //confirm ban for clients that cant read banlist events - client - .sendEvent(logchannel, "m.reaction", { - "m.relates_to": { - event_id: logmsgid, - key: "🔨 | banned", - rel_type: "m.annotation", - }, - }) - //catch it in case edge case of duplicate actions, this way it wont error - .catch(() => {}); - - //attempt to redact the scam - client - .redactEvent(roomId, event.event_id, "confirmed scam") - .catch(() => {}); - }) - - //delete mod response even if it fails to enable trying again - .finally(async () => { - client - .redactEvent(logchannel, userReactionId, "related reaction") - .catch(() => {}); - }) - - //catch errors with sending the state event - .catch((err) => - client.sendHtmlNotice( - logchannel, - `

🍃 | I unfortunately ran into the following error while trying to add that to the banlist:\n

${err}`, - ), - ); - } else if (reactionEvent.content["m.relates_to"].key.includes("❌")) { - //only allow to run once - reactionQueue.delete(logmsgid); - - //delete events already existing - client.redactEvent(logchannel, logmsgid, "not a scam"); - client.redactEvent(logchannel, logfileid, "not a scam"); - client.redactEvent(logchannel, userReactionId, "related reaction"); - - //didnt await these earler for speed and performance, so need to await the promises now - client.redactEvent( - logchannel, - await checkMessagePromise, - "related reaction", - ); - client.redactEvent( - logchannel, - await xMessagePromise, - "related reaction", - ); - - //fetch the bots response to the scam - const response = responses.get(event.event_id); - const reaction = reactions.get(event.event_id); - - //if there is a response to the redacted message then redact the response - try { - if (response) { - await client.redactEvent( - response.roomId, - response.responseID, - "False positive.", - ); - } - if (reaction) { - await client.redactEvent( - reaction.roomId, - reaction.responseID, - "False positive.", - ); - } - - //on the rare occasion that the room disables self redactions, or other error, this for some reason crashes the entire process - //fuck you nodejs v20 - } catch (e) { - // error to send - const en = "🍃 | Error redacting warning."; - - //send to both log room and that room which it is supposed to redact - client - .sendHtmlNotice(response.roomId, en) - .catch(() => {}) - .finally(() => { - client.sendHtmlNotice(logchannel, en); - }); - } - } - }); - } -} - -//function to scan if it matches the keywords -function includesWord(str, catgs) { - //assume true if you dont have any missing - let result = true; - - for (const cat of catgs) { - if (!cat.some((word) => str.includes(word))) result = false; - } - - return result; -} - -export { Sendjson }; diff --git a/package-lock.json b/package-lock.json index 86c25ca..93d4b3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,156 @@ "license": "GPL-3.0", "dependencies": { "@matrix-org/matrix-sdk-crypto-nodejs": "0.1.0-beta.12", + "@sinclair/typebox": "^0.32.29", "matrix-bot-sdk": "^0.7.1", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@^0.21.0", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@^0.21.0", "yaml": "^2.4.2" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^7.7.0", + "@typescript-eslint/parser": "^7.7.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.2", + "gnuxie-tsconfig": "github:Gnuxie/tsconfig", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", + "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz", + "integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "dev": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "dev": true + }, "node_modules/@matrix-org/matrix-sdk-crypto-nodejs": { "version": "0.1.0-beta.12", "resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-nodejs/-/matrix-sdk-crypto-nodejs-0.1.0-beta.12.tgz", @@ -27,6 +173,53 @@ "node": ">= 16" } }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -39,6 +232,11 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/@sinclair/typebox": { + "version": "0.32.29", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.32.29.tgz", + "integrity": "sha512-GWKskKPGQV0vVYizqCu0E1YLwGthvlkDqpRxB3iBuqxJ8dN/9n1cnDRSQHF59GMoxDJwzSgmxpU617SidtUnMw==" + }, "node_modules/@types/body-parser": { "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", @@ -83,6 +281,12 @@ "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -106,6 +310,12 @@ "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==" }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", + "dev": true + }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -125,6 +335,202 @@ "@types/node": "*" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.7.0.tgz", + "integrity": "sha512-GJWR0YnfrKnsRoluVO3PRb9r5aMZriiMMM/RHj5nnTrBy1/wIgk76XCtCKcnXGjpZQJQRFtGV9/0JJ6n30uwpQ==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "7.7.0", + "@typescript-eslint/type-utils": "7.7.0", + "@typescript-eslint/utils": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0", + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.7.0.tgz", + "integrity": "sha512-fNcDm3wSwVM8QYL4HKVBggdIPAy9Q41vcvC/GtDobw3c4ndVT3K6cqudUmjHPw8EAp4ufax0o58/xvWaP2FmTg==", + "dev": true, + "dependencies": { + "@typescript-eslint/scope-manager": "7.7.0", + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/typescript-estree": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.7.0.tgz", + "integrity": "sha512-/8INDn0YLInbe9Wt7dK4cXLDYp0fNHP5xKLHvZl3mOT5X17rK/YShXaiNmorl+/U4VKCVIjJnx4Ri5b0y+HClw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.7.0.tgz", + "integrity": "sha512-bOp3ejoRYrhAlnT/bozNQi3nio9tIgv3U5C0mVDdZC7cpcQEDZXvq8inrHYghLVwuNABRqrMW5tzAv88Vy77Sg==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "7.7.0", + "@typescript-eslint/utils": "7.7.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.7.0.tgz", + "integrity": "sha512-G01YPZ1Bd2hn+KPpIbrAhEWOn5lQBrjxkzHkWvP6NucMXFtfXoevK82hzQdpfuQYuhkvFDeQYbzXCjR1z9Z03w==", + "dev": true, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.7.0.tgz", + "integrity": "sha512-8p71HQPE6CbxIBy2kWHqM1KGrC07pk6RJn40n0DSc6bMOBBREZxSDJ+BmRzc8B5OdaMh1ty3mkuWRg4sCFiDQQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/visitor-keys": "7.7.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.7.0.tgz", + "integrity": "sha512-LKGAXMPQs8U/zMRFXDZOzmMKgFv3COlxUQ+2NMPhbqgVm6R1w+nU1i4836Pmxu9jZAuIeyySNrN/6Rc657ggig==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.15", + "@types/semver": "^7.5.8", + "@typescript-eslint/scope-manager": "7.7.0", + "@typescript-eslint/types": "7.7.0", + "@typescript-eslint/typescript-estree": "7.7.0", + "semver": "^7.6.0" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.56.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.7.0.tgz", + "integrity": "sha512-h0WHOj8MhdhY8YWkzIF30R379y0NqyOHExI9N9KCzvmu05EgG4FumeYa3ccfKUSphyWkWQE1ybVrgz/Pbam6YA==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "7.7.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || >=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "dev": true + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -137,6 +543,27 @@ "node": ">= 0.6" } }, + "node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/agent-base": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", @@ -168,6 +595,15 @@ "resolved": "https://registry.npmjs.org/another-json/-/another-json-0.2.0.tgz", "integrity": "sha512-/Ndrl68UQLhnCdsAzEXLMFuOR546o2qbYRqCglaNHbjXrwG1ayTcdwr3zkSGOGtGXDyR5X9nCFfnyG2AFJIsqg==" }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -182,11 +618,26 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/asn1": { "version": "0.2.6", "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", @@ -213,6 +664,11 @@ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, + "node_modules/await-lock": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz", + "integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==" + }, "node_modules/aws-sign2": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", @@ -226,6 +682,12 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -291,6 +753,27 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -317,6 +800,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -364,6 +856,12 @@ "node": ">= 0.8" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -401,6 +899,25 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -428,6 +945,12 @@ } } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -477,6 +1000,30 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -596,6 +1143,212 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz", + "integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.56.0", + "@humanwhocodes/config-array": "^0.11.13", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -676,11 +1429,84 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", @@ -711,6 +1537,42 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -748,6 +1610,12 @@ "node": ">= 0.6" } }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -782,11 +1650,114 @@ "assert-plus": "^1.0.0" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gnuxie-tsconfig": { + "version": "0.0.0", + "resolved": "git+ssh://git@github.com/Gnuxie/tsconfig.git#52d9b0d4f2652c0c1682df5511aba4f2cf4f33a4", + "dev": true, + "dependencies": { + "@typescript-eslint/eslint-plugin": "^7.7.0", + "@typescript-eslint/parser": "^7.7.0", + "eslint": "8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "prettier": "^3.2.5", + "typescript": "^5.4.5" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -803,6 +1774,12 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, "node_modules/har-schema": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", @@ -975,6 +1952,55 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", + "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "dev": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.0.0-beta.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.0-beta.5.tgz", + "integrity": "sha512-1RO6wxfJdh/uyWb2MTn3RuCPXYmpRiAhoKm8vEnA50+2Gy0j++6GBtu5q6sq2d4tpcL+e1sCHzk8NkWnRhT2/Q==" + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -988,6 +2014,45 @@ "node": ">= 0.10" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-object": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", @@ -1006,16 +2071,40 @@ "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, "node_modules/isstream": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsbn": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true + }, "node_modules/json-schema": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", @@ -1026,6 +2115,12 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -1045,6 +2140,20 @@ "node": ">=0.6.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/layerr": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-2.1.0.tgz", + "integrity": "sha512-xDD9suWxfBYeXgqffRVH/Wqh+mqZrQcqPRn0I0ijl7iJQ7vu8gMGPt1Qop59pEW/jaIDNUN7+PX1Qk40+vuflg==" + }, "node_modules/leac": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz", @@ -1053,11 +2162,45 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, "node_modules/lowdb": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/lowdb/-/lowdb-1.0.0.tgz", @@ -1122,6 +2265,33 @@ "node": ">= 16" } }, + "node_modules/matrix-protection-suite": { + "name": "@gnuxie/matrix-protection-suite", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@gnuxie/matrix-protection-suite/-/matrix-protection-suite-0.21.0.tgz", + "integrity": "sha512-hIZpkIv0bEl5aKMv/AzK9Lcbg6CD3lw5VDYQmv04pM2/ja4z5nCsxD9rWeEUl+1IP+i0RjFWLNnbhl8n9nPsnA==", + "dependencies": { + "await-lock": "^2.2.2", + "crypto-js": "^4.2.0", + "glob-to-regexp": "^0.4.1", + "immutable": "^5.0.0-beta.5", + "ulidx": "^2.3.0" + }, + "peerDependencies": { + "@sinclair/typebox": "0.32.29" + } + }, + "node_modules/matrix-protection-suite-for-matrix-bot-sdk": { + "name": "@gnuxie/matrix-protection-suite-for-matrix-bot-sdk", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@gnuxie/matrix-protection-suite-for-matrix-bot-sdk/-/matrix-protection-suite-for-matrix-bot-sdk-0.21.0.tgz", + "integrity": "sha512-wRcNYrY8gDxX6ofPjRpE5DI+i+X0q5o2VqFXXYA2TSiYCues0B26DZeRH5qcw8pmMM+voCGSPurLD7RFnm1a8Q==", + "peerDependencies": { + "@sinclair/typebox": "0.32.29", + "matrix-bot-sdk": ">=0.6.4", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@0.21.0" + } + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -1135,6 +2305,15 @@ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -1143,6 +2322,19 @@ "node": ">= 0.6" } }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -1178,6 +2370,21 @@ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, + "node_modules/minimatch": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mkdirp": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", @@ -1253,6 +2460,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1307,6 +2520,74 @@ "node": ">= 0.8" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/parse-srcset": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", @@ -1332,11 +2613,47 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -1355,6 +2672,18 @@ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", @@ -1402,6 +2731,42 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", + "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1441,6 +2806,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1534,6 +2919,63 @@ "node": ">=0.6" } }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1582,6 +3024,18 @@ "url": "https://ko-fi.com/killymxi" } }, + "node_modules/semver": { + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", @@ -1658,6 +3112,27 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", @@ -1675,6 +3150,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -1731,6 +3215,30 @@ "graceful-fs": "^4.1.3" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -1742,6 +3250,40 @@ "node": ">=8" } }, + "node_modules/synckit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", + "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1762,6 +3304,24 @@ "node": ">=0.8" } }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "dev": true + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -1778,6 +3338,30 @@ "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1790,6 +3374,30 @@ "node": ">= 0.6" } }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ulidx": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ulidx/-/ulidx-2.3.0.tgz", + "integrity": "sha512-36piWNqcdp9hKlQewyeehCaALy4lyx3FodsCxHuV6i0YdexSkjDOubwxEVr2yi4kh62L/0MgyrxqG4K+qtovnw==", + "dependencies": { + "layerr": "^2.0.1" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", @@ -1849,6 +3457,36 @@ "extsprintf": "^1.2.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, "node_modules/yaml": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.2.tgz", @@ -1859,6 +3497,18 @@ "engines": { "node": ">= 14" } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index 93fec5c..d6cbe22 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,14 @@ { "name": "spam-police", "description": "A Matrix bot to monitor and respond to investment scam spamming across the Matrix platform, for example in rooms with a permanently offline admin.", - "main": "index.js", + "main": "dist/index.js", "type": "module", "version": "0.4.0", "scripts": { - "start": "node index.js" + "build": "rm -rf dist && npx tsc --project tsconfig.json", + "start": "node dist/index.js", + "prettier": "npx prettier src --check", + "lint": "npx eslint src -c .eslintrc.cjs --ext .ts && npm run prettier" }, "repository": { "type": "git", @@ -19,7 +22,20 @@ "homepage": "https://github.com/jjj333-p/spam-police#readme", "dependencies": { "@matrix-org/matrix-sdk-crypto-nodejs": "0.1.0-beta.12", + "@sinclair/typebox": "^0.32.29", "matrix-bot-sdk": "^0.7.1", + "matrix-protection-suite": "npm:@gnuxie/matrix-protection-suite@^0.21.0", + "matrix-protection-suite-for-matrix-bot-sdk": "npm:@gnuxie/matrix-protection-suite-for-matrix-bot-sdk@^0.21.0", "yaml": "^2.4.2" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^7.7.0", + "@typescript-eslint/parser": "^7.7.0", + "eslint": "^8.56.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.2", + "gnuxie-tsconfig": "github:Gnuxie/tsconfig", + "prettier": "^3.2.5", + "typescript": "^5.4.5" } } diff --git a/src/SpamBotMode.ts b/src/SpamBotMode.ts new file mode 100644 index 0000000..40e90b5 --- /dev/null +++ b/src/SpamBotMode.ts @@ -0,0 +1,106 @@ +// Copyright 2022 - 2024 Gnuxie +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// +// SPDX-FileAttributionText: +// This modified file incorporates work from draupnir +// https://github.com/the-draupnir-project/Draupnir +// + +import { + ActionResult, + DefaultEventDecoder, + MatrixRoomReference, + StandardClientsInRoomMap, + StringUserID, + isError, + isStringRoomAlias, + isStringRoomID, + isStringUserID, +} from "matrix-protection-suite"; +import { + ClientCapabilityFactory, + MatrixSendClient, + RoomStateManagerFactory, + SafeMatrixEmitter, + resolveRoomReferenceSafe, +} from "matrix-protection-suite-for-matrix-bot-sdk"; +import { SpamPoliceBotFactory } from "./SpamPoliceBotFactory"; +import { SpamPoliceBot } from "./SpamPoliceBot"; + +/** + * This is a simple helper to create an entire the SpamPoliceBot from just + * one bot account. In future we will want to source events from multiple clients. + */ +export async function makeSpamBotMode( + client: MatrixSendClient, + matrixEmitter: SafeMatrixEmitter, + rawManagementRoom: string, +): Promise> { + const clientUserId = await client.getUserId(); + if (!isStringUserID(clientUserId)) { + throw new TypeError(`${clientUserId} is not a valid mxid`); + } + if ( + !isStringRoomAlias(rawManagementRoom) && + !isStringRoomID(rawManagementRoom) + ) { + throw new TypeError(`${rawManagementRoom} is not a valid room id or alias`); + } + const configManagementRoomReference = + MatrixRoomReference.fromRoomIDOrAlias(rawManagementRoom); + const managementRoom = await resolveRoomReferenceSafe( + client, + configManagementRoomReference, + ); + if (isError(managementRoom)) { + // we throw because we're almost at the top level and if this is wrong, then all our code must be. + throw managementRoom.error; + } + await client.joinRoom( + managementRoom.ok.toRoomIDOrAlias(), + managementRoom.ok.getViaServers(), + ); + const clientsInRoomMap = new StandardClientsInRoomMap(); + const clientProvider = async (userID: StringUserID) => { + if (userID !== clientUserId) { + throw new TypeError(`Bot mode shouldn't be requesting any other mxids`); + } + return client; + }; + const roomStateManagerFactory = new RoomStateManagerFactory( + clientsInRoomMap, + clientProvider, + DefaultEventDecoder, + ); + const clientCapabilityFactory = new ClientCapabilityFactory(clientsInRoomMap); + const spamBotFactory = new SpamPoliceBotFactory( + clientsInRoomMap, + clientCapabilityFactory, + clientProvider, + roomStateManagerFactory, + ); + const spamBot = await spamBotFactory.makeSpamPoliceBot( + clientUserId, + managementRoom.ok, + ); + if (isError(spamBot)) { + const error = spamBot.error; + throw new Error(`Unable to create Draupnir: ${error.message}`); + } + matrixEmitter.on("room.invite", (roomID, event) => { + // for jjj: the clientsInRoomMap handles all event ingress into MPS for us, + // and in turn informs the various room state managers so that they are up to date + clientsInRoomMap.handleTimelineEvent(roomID, event); + }); + matrixEmitter.on("room.event", (roomID, event) => { + roomStateManagerFactory.handleTimelineEvent(roomID, event); + clientsInRoomMap.handleTimelineEvent(roomID, event); + }); + return spamBot; +} diff --git a/src/SpamPoliceBot.ts b/src/SpamPoliceBot.ts new file mode 100644 index 0000000..15d2ba3 --- /dev/null +++ b/src/SpamPoliceBot.ts @@ -0,0 +1,50 @@ +// Copyright 2024 Gnuxie +// +// SPDX-License-Identifier: Apache-2.0 + +import { + ProtectedRoomsSet, + ClientPlatform, + RoomStateManager, + PolicyRoomManager, + RoomMembershipManager, + StringUserID, + StringRoomID, + RoomEvent, + ClientRooms, +} from "matrix-protection-suite"; +import { MatrixSendClient } from "matrix-protection-suite-for-matrix-bot-sdk"; + +export class SpamPoliceBot { + private handleTimelineEventListener = this.handleTimelineEvent.bind(this); + public constructor( + /** The userID for the main bot user. */ + public readonly clientUserID: StringUserID, + public readonly managementRoomID: StringRoomID, + public readonly protectedRoomsSet: ProtectedRoomsSet, + // note for jjj: This is the MatrixClient from the bot-sdk, but only allows + // you to do actions that interact with Matrix, rather than listen for events. + // we restrict that on purpose so that people don't add adhoc listeners to it, + // which can easily get lost and become untracked / leaky etc. + public readonly client: MatrixSendClient, + // This provides the same functionality as the client, but each individual + // request into its own capability that can be provided to keep the scope + // of client dependencies restricted. (This really really helps when testing them). + // It will also help if you want the actions to go through a different client if + // say one server is down as you have suggested. Since you can easily implement + // your own client platform. + public readonly clientPlatform: ClientPlatform, + // These are used to access the various revision issuers. + public readonly roomStateManager: RoomStateManager, + public readonly policyRoomManager: PolicyRoomManager, + public readonly roomMembershipManager: RoomMembershipManager, + // This gives access to the list of joined rooms and also timelien events. + private readonly clientRooms: ClientRooms, + ) { + this.clientRooms.on("timeline", this.handleTimelineEventListener); + } + + private handleTimelineEvent(roomID: StringRoomID, event: RoomEvent): void { + this.protectedRoomsSet.handleTimelineEvent(roomID, event); + } +} diff --git a/src/SpamPoliceBotFactory.ts b/src/SpamPoliceBotFactory.ts new file mode 100644 index 0000000..34eb80a --- /dev/null +++ b/src/SpamPoliceBotFactory.ts @@ -0,0 +1,88 @@ +// Copyright 2023 - 2024 Gnuxie +// +// SPDX-License-Identifier: AFL-3.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from draupnir +// https://github.com/the-draupnir-project/Draupnir +// + +import { + ClientsInRoomMap, + StringUserID, + ActionResult, + isError, + StandardLoggableConfigTracker, + MatrixRoomID, + Ok, +} from "matrix-protection-suite"; +import { + ClientCapabilityFactory, + RoomStateManagerFactory, + ClientForUserID, + joinedRoomsSafe, +} from "matrix-protection-suite-for-matrix-bot-sdk"; +import { SpamPoliceBot } from "./SpamPoliceBot"; +import { makeProtectedRoomsSet } from "./SpamProtectedRoomsSet"; + +export class SpamPoliceBotFactory { + public constructor( + private readonly clientsInRoomMap: ClientsInRoomMap, + private readonly clientCapabilityFactory: ClientCapabilityFactory, + private readonly clientProvider: ClientForUserID, + private readonly roomStateManagerFactory: RoomStateManagerFactory, + ) { + // nothing to do. + } + + public async makeSpamPoliceBot( + clientUserID: StringUserID, + managementRoom: MatrixRoomID, + ): Promise> { + const roomStateManager = + await this.roomStateManagerFactory.getRoomStateManager(clientUserID); + const policyRoomManager = + await this.roomStateManagerFactory.getPolicyRoomManager(clientUserID); + const roomMembershipManager = + await this.roomStateManagerFactory.getRoomMembershipManager(clientUserID); + const client = await this.clientProvider(clientUserID); + const clientRooms = await this.clientsInRoomMap.makeClientRooms( + clientUserID, + async () => joinedRoomsSafe(client), + ); + if (isError(clientRooms)) { + return clientRooms; + } + const clientPlatform = this.clientCapabilityFactory.makeClientPlatform( + clientUserID, + client, + ); + const configLogTracker = new StandardLoggableConfigTracker(); + const protectedRoomsSet = await makeProtectedRoomsSet( + managementRoom, + roomStateManager, + policyRoomManager, + roomMembershipManager, + client, + clientPlatform, + clientUserID, + configLogTracker, + ); + if (isError(protectedRoomsSet)) { + return protectedRoomsSet; + } + return Ok( + new SpamPoliceBot( + clientUserID, + managementRoom.toRoomIDOrAlias(), + protectedRoomsSet.ok, + client, + clientPlatform, + roomStateManager, + policyRoomManager, + roomMembershipManager, + clientRooms.ok, + ), + ); + } +} diff --git a/src/SpamProtectedRoomsSet.ts b/src/SpamProtectedRoomsSet.ts new file mode 100644 index 0000000..0283056 --- /dev/null +++ b/src/SpamProtectedRoomsSet.ts @@ -0,0 +1,196 @@ +// Copyright 2022 - 2024 Gnuxie +// Copyright 2019 - 2021 The Matrix.org Foundation C.I.C. +// +// SPDX-License-Identifier: AFL-3.0 AND Apache-2.0 +// +// SPDX-FileAttributionText: +// This modified file incorporates work from mjolnir +// https://github.com/matrix-org/mjolnir +// +// SPDX-FileAttributionText: +// This modified file incorporates work from draupnir +// https://github.com/the-draupnir-project/Draupnir +// + +import { + ActionResult, + ClientPlatform, + LoggableConfigTracker, + Logger, + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, + MatrixRoomID, + MissingProtectionCB, + MjolnirEnabledProtectionsEvent, + MjolnirEnabledProtectionsEventType, + MjolnirPolicyRoomsConfig, + MjolnirProtectedRoomsConfig, + MjolnirProtectedRoomsEvent, + MjolnirProtectionSettingsEventType, + MjolnirProtectionsConfig, + MjolnirWatchedPolicyRoomsEvent, + Ok, + PolicyListConfig, + PolicyRoomManager, + ProtectedRoomsConfig, + ProtectedRoomsSet, + ProtectionsManager, + RoomJoiner, + RoomMembershipManager, + RoomResolver, + RoomStateManager, + StandardProtectedRoomsManager, + StandardProtectedRoomsSet, + StandardProtectionsManager, + StandardSetMembership, + StandardSetRoomState, + StringUserID, + isError, +} from "matrix-protection-suite"; +import { + BotSDKMatrixAccountData, + BotSDKMatrixStateData, + MatrixSendClient, +} from "matrix-protection-suite-for-matrix-bot-sdk"; + +const log = new Logger("SpamProtectedRoomsSet"); + +async function makePolicyListConfig( + client: MatrixSendClient, + policyRoomManager: PolicyRoomManager, + roomJoiner: RoomJoiner, +): Promise> { + const result = await MjolnirPolicyRoomsConfig.createFromStore( + new BotSDKMatrixAccountData( + MJOLNIR_WATCHED_POLICY_ROOMS_EVENT_TYPE, + MjolnirWatchedPolicyRoomsEvent, + client, + ), + policyRoomManager, + roomJoiner, + ); + return result; +} + +async function makeProtectedRoomsConfig( + client: MatrixSendClient, + roomResolver: RoomResolver, + loggableConfigTracker: LoggableConfigTracker, +): Promise> { + return await MjolnirProtectedRoomsConfig.createFromStore( + new BotSDKMatrixAccountData( + MJOLNIR_PROTECTED_ROOMS_EVENT_TYPE, + MjolnirProtectedRoomsEvent, + client, + ), + roomResolver, + loggableConfigTracker, + ); +} + +function missingProtectionCB(protectionName: string): void { + log.warn( + `Unable to find a protection description for the protection named`, + protectionName, + ); +} + +function makeMissingProtectionCB(): MissingProtectionCB { + return missingProtectionCB; +} + +async function makeProtectionsManager( + client: MatrixSendClient, + roomStateManager: RoomStateManager, + managementRoom: MatrixRoomID, + loggableConfigTracker: LoggableConfigTracker, +): Promise> { + const result = + await roomStateManager.getRoomStateRevisionIssuer(managementRoom); + if (isError(result)) { + return result; + } + // FIXME: You probably want to create your own class that impleemnts the `ProtectionsConfig` + // interface (Ctrl+click on MjolnirProtectionsConfig to drill down and find it). + // As this one reuses a state event used by Mjolnir to store it. + // You don't need to though. + const protectionsConfigResult = await MjolnirProtectionsConfig.create( + new BotSDKMatrixAccountData( + MjolnirEnabledProtectionsEventType, + MjolnirEnabledProtectionsEvent, + client, + ), + loggableConfigTracker, + { + missingProtectionCB: makeMissingProtectionCB(), + }, + ); + if (isError(protectionsConfigResult)) { + return protectionsConfigResult; + } + return Ok( + new StandardProtectionsManager( + protectionsConfigResult.ok, + new BotSDKMatrixStateData( + MjolnirProtectionSettingsEventType, + result.ok, + client, + ), + ), + ); +} + +export async function makeProtectedRoomsSet( + managementRoom: MatrixRoomID, + roomStateManager: RoomStateManager, + policyRoomManager: PolicyRoomManager, + roomMembershipManager: RoomMembershipManager, + client: MatrixSendClient, + clientPlatform: ClientPlatform, + userID: StringUserID, + loggableConfigTracker: LoggableConfigTracker, +): Promise> { + const protectedRoomsConfig = await makeProtectedRoomsConfig( + client, + clientPlatform.toRoomResolver(), + loggableConfigTracker, + ); + if (isError(protectedRoomsConfig)) { + return protectedRoomsConfig; + } + const protectedRoomsManager = await StandardProtectedRoomsManager.create( + protectedRoomsConfig.ok, + roomStateManager, + roomMembershipManager, + clientPlatform.toRoomJoiner(), + StandardSetMembership.blankSet(), + StandardSetRoomState.blankSet(), + ); + if (isError(protectedRoomsManager)) { + return protectedRoomsManager; + } + const policyListConfig = await makePolicyListConfig( + client, + policyRoomManager, + clientPlatform.toRoomJoiner(), + ); + if (isError(policyListConfig)) { + return policyListConfig; + } + const protectionsConfig = await makeProtectionsManager( + client, + roomStateManager, + managementRoom, + loggableConfigTracker, + ); + if (isError(protectionsConfig)) { + return protectionsConfig; + } + const protectedRoomsSet = new StandardProtectedRoomsSet( + policyListConfig.ok, + protectedRoomsManager.ok, + protectionsConfig.ok, + userID, + ); + return Ok(protectedRoomsSet); +} diff --git a/src/banlistReader.js b/src/banlistReader.js new file mode 100644 index 0000000..372a492 --- /dev/null +++ b/src/banlistReader.js @@ -0,0 +1,196 @@ +//see if the State Event `se` is intended to ban the mxid `matchMXID` +function matchBanlistEventToUser(se, mm) { + let matchMXID = mm; + + //if the ban was erased + if (!se.content) { + return false; + } + + //parce out the mxid from the key + const potentialMXID = se.state_key.substring(5); + + //if mismatch, its invalid + //mothet sidev + if ( + se.content.entity !== potentialMXID || + se.content.recommendation !== "org.matrix.mjolnir.ban" + ) { + return false; + } + + //if exact match, it is match + if (potentialMXID === matchMXID) { + return true; + } + + //if theres no wildcards, and not an exact match, its not a match + if (!potentialMXID.includes("*")) { + return false; + } + + //split around the wildcards + const p = potentialMXID.split("*"); + + //check before first wildcard + const firstpart = p.shift(); + if (!matchMXID.startsWith(firstpart)) { + return false; + } + + //check after last wildcard + const lastpart = p.pop(); + if (!matchMXID.endsWith(lastpart)) { + return false; + } + + //parce off the evaluated start and end + matchMXID = matchMXID.substring( + firstpart.length, + matchMXID.length - lastpart.length, + ); + + //loop until a condition to return arises + while (true) { + //if there is no more parts to match and everything else around wildcards matched + //it is match + if (p.length < 1) { + return true; + } + + //if one of the bits between wildcards doesnt exist, it cant be a match + if (!matchMXID.includes(p[0])) { + return false; + } + + //match the remaining parts in order starting with the first one left to match, then removing everything + //before it, and going back through the process untill theres nothing left to match + //have to use a substring of the original in case there was multiple matches of the next item to match + const nexttomatch = p.shift(); + const r = matchMXID.split(nexttomatch); + matchMXID = matchMXID.substring(nexttomatch.length + r[0]); + } +} + +class BanlistReader { + constructor(client) { + this.client = client; + + this.rooms = new Map(); + } + + //synchronize all the state events of the provided room + async syncRoom(roomId) { + let list = (await this.client.getRoomState(roomId)).filter( + (event) => event.type === "m.policy.rule.user", + ); + + //allow .find to be run + if (!list) list = []; + + //organize away that list for later + this.rooms.set(roomId, list); + } + + //event run loop + async run({ roomId, event, config }) { + //fetch room's list of banlist events + const roomEvents = this.rooms.get(roomId); + + //see comment below + await this.syncRoom(roomId); + + /* + if the run pipeline was called without an event for whatever reason dont proceed + + the run method will only be directly triggered on a banlist recomendation event + + the run method may be triggered on redaction events without an event fed in *if* + the event it is redacting is a banlist recomendation + */ + if (!event) { + return; + } + + //confirm that the bot updated its list with the new event + this.client.sendReadReceipt(roomId, event.event_id); + + //fetch the set alias of the room + let mainRoomAlias = await this.client.getPublishedAlias(roomId); + + //if there is no alias of the room + if (!mainRoomAlias) { + //dig through the state, find room name, and use that in place of the main room alias + mainRoomAlias = (await this.client.getRoomState(roomId)).find( + (state) => state.type === "m.room.name", + ).content.name; + } + + //all rooms the bot is in + const joinedrooms = await this.client.getJoinedRooms(); + + //look through all these rooms for any that may be following the banlist this was + //written to + // biome-ignore lint/complexity/noForEach: + joinedrooms.forEach(async (r) => { + //fetch banlists for room + let roomBanlists = config.getConfig(r, "banlists"); + + //if there is no config, create a temporary one with just the room id + if (!roomBanlists) { + roomBanlists = [r]; + } + + //if there is a config, set the room up to check its own banlist + else { + roomBanlists.push(r); + } + + //if the room isn't following the banlist that the recomendation was written to, + //we shouldn't continue + if (!roomBanlists.some((rm) => rm === roomId)) { + return; + } + + //find any joined users matching the ban rule + const matched = (await this.client.getJoinedRoomMembers(r)).filter((m) => + matchBanlistEventToUser(event, m), + ); + + //ban found users + // biome-ignore lint/complexity/noForEach: + matched.forEach(async (mm) => + this.client + .banUser(mm, r, `${event.content.reason} (${mainRoomAlias})`) + .catch(() => {}), + ); + }); + } + + // get all "org.matrix.mjolnir.ban" type state events for a room + async getRules(roomId) { + //fetch room's list of banlist events + //rooms is a map of just the "org.matrix.mjolnir.ban" events + let roomEvents = this.rooms.get(roomId); + + //if the room was never synced, sync it + if (!Array.isArray(roomEvents)) { + await this.syncRoom(roomId); + roomEvents = this.rooms.get(roomId); + } + + return roomEvents; + } + + //find if there is a rule recommending the ban of a provided mxid + async match(roomId, matchMXID) { + //look through all the state events + const match = (await this.getRules(roomId)).find((se) => + matchBanlistEventToUser(se, matchMXID), + ); + + return match; + } +} + +export { BanlistReader }; diff --git a/src/blacklist.js b/src/blacklist.js new file mode 100644 index 0000000..29fd9e5 --- /dev/null +++ b/src/blacklist.js @@ -0,0 +1,94 @@ +import { openSync, readdirSync, readFileSync, writeFile } from "fs"; + +class blacklist { + constructor() { + this.filepath = "./db/blacklist.txt"; + + //make sure file exists + if (!readdirSync("./db/").some((d) => d === "blacklist.txt")) { + closeSync(openSync(this.filepath, "w")); + } + + //read file + this.blacklistTXT = readFileSync(this.filepath, "utf-8"); + + //split into entry lines + this.blacklistARRAY = this.blacklistTXT.split("\n"); + } + + //fetches file from disk again, perhaps in case of updated file + reload() { + //read file + this.blacklistTXT = readFileSync(this.filepath, "utf-8"); + + //split into entry lines + this.blacklistARRAY = this.blacklistTXT.split("\n"); + } + + //returns the reason for blacklisting if roomId is in blacklist, otherwise returns null + match(roomId) { + //check if match + const match = this.blacklistARRAY.find((entry) => + entry.split(" ")[0].includes(roomId), + ); + + if (match) { + //return the reason part of the entry + return match.substring(match.split(" ")[0].length + 1); + + //if no entry, return empty + } + } + + //add roomId to the blacklist with reason + async add(roomId, reason) { + //check if the room is already in the blacklist + if ( + this.blacklistARRAY.some((entry) => entry.split(" ")[0].includes(roomId)) + ) { + //return that as msg + return "Already added."; + } + + //add roomId and reason together to make entry line + const entry = `${roomId} ${reason}`; + + //add entry to array + this.blacklistARRAY.push(entry); + + //add entry to txt file + this.blacklistTXT = `${this.blacklistTXT}\n${entry}`; + + //var to store error + let er; + + //write blacklist text file + writeFile(this.filepath, this.blacklistTXT, null, (err) => { + er = err; + }); + + //return whatever error message was found + return er; + } + + async remove(roomId) { + let er; + + //filter to only include entries without that room id + this.blacklistARRAY = this.blacklistARRAY.filter( + (entry) => !entry.split(" ")[0].includes(roomId), + ); + + //join array into text file + this.blacklistTXT = this.blacklistARRAY.join("\n"); + + writeFile(this.filepath, this.blacklistTXT, null, (err) => { + er = err; + }); + + //return whatever error message was found + return er; + } +} + +export { blacklist }; diff --git a/src/commands/banlist.js b/src/commands/banlist.js new file mode 100644 index 0000000..912df02 --- /dev/null +++ b/src/commands/banlist.js @@ -0,0 +1,169 @@ +class Banlist { + async run({ client, roomId, event }, { offset, contentByWords }) { + try { + //parce the room that the cmd is referring to + let banlist = contentByWords[2 + offset]; + + if (!banlist) { + client.sendNotice(roomId, "❌ | invalid usage."); + + return; + } + + //use here so people dont have to type the room alias on an asinine client + if (banlist.toLowerCase() === "here") banlist = roomId; + + //fetch spam police config + const stateconfigevent = (await client.getRoomState(roomId)).filter( + (event) => event.type === "agency.pain.anti-scam.config", + )[0]; + + if (stateconfigevent?.content?.children?.[banlist]) + banlist = stateconfigevent.content.children[banlist]; + + //resolve alias to an id for easier use + client + .resolveRoom(banlist) + .then(async (banlistid) => { + let blconfigevent; + let anonymous; + try { + //fetch spam police config + blconfigevent = (await client.getRoomState(banlistid)).filter( + (event) => event.type === "agency.pain.anti-scam.config", + )[0]; + + anonymous = + (blconfigevent?.content?.parent && + blconfigevent.content.parent === roomId) || + roomId === banlistid; + + //check pl + if ( + !( + (await client.userHasPowerLevelForAction( + event.sender, + banlistid, + "ban", + )) || + (await client.userHasPowerLevelFor( + event.sender, + banlistid, + "m.policy.rule.user", + true, + )) + ) + ) { + client.sendNotice( + roomId, + "❌ | You don't have sufficent permission in the banlist room.", + ); + + return; + } + } catch (e) { + client.sendNotice(roomId, JSON.stringify(e)); + return; + } + + //account for all the chars when spit by spaces + let reasonStart = offset + 3 + 1; + + //for every word before the reason starts, count the length of that word + //and add it to the offset + for (let i = 0; i < offset + 3 + 1; i++) { + reasonStart += contentByWords[i].length; + } + + //parce the reason using the offset + let reason = event.content.body.substring(reasonStart); + + if (!anonymous) reason += ` (by ${event.sender})`; + + //parce out banned user + const bannedUser = contentByWords[3 + offset]; + + const action = contentByWords[1 + offset].toLowerCase(); + + if (action === "add") { + //make banlist rule + client + .sendStateEvent( + banlistid, + "m.policy.rule.user", + `rule:${bannedUser}`, + { + entity: bannedUser, + reason: reason, + recommendation: "org.matrix.mjolnir.ban", + }, + ) + .then(() => + client + .sendNotice( + roomId, + "✅ | Successfully set ban recommendation.", + ) + .catch(() => {}), + ) + .catch((err) => + client + .sendHtmlNotice( + roomId, + `

🍃 | I unfortunately ran into the following error while trying to add that to the banlist:\n

${err}`, + ) + .catch(() => {}), + ); + } else if (action === "remove" || action === "delete") { + //make banlist rule + client + .sendStateEvent( + banlistid, + "m.policy.rule.user", + `rule:${bannedUser}`, + { + reason: reason, + }, + ) + .then(() => + client + .sendNotice( + roomId, + "✅ | Successfully removed ban recommendation.", + ) + .catch(() => {}), + ) + .catch((err) => + client + .sendHtmlNotice( + roomId, + `

🍃 | I unfortunately ran into the following error while trying to remove that banlist rule:\n

${err}`, + ) + .catch(() => {}), + ); + } else { + client + .sendHtmlNotice( + roomId, + `

❌ | Invalid action, ${action} != add or remove/delete.

`, + ) + .catch(() => {}); + } + }) + .catch((err) => + client + .sendHtmlNotice( + roomId, + `

🍃 | I unfortunately ran into the following error while trying to resolve that room:\n

${err}`, + ) + .catch(() => {}), + ); + + //this might fail, just catch it and move on + } catch (e) { + console.log(e); + } + } +} + +export { Banlist }; diff --git a/src/commands/create-community.js b/src/commands/create-community.js new file mode 100644 index 0000000..43abb64 --- /dev/null +++ b/src/commands/create-community.js @@ -0,0 +1,143 @@ +class CC { + async run( + { client, roomId, event, mxid, blacklist, server }, + { offset, commandRoom }, + ) { + //create space + const newSpaceId = await client + .createRoom({ + creation_content: { + type: "m.space", + }, + initial_state: [ + { + content: { + join_rule: "public", + }, + state_key: "", + type: "m.room.join_rules", + }, + { + content: { + history_visibility: "joined", + }, + state_key: "", + type: "m.room.history_visibility", + }, + ], + power_level_content_override: { + ban: 50, + events: { + "m.room.avatar": 50, + "m.room.canonical_alias": 50, + "m.room.encryption": 100, + "m.room.history_visibility": 100, + "m.room.name": 50, + "m.room.power_levels": 100, + "m.room.server_acl": 100, + "m.room.tombstone": 100, + }, + events_default: 50, + invite: 50, + kick: 50, + notifications: { + room: 50, + }, + redact: 50, + state_default: 50, + users: { + [mxid]: 102, + [event.sender]: 103, + }, + users_default: 0, + }, + invite: [event.sender], + is_direct: false, + name: "Community Management Room", + room_version: "11", + }) + .catch(() => + client + .sendHtmlNotice( + roomId, + "❌ | I encountered an error attempting to create the space", + ) + .catch(() => {}), + ); + + const newRoomId = await client + .createRoom({ + initial_state: [ + { + content: { + join_rule: "invite", + }, + state_key: "", + type: "m.room.join_rules", + }, + { + content: { + history_visibility: "joined", + }, + state_key: "", + type: "m.room.history_visibility", + }, + { + content: { + canonical: true, + via: [server], + }, + state_key: newSpaceId, + type: "m.space.parent", + }, + ], + power_level_content_override: { + ban: 50, + events: { + "m.room.avatar": 50, + "m.room.canonical_alias": 50, + "m.room.encryption": 100, + "m.room.history_visibility": 100, + "m.room.name": 50, + "m.room.power_levels": 100, + "m.room.server_acl": 100, + "m.room.tombstone": 100, + }, + events_default: 0, + invite: 50, + kick: 50, + notifications: { + room: 50, + }, + redact: 50, + state_default: 50, + users: { + [mxid]: 102, + [event.sender]: 103, + }, + users_default: 0, + }, + invite: [event.sender], + is_direct: false, + name: "Community Management Room", + room_version: "11", + }) + .catch(() => + client + .sendHtmlNotice( + roomId, + "❌ | I encountered an error attempting to create the management room", + ) + .catch(() => {}), + ); + + client + .sendStateEvent(newSpaceId, "m.space.child", newRoomId, { + suggested: false, + via: [server], + }) + .catch(() => {}); + } +} + +export { CC }; diff --git a/src/commands/followbanlist.js b/src/commands/followbanlist.js new file mode 100644 index 0000000..8b0dd2f --- /dev/null +++ b/src/commands/followbanlist.js @@ -0,0 +1,194 @@ +class FollowBanList { + async run( + { client, roomId, event, mxid, blacklist }, + { offset, contentByWords, config }, + ) { + //check if the command even has the required feilds + if (contentByWords.length !== 3 + offset) { + client.sendNotice(roomId, "❌ | Malformed command.").catch(() => {}); + + return; + } + + //make sure the user has ban permissions before adding banlist + if ( + !(await client.userHasPowerLevelForAction(event.sender, roomId, "ban")) + ) { + client + .sendNotice( + roomId, + "❌ | You don't have sufficent permission. (need ban permission)", + ) + .catch(() => {}); + + return; + } + + //make sure the bot has ban permissions before adding banlist + if (!(await client.userHasPowerLevelForAction(mxid, roomId, "ban"))) { + client + .sendNotice( + roomId, + "❌ | I don't have sufficent permission. (need ban permission)", + ) + .catch(() => {}); + + return; + } + + //get already set banlists + let currentBanlists = config.getConfig(roomId, "banlists"); + + //if there is none, give it something to prevent erroring + if (!currentBanlists) { + currentBanlists = []; + } + + //if the user wants a list + if (contentByWords[offset + 1].toLowerCase() === "list") { + client.sendNotice(roomId, "banlist list here").catch(() => {}); + + return; + } + + //grep out the room indicated by the user + const joinroom = contentByWords[2 + offset]; + + //evaluate if its a valid alias + client + .resolveRoom(joinroom) + .then(async (joinroomid) => { + if (contentByWords[1 + offset] === "add") { + //if already following banlist, there is nothing to do + if (currentBanlists.includes(joinroomid)) { + client + .sendNotice(roomId, "♻️ | Already following this banlist.") + .catch(() => {}); + + return; + } + + //check blacklist for a blacklisted reason + const blReason = blacklist.match(joinroomid); + + //if there is a reason that means the room was blacklisted + if (blReason) { + //send error + client + .sendHtmlNotice( + roomId, + `❌ | The bot was blacklisted from this room for reason ${blReason}.`, + ) + .catch(() => {}); + + //dont continue trying to join + return; + } + + //if the bot is already joined to the banlist, no need to try to join + if ((await client.getJoinedRooms()).includes(joinroomid)) { + //add the new banlist to the banlists + currentBanlists.push(joinroomid); + + //set the config + config.setConfig(roomId, "banlists", currentBanlists, (err) => { + client.sendNotice(roomId, err).catch(() => {}); + }); + + return; + } + + //deduce possible servers with the required information to join into the room + const aliasServer = joinroom.split(":")[1]; + const senderServer = event.sender.split(":")[1]; + const botServer = mxid.split(":")[1]; + + //try to join + client + .joinRoom(joinroomid, [ + aliasServer, + senderServer, + botServer, + "matrix.org", + ]) + .then(() => { + //greeting message + const greeting = `Greetings! I am brought here by ${event.sender}, bot by @jjj333:pain.agency (pls dm for questions). I am joining this room purely to read from the banlist, and as such will default to muted mode, however this can be changed with [prefix]mute. For more information please visit https://github.com/jjj333-p/spam-police`; + + //add the new banlist to the banlists + currentBanlists.push(joinroomid); + + //set the config + config.setConfig(roomId, "banlists", currentBanlists, (err) => { + client.sendNotice(roomId, err); + }); + + //try to send the greeting + client + .sendNotice(joinroomid, greeting) + .finally(() => { + //confirm joined and can send messages + client + .sendNotice(roomId, "✅ | successfully joined banlist!") + .catch(() => {}); + }) + .catch((err) => {}); + }) + .catch((err) => { + //throw error about joining room + client + .sendHtmlNotice( + roomId, + `❌ | I ran into the following error while trying to join that room:
${JSON.stringify( + err.body, + null, + 2, + )}
`, + ) + .catch(() => {}); + }); + } else if (contentByWords[1 + offset] === "remove") { + //if not currently subscribed, there is nothing to remove + if (!currentBanlists.some((b) => b === joinroomid)) { + client + .sendHtmlNotice( + roomId, + `Not currently subscribed to any banlist with the RoomID ${joinroomid}.`, + ) + .catch(() => {}); + + return; + } + + config.setConfig( + roomId, + "banlists", + currentBanlists.filter((b) => b !== joinroomid), + (err) => { + client.sendNotice(roomId, err); + }, + ); + } else { + client + .sendHtmlNotice( + roomId, + `❌ | Unknown operation
${ + contentByWords[1 + offset] + }
, expected
add
or
remove.`, + ) + .catch(() => {}); + } + }) + .catch((err) => { + //throw error about invalid alias + client + .sendHtmlNotice( + roomId, + `❌ | I ran into the following error while trying to resolve that room ID:
${err.message}
`, + ) + .catch(() => {}); + }); + } +} + +export { FollowBanList }; diff --git a/src/commands/join.js b/src/commands/join.js new file mode 100644 index 0000000..541ca31 --- /dev/null +++ b/src/commands/join.js @@ -0,0 +1,106 @@ +class Join { + async run( + { client, roomId, event, mxid, blacklist }, + { offset, commandRoom }, + ) { + //if not run in the command room + if (roomId !== commandRoom) { + client + .sendNotice( + roomId, + `❌ | you must run +join commands in https://matrix.to/#/${commandRoom}?via=${ + mxid.split(":")[1] + }`, + ) + .catch(() => {}); + + return; + } + + //grep out the room indicated by the user + const joinroom = event.content.body.split(" ")[1 + offset]; + + //evaluate if its a valid alias + client + .resolveRoom(joinroom) + .then(async (joinroomid) => { + //check blacklist for a blacklisted reason + const blReason = blacklist.match(joinroomid); + + //if there is a reason that means the room was blacklisted + if (blReason) { + //send error + client + .sendHtmlNotice( + roomId, + `❌ | The bot was blacklisted from this room for reason ${blReason}.`, + ) + .catch(() => {}); + + //dont continue trying to join + return; + } + + //deduce possible servers with the required information to join into the room + const aliasServer = joinroom.split(":")[1]; + const senderServer = event.sender.split(":")[1]; + const botServer = mxid.split(":")[1]; + + //try to join + client + .joinRoom(joinroomid, [ + aliasServer, + senderServer, + botServer, + "matrix.org", + ]) + .then(() => { + //greeting message + const greeting = `Greetings! I am brought here by ${event.sender}, bot by @jjj333:pain.agency (pls dm for questions). + My MO is I warn people about telegram and whatsapp investment scams whenever they are posted in the room. If I am unwanted please just kick me. + For more information please visit https://github.com/jjj333-p/spam-police`; + + //try to send the greeting + client + .sendNotice(joinroomid, greeting) + .then(() => { + //confirm joined and can send messages + client.sendNotice(roomId, "✅ | successfully joined room!"); + }) + .catch((err) => { + //confirm could join, but show error that couldn't send messages + client + .sendNotice( + roomId, + "🍃 | I was able to join the provided room however I am unable to send messages, and therefore will only be able to react to messages with my warning.", + ) + .catch(() => {}); + }); + }) + .catch((err) => { + //throw error about joining room + client + .sendHtmlNotice( + roomId, + `❌ | I ran into the following error while trying to join that room:
${JSON.stringify( + err.body, + null, + 2, + )}
`, + ) + .catch(() => {}); + }); + }) + .catch((err) => { + //throw error about invalid alias + client + .sendHtmlNotice( + roomId, + `❌ | I ran into the following error while trying to resolve that room ID:
${err.message}
`, + ) + .catch(() => {}); + }); + } +} + +export { Join }; diff --git a/src/commands/leave.js b/src/commands/leave.js new file mode 100644 index 0000000..5411cfe --- /dev/null +++ b/src/commands/leave.js @@ -0,0 +1,98 @@ +class Leave { + async run({ client, roomId, event, blacklist }, { authorizedUsers, offset }) { + //verify is sent by an admin + if (authorizedUsers.some((u) => u === event.sender)) { + //parce out the possible room id + const leaveRoom = event.content.body.split(" ")[1 + offset]; + + //"+leave" as well as a space afterwards + let substringStart = 7; + + //if has the characters required for a room id or alias + if ( + (leaveRoom.includes("#") || leaveRoom.includes("!")) && + leaveRoom.includes(":") && + leaveRoom.includes(".") + ) { + //evaluate if its a valid alias + client + .resolveRoom(leaveRoom) + .then(async (leaveroomid) => { + //add room id or alias to start the reason at the right part of the string + substringStart = substringStart + leaveRoom.length + 1; + + //parce out the reason + let reason = event.content.body.substring(substringStart); + + //make sure reason is in the banlist + if (!reason) { + reason = ""; + } + + //add room to blacklist + blacklist.add(leaveroomid, reason); + + //let the room know why the bot is leaving + client + .sendHtmlNotice( + leaveroomid, + `Leaving room for reason ${reason}.`, + ) + .catch(() => {}) //doesnt matter if unable to send to the room + .finally(() => { + //attempt to leave the room + client + .leaveRoom(leaveroomid) + .then(() => { + //success message + client + .sendHtmlNotice( + roomId, + `✅ | left room ${leaveroomid} with reason ${reason}.`, + ) + .catch(() => {}); + }) + .catch((err) => { + //error message + client + .sendHtmlNotice( + roomId, + `❌ | I ran into the following error leaving the room: ${err}`, + ) + .catch(() => {}); + }); + }); + }) + .catch((err) => { + //throw error about invalid alias + client + .sendHtmlNotice( + roomId, + `❌ | I ran into the following error while trying to resolve that room ID:
${err.message}
`, + ) + .catch(() => {}); + }); + + //if cant possibly be a room alias, leave *this* room + } else { + //parce out reason + const reason = event.content.body.substring(substringStart); + + //add to blacklist + blacklist.add(roomId, reason); + + //leave room + client.leaveRoom(roomId).catch(() => {}); + } + } else { + client + .sendText( + roomId, + "Sorry, only my owner(s) can do that. If you are a moderator of the room please just kick me from the room, I will not join back unless someone tells me to (and I will disclose who told me to).", + ) + .catch(() => {}); + } + } +} + +export { Leave }; diff --git a/src/commands/mute.js b/src/commands/mute.js new file mode 100644 index 0000000..73aaa26 --- /dev/null +++ b/src/commands/mute.js @@ -0,0 +1,51 @@ +class Mute { + async run({ client, roomId, event }, { config }) { + //im equivicating muting the bot to redacting its messages right after they are sent. + if ( + !(await client.userHasPowerLevelForAction(event.sender, roomId, "redact")) + ) { + //"redact")){ + + //error msg + client + .sendNotice( + roomId, + "🍃 | This command requires you have a powerlevel high enough to redact other users messages.", + ) + .catch(() => {}); + + //dont run the cmd + return; + } + + //confirm got message, idk if this actually works lmao + client.sendReadReceipt(roomId, event.event_id).catch(() => {}); + + //grab the opposite of what is in the db + const mute = !config.getConfig(roomId, "muted"); + + if (mute) { + client + .sendNotice( + roomId, + "Putting the bot into mute mode for this channel...", + ) + .catch(() => {}); + } else { + client + .sendNotice( + roomId, + "Taking the bot out of mute mode for this channel...", + ) + .catch(() => {}); + } + + //set the new config + config.setConfig(roomId, "muted", mute, (response) => { + //send confirmation + client.sendNotice(roomId, response).catch(() => {}); + }); + } +} + +export { Mute }; diff --git a/src/commands/restart.js b/src/commands/restart.js new file mode 100644 index 0000000..a0a0512 --- /dev/null +++ b/src/commands/restart.js @@ -0,0 +1,37 @@ +class Restart { + async run({ client, event, roomId }, { authorizedUsers }) { + //only for authorized users + if (authorizedUsers.some((u) => u === event.sender)) { + client + .sendEvent(roomId, "m.reaction", { + "m.relates_to": { + event_id: event.event_id, + key: "✅♻️", + rel_type: "m.annotation", + }, + }) + + //catch the error to prevent crashing, however if it cant send theres not much to do + .catch(() => {}) + + //just exit, setup on vps is for systemd to restart the service on exit + .finally(() => { + process.exit(0); + }); + } else { + client + .sendEvent(roomId, "m.reaction", { + "m.relates_to": { + event_id: event.event_id, + key: "❌ | unauthorized", + rel_type: "m.annotation", + }, + }) + + //catch the error to prevent crashing, however if it cant send theres not much to do + .catch(() => {}); + } + } +} + +export { Restart }; diff --git a/src/commands/rules.js b/src/commands/rules.js new file mode 100644 index 0000000..59aa6ca --- /dev/null +++ b/src/commands/rules.js @@ -0,0 +1,64 @@ +import fs from "node:fs"; + +class Rules { + async run( + { client, roomId, event, config, banListReader }, + { offset, contentByWords }, + ) { + //fetch banlists for room + let roomBanlists = config.getConfig(roomId, "banlists"); + + //if there is no config, create a temporary one with just the room id + if (!roomBanlists) { + roomBanlists = [roomId]; + } + + //if there is a config, set the room up to check its own banlist + else { + if (!roomBanlists.includes(roomId)) { + roomBanlists = [roomId, ...roomBanlists]; + } + } + + //object to write rules to + const rules = {}; + + //look through all banlists + for (let i = 0; i < roomBanlists.length; i++) { + const rm = roomBanlists[i]; + + //find recommendation + const rulesForRoom = await banListReader.getRules(rm); + + rules[rm] = rulesForRoom; + } + + //generate filename to write to + const filename = `UserBanRecommendations_${roomId}_${new Date().toISOString()}.json`; + + //convert json into binary buffer + const file = Buffer.from(JSON.stringify(rules, null, 2)); + + //upload the file buffer to the matrix homeserver, and grab mxc:// url + const linktofile = await client + .uploadContent(file, "application/json", filename) + .catch(() => { + client.sendNotice(roomId, "Error uploading file.").catch(() => {}); + }); + + //send file + client + .sendMessage(roomId, { + body: filename, + info: { + mimetype: "application/json", + size: file.byteLength, + }, + msgtype: "m.file", + url: linktofile, + }) + .catch(() => {}); + } +} + +export { Rules }; diff --git a/src/commands/unblacklist.js b/src/commands/unblacklist.js new file mode 100644 index 0000000..d86b9d9 --- /dev/null +++ b/src/commands/unblacklist.js @@ -0,0 +1,64 @@ +class Unblacklist { + async run({ client, roomId, event, blacklist }, { authorizedUsers, offset }) { + //verify is sent by an admin + if (authorizedUsers.some((u) => u === event.sender)) { + //parce out the possible room id + const leaveRoom = event.content.body.split(" ")[1 + offset]; + + //if has the characters required for a room id or alias + if ( + (leaveRoom.includes("#") || leaveRoom.includes("!")) && + leaveRoom.includes(":") && + leaveRoom.includes(".") + ) { + //evaluate if its a valid alias + client + .resolveRoom(leaveRoom) + .then(async (leaveroomid) => { + //remove room to blacklist + blacklist.remove(leaveroomid).then(() => { + client + .sendEvent(roomId, "m.reaction", { + "m.relates_to": { + event_id: event.event_id, + key: "✅", + rel_type: "m.annotation", + }, + }) + .catch(() => {}); + }); + }) + .catch((err) => { + //throw error about invalid alias + client + .sendHtmlNotice( + roomId, + `❌ | I ran into the following error while trying to resolve that room ID:
${err.message}
`, + ) + .catch(() => {}); + }); + + //if cant possibly be a room alias + } else { + client + .sendEvent(roomId, "m.reaction", { + "m.relates_to": { + event_id: event.event_id, + key: "❌", + rel_type: "m.annotation", + }, + }) + .catch(() => {}); + } + } else { + client + .sendText( + roomId, + "Sorry, only my owner(s) can do that. If you are a moderator of the room please just kick me from the room, I will not join back unless someone tells me to (and I will disclose who told me to).", + ) + .catch(() => {}); + } + } +} + +export { Unblacklist }; diff --git a/src/commands/uptime.js b/src/commands/uptime.js new file mode 100644 index 0000000..ec37a5a --- /dev/null +++ b/src/commands/uptime.js @@ -0,0 +1,27 @@ +class Uptime { + async run({ client, roomId, event }) { + //const user know that the bot is online even if the matrix room is being laggy and the message event isnt comming across + client.sendReadReceipt(roomId, event.event_id); + + //maths + const seconds = process.uptime(); + + const minutes = Math.floor(seconds / 60); + + const rSeconds = seconds - minutes * 60; + + const hours = Math.floor(minutes / 60); + + const rMinutes = minutes - hours * 60; + + //send the uptime to the room + client.sendHtmlText( + roomId, + `
\n

${seconds}

\n
\n

${hours} hours, ${rMinutes} minutes, and ${Math.floor( + rSeconds, + )} seconds.

`, + ); + } +} + +export { Uptime }; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..91e6f65 --- /dev/null +++ b/src/db.js @@ -0,0 +1,109 @@ +import { mkdirSync, readdirSync, readFileSync, writeFile } from "fs"; + +class database { + constructor() { + //check if the config part of the db is there + const a = readdirSync("./db"); + + if (!a.some((b) => b === "config")) { + //if not, make the folder for it + mkdirSync("./db/config"); + } + + //fetch the stored config files + const configfilelist = readdirSync("./db/config"); + + //set up cache for db so dont have to wait on disk every time + this.cache = new Map(); + + // go ahead and load configs so dont have to wait for disk. + // size should be small enough to cache it all without + // worrying about ram usage + for (const fileName of configfilelist) { + //filename is derived from the room id (map key) + const id = fileName.substring(0, fileName.length - 5); + + //map to shove data into + const configMap = new Map(); + + //read the config and parse it to add it to cache + const rawconfig = JSON.parse(readFileSync(`./db/config/${fileName}`)); + + //pull the individual configs into a uniform map format + // biome-ignore lint/complexity/noForEach: + Object.entries(rawconfig).forEach(([key, value]) => { + configMap.set(key, value); + }); + + this.cache.set(id, configMap); + } + } + + getConfig(roomId, config) { + //make sure there is a config for this room + const cache = this.cache.get(roomId); + + //if we have a config file for the room, return the requested config + if (cache) return cache.get(config); + + //if not return null (defaults to falsey value for config) + return null; + } + + setConfig(roomId, config, value, callback) { + //fetch the existing config + let cachedconfig = this.cache.get(roomId); + + //if no config exists, make one + if (!cachedconfig) cachedconfig = new Map(); + + //write setting to the config + cachedconfig.set(config, value); + + //write the config to the global cache + this.cache.set(roomId, cachedconfig); + + //write current config to disk + writeFile( + `./db/config/${roomId}.json`, + JSON.stringify(Object.fromEntries(cachedconfig), null, 2), + (err) => { + if (err) + callback( + `🍃 | I ran into the following error trying to write this config to disk. Please report this to @jjj333:pain.agency in #anti-scam-support:matrix.org\n\n${err}`, + ); + else callback("✅ | Successfully saved."); + }, + ); + } + + cloneRoom(fromId, toId, callback) { + //fetch the existing config + const from = this.cache.get(fromId); + + //make sure we have something to copy + if (!from) { + callback("🍃 | There is no customized config to copy."); + + return; + } + + //clone the config in memory + this.cache.set(toId, from); + + //write current config to disk + writeFile( + `./db/config/${toId}.json`, + JSON.stringify(Object.fromEntries(from), null, 2), + (err) => { + if (err) + callback( + `🍃 | I ran into the following error trying to write this config to disk. Please report this to @jjj333_p_1325:matrix.org in #anti-scam-support:matrix.org\n\n${err}`, + ); + else callback("✅ | Successfully copied config."); + }, + ); + } +} + +export { database }; diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..d5b3e8c --- /dev/null +++ b/src/index.js @@ -0,0 +1,277 @@ +//Import dependencies +import { + AutojoinRoomsMixin, + MatrixClient, + SimpleFsStorageProvider, +} from "matrix-bot-sdk"; +import { readFileSync } from "node:fs"; +import { parse } from "yaml"; + +//Import modules +import { blacklist } from "./blacklist.js"; +import { redaction } from "./redaction.js"; +import { database } from "./db.js"; +import { message } from "./message.js"; +import { Reaction } from "./reaction.js"; +import { BanlistReader } from "./banlistReader.js"; + +//Parse YAML configuration file +const loginFile = readFileSync("./db/login.yaml", "utf8"); +const loginParsed = parse(loginFile); +const homeserver = loginParsed["homeserver-url"]; +const accessToken = loginParsed["login-token"]; +const logRoom = loginParsed["log-room"]; +const commandRoom = loginParsed["command-room"]; +const authorizedUsers = loginParsed["authorized-users"]; +const name = loginParsed.name; + +//the bot sync something idk bro it was here in the example so i dont touch it ;-; +const storage = new SimpleFsStorageProvider("bot.json"); + +//login to client +const client = new MatrixClient(homeserver, accessToken, storage); + +//currently testing without accepting invites +//AutojoinRoomsMixin.setupOnClient(client); + +//map to put the handlers for each event type in (i guess this is fine here) +const eventhandlers = new Map(); + +const config = new database(); // Database for the config +const nogoList = new blacklist(); // Blacklist object +eventhandlers.set( + "m.room.message", + new message(logRoom, commandRoom, config, authorizedUsers), +); // Event handler for m.room.message +eventhandlers.set("m.policy.rule.user", new BanlistReader(client)); +eventhandlers.set("m.reaction", new Reaction(logRoom)); +eventhandlers.set("m.room.redaction", new redaction(eventhandlers)); // Event handler for m.room.redaction + +//preallocate variables so they have a global scope +let mxid; +let server; + +const reactionQueue = new Map(); + +const filter = { + //dont expect any presence from m.org, but in the case presence shows up its irrelevant to this bot + presence: { senders: [] }, + room: { + //ephemeral events are never used in this bot, are mostly inconsequentail and irrelevant + ephemeral: { senders: [] }, + //we fetch state manually later, hopefully with better load balancing + state: { + senders: [], + not_types: [ + "im.vector.modular.widgets", + "im.ponies.room_emotes", + "m.room.pinned_events", + ], + lazy_load_members: true, + }, + //we will manually fetch events anyways, this is just limiting how much backfill bot gets as to not + //respond to events far out of view + timeline: { + limit: 20, + }, + }, +}; + +//Start Client +client.start(filter).then(async (filter) => { + console.log("Client started!"); + + //to remotely monitor how often the bot restarts, to spot issues + client.sendText(logRoom, "Started.").catch((e) => { + console.log("Error sending basic test message to log room."); + process.exit(1); + }); + + //get mxid + mxid = await client.getUserId(); + server = mxid.split(":")[1]; + + //fetch rooms the bot is in + const rooms = await client.getJoinedRooms(); + + //fetch avatar url so we dont overrite it + let avatar_url; + + try { + const userProfile = await client.getUserProfile(mxid); + avatar_url = userProfile.avatar_url; + } catch (error) { + console.error("Error fetching user profile:", error); + // Handle the error as needed (e.g., provide a default avatar URL) + avatar_url = ""; + } + + //slowly loop through rooms to avoid ratelimit + const i = setInterval(async () => { + //if theres no rooms left to work through, stop the loop + if (rooms.length < 1) { + clearInterval(i); + + return; + } + + //work through the next room on the list + const currentRoom = rooms.pop(); + + //debug + console.log(`Setting displayname for room ${currentRoom}`); + + //fetch the room list of members with their profile data + const mwp = await client + .getJoinedRoomMembersWithProfiles(currentRoom) + .catch(() => { + console.log(`error ${currentRoom}`); + }); + + //variable to store the current display name of the bot + let cdn = ""; + + //if was able to fetch member profiles (sometimes fails for certain rooms) then fetch the current display name + if (mwp) cdn = mwp[mxid].display_name; + + //fetch prefix for that room + let prefix = config.getConfig(currentRoom, "prefix"); + + //default prefix if none set + if (!prefix) prefix = "+"; + + //establish desired display name based on the prefix + const ddn = `${prefix} | ${name}`; + + //if the current display name isnt the desired one + if (cdn !== ddn) { + //send member state with the new displayname + client + .sendStateEvent(currentRoom, "m.room.member", mxid, { + avatar_url: avatar_url, + displayname: ddn, + membership: "join", + }) + .then(console.log(`done ${currentRoom}`)) + .catch((e) => console.error); + } + + // 3 second delay to avoid ratelimit + }, 3000); + + // displayname = (await client.getUserProfile(mxid))["displayname"] +}); + +//when the client recieves an event +client.on("room.event", async (roomId, event) => { + //ignore events sent by self, unless its a banlist policy update + if (event.sender === mxid && !(event.type === "m.policy.rule.user")) { + return; + } + + //check banlists + bancheck(roomId, event); + + //fetch the handler for that event type + const handler = eventhandlers.get(event.type); + + //if there is no handler for that event, exit. + if (!handler) return; + + //fetch the room list of members with their profile data + const mwp = await client + .getJoinedRoomMembersWithProfiles(roomId) + .catch(() => { + console.log(`error ${roomId}`); + }); + + //variable to store the current display name of the bot + let cdn = ""; + + //if was able to fetch member profiles (sometimes fails for certain rooms) then fetch the current display name + if (mwp) cdn = mwp[mxid].display_name; + else { + //fetch prefix for that room + let prefix = config.getConfig(roomId, "prefix"); + + //default prefix if none set + if (!prefix) prefix = "+"; + + //establish desired display name based on the prefix + cdn = `${prefix} | ${name}`; + } + + handler.run({ + client: client, + roomId: roomId, + event: event, + mxid: mxid, + server: server, + displayname: cdn, + blacklist: nogoList, + reactionQueue: reactionQueue, + banListReader: eventhandlers.get("m.policy.rule.user"), + config: config, + }); +}); + +client.on("room.leave", (roomId) => { + nogoList.add(roomId, "kicked"); +}); + +async function bancheck(roomId, event) { + //if the bot cant ban users in the room, theres no reason to waste resources and check if it should ban the user + if (!(await client.userHasPowerLevelForAction(mxid, roomId, "ban"))) { + return; + } + + //fetch banlists for room + let roomBanlists = config.getConfig(roomId, "banlists"); + + //if there is no config, create a temporary one with just the room id + if (!roomBanlists) { + roomBanlists = [roomId]; + } + + //if there is a config, set the room up to check its own banlist + else { + roomBanlists.push(roomId); + } + + //variable to store reason + let reason; + + //look through all banlists + for (let i = 0; i < roomBanlists.length; i++) { + const rm = roomBanlists[i]; + + //find recommendation + const recomend = await eventhandlers + .get("m.policy.rule.user") + .match(rm, event.sender)[0]; + + //if that room doesn't recommend a ban, go ahead and exit out + if (!recomend) { + continue; + } + + //fetch the set alias of the room + let mainRoomAlias = await client.getPublishedAlias(rm); + + //if there is no alias of the room + if (!mainRoomAlias) { + //dig through the state, find room name, and use that in place of the main room alias + mainRoomAlias = (await client.getRoomState(roomId)).find( + (state) => state.type === "m.room.name", + ).content.name; + } + + //format together a reason + reason = `${recomend.content.reason} (${mainRoomAlias})`; + } + + //if there is a reason to be had, then we can ban + if (reason) { + client.banUser(event.sender, roomId, reason).catch(() => {}); + } +} diff --git a/src/message.js b/src/message.js new file mode 100644 index 0000000..f3afa25 --- /dev/null +++ b/src/message.js @@ -0,0 +1,403 @@ +//misc imports +import { PowerLevelAction } from "matrix-bot-sdk"; +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); + +import { Sendjson } from "./sendjson.js"; + +// commands +import { Uptime } from "./commands/uptime.js"; +import { Join } from "./commands/join.js"; +import { Leave } from "./commands/leave.js"; //does blacklisting +import { Unblacklist } from "./commands/unblacklist.js"; +import { Restart } from "./commands/restart.js"; +import { Mute } from "./commands/mute.js"; +import { Banlist } from "./commands/banlist.js"; +import { FollowBanList } from "./commands/followbanlist.js"; +import { Rules } from "./commands/rules.js"; +import { CC } from "./commands/create-community.js"; + +const sendjson = new Sendjson(); + +class message { + constructor(logRoom, commandRoom, config, authorizedUsers) { + //map to relate scams and their responses (for deletion) + this.tgScamResponses = new Map(); + this.tgScamReactions = new Map(); + + //config thingys + this.logRoom = logRoom; + this.commandRoom = commandRoom; + this.config = config; + this.authorizedUsers = authorizedUsers; + + //fetch keywords + this.keywords = require("../keywords.json"); + + //create collection of different commands to run + this.commands = new Map(); + + this.commands.set("uptime", new Uptime()); + this.commands.set("join", new Join()); + this.commands.set("leave", new Leave()); + this.commands.set("unblacklist", new Unblacklist()); + this.commands.set("restart", new Restart()); + this.commands.set("mute", new Mute()); + this.commands.set("banlist", new Banlist()); + this.commands.set("followbanlist", new FollowBanList()); + this.commands.set("rules", new Rules()); + this.commands.set("create-community", new CC()); + } + + // async run ({client, roomId, event, mxid, displayname, blacklist}){ + async run(datapoints) { + //if no content in message + if (!datapoints.event.content) return; + + // Don't handle non-text events + if (datapoints.event.content.msgtype !== "m.text") return; + + //grab the content from the message, and put it to lowercase to prevent using caps to evade + const scannableContent = datapoints.event.content.body.toLowerCase(); + + // this.commands.run(datapoints, scannableContent) + + //scan for common scam words + if ( + includesWord(scannableContent, [ + this.keywords.scams.currencies, + this.keywords.scams.socials, + this.keywords.scams.verbs, + ]) + ) { + //if the scam is posted in the room deticated to posting tg scams + if (datapoints.roomId === this.logRoom) { + //confirm it matches the keywords + datapoints.client + .sendEvent(datapoints.roomId, "m.reaction", { + "m.relates_to": { + event_id: datapoints.event.event_id, + key: "Detected", + rel_type: "m.annotation", + }, + }) + // ??? + .catch(() => {}); + } else { + //custom function to handle the fetching and sending of the json file async as to not impact responsiveness + sendjson.send( + datapoints, + this.logRoom, + datapoints.banListReader, + this.tgScamReactions, + this.tgScamResponses, + ); + + //React to the message with a little warning so its obvious what msg im referring to + datapoints.client + .sendEvent(datapoints.roomId, "m.reaction", { + "m.relates_to": { + event_id: datapoints.event.event_id, + key: "🚨 scam! 🚨", + rel_type: "m.annotation", + }, + }) + + //if reaction is sent, associate it with the original scam for later redaction + .then((responseID) => { + this.tgScamReactions.set(datapoints.event.event_id, { + roomId: datapoints.roomId, + responseID: responseID, + }); + }) + + //catch the error to prevent crashing, however if it cant send theres not much to do + .catch(() => {}) + + //dont care if it was successful, carry on with the code + .finally(async () => { + //if the room is in mute mode, dont respond + if (this.config.getConfig(datapoints.roomId, "muted")) return; + + //send warning message + datapoints.client + .sendHtmlText(datapoints.roomId, this.keywords.scams.response) + + //if warning is sent, associate it with the original scam for later redaction + .then((responseID) => { + this.tgScamResponses.set(datapoints.event.event_id, { + roomId: datapoints.roomId, + responseID: responseID, + }); + }) + + //catch the error to prevent crashing, however if it cant send theres not much to do + .catch(() => {}) + + .finally(async () => { + //if the message is replying + const replyRelation = datapoints.event.content["m.relates_to"]; //["m.in_reply_to"].event_id + if (replyRelation) { + //pull the id of the event its replying to + if (replyRelation["m.in_reply_to"]) { + const replyID = replyRelation["m.in_reply_to"].event_id; + + //fetch the event from that id + const repliedEvent = await datapoints.client + .getEvent(datapoints.roomId, replyID) + .catch(() => {}); + + //make the content scanable + const scannableContent = + repliedEvent.content.body.toLowerCase(); + + //if the message is replying to a scam, it doesnt need to be acted upon + if ( + includesWord(scannableContent, [ + this.keywords.scams.currencies, + this.keywords.scams.socials, + this.keywords.scams.verbs, + ]) + ) { + return; + } + } + } + + const scamAction = this.config.getConfig( + datapoints.roomId, + "scamAction", + ); + + const reason = "Scam Likely"; + + try { + if (!scamAction) { + datapoints.client + .kickUser( + datapoints.event.sender, + datapoints.roomId, + reason, + ) + .catch(() => {}); + } else if (scamAction === -1) { + datapoints.client + .redactEvent( + datapoints.roomId, + datapoints.event.event_id, + reason, + ) + .catch(() => {}); + } else if (scamAction === 1) { + // userHasPowerLevelFor(userId: string, datapoints.roomId: string, eventType: string, isState: boolean): Promise; + // setUserPowerLevel(userId: string, roomId: string, newLevel: number): Promise; + // datapoints.client.setUserPowerLevel(user, roomId, newlevel) + // if ( await datapoints.client.userHasPowerLevelFor(mxid, roomId, "m.room.power_levels", true) ){ + // } + } + } catch (e) { + /*TODO*/ + } + }); + }); + } + + //check if can respond + } else if ( + !(await datapoints.client + .userHasPowerLevelFor( + datapoints.mxid, + datapoints.roomId, + "m.room.message", + false, + ) + .catch(() => {})) + ) { + return; + } else { + //greeting message + const greeting = + "Greetings! I am a bot by @jjj333:pain.agency (pls dm for questions). " + + "My MO is I warn people about telegram and whatsapp investment scams whenever they are posted in the room. If I am unwanted please just kick me. " + + "For more information please visit https://github.com/jjj333-p/spam-police"; + + //split into words, and filter out the empty strings because js is an actual meme language + const contentByWords = datapoints.event.content.body + .split(" ") + .filter((a) => a); + const displaynameByWords = datapoints.displayname + .split(" ") + .filter((a) => a); + + //if the user is trying to mention the bot + if ( + datapoints.event.content.body.includes(datapoints.mxid) || + datapoints.event.content.body.includes(datapoints.displayname) + ) { + //if someone starts the message with the mxid + if (contentByWords[0].includes(datapoints.mxid)) { + //help command + if (!contentByWords[1] || contentByWords[1] === "help") { + datapoints.client + .sendText(datapoints.roomId, greeting) + .catch(() => {}); + return; + } + + //definitely not a command + if ( + contentByWords[1].startsWith("+") || + contentByWords[1].startsWith("1") + ) + return; + + //if that is a command, run the command + const handler = this.commands.get(contentByWords[1]); + + //if no handler its not a valid command + if (!handler) { + await datapoints.client + .sendEvent(datapoints.roomId, "m.reaction", { + "m.relates_to": { + event_id: datapoints.event.event_id, + key: "❌ | invalid cmd", + rel_type: "m.annotation", + }, + }) + .catch(() => {}); + + return; + } + + client + .sendReadReceipt(datapoints.roomId, datapoints.event.event_id) + .catch(() => {}); + + //run the command + handler.run(datapoints, { + scannableContent: scannableContent, + contentByWords: contentByWords, + keywords: this.keywords, + logRoom: this.logRoom, + commandRoom: this.commandRoom, + config: this.config, + authorizedUsers: this.authorizedUsers, + offset: displaynameByWords.length, + }); + + //if someone starts the message with the display name + } else if ( + datapoints.event.content.body.startsWith(datapoints.displayname) && + contentByWords.length > displaynameByWords.length + ) { + //if that is a command, run the command + const handler = this.commands.get( + contentByWords[displaynameByWords.length], + ); + + //if no handler its not a valid command + if (!handler) { + await datapoints.client + .sendEvent(datapoints.roomId, "m.reaction", { + "m.relates_to": { + event_id: datapoints.event.event_id, + key: "❌ | invalid cmd", + rel_type: "m.annotation", + }, + }) + .catch(() => {}); + + return; + } + + datapoints.client + .sendReadReceipt(datapoints.roomId, datapoints.event.event_id) + .catch(() => {}); + + //run the command + handler.run(datapoints, { + scannableContent: scannableContent, + contentByWords: contentByWords, + keywords: this.keywords, + logRoom: this.logRoom, + commandRoom: this.commandRoom, + config: this.config, + authorizedUsers: this.authorizedUsers, + offset: displaynameByWords.length, + }); + } else { + datapoints.client + .sendText(datapoints.roomId, greeting) + .catch(() => {}); + } + } else { + //fetch prefix for that room + let prefix = this.config.getConfig(datapoints.roomId, "prefix"); + + //default prefix if none set + if (!prefix) prefix = "+"; + + if (!scannableContent.startsWith(prefix)) return; + + //parce out command + const command = contentByWords[0].substring(prefix.length); + + //not a command + if (!command || command.startsWith("+") || command.startsWith("1")) + return; + + //help + if (command === "help") { + datapoints.client + .sendText(datapoints.roomId, greeting) + .catch(() => {}); + return; + } + + //if that is a command, run the command + const handler = this.commands.get(command); + + //if no handler, than its not a valid command + if (!handler) { + await datapoints.client + .sendEvent(datapoints.roomId, "m.reaction", { + "m.relates_to": { + event_id: datapoints.event.event_id, + key: "❌ | invalid cmd", + rel_type: "m.annotation", + }, + }) + .catch(() => {}); + + return; + } + + //run the handler + handler.run(datapoints, { + scannableContent: scannableContent, + contentByWords: contentByWords, + keywords: this.keywords, + logRoom: this.logRoom, + commandRoom: this.commandRoom, + config: this.config, + authorizedUsers: this.authorizedUsers, + offset: 0, + }); + } + } + } +} + +//function to scan if it matches the keywords +function includesWord(str, catgs) { + //assume true if you dont have any missing + let result = true; + + for (const cat of catgs) { + if (!cat.some((word) => str.includes(word))) result = false; + } + + return result; +} + +export { message }; diff --git a/src/reaction.js b/src/reaction.js new file mode 100644 index 0000000..770a7d2 --- /dev/null +++ b/src/reaction.js @@ -0,0 +1,25 @@ +class Reaction { + constructor(logRoom) { + this.logRoom = logRoom; + } + + async run({ client, roomId, event, mxid, reactionQueue }) { + //should never happen but aparently it does + //https://matrix.pain.agency/_matrix/media/v3/download/pain.agency/51cc6283f64310640f67daa84f284ae8e7a08a969bd2f7f57920a4d30aa83c00 + if (!event.content["m.relates_to"]?.event_id) return; + + if (roomId === this.logRoom) { + //get queued function + const qf = reactionQueue.get(event.content["m.relates_to"].event_id); + + //make sure the reaction was to a scam entry + if (!qf) { + return; + } + + qf(event); + } + } +} + +export { Reaction }; diff --git a/modules/readme.md b/src/readme.md similarity index 100% rename from modules/readme.md rename to src/readme.md diff --git a/src/redaction.js b/src/redaction.js new file mode 100644 index 0000000..51a7de2 --- /dev/null +++ b/src/redaction.js @@ -0,0 +1,53 @@ +class redaction { + constructor(eventhandlers) { + this.eventhandlers = eventhandlers; + } + + async run({ client, roomId, event, config }) { + let redactedEvent; + try { + redactedEvent = await client.getEvent(roomId, event.redacts); + } catch (e) { + console.log( + `Attempted to fetch redacted event ${event.redacts}, but it does not exist?`, + e, + ); + return; //if there was no event redacted, that means theres inherently nothing to do + } + + //deleting a chat message + if (redactedEvent.type === "m.room.message") { + //fetch the bots response to the scam + const response = this.eventhandlers + .get("m.room.message") + .tgScamResponses.get(event.redacts); + const reaction = this.eventhandlers + .get("m.room.message") + .tgScamReactions.get(event.redacts); + + //if there is a response to the redacted message then redact the response + if (response) { + client + .redactEvent( + response.roomId, + response.responseID, + "The message that this message was replying to was deleted.", + ) + .catch(() => {}); + } + if (reaction) { + client + .redactEvent(reaction.roomId, reaction.responseID) + .catch(() => {}); + } + + //if deleting a banlist event just reprocess banlist + } else if (redactedEvent.type === "m.policy.rule.user") { + this.eventhandlers + .get("m.policy.rule.user") + .run({ roomId: roomId, config: config }); + } + } +} + +export { redaction }; diff --git a/src/sendjson.js b/src/sendjson.js new file mode 100644 index 0000000..dbfdfca --- /dev/null +++ b/src/sendjson.js @@ -0,0 +1,349 @@ +import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +const require = createRequire(import.meta.url); + +class Sendjson { + constructor() { + //create array to store scams to help limit duplicates (when spammed) + this.tgScams = []; + + //fetch keywords + this.keywords = require("../keywords.json"); + } + + async send( + { client, roomId, event, mxid, reactionQueue }, + logchannel, + banlistReader, + reactions, + responses, + ) { + //if the message is replying + const replyRelation = event.content["m.relates_to"]; //["m.in_reply_to"].event_id + if (replyRelation) { + //pull the id of the event its replying to + if (replyRelation["m.in_reply_to"]) { + const replyID = replyRelation["m.in_reply_to"].event_id; + + //fetch the event from that id + let repliedEvent; + + try { + repliedEvent = await client.getEvent(roomId, replyID); + } catch (e) { + console.error("Error loading message that was replied to:", e); + return; + } + + //make the content scanable + const scannableContent = repliedEvent.content.body.toLowerCase(); + + //if the message is replying to a scam, it doesnt need to be logged + if ( + includesWord(scannableContent, [ + this.keywords.scams.currencies, + this.keywords.scams.socials, + this.keywords.scams.verbs, + ]) + ) { + return; + } + } + } + + //fetch the set alias of the room + let mainRoomAlias; + try { + mainRoomAlias = + (await client.getPublishedAlias(roomId)) ?? + (await client.getRoomState(roomId)).find( + (state) => state.type === "m.room.name", + ).content.name; + } catch (e) { + console.error( + "Error fetching alias and or room name in scam reporting\n", + e, + ); + mainRoomAlias = ""; + } + + //check if already on banlist + const entry = await banlistReader.match(logchannel, event.sender); + + if (entry) { + //get the reason its already on the banlist + const existingReason = entry.content.reason; + + //if the ban reason already includes that scam was sent in this room, theres nothing to add + if (existingReason.includes(mainRoomAlias)) { + return; + } + + //make banlist rule + client + .sendStateEvent( + logchannel, + "m.policy.rule.user", + `rule:${event.sender}`, + { + entity: event.sender, + reason: `${existingReason} ${mainRoomAlias}`, + recommendation: "org.matrix.mjolnir.ban", + }, + ) + //it literally does not matter if this fails + .catch(() => {}); + + //dont send a log if its already been reported + return; + } + + //limit duplicates + if ( + this.tgScams.some( + (scam) => + scam.event.content.body === event.content.body && + scam.roomId === roomId && + scam.event.sender === event.sender, + ) + ) { + return; + } + + this.tgScams.push({ event: event, roomId: roomId }); + + //filename + const filename = `${ + event.sender + }_${roomId}_@_${new Date().toISOString()}.json`; + + //convert json into binary buffer + const file = Buffer.from(JSON.stringify(event, null, 2)); + + //upload the file buffer to the matrix homeserver, and grab mxc:// url + let linktofile; + try { + linktofile = await client.uploadContent( + file, + "application/json", + filename, + ); + } catch (e) { + console.error("Unable to upload scam detection json file", e); + } + + //if the bot is in the room, that mean it's homeserver can be used for a via + const via = mxid.split(":")[1]; + + //escape html and '@' to avoid mentions + const escapedText = event.content.body + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("@", "&64;"); + + //send log message + let logmsgid; + try { + logmsgid = await client.sendHtmlText( + logchannel, + `${event.sender} in ${mainRoomAlias}\n
${escapedText}
\nhttps://matrix.to/#/${roomId}/${event.event_id}?via=${via}`, + ); + } catch (e) { + console.error("Unable to send message to log room:", e); + //this is all pointless if we cant send a message + return; + } + + //send the file that was uploaded + let logfileid; + if (linktofile) { + try { + logfileid = await client.sendMessage(logchannel, { + body: filename, + info: { + mimetype: "application/json", + size: file.byteLength, + }, + msgtype: "m.file", + url: linktofile, + }); + + //makes no sense to happen if the earlier message succeeded + //if that somehow does happen, makes no sense to try to do anything + } catch (e) {} + } + + //easy reaction for moderators + const checkMessagePromise = client.sendEvent(logchannel, "m.reaction", { + "m.relates_to": { + event_id: logmsgid, + key: "✅ confirm", + rel_type: "m.annotation", + }, + }); + + //easy reaction for moderators + const xMessagePromise = client.sendEvent(logchannel, "m.reaction", { + "m.relates_to": { + event_id: logmsgid, + key: "❌ falsepos", + rel_type: "m.annotation", + }, + }); + + //add callback to the map to be called upon reaction event + reactionQueue.set(logmsgid, async (reactionEvent) => { + const senderpl = ( + await client.getRoomStateEvent(logchannel, "m.room.power_levels", null) + ).users[reactionEvent.sender]; + + if (senderpl === undefined || senderpl < 10) { + return; + } + + const userReactionId = reactionEvent.event_id; + + if (reactionEvent.content["m.relates_to"].key.includes("✅")) { + //only allow to run once + reactionQueue.delete(logmsgid); + + //generate reason + const reason = `telegram scam in ${mainRoomAlias}`; //+ " (see " + await client.getPublishedAlias(logchannel) + " )" + + //make banlist rule + client + .sendStateEvent( + logchannel, + "m.policy.rule.user", + `rule:${event.sender}`, + { + entity: event.sender, + reason: reason, + recommendation: "org.matrix.mjolnir.ban", + }, + ) + + .then(async () => { + //delete reactions to limit duplicate responses + //didnt await these earler for speed and performance, so need to await the promises now + client + .redactEvent( + logchannel, + await checkMessagePromise, + "related reaction", + ) + .catch(() => {}); + client + .redactEvent( + logchannel, + await xMessagePromise, + "related reaction", + ) + .catch(() => {}); + + //confirm ban for clients that cant read banlist events + client + .sendEvent(logchannel, "m.reaction", { + "m.relates_to": { + event_id: logmsgid, + key: "🔨 | banned", + rel_type: "m.annotation", + }, + }) + //catch it in case edge case of duplicate actions, this way it wont error + .catch(() => {}); + + //attempt to redact the scam + client + .redactEvent(roomId, event.event_id, "confirmed scam") + .catch(() => {}); + }) + + //delete mod response even if it fails to enable trying again + .finally(async () => { + client + .redactEvent(logchannel, userReactionId, "related reaction") + .catch(() => {}); + }) + + //catch errors with sending the state event + .catch((err) => + client.sendHtmlNotice( + logchannel, + `

🍃 | I unfortunately ran into the following error while trying to add that to the banlist:\n

${err}`, + ), + ); + } else if (reactionEvent.content["m.relates_to"].key.includes("❌")) { + //only allow to run once + reactionQueue.delete(logmsgid); + + //delete events already existing + client.redactEvent(logchannel, logmsgid, "not a scam"); + client.redactEvent(logchannel, logfileid, "not a scam"); + client.redactEvent(logchannel, userReactionId, "related reaction"); + + //didnt await these earler for speed and performance, so need to await the promises now + client.redactEvent( + logchannel, + await checkMessagePromise, + "related reaction", + ); + client.redactEvent( + logchannel, + await xMessagePromise, + "related reaction", + ); + + //fetch the bots response to the scam + const response = responses.get(event.event_id); + const reaction = reactions.get(event.event_id); + + //if there is a response to the redacted message then redact the response + try { + if (response) { + await client.redactEvent( + response.roomId, + response.responseID, + "False positive.", + ); + } + if (reaction) { + await client.redactEvent( + reaction.roomId, + reaction.responseID, + "False positive.", + ); + } + + //on the rare occasion that the room disables self redactions, or other error, this for some reason crashes the entire process + //fuck you nodejs v20 + } catch (e) { + // error to send + const en = "🍃 | Error redacting warning."; + + //send to both log room and that room which it is supposed to redact + client + .sendHtmlNotice(response.roomId, en) + .catch(() => {}) + .finally(() => { + client.sendHtmlNotice(logchannel, en); + }); + } + } + }); + } +} + +//function to scan if it matches the keywords +function includesWord(str, catgs) { + //assume true if you dont have any missing + let result = true; + + for (const cat of catgs) { + if (!cat.some((word) => str.includes(word))) result = false; + } + + return result; +} + +export { Sendjson }; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c205ea3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "gnuxie-tsconfig/tsconfig.json", + "compilerOptions": { + "declarationDir": "dist", + "outDir": "dist", + "allowJs": true, + } +}