27 Commits

Author SHA1 Message Date
4bb267f990 merge upstream
All checks were successful
Build Dev instance / build (push) Successful in 1m15s
2026-03-10 15:38:47 +11:00
2b31cc2ae9 perf: Merge develop into main (#36)
* Update dockerfile for standalone deployment (#23)

* Add files via upload

* Update Docker image for pluralflux service

* removing unnecessary network and container name definitions

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* Converting ES6 back to CJS (#25)

converting back to cjs

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* Fix: Further converting ES6 to CJS - Making exports named instead of default (#26)

* adding to git ignore

* making imports named not default to not break all my tests

* adjusted setup for memberhelper test

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* feat: add db migrations with typeORM (#28)

* adding typescript packages for typeORM

* add typeORM initial files

* updating package scripts

* updating compose.yaml to have an exposed port for the postgres

* modifying setup for typeORM

* update database stuff and and package.json to help generate migrations

* made models and migrations in typeORM

* delete unneeded database.js

* made database pattern ignored by jest

* remove sequelize

* separate member repo from member helper

* not sure why i made everything numbers in the model but it's fixed now

* edited package.json script

* remove unused index.ts

* adjusted files to reference repository correctly and appdatasource

* made appdatasource export as named

* removed start-db script

* added init to appdatasource in bot.js

* migrations finally!

* new migration matching model names I want

* updating tests

* removing testpathignore patterns since it seems to be unecessary?

* adjusting migrations to match current schema

* removed reference to secrets file

* delete old migration

* Revert "delete old migration"

This reverts commit db1efa39a7a80d8976878856250ccaac6a753ab2.

* Revert "adjusting migrations to match current schema"

This reverts commit ef89a83f6a2ef0643d6ace0a3fcf9c40f4bc6dd6.

* just deleted system creation since it's got nothing in it anyway

* renamed memberRepository to memberRepo for consistency

* added await back to parseMemberCommand call to memberArgumentHandler

* changed call to memberHelper.getMembersByAuthor to memberRepo

* renamed repo updateMemberValue to updateMemberField

* removed throw references in repo docstrings

* remove unneeded subscriber directory ref

* changed createdAt and updatedAt columns to be auto-generated

made member table have timezone

* changed casing of isInitialized in mock for bot.js

* removed % from ILike query so that it doesn't match substrings/wildcard

* renamed some stray updateMemberValue in mocks -> updateMemberField

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* feat: Add migration to migrate existing data to new member table (#29)

added migration to fill new Member table with data from Members

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* fix: update message helper reference hotfix (#30)

* forgot to update a reference in messageHelper to memberRepo instead of memberHelper

* turned off data-source logging

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* fix: update dockerfile to run npm start (#31)

fix: update dockerfile to run npm start (which runs ts-node) instead of node

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* added .env with examples, updated data-source to be access a docker container instead of relying on loopback

* i forgot to git add data-source.ts 🤦

* fix: changed property reference for createMember in repo (#32)

fix for createMember object references (was referencing non-existent properties)

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* fix: memberRepo methods syntax (#35)

* rearranged update member field and remove member to match expected structure in typeORM

* update docstring

* change insert to save in memberRepo

* added command in package.json

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* Delete duplicate members migration (#33)

* add migration to delete duplicates that currently exist in the db

* added a name attribute for consistency

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* Add unique index migration (#34)

* add migration to delete duplicates that currently exist in the db

* change model and migration to add a unique index constraint to id and name

* renamed unique index name to be readable

* redid model and migration to use @Unique instead of @Index

* remove //Here comment

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>

* why wont these workflows stay up gahdamn

---------

Co-authored-by: Laika Bozhko <63646916+LaikaBzko@users.noreply.github.com>
Co-authored-by: Aster Fialla <asterfialla@gmail.com>
Co-authored-by: laika <laika@sanya.gay>
2026-03-09 09:00:15 -04:00
b602e654ec why wont these workflows stay up gahdamn
All checks were successful
Build Dev instance / build (push) Successful in 1m40s
2026-03-06 17:15:51 -05:00
732ad36bba Add unique index migration (#34)
* add migration to delete duplicates that currently exist in the db

* change model and migration to add a unique index constraint to id and name

* renamed unique index name to be readable

* redid model and migration to use @Unique instead of @Index

* remove //Here comment

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-03-06 16:52:26 -05:00
8446559bfb Delete duplicate members migration (#33)
* add migration to delete duplicates that currently exist in the db

* added a name attribute for consistency

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-03-06 16:43:44 -05:00
14ef1581c1 fix: memberRepo methods syntax (#35)
* rearranged update member field and remove member to match expected structure in typeORM

* update docstring

* change insert to save in memberRepo

* added command in package.json

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-03-06 16:39:23 -05:00
10eab6de74 fix: changed property reference for createMember in repo (#32)
fix for createMember object references (was referencing non-existent properties)

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-03-05 13:16:29 -05:00
d8682c2a1b i forgot to git add data-source.ts 🤦 2026-03-05 17:25:39 +11:00
20e8564c15 added .env with examples, updated data-source to be access a docker container instead of relying on loopback 2026-03-05 17:22:22 +11:00
78cda7d3c4 fix: update dockerfile to run npm start (#31)
fix: update dockerfile to run npm start (which runs ts-node) instead of node

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-03-03 07:43:25 -05:00
21587dcb7a fix: update message helper reference hotfix (#30)
* forgot to update a reference in messageHelper to memberRepo instead of memberHelper

* turned off data-source logging

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-03-02 08:50:41 -05:00
aeb7d88ee9 feat: Add migration to migrate existing data to new member table (#29)
added migration to fill new Member table with data from Members

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-03-01 22:10:03 -05:00
8fe53563d0 feat: add db migrations with typeORM (#28)
* adding typescript packages for typeORM

* add typeORM initial files

* updating package scripts

* updating compose.yaml to have an exposed port for the postgres

* modifying setup for typeORM

* update database stuff and and package.json to help generate migrations

* made models and migrations in typeORM

* delete unneeded database.js

* made database pattern ignored by jest

* remove sequelize

* separate member repo from member helper

* not sure why i made everything numbers in the model but it's fixed now

* edited package.json script

* remove unused index.ts

* adjusted files to reference repository correctly and appdatasource

* made appdatasource export as named

* removed start-db script

* added init to appdatasource in bot.js

* migrations finally!

* new migration matching model names I want

* updating tests

* removing testpathignore patterns since it seems to be unecessary?

* adjusting migrations to match current schema

* removed reference to secrets file

* delete old migration

* Revert "delete old migration"

This reverts commit db1efa39a7a80d8976878856250ccaac6a753ab2.

* Revert "adjusting migrations to match current schema"

This reverts commit ef89a83f6a2ef0643d6ace0a3fcf9c40f4bc6dd6.

* just deleted system creation since it's got nothing in it anyway

* renamed memberRepository to memberRepo for consistency

* added await back to parseMemberCommand call to memberArgumentHandler

* changed call to memberHelper.getMembersByAuthor to memberRepo

* renamed repo updateMemberValue to updateMemberField

* removed throw references in repo docstrings

* remove unneeded subscriber directory ref

* changed createdAt and updatedAt columns to be auto-generated

made member table have timezone

* changed casing of isInitialized in mock for bot.js

* removed % from ILike query so that it doesn't match substrings/wildcard

* renamed some stray updateMemberValue in mocks -> updateMemberField

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-03-01 21:25:49 -05:00
d14e89e8b2 Fix: Further converting ES6 to CJS - Making exports named instead of default (#26)
* adding to git ignore

* making imports named not default to not break all my tests

* adjusted setup for memberhelper test

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-02-28 15:28:27 -05:00
39a7115803 Converting ES6 back to CJS (#25)
converting back to cjs

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-02-28 14:39:32 -05:00
Laika Bozhko
72b70f5175 Update dockerfile for standalone deployment (#23)
* Add files via upload

* Update Docker image for pluralflux service

* removing unnecessary network and container name definitions

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-02-28 14:37:59 -05:00
df80eca0ec refactor: Removing then/catch from async/await calls (#22)
* refactored async/await for import helper to not also use then/catch

* added enum

* refactor webhookHelper and tests to not use then/catch

* changed docstring

* refactoring bot and tests to not use then/catch

* refactoring commands.js and tests to not use then/catch

* refactoring memberHelper.js and tests to not use then/catch

* removing then/catch from messageHelper.test.js

* fixed set up for commands tests

* edited bot to have top level main function

* one more test in commands.js, and removed console.error

* fixed typo in webhookHelper

* forgot to switch over some tests in bot.test and commands.test

* removed console.log from import helper

* put console.error in commands

* converted utils.js to not use then/catch

* tested utils checkImageFormatValidity

* removed jest-fetch-mock since it turns out I was just manually mocking it anyway

* refactored database to not use then/catch

* added dash to commands.js and test to pass

* added the remaining webhook tests

* changed utils to check for 10MB size not 1MB

* removed unnecessary try/catch from utils

* Simplify getWebhook to use .find() instead of foreach logic

* make memberCommand exit when error occurs with parseMemberCommand

* changed commands.js to not have user interaction within the catch

* updated console.error message in database.js

* made importHelper mock throw error instead of "resolve" error

* replaced "pk;" with "pf;" in test

* Got rid of unnecessary check for empty message from user (Fluxer doesn't allow this to happen)

Removed export of token

* getAllMembersInfo checks for fields.length

* added default case to memberCommandHandler to throw error if command is not recognized

* reversed check for valid proxy (was returning valid if the proxy existed and invalid if it didn't)

* pushes e.message instead of full error object to errors array in importHelper

* adjusted tests to properly use mockRejectedValue for async rejections

* changed getAllMembersInfo map to say `index` not `name` as it actually gets the index of a member and then the member object

* adjusted importHelper to properly test throwing of aggregate error

* revamped setting of expiration warning (moved to utils and changed logic, wrote tests)

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-02-25 19:30:39 -05:00
7fead5e3d7 feat: Add unit tests (#21)
Tests are mostly complete (though some are failing -- to be fixed in the
async/await refactor.

Also refactored, fixed bugs found while testing, and added ability to
type `pf;m` instead of `pf;member`.

---------

Co-authored-by: Aster Fialla <asterfialla@gmail.com>
2026-02-24 12:42:23 -05:00
428310dfad Delete .github/workflows/node.js.yml (#20)
Since we're moving to local DevOps, this is no longer required. Also it
was broken.
2026-02-24 09:30:27 -05:00
dc0de4b092 Update README.md
added LLM note
2026-02-24 09:23:07 -05:00
6898e3142c Create CONTRIBUTING.md 2026-02-24 09:16:55 -05:00
adcb05a38b Create LICENSE
Add the peer production license as license
2026-02-22 11:57:12 -05:00
Aster Fialla
db3c588745 updated tests 2026-02-20 11:36:41 -05:00
Aster Fialla
56d7f7d1fa call getMemberCommandInfo in parseMemberCommand 2026-02-20 11:33:16 -05:00
Aster Fialla
8f6ae668b0 update getMemberCommandInfo
# Conflicts:
#	src/helpers/memberHelper.js
2026-02-20 11:31:54 -05:00
Aster Fialla
211a705f31 update enums 2026-02-20 11:29:28 -05:00
Aster Fialla
69c242350f updated readme 2026-02-20 10:01:56 -05:00
39 changed files with 4226 additions and 1881 deletions

View File

@@ -1,5 +1,5 @@
**/.dockerignore
**/.env
.env.jest
**/.git
**/.gitignore
**/.project

2
.env.jest Normal file
View File

@@ -0,0 +1,2 @@
FLUXER_BOT_TOKEN=jest-fluxer-bot-token
POSTGRES_PASSWORD=jest-postgres-password

View File

@@ -0,0 +1,49 @@
name: Build Dev instance
on:
push:
branches: ["develop", "Develop"]
pull_request:
branches: ["develop", "Develop"]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: login to gitea registry
uses: docker/login-action@v3
with:
registry: ${{ gitea.server_url }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
engineering.sanya.gay/pluralflux/pluralflux-dev:latest
- name: Deploy bot
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
cd ${{ secrets.BOT_DIRECTORY }}
docker compose pull
docker compose up -d pluralflux-dev

View File

@@ -0,0 +1,47 @@
name: nodeJS remote worker
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker BuildX
uses: docker/setup-buildx-action@v3
- name: login to gitea registry
uses: docker/login-action@v3
with:
registry: ${{ gitea.server_url }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA }}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
engineering.sanya.gay/pluralflux/pluralflux:latest
- name: Deploy bot
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
port: 22
script: |
cd ${{ secrets.BOT_DIRECTORY }}
docker compose pull
docker compose up -d pluralflux-prod

View File

@@ -0,0 +1,26 @@
name: Auto-Sync from Mirror
on:
push:
repository: "Pluralflux/Pluralflux"
branches: [main,develop]
workflow_dispatch:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout Fork
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN }}
- name: Pull from Mirror
run: |
git remote add upstream https://engineering.sanya.gay/PluralFlux/PluralFlux.git
git fetch upstream --prune
git reset --hard origin/main
git push origin "refs/remotes/upstream/*:refs/heads/*" --force-with-lease
git merge upstream/main -m "Syncing from github"
git push origin main

View File

@@ -1,39 +0,0 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: Node.js CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [25.3.0]
steps:
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Run tests
run: npm test
working-directory: tests
- name: Tests failed
if: failure()
run: exit 1
- name: Tests passed
run: npm run build --if-present
working-directory: src

11
.gitignore vendored
View File

@@ -1,8 +1,13 @@
node_modules
node_modules/
build/
tmp/
temp/
.idea
secrets/
config.json
coverage
config.json
log.txt
.env
oya.png
oya.png
variables.env
.env.production

32
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,32 @@
Thanks for being interested in contributing to PluralFlux! I really can't do this by myself, nor do I want to!
This is a guide for code contributions only. If you're looking to contribute _money_, please go to my [sponsorship page](https://github.com/sponsors/pieartsy)!
## Disclaimer
The PluralFlux team is endogenic-friendly. Even if you disagree with this, keep discourse takes to yourself. If you can't be civil about it, please do not contribute. Other bigotry (transphobia, racism, ableism, fatphobia, etc) will not be tolerated either.
## Resources:
Not too many right now, but I'm hoping to get a wiki up.
- [Issues tracker](https://github.com/pieartsy/PluralFlux/issues)
- [Pluralflux Support server](https://fluxer.gg/WaO6qGdU) where you can contact me (there's a #contributing channel for contributors)
- You can also reach me @pieartsy on Discord (or anywhere, really) if/when Fluxer is down.
## Requirements
- [Fluxer.js](https://fluxerjs.blstmo.com/)
- Docker
- Node version 25.3.8
## Submitting changes
- Submit a pull request to this repository and explain your code and changes.
- We squash-merge commits, but keep to the [Conventional Commit](https://www.conventionalcommits.org/en/v1.0.0-beta.2/) structure for your PR titles. Conventions are not necessary for commits themselves, but try to keep them readable anyway.
- Branches should target one specific issue in the Issue Tracker and try not to touch other features. Link to the issue in your PR.
- All commits will undergo PR review, at minimum by the main dev right now. If you can't explain or defend your code, it may be rejected.
## Standards
- Docstrings are *mandatory*, following the standards in [JSDoc](https://michaelcurrin.github.io/dev-cheatsheets/cheatsheets/javascript/general/jsdoc.html).
- Comments are encouraged for confusing code. Prioritize readability (for example, just write an if/else instead of chaining ternaries).
- Reusable message replies should go in the enums file so we don't have to hunt them down to change wording.
- We use [jest](https://jestjs.io/) for testing. Please write unit tests and ideally integration tests for your code. Shoot for 60% coverage at minimum. Check that other features that touch your changes don't break.
### LLM usage
**Do *not* insert code that has been LLM/GenAI generated.** All code you submit must be handwritten by yourself. This includes writing tests. Vibe coding is especially **not** allowed. Please disclose if you've used any AI for any other reasons, such as rubber-ducking or figuring out bugs or something. The main dev is somewhat more open to these uses because of search engines enshittifying--but frequent LLM usage is heavily discouraged due to the ethical concerns as well as damage to critical thinking skills. Only turn to LLMs if scouring search engines, Stack Overflow, and your friends list has not worked.

View File

@@ -7,4 +7,4 @@ FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
CMD ["node", "src/bot.js"]
CMD ["npm", "start"]

60
LICENSE Normal file
View File

@@ -0,0 +1,60 @@
Peer Production License
Created by John Magyar, B.A., J.D. and Dmytri Kleiner, the following Peer Production License, a model for a Copyfarleft license, has been derived from the Creative Commons 'Attribution-NonCommercial-ShareAlike' license available at http://creativecommons.org/licenses/by-nc-sa/3.0/legalcode.
LICENSE
THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS COPYFARLEFT PUBLIC LICENSE ("LICENSE"). THE WORK IS PROTECTED BY COPYRIGHT AND ALL OTHER APPLICABLE LAWS. ANY USE OF THE WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS PROHIBITED. BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED IN THIS LICENSE, YOU AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE. TO THE EXTENT THIS LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU THE RIGHTS CONTAINED HERE IN AS CONSIDERATION FOR ACCEPTING THE TERMS AND CONDITIONS OF THIS LICENSE AND FOR AGREEING TO BE BOUND BY THE TERMS AND CONDITIONS OF THIS LICENSE.
1. DEFINITIONS
a. "Adaptation" means a work based upon the Work, or upon the Work and other pre-existing works, such as a translation, adaptation, derivative work, arrangement of music or other alterations of a literary or artistic work, or phonogram or performance and includes cinematographic adaptations or any other form in which the Work may be recast, transformed, or adapted including in any form recognizably derived from the original, except that a work that constitutes a Collection will not be considered an Adaptation for the purpose of this License. For the avoidance of doubt, where the Work is a musical work, performance or phonogram, the synchronization of the Work in timed-relation with a moving image ("synching") will be considered an Adaptation for the purpose of this License.
b. "Collection" means a collection of literary or artistic works, such as encyclopedias and anthologies, or performances, phonograms or broadcasts, or other works or subject matter other than works listed in Section 1(f) below, which, by reason of the selection and arrangement of their contents, constitute intellectual creations, in which the Work is included in its entirety in unmodified form along with one or more other contributions, each constituting separate and independent works in themselves, which together are assembled into a collective whole. A work that constitutes a Collection will not be considered an Adaptation (as defined above) for the purposes of this License.
c. "Distribute" means to make available to the public the original and copies of the Work or Adaptation, as appropriate, through sale, gift or any other transfer of possession or ownership.
d. "Licensor" means the individual, individuals, entity or entities that offer(s) the Work under the terms of this License.
e. "Original Author" means, in the case of a literary or artistic work, the individual, individuals, entity or entities who created the Work or if no individual or entity can be identified, the publisher; and in addition (i) in the case of a performance the actors, singers, musicians, dancers, and other persons who act, sing, deliver, declaim, play in, interpret or otherwise perform literary or artistic works or expressions of folklore; (ii) in the case of a phonogram the producer being the person or legal entity who first fixes the sounds of a performance or other sounds; and, (iii) in the case of broadcasts, the organization that transmits the broadcast.
f. "Work" means the literary and/or artistic work offered under the terms of this License including without limitation any production in the literary, scientific and artistic domain, whatever may be the mode or form of its expression including digital form, such as a book, pamphlet and other writing; a lecture, address, sermon or other work of the same nature; a dramatic or dramatico-musical work; a choreographic work or entertainment in dumb show; a musical composition with or without words; a cinematographic work to which are assimilated works expressed by a process analogous to cinematography; a work of drawing, painting, architecture, sculpture, engraving or lithography; a photographic work to which are assimilated works expressed by a process analogous to photography; a work of applied art; an illustration, map, plan, sketch or three-dimensional work relative to geography, topography, architecture or science; a performance; a broadcast; a phonogram; a compilation of data to the extent it is protected as a copyrightable work; or a work performed by a variety or circus performer to the extent it is not otherwise considered a literary or artistic work.
g. "You" means an individual or entity exercising rights under this License who has not previously violated the terms of this License with respect to the Work, or who has received express permission from the Licensor to exercise rights under this License despite a previous violation.
h. "Publicly Perform" means to perform public recitations of the Work and to communicate to the public those public recitations, by any means or process, including by wire or wireless means or public digital performances; to make available to the public Works in such a way that members of the public may access these Works from a place and at a place individually chosen by them; to perform the Work to the public by any means or process and the communication to the public of the performances of the Work, including by public digital performance; to broadcast and rebroadcast the Work by any means including signs, sounds or images.
i. "Reproduce" means to make copies of the Work by any means including without limitation by sound or visual recordings and the right of fixation and reproducing fixations of the Work, including storage of a protected performance or phonogram in digital form or other electronic medium.
2. FAIR DEALING RIGHTS
Nothing in this License is intended to reduce, limit, or restrict any uses free from copyright or rights arising from limitations or exceptions that are provided for in connection with the copyright protection under copyright law or other applicable laws.
3. LICENSE GRANT
Subject to the terms and conditions of this License, Licensor hereby grants You a worldwide, royalty-free, non-exclusive, perpetual (for the duration of the applicable copyright) license to exercise the rights in the Work as stated below:
a. to Reproduce the Work, to incorporate the Work into one or more Collections, and to Reproduce the Work as incorporated in the Collections;
b. to create and Reproduce Adaptations provided that any such Adaptation, including any translation in any medium, takes reasonable steps to clearly label, demarcate or otherwise identify that changes were made to the original Work. For example, a translation could be marked "The original work was translated from English to Spanish," or a modification could indicate "The original work has been modified.";
c. to Distribute and Publicly Perform the Work including as incorporated in Collections; and,
d. to Distribute and Publicly Perform Adaptations. The above rights may be exercised in all media and formats whether now known or hereafter devised. The above rights include the right to make such modifications as are technically necessary to exercise the rights in other media and formats. Subject to Section 8(f), all rights not expressly granted by Licensor are hereby reserved, including but not limited to the rights set forth in Section 4(f).
4. RESTRICTIONS
The license granted in Section 3 above is expressly made subject to and limited by the following restrictions:
a. You may Distribute or Publicly Perform the Work only under the terms of this License. You must include a copy of, or the Uniform Resource Identifier (URI) for, this License with every copy of the Work You Distribute or Publicly Perform. You may not offer or impose any terms on the Work that restrict the terms of this License or the ability of the recipient of the Work to exercise the rights granted to that recipient under the terms of the License. You may not sublicense the Work. You must keep intact all notices that refer to this License and to the disclaimer of warranties with every copy of the Work You Distribute or Publicly Perform. When You Distribute or Publicly Perform the Work, You may not impose any effective technological measures on the Work that restrict the ability of a recipient of the Work from You to exercise the rights granted to that recipient under the terms of the License. This Section 4(a) applies to the Work as incorporated in a Collection, but this does not require the Collection apart from the Work itself to be made subject to the terms of this License. If You create a Collection, upon notice from any Licensor You must, to the extent practicable, remove from the Collection any credit as required by Section 4(d), as requested. If You create an Adaptation, upon notice from any Licensor You must, to the extent practicable, remove from the Adaptation any credit as required by Section 4(d), as requested.
b. Subject to the exception in Section 4(c), you may not exercise any of the rights granted to You in Section 3 above in any manner that is primarily intended for or directed toward commercial advantage or private monetary compensation. The exchange of the Work for other copyrighted works by means of digital file-sharing or otherwise shall not be considered to be intended for or directed toward commercial advantage or private monetary compensation, provided there is no payment of any monetary compensation in connection with the exchange of copyrighted works.
c. You may exercise the rights granted in Section 3 for commercial purposes only if:
i. You are a worker-owned business or worker-owned collective; and
ii. all financial gain, surplus, profits and benefits produced by the business or collective are distributed among the worker-owners
d. Any use by a business that is privately owned and managed, and that seeks to generate profit from the labor of employees paid by salary or other wages, is not permitted under this license.
e. If You Distribute, or Publicly Perform the Work or any Adaptations or Collections, You must, unless a request has been made pursuant to Section 4(a), keep intact all copyright notices for the Work and provide, reasonable to the medium or means You are utilizing: (i) the name of the Original Author (or pseudonym, if applicable) if supplied, and/or if the Original Author and/or Licensor designate another party or parties (e.g., a sponsor institute, publishing entity, journal) for attribution ("Attribution Parties") in Licensor's copyright notice, terms of service or by other reasonable means, the name of such party or parties; (ii) the title of the Work if supplied; (iii) to the extent reasonably practicable, the URI, if any, that Licensor specifies to be associated with the Work, unless such URI does not refer to the copyright notice or licensing information for the Work; and, (iv) consistent with Section 3(b), in the case of an Adaptation, a credit identifying the use of the Work in the Adaptation (e.g., "French translation of the Work by Original Author," or "Screenplay based on original Work by Original Author"). The credit required by this Section 4(d) may be implemented in any reasonable manner; provided, however, that in the case of a Adaptation or Collection, at a minimum such credit will appear, if a credit for all contributing authors of the Adaptation or Collection appears, then as part of these credits and in a manner at least as prominent as the credits for the other contributing authors. For the avoidance of doubt, You may only use the credit required by this Section for the purpose of attribution in the manner set out above and, by exercising Your rights under this License, You may not implicitly or explicitly assert or imply any connection with, sponsorship or endorsement by the Original Author, Licensor and/or Attribution Parties, as appropriate, of You or Your use of the Work, without the separate, express prior written permission of the Original Author, Licensor and/or Attribution Parties.
f. For the avoidance of doubt:
i. Non-waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme cannot be waived, the Licensor reserves the exclusive right to collect such royalties for any exercise by You of the rights granted under this License;
ii. Waivable Compulsory License Schemes. In those jurisdictions in which the right to collect royalties through any statutory or compulsory licensing scheme can be waived, the Licensor reserves the exclusive right to collect such royalties for any exercise by You of the rights granted under this License if Your exercise of such rights is for a purpose or use which is otherwise than noncommercial as permitted under Section 4(b) and otherwise waives the right to collect royalties through any statutory or compulsory licensing scheme; and,
iii. Voluntary License Schemes. The Licensor reserves the right to collect royalties, whether individually or, in the event that the Licensor is a member of a collecting society that administers voluntary licensing schemes, via that society, from any exercise by You of the rights granted under this License that is for a purpose or use which is otherwise than noncommercial as permitted under Section 4(b).
g. Except as otherwise agreed in writing by the Licensor or as may be otherwise permitted by applicable law, if You Reproduce, Distribute or Publicly Perform the Work either by itself or as part of any Adaptations or Collections, You must not distort, mutilate, modify or take other derogatory action in relation to the Work which would be prejudicial to the Original Author's honor or reputation. Licensor agrees that in those jurisdictions (e.g. Japan), in which any exercise of the right granted in Section 3(b) of this License (the right to make Adaptations) would be deemed to be a distortion, mutilation, modification or other derogatory action prejudicial to the Original Author's honor and reputation, the Licensor will waive or not assert, as appropriate, this Section, to the fullest extent permitted by the applicable national law, to enable You to reasonably exercise Your right under Section 3(b) of this License (right to make Adaptations) but not otherwise.
5. REPRESENTATIONS, WARRANTIES AND DISCLAIMER
UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING, LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED, STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE, NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE. SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES, SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
6. LIMITATION ON LIABILITY
EXCEPT TO THE EXTENT REQUIRED BY APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
7. TERMINATION
a. This License and the rights granted hereunder will terminate automatically upon any breach by You of the terms of this License. Individuals or entities who have received Adaptations or Collections from You under this License, however, will not have their licenses terminated provided such individuals or entities remain in full compliance with those licenses. Sections 1, 2, 5, 6, 7, and 8 will survive any termination of this License.
b. Subject to the above terms and conditions, the license granted here is perpetual (for the duration of the applicable copyright in the Work). Notwithstanding the above, Licensor reserves the right to release the Work under different license terms or to stop distributing the Work at any time; provided, however that any such election will not serve to withdraw this License (or any other license that has been, or is required to be, granted under the terms of this License), and this License will continue in full force and effect unless terminated as stated above.
8. MISCELLANEOUS
a. Each time You Distribute or Publicly Perform the Work or a Collection, the Licensor offers to the recipient a license to the Work on the same terms and conditions as the license granted to You under this License.
b. Each time You Distribute or Publicly Perform an Adaptation, Licensor offers to the recipient a license to the original Work on the same terms and conditions as the license granted to You under this License.
c. If any provision of this License is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this License, and without further action by the parties to this agreement, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable.
d. No term or provision of this License shall be deemed waived and no breach consented to unless such waiver or consent shall be in writing and signed by the party to be charged with such waiver or consent.
e. This License constitutes the entire agreement between the parties with respect to the Work licensed here. There are no understandings, agreements or representations with respect to the Work not specified here. Licensor shall not be bound by any additional provisions that may appear in any communication from You. This License may not be modified without the mutual written agreement of the Licensor and You.
f. The rights granted under, and the subject matter referenced, in this License were drafted utilizing the terminology of the Berne Convention for the Protection of Literary and Artistic Works (as amended on September 28, 1979), the Rome Convention of 1961, the WIPO Copyright Treaty of 1996, the WIPO Performances and Phonograms Treaty of 1996 and the Universal Copyright Convention (as revised on July 24, 1971). These rights and subject matter take effect in the relevant jurisdiction in which the License terms are sought to be enforced according to the corresponding provisions of the implementation of those treaty provisions in the applicable national law. If the standard suite of rights granted under applicable copyright law includes additional rights not granted under this License, such additional rights are deemed to be included in the License; this License is not intended to restrict the license of any rights under applicable law.

View File

@@ -36,13 +36,10 @@ All commands are prefixed by `pf;`. Currently only a few are implemented.
1. Pass in a direct remote image URL, for example: `pf;member jane propic <https://cdn.pixabay.com/photo/2020/05/02/02/54/animal-5119676_1280.jpg>`. You can upload images on sites like <https://imgbb.com/>.
2. Upload an attachment directly.
**NOTE:** Fluxer does not save your attachments forever, so option #1 is recommended.
- `proxy` Updates the proxy tag for a specific member based on their name. The proxy must be formatted with the tags surrounding the word 'text', for example: `pf;member jane proxy Jane:text` or `pf;member amal proxy [text]` This is so the bot can detect what the proxy tags are. **Only one proxy can be set per member currently.**
## Notes
- Attaching files to messages with the proxy does not work, due to either the limitations of Fluxer itself :(
- `proxy` Updates the proxy tag for a specific member based on their name. The proxy must be formatted with the tags surrounding the word 'text', for example: `pf;member jane proxy Jane:text` or `pf;member amal proxy A{text}` This is so the bot can detect what the proxy tags are. **Only one proxy can be set per member currently.**
## Upcoming
- [ ] React with x to delete message
- [ ] System tag at the end of messages
- [ ] Optionally keep proxy tag in message
- [ ] Autoproxy front
Check for, and add, feature requests in the [Issues tracker](https://github.com/pieartsy/PluralFlux/issues).
## LLM note
I do **not** use LLMs or other GenAI to generate code, nor do I ever plan to. _Very_ rarely, I ask questions of LLMs to troubleshoot bugs after search engines/StackOverflow/friends' knowledge has failed me, but that should lessen even more over time. As well, I used the Docker "Gordon" LLM to fix the many errors in my initial docker compose, but now that I have a devops person helping me, that should never happen again.

View File

@@ -1,46 +1,25 @@
services:
main:
build: .
image: engineering.sanya.gay/pluralflux/pluralflux
container_name: pluralflux
restart: unless-stopped
networks:
- pluralflux-net
env_file: "variables.env"
postgres:
image: postgres:latest
container_name: pluralflux-postgres
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_pwd
secrets:
- postgres_pwd
env_file: "variables.env"
volumes:
- pgdata:/var/lib/postgresql
- ./pgBackup:/mnt/pgBackup
ports:
- "5432:5432"
networks:
- pluralflux-net
pgadmin:
image: dpage/pgadmin4:latest
container_name: pluralflux-pgadmin
ports:
- "5050:80"
environment:
PGADMIN_DEFAULT_EMAIL: code@asterfialla.com
PGADMIN_DEFAULT_PASSWORD_FILE: /run/secrets/postgres_pwd
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'
secrets:
- postgres_pwd
env_file: "variables.env"
depends_on:
- postgres
networks:
- pluralflux-net
networks:
pluralflux-net:
volumes:
- pgadmindata:/var/lib/pgadmin
volumes:
pgdata:
secrets:
postgres_pwd:
file: ./secrets/postgres-password.txt
pgdata:

26
database/data-source.ts Normal file
View File

@@ -0,0 +1,26 @@
import "reflect-metadata"
import { DataSource } from "typeorm"
import * as env from 'dotenv';
import * as path from "path";
env.config();
export const AppDataSource = new DataSource({
type: "postgres",
host: process.env.POSTGRES_ENDPOINT,
port: 5432,
username: "postgres",
password: process.env.POSTGRES_PASSWORD,
database: "postgres",
synchronize: false,
logging: false,
entities: [path.join(__dirname, "./entity/*.{ts,js}")],
migrations: [path.join(__dirname, "./migrations/*.{ts,js}")],
migrationsRun: true,
migrationsTableName: 'migrations',
migrationsTransactionMode: 'all',
invalidWhereValuesBehavior: {
null: "sql-null",
undefined: "throw",
},
});

40
database/entity/Member.ts Normal file
View File

@@ -0,0 +1,40 @@
import {Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Unique} from "typeorm"
@Entity({name: "Member", synchronize: true})
@Unique("UQ_Member_userid_name", ['userid', 'name'])
export class Member {
@PrimaryGeneratedColumn()
id: number
@Column()
userid: string
@Column({
length: 100
})
name: string
@Column({
type: "varchar",
nullable: true,
length: 100
})
displayname: string
@Column({
nullable: true,
})
proxy: string
@Column({
nullable: true,
})
propic: string
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date
}

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Update1772417745487 implements MigrationInterface {
name = 'Update1772417745487'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "Member" ("id" SERIAL NOT NULL, "userid" character varying NOT NULL, "name" character varying(100) NOT NULL, "displayname" character varying(100), "proxy" character varying, "propic" character varying, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_235428a1d87c5f639ef7b7cf170" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "Member"`);
}
}

View File

@@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddData1772419448503 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`INSERT INTO "Member"(id, userid, name,displayname, proxy, propic, "createdAt", "updatedAt") SELECT id,userid, name,displayname, proxy, propic, "createdAt", "updatedAt" FROM "Members";`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`TRUNCATE TABLE "Member"`);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class DeleteDuplicates1772825438973 implements MigrationInterface {
name= "DeleteDuplicates1772825438973"
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE
FROM "Member" a USING "Member" b
WHERE a.id
> b.id
AND a.name = b.name
AND a.userid = b.userid;`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class Update1772830252670 implements MigrationInterface {
name = 'Update1772830252670'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Member" ADD CONSTRAINT "UQ_Member_userid_name" UNIQUE ("userid", "name")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "Member" DROP CONSTRAINT "UQ_Member_userid_name"`);
}
}

2198
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,24 +7,36 @@
"type": "git",
"url": "https://github.com/pieartsy/PluralFlux.git"
},
"type": "commonjs",
"private": true,
"dependencies": {
"@fluxerjs/core": "^1.1.5",
"@fluxerjs/core": "^1.2.2",
"dotenv": "^17.3.1",
"pg": "^8.18.0",
"pg": "^8.19.0",
"pg-hstore": "^2.3.4",
"pm2": "^6.0.14",
"sequelize": "^6.37.7",
"tmp": "^0.2.5"
"psql": "^0.0.1",
"reflect-metadata": "^0.2.2",
"tmp": "^0.2.5",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/plugin-transform-modules-commonjs": "^7.28.6",
"@babel/preset-env": "^7.29.0",
"@fetch-mock/jest": "^0.2.20",
"@types/node": "^25.3.3",
"babel-jest": "^30.2.0",
"jest": "^30.2.0"
"fetch-mock": "^12.6.0",
"jest": "^30.2.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
},
"scripts": {
"test": "jest"
"test": "jest",
"start": "ts-node src/bot.js",
"new-migration": "typeorm-ts-node-commonjs migration:create database/migrations/update",
"generate-db": "typeorm-ts-node-commonjs migration:generate -d database/data-source.ts database/migrations/update",
"run-migration": "typeorm-ts-node-commonjs migration:run -d database/data-source.ts"
}
}

View File

@@ -1,9 +1,11 @@
import { Client, Events } from '@fluxerjs/core';
import { messageHelper } from "./helpers/messageHelper.js";
import {enums} from "./enums.js";
import {commands} from "./commands.js";
import {webhookHelper} from "./helpers/webhookHelper.js";
import * as env from 'dotenv';
const {Client, Events, Message} = require('@fluxerjs/core');
const {messageHelper} = require("./helpers/messageHelper.js");
const {enums} = require("./enums.js");
const {commands} = require("./commands.js");
const {webhookHelper} = require("./helpers/webhookHelper.js");
const env = require('dotenv');
const {utils} = require("./helpers/utils.js");
const { AppDataSource } = require("../database/data-source");
env.config();
@@ -14,35 +16,47 @@ if (!token) {
process.exit(1);
}
const client = new Client({ intents: 0 });
client = new Client({ intents: 0 });
module.exports.client = client;
client.on(Events.MessageCreate, async (message) => {
try {
// Ignore bots and messages without content
if (message.author.bot || !message.content) return;
await module.exports.handleMessageCreate(message);
});
/**
* Calls functions based off the contents of a message object.
*
* @async
* @param {Message} message - The message object
*
**/
module.exports.handleMessageCreate = async function(message) {
try {
// Ignore bots
if (message.author.bot) return;
// Parse command and arguments
const content = message.content.trim();
// If message doesn't start with the bot prefix, it could still be a message with a proxy tag. If it's not, return.
if (!content.startsWith(messageHelper.prefix)) {
await webhookHelper.sendMessageAsMember(client, message, content).catch((e) => {
throw e
});
return;
return await webhookHelper.sendMessageAsMember(client, message);
}
const commandName = content.slice(messageHelper.prefix.length).split(" ")[0];
// If there's no command name (ie just the prefix)
if (!commandName) return await message.reply(enums.help.SHORT_DESC_PLURALFLUX);
const args = messageHelper.parseCommandArgs(content, commandName);
const command = commands.get(commandName);
let command = commands.commandsMap.get(commandName)
if (!command) {
const commandFromAlias = commands.aliasesMap.get(commandName);
command = commandFromAlias ? commands.commandsMap.get(commandFromAlias.command) : null;
}
if (command) {
await command.execute(message, client, args).catch(e => {
throw e
});
await command.execute(message, args);
}
else {
await message.reply(enums.err.COMMAND_NOT_RECOGNIZED);
@@ -50,9 +64,8 @@ client.on(Events.MessageCreate, async (message) => {
}
catch(error) {
console.error(error);
// return await message.reply(error.message);
}
});
}
client.on(Events.Ready, () => {
console.log(`Logged in as ${client.user?.username}`);
@@ -61,27 +74,31 @@ client.on(Events.Ready, () => {
let guildCount = 0;
client.on(Events.GuildCreate, () => {
guildCount++;
callback();
debouncePrintGuilds();
});
function printGuilds() {
console.log(`Serving ${client.guilds.size} guild(s)`);
}
const callback = Debounce(printGuilds, 2000);
const debouncePrintGuilds = utils.debounce(printGuilds, 2000);
// export const debounceLogin = utils.debounce(client.login, 60000);
function Debounce(func, delay) {
let timeout = null;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), delay);
};
module.exports.login = async function() {
try {
if (!AppDataSource.isInitialized) {
await AppDataSource.initialize();
}
await client.login(token);
} catch (err) {
console.error('Login failed:', err);
process.exit(1);
}
}
try {
await client.login(token);
// await db.check_connection();
} catch (err) {
console.error('Login failed:', err);
process.exit(1);
}
function main()
{
exports.login();
}
main();

View File

@@ -1,36 +1,62 @@
import {messageHelper} from "./helpers/messageHelper.js";
import {enums} from "./enums.js";
import {memberHelper} from "./helpers/memberHelper.js";
import {EmbedBuilder} from "@fluxerjs/core";
import {importHelper} from "./helpers/importHelper.js";
const {messageHelper} = require("./helpers/messageHelper.js");
const {enums} = require("./enums.js");
const {memberHelper} = require("./helpers/memberHelper.js");
const {EmbedBuilder} = require("@fluxerjs/core");
const {importHelper} = require("./helpers/importHelper.js");
const cmds = new Map();
const commands = {
commandsMap: new Map(),
aliasesMap: new Map()
};
cmds.set('member', {
commands.aliasesMap.set('m', {command: 'member'})
commands.commandsMap.set('member', {
description: enums.help.SHORT_DESC_MEMBER,
async execute(message, client, args) {
const authorFull = `${message.author.username}#${message.author.discriminator}`
const attachmentUrl = message.attachments.size > 0 ? message.attachments.first().url : null;
const attachmentExpires = message.attachments.size > 0 ? message.attachments.first().expires_at : null;
const reply = await memberHelper.parseMemberCommand(message.author.id, authorFull, args, attachmentUrl, attachmentExpires).catch(async (e) =>{await message.reply(e.message);});
if (typeof reply === 'string') {
return await message.reply(reply);
}
else if (reply instanceof EmbedBuilder) {
await message.reply({embeds: [reply.toJSON()]})
}
else if (typeof reply === 'object') {
const errorsText = reply.errors.length > 0 ? reply.errors.join('\n- ') : null;
return await message.reply({content: `${reply.success} ${errorsText ? "\nThese errors occurred:\n" + errorsText : ""}`, embeds: [reply.embed.toJSON()]})
}
async execute(message, args) {
await commands.memberCommand(message, args)
}
})
cmds.set('help', {
/**
* Calls the member-related functions.
*
* @async
* @param {Message} message - The message object
* @param {string[]} args - The parsed arguments
*
**/
commands.memberCommand = async function (message, args) {
const authorFull = `${message.author.username}#${message.author.discriminator}`
const attachmentUrl = message.attachments.size > 0 ? message.attachments.first().url : null;
const attachmentExpires = message.attachments.size > 0 ? message.attachments.first().expires_at : null;
let reply;
try {
reply = await memberHelper.parseMemberCommand(message.author.id, authorFull, args, attachmentUrl, attachmentExpires)
} catch (e) {
return await message.reply(e.message);
}
if (typeof reply === 'string') {
await message.reply(reply);
} else if (reply instanceof EmbedBuilder) {
await message.reply({embeds: [reply]})
} else if (typeof reply === 'object') {
// The little dash is so that the errors print out in bullet points in Fluxer
const errorsText = reply.errors.length > 0 ? '- ' + reply.errors.join('\n- ') : null;
return await message.reply({
content: `${reply.success} ${errorsText ? `\n\n${enums.err.ERRORS_OCCURRED}\n` + errorsText : ""}`,
embeds: [reply.embed]
})
}
}
commands.commandsMap.set('help', {
description: enums.help.SHORT_DESC_HELP,
async execute(message) {
const fields = [...cmds.entries()].map(([name, cmd]) => ({
const fields = [...commands.commandsMap.entries()].map(([name, cmd]) => ({
name: `${messageHelper.prefix}${name}`,
value: cmd.description,
inline: true,
@@ -40,39 +66,57 @@ cmds.set('help', {
.setTitle('Commands')
.setDescription(enums.help.PLURALFLUX)
.addFields(...fields)
.setFooter({ text: `Prefix: ${messageHelper.prefix}` })
.setFooter({text: `Prefix: ${messageHelper.prefix}`})
.setTimestamp();
await message.reply({ embeds: [embed.toJSON()] });
await message.reply({embeds: [embed]});
},
})
cmds.set('import', {
commands.commandsMap.set('import', {
description: enums.help.SHORT_DESC_IMPORT,
async execute(message, client, args) {
const attachmentUrl = message.attachments.size > 0 ? message.attachments.first().url : null;
if ((message.content.includes('--help') || (args[0] === '' && args.length === 1)) && !attachmentUrl ) {
return await message.reply(enums.help.IMPORT);
}
return await importHelper.pluralKitImport(message.author.id, attachmentUrl).then(async (successfullyAdded) => {
await message.reply(successfullyAdded);
}).catch(async (error) => {
if (error instanceof AggregateError) {
// errors.message can be a list of successfully added members, or say that none were successful.
let errorsText = `${error.message}.\nThese errors occurred:\n${error.errors.join('\n')}`;
await message.reply(errorsText).catch(async () => {
const returnedBuffer = messageHelper.returnBufferFromText(errorsText);
await message.reply({content: returnedBuffer.text, files: [{ name: 'text.pdf', data: returnedBuffer.file }]
})
});
}
// If just one error was returned.
else {
return await message.reply(error.message);
}
})
async execute(message, args) {
await commands.importCommand(message, args);
}
})
export const commands = cmds;
/**
* Calls the import-related functions.
*
* @async
* @param {Message} message - The message object
* @param {string[]} args - The parsed arguments
*
**/
commands.importCommand = async function (message, args) {
const attachmentUrl = message.attachments.size > 0 ? message.attachments.first().url : null;
if ((message.content.includes('--help') || (args[0] === '' && args.length === 1)) && !attachmentUrl) {
return await message.reply(enums.help.IMPORT);
}
let errorsText;
try {
const successfullyAdded = await importHelper.pluralKitImport(message.author.id, attachmentUrl)
return await message.reply(successfullyAdded);
} catch (error) {
if (error instanceof AggregateError) {
// errors.message can be a list of successfully added members, or say that none were successful.
errorsText = `${error.message}.\n\n${enums.err.ERRORS_OCCURRED}\n\n${error.errors.join('\n')}`;
}
// If just one error was returned.
else {
console.error(error);
errorsText = error.message;
}
}
if (errorsText.length > 2000) {
const returnedBuffer = messageHelper.returnBufferFromText(errorsText);
await message.reply({
content: returnedBuffer.text, files: [{name: 'text.txt', data: returnedBuffer.file}]
})
} else {
await message.reply(errorsText)
}
}
module.exports.commands = commands;

View File

@@ -1,84 +0,0 @@
import {DataTypes, Sequelize} from 'sequelize';
import * as env from 'dotenv';
env.config();
const password = process.env.POSTGRES_PASSWORD;
if (!password) {
console.error("Missing POSTGRES_PWD environment variable.");
process.exit(1);
}
const db = {};
const sequelize = new Sequelize('postgres', 'postgres', password, {
host: 'localhost',
logging: false,
dialect: 'postgres'
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
db.members = sequelize.define('Member', {
userid: {
type: DataTypes.STRING,
allowNull: false,
},
name: {
type: DataTypes.STRING,
allowNull: false,
},
displayname: {
type: DataTypes.STRING,
},
propic: {
type: DataTypes.STRING,
},
proxy: {
type: DataTypes.STRING,
}
});
db.systems = sequelize.define('System', {
userid: {
type: DataTypes.STRING,
},
fronter: {
type: DataTypes.STRING
},
grouptag: {
type: DataTypes.STRING
},
autoproxy: {
type: DataTypes.BOOLEAN,
}
})
/**
* Checks Sequelize database connection.
*/
db.check_connection = async function() {
await sequelize.authenticate().then(async () => {
console.log('Connection has been established successfully.');
await syncModels();
}).catch(err => {
console.error('Unable to connect to the database:', err);
process.exit(1);
});
}
/**
* Syncs Sequelize models.
*/
async function syncModels() {
await sequelize.sync().then(() => {
console.log('Models synced successfully.');
}).catch((err) => {
console.error('Syncing models did not work', err);
process.exit(1);
});
}
export const database = db;

View File

@@ -1,17 +1,18 @@
const helperEnums = {};
const enums = {};
helperEnums.err = {
enums.err = {
NO_MEMBER: "No such member was found.",
NO_NAME_PROVIDED: "No member name was provided for",
NO_VALUE: "has not been set for this member. Please provide a value.",
NO_VALUE: "has not been set for this member.",
ADD_ERROR: "Error adding member.",
MEMBER_EXISTS: "A member with that name already exists. Please pick a unique name.",
USER_NO_MEMBERS: "You have no members created.",
NAME_REQUIRED: "You must set a unique name for the member for them to save.",
DISPLAY_NAME_TOO_LONG: "The maximum length of a display name is 32 characters.",
PROXY_EXISTS: "A duplicate proxy already exists for one of your members. Please pick a new one, or change the old one first.",
NO_SUCH_COMMAND: "No such command exists.",
PROPIC_FAILS_REQUIREMENTS: "Profile picture must be in JPG, PNG, or WEBP format and less than 10MB.",
PROPIC_CANNOT_LOAD: "Profile picture could not be loaded from URL.",
PROPIC_CANNOT_LOAD: "Profile picture could not be loaded. Are you sure this is a valid URL? (Try visiting the link to make sure!)",
NO_WEBHOOKS_ALLOWED: "Channel does not support webhooks.",
NOT_IN_SERVER: "You can only proxy in a server.",
NO_MESSAGE_SENT_WITH_PROXY: 'Proxied message has no content.',
@@ -19,30 +20,34 @@ helperEnums.err = {
NO_PROXY_WRAPPER: "You need at least one proxy tag surrounding 'text', either before or after.\nCorrect usage examples: `pf;member jane proxy J:text`, `pf;member jane [text]`",
NOT_JSON_FILE: "Please attach a valid JSON file.",
NO_MEMBERS_IMPORTED: 'No members were imported.',
IMPORT_ERROR: "Please see attached file for logs on the member import process.",
ERRORS_OCCURRED: "These errors occurred:",
COMMAND_NOT_RECOGNIZED: "Command not recognized. Try typing `pf;help` for command list.",
SET_TO_NULL: "It has been set to null instead."
SET_TO_NULL: "It has been set to null instead.",
CANNOT_FETCH_RESOURCE: "Could not download the file at this time."
}
helperEnums.help = {
enums.help = {
SHORT_DESC_HELP: "Lists available commands.",
SHORT_DESC_MEMBER: "Accesses subcommands related to proxy members.",
SHORT_DESC_IMPORT: "Imports from PluralKit.",
SHORT_DESC_PLURALFLUX: "PluralFlux is a proxybot akin to PluralKit and Tupperbot, but for Fluxer. All commands are prefixed by `pf;`. Type `pf;help` for info on the bot itself.",
PLURALFLUX: "PluralFlux is a proxybot akin to PluralKit and Tupperbot, but for Fluxer. All commands are prefixed by `pf;`. Add ` --help` to the end of a command to find out more about it, or just send it without arguments.",
MEMBER: "Accesses the sub-commands related to editing proxy members. The available subcommands are `list`, `new`, `remove`, `displayname`, `proxy`, and `propic`. Add ` --help` to the end of a subcommand to find out more about it, or just send it without arguments.",
MEMBER: "Accesses the sub-commands related to adding, editing, and removing proxy members and the fields associated with them. Type `pf;member` and then the command name afterward to access it.\nAdd ` --help` to the end of a subcommand to find out more about it, or just send it without arguments.\nTo get information on a member, just write their name with no arguments afterward, for example: `pf;member jane`. To get the current value of a field instead of updating it, write without the last argument, for example: `pf;member jane displayname`; `pf;member jane propic`",
NEW: "Creates a new member to proxy with, for example: `pf;member new jane`. The member name should ideally be short so you can write other commands with it easily. \n\nThe order of values is `pf;member new [name] [displayname] [proxy] [propic]`, _without brackets_. The name is **required**, but the rest are optional.\nUsage notes:\n- If anything has spaces, put it in quotes.\n- If anything is unset and you want to set something after it (for ex: you haven't set a display name but you want to add a proxy), put the unset value in empty quotes in the same position: \"\" If you leave it out, the bot will set things wrong.\n- The maximum length of a display name is 32 characters.\n- You can't use the same proxy for two different members.\n- You can also upload an image directly instead of using a url.\nExamples:\n- Everything filled out: `pf;member new jane \"Jane Doe\" J:text https://cdn.pixabay.com/photo/2023/10/20/19/07/aster-8330078_1280.jpg`\n- Example with gaps: `pf;member new bob \"Bob he/him\" \"\" https://cdn.pixabay.com/photo/2016/05/09/11/09/tennis-1381230_1280.jpg`",
REMOVE: "Removes a member based on their name, for example: `pf;member remove jane`.",
LIST: "Lists members in the system. Currently only lists the first 25.",
LIST: "Lists members in the system. **Currently only lists the first 25.**",
NAME: "Updates the name for a specific member based on their current name, for ex: `pf;member john name jane`. The member name should ideally be short so you can write other commands with it easily.",
DISPLAY_NAME: "Updates the display name for a specific member based on their name, for example: `pf;member jane \"Jane Doe | ze/hir\"`.This can be up to 32 characters long. If it has spaces, put it in __double quotes__.",
PROXY: "Updates the proxy tag for a specific member based on their name. The proxy must be formatted with the tags surrounding the word 'text', for example: `pf;member jane proxy Jane:text` or `pf;member amal proxy [text]` This is so the bot can detect what the proxy tags are. **Only one proxy can be set per member currently.**",
DISPLAY_NAME: "Updates the display name for a specific member based on their name, for example: `pf;member jane displayname \"Jane Doe | ze/hir\"`.This can be up to 32 characters long. If it has spaces, __put it in quotes__.",
PROXY: "Updates the proxy tag for a specific member based on their name. The proxy must be formatted with the tags surrounding the word 'text', for example: `pf;member jane proxy Jane:text` or `pf;member amal proxy A{text}` This is so the bot can detect what the proxy tags are. **Only one proxy can be set per member currently.**",
PROPIC: "Updates the profile picture for the member. Must be in JPG, PNG, or WEBP format and less than 10MB. The two options are:\n1. Pass in a direct remote image URL, for example: `pf;member jane propic https://cdn.pixabay.com/photo/2020/05/02/02/54/animal-5119676_1280.jpg`. You can upload images on sites like https://imgbb.com/.\n2. Upload an attachment directly.\n\n**NOTE:** Fluxer does not save your attachments forever, so option #1 is recommended.",
IMPORT: "Imports from PluralKit using the JSON file provided by their export command. Importing from other proxy bots is TBD. `pf;import` and attach your JSON file to the message. This will only save the fields that are present in the bot currently, not anything else like birthdays or system handles (yet?). **Only one proxy can be set per member currently.**"
IMPORT: "Imports from PluralKit using the JSON file provided by their export command. Importing from other proxy bots is TBD. `pf;import` and attach your JSON file to the message. This will only save the fields that are present in the bot currently, not anything else like birthdays or system handles (yet?). **Only one proxy can be set per member currently.**\n\n**PRO TIP**: For privacy reasons, try DMing the bot with this command and your JSON file--it should still work the same."
}
helperEnums.misc = {
ATTACHMENT_SENT_BY: "Attachment sent by:"
enums.misc = {
ATTACHMENT_SENT_BY: "Attachment sent by:",
ATTACHMENT_EXPIRATION_WARNING: "**NOTE:** Because this profile picture is hosted on Fluxer, it will expire. To avoid this, upload the picture to another website like <https://imgbb.com/> and link to it directly.",
FLUXER_ATTACHMENT_URL: "https://fluxerusercontent.com/attachments/"
}
export const enums = helperEnums;
module.exports.enums = enums;

View File

@@ -1,43 +1,58 @@
import {enums} from "../enums.js";
import {memberHelper} from "./memberHelper.js";
const {enums} = require("../enums.js");
const {memberHelper} = require("./memberHelper.js");
const ih = {};
const importHelper = {};
/**
* Tries to import from Pluralkit.
*
* @async
* @param {string} authorId - The author of the message
* @param {string} attachmentUrl - The attached JSON url.
* @returns {string} A successful addition of all members.
* @param {string | null} [attachmentUrl] - The attached JSON url.
* @returns {Promise<string>} A successful addition of all members.
* @throws {Error} When the member exists, or creating a member doesn't work.
*/
ih.pluralKitImport = async function (authorId, attachmentUrl) {
importHelper.pluralKitImport = async function (authorId, attachmentUrl= null) {
let fetchResult, pkData;
if (!attachmentUrl) {
throw new Error(enums.err.NOT_JSON_FILE);
}
return fetch(attachmentUrl).then((res) => res.json()).then(async(pkData) => {
const pkMembers = pkData.members;
let errors = [];
const addedMembers = [];
for (let pkMember of pkMembers) {
const proxy = pkMember.proxy_tags[0] ? `${pkMember.proxy_tags[0].prefix ?? ''}text${pkMember.proxy_tags[0].suffix ?? ''}` : null;
await memberHelper.addFullMember(authorId, pkMember.name, pkMember.display_name, proxy, pkMember.avatar_url).then((memberObj) => {
addedMembers.push(memberObj.member.name);
if (memberObj.errors.length > 0) {
errors.push(`\n**${pkMember.name}:** `)
errors = errors.concat(memberObj.errors);
}
}).catch(e => {
errors.push(e.message);
});
try {
fetchResult = await fetch(attachmentUrl);
}
catch(e) {
throw new Error(enums.err.CANNOT_FETCH_RESOURCE, { cause: e });
}
try {
pkData = await fetchResult.json();
}
catch(e) {
throw new Error(enums.err.NOT_JSON_FILE, { cause: e })
}
const pkMembers = pkData.members;
let errors = [];
let addedMembers = [];
for (let pkMember of pkMembers) {
const proxy = pkMember.proxy_tags[0] ? `${pkMember.proxy_tags[0].prefix ?? ''}text${pkMember.proxy_tags[0].suffix ?? ''}` : null;
try {
const memberObj = await memberHelper.addFullMember(authorId, pkMember.name, pkMember.display_name, proxy, pkMember.avatar_url);
addedMembers.push(memberObj.member.name);
if (memberObj.errors.length > 0) {
errors.push(`\n**${pkMember.name}:** `);
errors = errors.concat(memberObj.errors);
}
const aggregatedText = addedMembers.length > 0 ? `Successfully added members: ${addedMembers.join(', ')}` : enums.err.NO_MEMBERS_IMPORTED;
if (errors.length > 0) {
throw new AggregateError(errors, aggregatedText);
}
return aggregatedText;
});
}
catch(e) {
errors.push(e.message);
}
}
const aggregatedText = addedMembers.length > 0 ? `Successfully added members: ${addedMembers.join(', ')}` : `${enums.err.NO_MEMBERS_IMPORTED}`;
if (errors.length > 0) {
throw new AggregateError(errors, aggregatedText);
}
return aggregatedText;
}
export const importHelper = ih;
exports.importHelper = importHelper;

View File

@@ -1,44 +1,149 @@
import {database} from '../database.js';
import {enums} from "../enums.js";
import {EmptyResultError, Op} from "sequelize";
import {EmbedBuilder} from "@fluxerjs/core";
const {enums} = require("../enums.js");
const {EmbedBuilder} = require("@fluxerjs/core");
const {utils} = require("./utils.js");
const {memberRepo} = require("../repositories/memberRepo.js");
const mh = {};
const memberHelper = {};
// Has an empty "command" to parse the help message properly
const commandList = ['--help', 'new', 'remove', 'name', 'list', 'displayName', 'proxy', 'propic', ''];
const commandList = ['new', 'remove', 'name', 'list', 'displayname', 'proxy', 'propic'];
const newAndRemoveCommands = ['new', 'remove'];
/**
* Parses through the subcommands that come after "pf;member" and calls functions accordingly.
* Parses through the subcommands that come after "pf;member" to identify member name, command, and associated values.
*
* @async
* @param {string} authorId - The id of the message author
* @param {string} authorFull - The username and discriminator of the message author
* @param {string[]} args - The message arguments
* @param {string | null} attachmentUrl - The message attachment url.
* @param {string | null} attachmentExpiration - The message attachment expiration (if uploaded via Fluxer)
* @param {string | null} [attachmentUrl] - The attachment URL, if any
* @param {string | null} [attachmentExpiration] - The attachment expiry date, if any
* @returns {Promise<string>} A success message.
* @returns {Promise <EmbedBuilder>} A list of 25 members as an embed.
* @returns {Promise<{EmbedBuilder, [], string}>} A member info embed + info/errors.
* @throws {Error}
* @returns {Promise <EmbedBuilder>} A list of member commands and descriptions.
* @returns {Promise<{EmbedBuilder, string[], string}>} A member info embed + info/errors.
*/
mh.parseMemberCommand = async function (authorId, authorFull, args, attachmentUrl = null, attachmentExpiration = null) {
let member;
memberHelper.parseMemberCommand = async function (authorId, authorFull, args, attachmentUrl = null, attachmentExpiration = null) {
let memberName, command, isHelp = false;
// checks whether command is in list, otherwise assumes it's a name
if (!commandList.includes(args[0]) && !args[1]) {
member = await mh.getMemberInfo(authorId, args[0]);
// ex: pf;member remove, pf;member remove --help
// ex: pf;member, pf;member --help
if (args.length === 0 || args[0] === '--help' || args[0] === '') {
return memberHelper.getMemberCommandInfo();
}
switch (args[0]) {
case '--help':
return enums.help.MEMBER;
// ex: pf;member remove somePerson
if (commandList.includes(args[0])) {
command = args[0];
if (args[1]) {
memberName = args[1];
}
}
// ex: pf;member somePerson propic
else if (args[1] && commandList.includes(args[1])) {
command = args[1];
memberName = args[0];
}
// ex: pf;member somePerson
else if (!commandList.includes(args[0]) && !args[1]) {
memberName = args[0];
}
if (args[1] === "--help" || command && (memberName === "--help" || !memberName && command !== 'list')) {
isHelp = true;
}
return await memberHelper.memberArgumentHandler(authorId, authorFull, isHelp, command, memberName, args, attachmentUrl, attachmentExpiration);
}
/**
* Parses through the command, argument, and values and calls appropriate functions based on their presence or absence.
*
* @async
* @param {string} authorId - The id of the message author
* @param {string} authorFull - The username and discriminator of the message author
* @param {boolean} isHelp - Whether this is a help command or not
* @param {string | null} [command] - The command name
* @param {string | null} [memberName] - The member name
* @param {string[]} [args] - The message arguments
* @param {string | null} [attachmentUrl] - The attachment URL, if any
* @param {string | null} [attachmentExpiration] - The attachment expiry date, if any
* @returns {Promise<string>} A success message.
* @returns {Promise <EmbedBuilder>} A list of 25 members as an embed.
* @returns {Promise <EmbedBuilder>} A list of member commands and descriptions.
* @returns {Promise<{EmbedBuilder, [string], string}>} A member info embed + info/errors.
* @returns {Promise<string>} - A help message
* @throws {Error} When there's no member or a command is not recognized.
*/
memberHelper.memberArgumentHandler = async function(authorId, authorFull, isHelp, command = null, memberName = null, args = [], attachmentUrl = null, attachmentExpiration = null) {
if (!command && !memberName && !isHelp) {
throw new Error(enums.err.COMMAND_NOT_RECOGNIZED);
}
else if (isHelp) {
return memberHelper.sendHelpEnum(command);
}
else if (command === "list") {
return await memberHelper.getAllMembersInfo(authorId, authorFull);
}
else if (!memberName && !isHelp) {
throw new Error(enums.err.NO_MEMBER);
}
// remove memberName and command from values to reduce confusion
const values = args.slice(2);
// ex: pf;member blah blah
if (command && memberName && (values.length > 0 || newAndRemoveCommands.includes(command) || attachmentUrl)) {
return await memberHelper.memberCommandHandler(authorId, command, memberName, values, attachmentUrl, attachmentExpiration);
}
else if (memberName && values.length === 0) {
return await memberHelper.sendCurrentValue(authorId, memberName, command);
}
}
/**
* Sends the current value of a field based on the command.
*
* @async
* @param {string} authorId - The id of the message author
* @param {string} memberName - The name of the member
* @param {string | null} [command] - The command being called to query a value.
* @returns {Promise<string>} A success message.
* @returns {Promise <EmbedBuilder>} A list of 25 members as an embed.
* @returns {Promise <EmbedBuilder>} A list of member commands and descriptions.
* @returns {Promise<{EmbedBuilder, string[], string}>} A member info embed + info/errors.
* @throws {Error} When there's no member
*/
memberHelper.sendCurrentValue = async function(authorId, memberName, command= null) {
const member = await memberRepo.getMemberByName(authorId, memberName);
if (!member) throw new Error(enums.err.NO_MEMBER);
if (!command) {
return memberHelper.getMemberInfo(member);
}
switch (command) {
case 'name':
return `The name of ${member.name} is \"${member.name}\" but you probably already knew that!`;
case 'displayname':
return member.displayname ? `The display name for ${member.name} is \"${member.displayname}\".` : `Display name ${enums.err.NO_VALUE}`;
case 'proxy':
return member.proxy ? `The proxy for ${member.name} is \"${member.proxy}\".` : `Proxy ${enums.err.NO_VALUE}`;
case 'propic':
return member.propic ? `The profile picture for ${member.name} is \"${member.propic}\".` : `Propic ${enums.err.NO_VALUE}`;
}
}
/**
* Sends the help text associated with a command.
*
* @param {string} command - The command being called.
* @returns {string} - The help text associated with a command.
*/
memberHelper.sendHelpEnum = function(command) {
switch (command) {
case 'new':
return await mh.addNewMember(authorId, args, attachmentUrl).catch((e) => {
throw e
});
return enums.help.NEW;
case 'remove':
return await mh.removeMember(authorId, args).catch((e) => {
throw e
});
return enums.help.REMOVE;
case 'name':
return enums.help.NAME;
case 'displayname':
@@ -48,37 +153,38 @@ mh.parseMemberCommand = async function (authorId, authorFull, args, attachmentUr
case 'propic':
return enums.help.PROPIC;
case 'list':
if (args[1] && args[1] === "--help") {
return enums.help.LIST;
}
return await mh.getAllMembersInfo(authorId, authorFull).catch((e) => {
throw e
});
case '':
return enums.help.MEMBER;
return enums.help.LIST;
}
switch (args[1]) {
}
/**
* Handles the commands that need to call other update/edit commands.
*
* @async
* @param {string} authorId - The id of the message author
* @param {string} memberName - The name of the member
* @param {string} command - The command being called.
* @param {string[]} values - The values to be passed in. Only includes the values after member name and command name.
* @param {string | null} attachmentUrl - The attachment URL, if any
* @param {string | null} attachmentExpiration - The attachment expiry date, if any
* @returns {Promise<string> | Promise <EmbedBuilder> | Promise<{EmbedBuilder, [string], string}>}
*/
memberHelper.memberCommandHandler = async function(authorId, command, memberName, values, attachmentUrl = null, attachmentExpiration = null) {
switch (command) {
case 'new':
return await memberHelper.addNewMember(authorId, memberName, values, attachmentUrl, attachmentExpiration);
case 'remove':
return await memberHelper.removeMember(authorId, memberName);
case 'name':
return await mh.updateName(authorId, args).catch((e) => {
throw e
});
return await memberHelper.updateName(authorId, memberName, values[0]);
case 'displayname':
return await mh.updateDisplayName(authorId, args).catch((e) => {
throw e
});
return await memberHelper.updateDisplayName(authorId, memberName, values[0]);
case 'proxy':
if (!args[2]) return await mh.getProxyByMember(authorId, args[0]).catch((e) => {
throw e
});
return await mh.updateProxy(authorId, args).catch((e) => {
throw e
});
return await memberHelper.updateProxy(authorId, memberName, values[0]);
case 'propic':
return await mh.updatePropic(authorId, args, attachmentUrl, attachmentExpiration).catch((e) => {
throw e
});
return await memberHelper.updatePropic(authorId, memberName, values[0], attachmentUrl, attachmentExpiration);
default:
return member;
throw new Error(enums.err.COMMAND_NOT_RECOGNIZED);
}
}
@@ -87,26 +193,20 @@ mh.parseMemberCommand = async function (authorId, authorFull, args, attachmentUr
*
* @async
* @param {string} authorId - The author of the message
* @param {string[]} args - The message arguments
* @param {string | null} attachmentURL - The attachment URL, if any exists
* @returns {Promise<string>} A successful addition.
* @throws {Error} When the member exists, or creating a member doesn't work.
* @param {string} memberName - The member name
* @param {string[]} values - The arguments following the member name and command
* @param {string | null} [attachmentUrl] - The attachment URL, if any
* @param {string | null} [attachmentExpiration] - The attachment expiry date, if any
* @returns {Promise<{EmbedBuilder, string[], string}>} A successful addition.
*/
mh.addNewMember = async function (authorId, args, attachmentURL = null) {
if (args[1] && args[1] === "--help" || !args[1]) {
return enums.help.NEW;
}
const memberName = args[1];
const displayName = args[2];
const proxy = args[3];
const propic = args[4] ?? attachmentURL;
memberHelper.addNewMember = async function (authorId, memberName, values, attachmentUrl = null, attachmentExpiration = null) {
const displayName = values[0];
const proxy = values[1];
const propic = values[2] ?? attachmentUrl;
return await mh.addFullMember(authorId, memberName, displayName, proxy, propic).then(async(response) => {
const memberInfoEmbed = await mh.getMemberInfo(authorId, memberName).catch((e) => {throw e})
return {embed: memberInfoEmbed, errors: response.errors, success: `${memberName} has been added successfully.`};
}).catch(e => {
throw e;
})
const memberObj = await memberHelper.addFullMember(authorId, memberName, displayName, proxy, propic, attachmentExpiration);
const memberInfoEmbed = memberHelper.getMemberInfo(memberObj.member);
return {embed: memberInfoEmbed, errors: memberObj.errors, success: `${memberName} has been added successfully.`}
}
/**
@@ -114,26 +214,17 @@ mh.addNewMember = async function (authorId, args, attachmentURL = null) {
*
* @async
* @param {string} authorId - The author of the message
* @param {string[]} args - The message arguments
* @param {string} memberName - The member to update
* @param {string} name - The message arguments
* @returns {Promise<string>} A successful update.
* @throws {RangeError} When the name doesn't exist.
*/
mh.updateName = async function (authorId, args) {
if (args[2] && args[2] === "--help") {
return enums.help.NAME;
}
const name = args[2];
if (!name) {
return `The name for ${args[0]} is ${args[0]}, but you probably knew that!`;
}
memberHelper.updateName = async function (authorId, memberName, name) {
const trimmedName = name.trim();
if (trimmedName === '') {
throw new RangeError(`Name ${enums.err.NO_VALUE}`);
}
return await mh.updateMemberField(authorId, args).catch((e) => {
throw e
});
return await memberHelper.updateMemberField(authorId, memberName, "name", trimmedName);
}
/**
@@ -141,36 +232,21 @@ mh.updateName = async function (authorId, args) {
*
* @async
* @param {string} authorId - The author of the message
* @param {string[]} args - The message arguments
* @param {string} membername - The member to update
* @param {string} displayname - The display name to set
* @returns {Promise<string>} A successful update.
* @throws {RangeError} When the display name is too long or doesn't exist.
*/
mh.updateDisplayName = async function (authorId, args) {
if (args[2] && args[2] === "--help") {
return enums.help.DISPLAY_NAME;
}
memberHelper.updateDisplayName = async function (authorId, membername, displayname) {
const trimmedName = displayname.trim();
const memberName = args[0];
const displayName = args[2];
const trimmedName = displayName ? displayName.trim() : null;
if (!displayName) {
return await mh.getMemberByName(authorId, memberName).then((member) => {
if (member && member.displayname) {
return `Display name for ${memberName} is: \"${member.displayname}\".`;
} else if (member) {
throw new Error(`Display name ${enums.err.NO_VALUE}`);
}
});
} else if (displayName.length > 32) {
if (trimmedName.length > 32) {
throw new RangeError(enums.err.DISPLAY_NAME_TOO_LONG);
}
else if (trimmedName === '') {
throw new RangeError(`Display name ${enums.err.NO_VALUE}`);
}
return await mh.updateMemberField(authorId, args).catch((e) => {
throw e
});
return await memberHelper.updateMemberField(authorId, membername, "displayname", trimmedName);
}
/**
@@ -178,24 +254,15 @@ mh.updateDisplayName = async function (authorId, args) {
*
* @async
* @param {string} authorId - The author of the message
* @param {string[]} args - The message arguments
* @param {string} memberName - The member to update
* @param {string} proxy - The proxy to set
* @returns {Promise<string> } A successful update.
* @throws {RangeError | Error} When an empty proxy was provided, or no proxy exists.
*/
mh.updateProxy = async function (authorId, args) {
if (args[2] && args[2] === "--help") {
return enums.help.PROXY;
}
const proxyExists = await mh.checkIfProxyExists(authorId, args[2]).then((proxyExists) => {
return proxyExists;
}).catch((e) => {
throw e
});
if (!proxyExists) {
return await mh.updateMemberField(authorId, args).catch((e) => {
throw e
});
}
memberHelper.updateProxy = async function (authorId, memberName, proxy) {
// Throws error if exists
await memberHelper.checkIfProxyExists(authorId, proxy);
return await memberHelper.updateMemberField(authorId, memberName, "proxy", proxy);
}
/**
@@ -203,53 +270,18 @@ mh.updateProxy = async function (authorId, args) {
*
* @async
* @param {string} authorId - The author of the message
* @param {string[]} args - The message arguments
* @param {string} attachmentUrl - The url of the first attachment in the message
* @param {string | null} attachmentExpiry - The expiration date of the first attachment in the message (if uploaded to Fluxer)
* @param {string} memberName - The member to update
* @param {string} values - The message arguments
* @param {string | null} attachmentUrl - The attachment URL, if any
* @param {string | null} attachmentExpiration - The attachment expiry date, if any
* @returns {Promise<string>} A successful update.
* @throws {Error} When loading the profile picture from a URL doesn't work.
*/
mh.updatePropic = async function (authorId, args, attachmentUrl, attachmentExpiry = null) {
if (args[2] && args[2] === "--help") {
return enums.help.PROPIC;
}
let img;
const updatedArgs = args;
if (!updatedArgs[1] && !attachmentUrl) {
return enums.help.PROPIC;
} else if (attachmentUrl) {
updatedArgs[2] = attachmentUrl;
updatedArgs[3] = attachmentExpiry;
}
if (updatedArgs[2]) {
img = updatedArgs[2];
}
const isValidImage = await mh.checkImageFormatValidity(img).catch((e) => {
throw e
});
if (isValidImage) {
return await mh.updateMemberField(authorId, updatedArgs).catch((e) => {
throw e
});
}
}
/**
* Checks if an uploaded picture is in the right format.
*
* @async
* @param {string} imageUrl - The url of the image
* @returns {Promise<boolean>} - If the image is a valid format.
* @throws {Error} When loading the profile picture from a URL doesn't work, or it fails requirements.
*/
mh.checkImageFormatValidity = async function (imageUrl) {
const acceptableImages = ['image/png', 'image/jpg', 'image/jpeg', 'image/webp'];
return await fetch(imageUrl).then(r => r.blob()).then(blobFile => {
if (blobFile.size > 1000000 || !acceptableImages.includes(blobFile.type)) throw new Error(enums.err.PROPIC_FAILS_REQUIREMENTS);
return true;
}).catch((error) => {
throw new Error(`${enums.err.PROPIC_CANNOT_LOAD}: ${error.message}`);
});
memberHelper.updatePropic = async function (authorId, memberName, values, attachmentUrl = null, attachmentExpiration = null) {
const imgUrl = values ?? attachmentUrl;
// Throws error if invalid
await utils.checkImageFormatValidity(imgUrl);
const expirationWarning = utils.setExpirationWarning(imgUrl, attachmentExpiration);
return await memberHelper.updateMemberField(authorId, memberName, "propic", imgUrl, expirationWarning);
}
/**
@@ -257,27 +289,17 @@ mh.checkImageFormatValidity = async function (imageUrl) {
*
* @async
* @param {string} authorId - The author of the message
* @param {string[]} args - The message arguments
* @param {string} memberName - The name of the member to remove
* @returns {Promise<string>} A successful removal.
* @throws {EmptyResultError} When there is no member to remove.
* @throws {Error} When there is no member to remove.
*/
mh.removeMember = async function (authorId, args) {
if (args[1] && args[1] === "--help" || !args[1]) {
return enums.help.REMOVE;
memberHelper.removeMember = async function (authorId, memberName) {
const destroyed = await memberRepo.removeMember(authorId, memberName);
if (destroyed > 0) {
return `Member "${memberName}" has been deleted.`;
} else {
throw new Error(`${enums.err.NO_MEMBER}`);
}
const memberName = args[1];
return await database.members.destroy({
where: {
name: {[Op.iLike]: memberName},
userid: authorId
}
}).then((result) => {
if (result) {
return `Member "${memberName}" has been deleted.`;
}
throw new EmptyResultError(`${enums.err.NO_MEMBER}`);
})
}
/*======Non-Subcommands======*/
@@ -288,24 +310,33 @@ mh.removeMember = async function (authorId, args) {
* @async
* @param {string} authorId - The author of the message
* @param {string} memberName - The name of the member.
* @param {string | null} displayName - The display name of the member.
* @param {string | null} proxy - The proxy tag of the member.
* @param {string | null} propic - The profile picture URL of the member.
* @returns {Promise<{model, []}>} A successful addition object, including errors if there are any.
* @param {string | null} [displayName] - The display name of the member.
* @param {string | null} [proxy] - The proxy tag of the member.
* @param {string | null} [propic] - The profile picture URL of the member.
* @param {string | null} [attachmentExpiration] - The expiration date of an uploaded profile picture.
* @returns {Promise<{Members, string[]}>} A successful addition object, including errors if there are any.
* @throws {Error} When the member already exists, there are validation errors, or adding a member doesn't work.
*/
mh.addFullMember = async function (authorId, memberName, displayName = null, proxy = null, propic = null) {
await mh.getMemberByName(authorId, memberName).then((member) => {
if (member) {
throw new Error(`Can't add ${memberName}. ${enums.err.MEMBER_EXISTS}`);
}
});
memberHelper.addFullMember = async function (authorId, memberName, displayName = null, proxy = null, propic = null, attachmentExpiration = null) {
const existingMember = await memberRepo.getMemberByName(authorId, memberName);
if (existingMember) {
throw new Error(`Can't add ${memberName}. ${enums.err.MEMBER_EXISTS}`);
}
const errors = [];
const trimmedName = memberName.trim();
if (trimmedName.length === 0) {
throw new Error(`Name ${enums.err.NO_VALUE}. ${enums.err.NAME_REQUIRED}`);
}
let isValidDisplayName;
if (displayName && displayName.length > 0) {
const trimmedName = displayName ? displayName.trim() : null;
if (trimmedName && trimmedName.length > 32) {
if (displayName) {
const trimmedDisplayName= displayName ? displayName.trim() : null;
if (!trimmedDisplayName || trimmedDisplayName.length === 0) {
errors.push(`Display name ${enums.err.NO_VALUE}. ${enums.err.SET_TO_NULL}`);
isValidDisplayName = false;
}
else if (trimmedDisplayName.length > 32) {
errors.push(`Tried to set displayname to \"${displayName}\". ${enums.err.DISPLAY_NAME_TOO_LONG}. ${enums.err.SET_TO_NULL}`);
isValidDisplayName = false;
}
@@ -316,201 +347,75 @@ mh.addFullMember = async function (authorId, memberName, displayName = null, pro
let isValidProxy;
if (proxy && proxy.length > 0) {
await mh.checkIfProxyExists(authorId, proxy).then(() => {
isValidProxy = true;
}).catch((e) => {
try {
const proxyExists = await memberHelper.checkIfProxyExists(authorId, proxy);
isValidProxy = !proxyExists;
}
catch(e) {
errors.push(`Tried to set proxy to \"${proxy}\". ${e.message}. ${enums.err.SET_TO_NULL}`);
isValidProxy = false;
});
}
}
let isValidPropic;
let isValidPropic, expirationWarning;
if (propic && propic.length > 0) {
await mh.checkImageFormatValidity(propic).then(() => {
isValidPropic = true;
}).catch((e) => {
try {
isValidPropic = await utils.checkImageFormatValidity(propic);
expirationWarning = utils.setExpirationWarning(propic, attachmentExpiration);
if (expirationWarning) {
errors.push(expirationWarning);
}
}
catch(e) {
errors.push(`Tried to set profile picture to \"${propic}\". ${e.message}. ${enums.err.SET_TO_NULL}`);
isValidPropic = false;
});
}
}
const member = await database.members.create({
const member = await memberRepo.createMember({
name: memberName, userid: authorId, displayname: isValidDisplayName ? displayName : null, proxy: isValidProxy ? proxy : null, propic: isValidPropic ? propic : null
});
return {member: member, errors: errors};
}
// mh.mergeFullMember = async function (authorId, memberName, displayName = null, proxy = null, propic = null) {
// await mh.getMemberByName(authorId, memberName).then((member) => {
// if (member) {
// throw new Error(`Can't add ${memberName}. ${enums.err.MEMBER_EXISTS}`);
// }
// });
//
// let isValidDisplayName;
// if (displayName) {
// const trimmedName = displayName ? displayName.trim() : null;
// if (trimmedName && trimmedName.length > 32) {
// if (!isImport) {
// throw new RangeError(`Can't add ${memberName}. ${enums.err.DISPLAY_NAME_TOO_LONG}`);
// }
// isValidDisplayName = false;
// }
// }
//
// let isValidProxy;
// if (proxy) {
// isValidProxy = await mh.checkIfProxyExists(authorId, proxy).then((res) => {
// return res;
// }).catch((e) => {
// if (!isImport) {
// throw e
// }
// return false;
// });
// }
//
// let isValidPropic;
// if (propic) {
// isValidPropic = await mh.checkImageFormatValidity(propic).then((valid) => {
// return valid;
// }).catch((e) => {
// if (!isImport) {
// throw (e);
// }
// return false;
// });
// }
//
// const member = await database.members.create({
// name: memberName, userid: authorId, displayname: isValidDisplayName ? displayName: null, proxy: isValidProxy ? proxy : null, propic: isValidPropic ? propic : null,
// });
// if (!member) {
// new Error(`${enums.err.ADD_ERROR}`);
// }
// return member;
// }
//
// mh.overwriteFullMemberFromImport = async function (authorId, memberName, displayName = null, proxy = null, propic = null) {
// await mh.getMemberByName(authorId, memberName).then((member) => {
// if (member) {
// throw new Error(`Can't add ${memberName}. ${enums.err.MEMBER_EXISTS}`);
// }
// });
//
// let isValidDisplayName;
// if (displayName) {
// const trimmedName = displayName ? displayName.trim() : null;
// if (trimmedName && trimmedName.length > 32) {
// if (!isImport) {
// throw new RangeError(`Can't add ${memberName}. ${enums.err.DISPLAY_NAME_TOO_LONG}`);
// }
// isValidDisplayName = false;
// }
// }
//
// let isValidProxy;
// if (proxy) {
// isValidProxy = await mh.checkIfProxyExists(authorId, proxy).then((res) => {
// return res;
// }).catch((e) => {
// if (!isImport) {
// throw e
// }
// return false;
// });
// }
//
// let isValidPropic;
// if (propic) {
// isValidPropic = await mh.checkImageFormatValidity(propic).then((valid) => {
// return valid;
// }).catch((e) => {
// if (!isImport) {
// throw (e);
// }
// return false;
// });
// }
//
// const member = await database.members.create({
// name: memberName, userid: authorId, displayname: isValidDisplayName ? displayName: null, proxy: isValidProxy ? proxy : null, propic: isValidPropic ? propic : null,
// });
// if (!member) {
// new Error(`${enums.err.ADD_ERROR}`);
// }
// return member;
// }
/**
* Updates one fields for a member in the database.
*
* @async
* @param {string} authorId - The author of the message
* @param {string[]} args - The message arguments
* @param {string} memberName - The member to update
* @param {string} columnName - The column name to update.
* @param {string} value - The value to update to.
* @param {string | null} [expirationWarning] - The attachment expiration warning (if any)
* @returns {Promise<string>} A successful update.
* @throws {EmptyResultError | Error} When the member is not found, or catchall error.
* @throws {Error} When no member row was updated.
*/
mh.updateMemberField = async function (authorId, args) {
const memberName = args[0];
const columnName = args[1];
const value = args[2];
let fluxerPropicWarning;
// indicates that an attachment was uploaded on Fluxer directly
if (columnName === "propic" && args[3]) {
fluxerPropicWarning = mh.setExpirationWarning(args[3]);
}
return await database.members.update({[columnName]: value}, {
where: {
name: {[Op.iLike]: memberName},
userid: authorId
}
}).then((res) => {
if (res[0] === 0) {
throw new EmptyResultError(`Can't update ${memberName}. ${enums.err.NO_MEMBER}.`);
} else {
return `Updated ${columnName} for ${memberName} to ${value}${fluxerPropicWarning ?? ''}.`;
}
})
}
/**
* Sets the warning for an expiration date.
*
* @param {string} expirationString - An expiration date string.
* @returns {string} A description of the expiration, interpolating the expiration string.
*/
mh.setExpirationWarning = function (expirationString) {
let expirationDate = new Date(expirationString);
if (!isNaN(expirationDate.valueOf())) {
expirationDate = expirationDate.toDateString();
return `\n**NOTE:** Because this profile picture was uploaded via Fluxer, it will currently expire on *${expirationDate}*. To avoid this, upload the picture to another website like <https://imgbb.com/> and link to it directly`
memberHelper.updateMemberField = async function (authorId, memberName, columnName, value, expirationWarning = null) {
const res = await memberRepo.updateMemberField(authorId, memberName, columnName, value);
if (res === 0) {
throw new Error(`Can't update ${memberName}. ${enums.err.NO_MEMBER}.`);
} else {
return `Updated ${columnName} for ${memberName} to ${value}${expirationWarning ? `. ${expirationWarning}.` : '.'}`;
}
}
/**
* Gets the details for a member.
*
* @async
* @param {string} authorId - The author of the message
* @param {string} memberName - The message arguments
* @returns {Promise<EmbedBuilder>} The member's info.
* @param {{Member, string[]}} member - The member object
* @returns {EmbedBuilder} The member's info.
*/
mh.getMemberInfo = async function (authorId, memberName) {
return await mh.getMemberByName(authorId, memberName).then((member) => {
if (member) {
return new EmbedBuilder()
.setTitle(member.name)
.setDescription(`Details for ${member.name}`)
.addFields({
name: 'Display name: ',
value: member.displayname ?? 'unset',
inline: true
}, {name: 'Proxy tag: ', value: member.proxy ?? 'unset', inline: true},)
.setImage(member.propic);
}
});
memberHelper.getMemberInfo = function (member) {
return new EmbedBuilder()
.setTitle(member.name)
.setDescription(`Details for ${member.name}`)
.addFields({
name: 'Display name: ',
value: member.displayname ?? 'unset',
inline: true
}, {name: 'Proxy tag: ', value: member.proxy ?? 'unset', inline: true},)
.setImage(member.propic ?? null);
}
/**
@@ -522,72 +427,17 @@ mh.getMemberInfo = async function (authorId, memberName) {
* @returns {Promise<EmbedBuilder>} The info for all members.
* @throws {Error} When there are no members for an author.
*/
mh.getAllMembersInfo = async function (authorId, authorName) {
const members = await mh.getMembersByAuthor(authorId);
if (members == null) throw Error(enums.err.USER_NO_MEMBERS);
const fields = [...members.entries()].map(([name, member]) => ({
memberHelper.getAllMembersInfo = async function (authorId, authorName) {
const members = await memberRepo.getMembersByAuthor(authorId);
if (members.length === 0) throw Error(enums.err.USER_NO_MEMBERS);
const fields = [...members.entries()].map(([index, member]) => ({
name: member.name, value: `(Proxy: \`${member.proxy ?? "unset"}\`)`, inline: true,
}));
return new EmbedBuilder()
.setTitle(`${fields > 25 ? "First 25 m" : "M"}embers for ${authorName}`)
.setTitle(`${fields.length > 25 ? "First 25 m" : "M"}embers for ${authorName}`)
.addFields(...fields);
}
/**
* Gets a member based on the author and proxy tag.
*
* @async
* @param {string} authorId - The author of the message.
* @param {string} memberName - The member's name.
* @returns {Promise<model>} The member object.
* @throws { EmptyResultError } When the member is not found.
*/
mh.getMemberByName = async function (authorId, memberName) {
return await database.members.findOne({where: {userid: authorId, name: {[Op.iLike]: memberName}}});
}
/**
* Gets a member based on the author and proxy tag.
*
* @async
* @param {string} authorId - The author of the message.
* @param {string} memberName - The member's name.
* @returns {Promise<string>} The member object.
* @throws { EmptyResultError } When the member is not found.
*/
mh.getProxyByMember = async function (authorId, memberName) {
return await mh.getMemberByName(authorId, memberName).then((member) => {
if (member) {
return member.proxy;
}
throw new EmptyResultError(enums.err.NO_MEMBER);
})
}
/**
* Gets a member based on the author and proxy tag.
*
* @async
* @param {string} authorId - The author of the message
* @param {string} proxy - The proxy tag
* @returns {Promise<model>} The member object.
*/
mh.getMemberByProxy = async function (authorId, proxy) {
return await db.members.findOne({where: {userid: authorId, proxy: proxy}});
}
/**
* Gets all members belonging to the author.
*
* @async
* @param {string} authorId - The author of the message
* @returns {Promise<model[] | null>} The member object array.
*/
mh.getMembersByAuthor = async function (authorId) {
return await database.members.findAll({where: {userid: authorId}});
}
/**
* Checks if proxy exists for a member.
*
@@ -596,23 +446,39 @@ mh.getMembersByAuthor = async function (authorId) {
* @returns {Promise<boolean> } Whether the proxy exists.
* @throws {Error} When an empty proxy was provided, or no proxy exists.
*/
mh.checkIfProxyExists = async function (authorId, proxy) {
if (proxy) {
const splitProxy = proxy.trim().split("text");
if (splitProxy.length < 2) throw new Error(enums.err.NO_TEXT_FOR_PROXY);
if (!splitProxy[0] && !splitProxy[1]) throw new Error(enums.err.NO_PROXY_WRAPPER);
memberHelper.checkIfProxyExists = async function (authorId, proxy) {
const splitProxy = proxy.trim().split("text");
if (splitProxy.length < 2) throw new Error(enums.err.NO_TEXT_FOR_PROXY);
if (!splitProxy[0] && !splitProxy[1]) throw new Error(enums.err.NO_PROXY_WRAPPER);
await mh.getMembersByAuthor(authorId).then((memberList) => {
const proxyExists = memberList.some(member => member.proxy === proxy);
if (proxyExists) {
throw new Error(enums.err.PROXY_EXISTS);
}
}).catch(e => {
throw e
});
const memberList = await memberRepo.getMembersByAuthor(authorId);
const proxyExists = memberList.some(member => member.proxy === proxy);
if (proxyExists) {
throw new Error(enums.err.PROXY_EXISTS);
}
return false;
}
/**
* Creates an embed with all member commands
*
* @returns {EmbedBuilder } An embed of member commands.
*/
memberHelper.getMemberCommandInfo = function() {
const fields = [
{name: `**new**`, value: enums.help.NEW, inline: false},
{name: `**remove**`, value: enums.help.REMOVE, inline: false},
{name: `**name**`, value: enums.help.NAME, inline: false},
{name: `**displayname**`, value: enums.help.DISPLAY_NAME, inline: false},
{name: `**proxy**`, value: enums.help.PROXY, inline: false},
{name: `**propic**`, value: enums.help.PROPIC, inline: false},
{name: `**list**`, value: enums.help.LIST, inline: false},
];
return new EmbedBuilder()
.setTitle("Member subcommands")
.setDescription(enums.help.MEMBER)
.addFields(...fields);
}
export const memberHelper = mh;
module.exports.memberHelper = memberHelper;

View File

@@ -1,15 +1,9 @@
import {memberHelper} from "./memberHelper.js";
import {enums} from "../enums.js";
import tmp, {setGracefulCleanup} from "tmp";
import fs from 'fs';
import {Message} from "@fluxerjs/core";
const {memberRepo} = require('../repositories/memberRepo.js');
const msgh = {};
msgh.prefix = "pf;"
setGracefulCleanup();
/**
* Parses and slices up message arguments, retaining quoted strings.
*
@@ -38,14 +32,14 @@ msgh.parseCommandArgs = function(content, commandName) {
/**
* Parses messages to see if any part of the text matches the tags of any member belonging to an author.
*
* @async
* @param {string} authorId - The author of the message.
* @param {string} content - The full message content
* @param {string | null} attachmentUrl - The url for an attachment to the message, if any exists.
* @returns {{model, string, bool}} The proxy message object.
* @throws {Error} If a proxy message is sent with no message within it.
* @param {string | null} [attachmentUrl] - The url for an attachment to the message, if any exists.
* @returns {Promise<{model, string, bool}>} The proxy message object.
*/
msgh.parseProxyTags = async function (authorId, content, attachmentUrl = null){
const members = await memberHelper.getMembersByAuthor(authorId);
const members = await memberRepo.getMembersByAuthor(authorId);
// If an author has no members, no sense in searching for proxy
if (members.length === 0) {
return;
@@ -86,4 +80,4 @@ msgh.returnBufferFromText = function (text) {
return {text: text, file: undefined}
}
export const messageHelper = msgh;
module.exports.messageHelper = msgh;

57
src/helpers/utils.js Normal file
View File

@@ -0,0 +1,57 @@
const {enums} = require('../enums');
const utils = {};
utils.debounce = function(func, delay) {
let timeout = null;
return function (...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), delay);
};
}
/**
* Checks if an uploaded picture is in the right format.
*
* @async
* @param {string} imageUrl - The url of the image
* @returns {bool} - Whether the image is in a valid format
* @throws {Error} When loading the profile picture from a URL doesn't work, or it fails requirements.
*/
utils.checkImageFormatValidity = async function (imageUrl) {
const acceptableImages = ['image/png', 'image/jpg', 'image/jpeg', 'image/webp'];
let response, blobFile;
try {
response = await fetch(imageUrl);
}
catch(e) {
throw new Error(`${enums.err.PROPIC_CANNOT_LOAD}: ${e.message}`);
}
blobFile = await response.blob();
if (blobFile.size > 10000000 || !acceptableImages.includes(blobFile.type)) throw new Error(enums.err.PROPIC_FAILS_REQUIREMENTS);
return true;
}
/**
* Sets the warning that a Fluxer-uploaded image will expire.
*
* @param {string | null} [imgUrl] - An image URL.
* @param {string | null} [expirationString] - An expiration date string.
* @returns {string | null} A description of the expiration, or null.
*/
utils.setExpirationWarning = function (imgUrl = null, expirationString = null) {
if (imgUrl && imgUrl.startsWith(enums.misc.FLUXER_ATTACHMENT_URL)) {
return enums.misc.ATTACHMENT_EXPIRATION_WARNING;
}
else if (expirationString) {
let expirationDate = new Date(expirationString);
if (!isNaN(expirationDate.valueOf())) {
return `${enums.misc.ATTACHMENT_EXPIRATION_WARNING}. Expiration date: *${expirationString}*.`;
}
}
return null;
}
module.exports.utils = utils;

View File

@@ -1,8 +1,8 @@
import {messageHelper} from "./messageHelper.js";
import {Webhook, Channel, Message, Client} from '@fluxerjs/core';
import {enums} from "../enums.js";
const {messageHelper} = require("./messageHelper.js");
const {Webhook, Channel, Message, Client} = require('@fluxerjs/core');
const {enums} = require("../enums.js");
const wh = {};
const webhookHelper = {};
const name = 'PluralFlux Proxy Webhook';
@@ -13,9 +13,9 @@ const name = 'PluralFlux Proxy Webhook';
* @param {Message} message - The full message object.
* @throws {Error} When the proxy message is not in a server.
*/
wh.sendMessageAsMember = async function(client, message) {
webhookHelper.sendMessageAsMember = async function(client, message) {
const attachmentUrl = message.attachments.size > 0 ? message.attachments.first().url : null;
const proxyMatch = await messageHelper.parseProxyTags(message.author.id, message.content, attachmentUrl).catch(e =>{throw e});
const proxyMatch = await messageHelper.parseProxyTags(message.author.id, message.content, attachmentUrl);
// If the message doesn't match a proxy, just return.
if (!proxyMatch || !proxyMatch.member || (proxyMatch.message.length === 0 && !proxyMatch.hasAttachment) ) {
return;
@@ -27,7 +27,7 @@ wh.sendMessageAsMember = async function(client, message) {
if (proxyMatch.hasAttachment) {
return await message.reply(`${enums.misc.ATTACHMENT_SENT_BY} ${proxyMatch.member.displayname ?? proxyMatch.member.name}`)
}
await wh.replaceMessage(client, message, proxyMatch.message, proxyMatch.member).catch(e =>{throw e});
await webhookHelper.replaceMessage(client, message, proxyMatch.message, proxyMatch.member);
}
/**
@@ -39,24 +39,23 @@ wh.sendMessageAsMember = async function(client, message) {
* @param {model} member - A member object from the database.
* @throws {Error} When there's no message to send.
*/
wh.replaceMessage = async function(client, message, text, member) {
webhookHelper.replaceMessage = async function(client, message, text, member) {
// attachment logic is not relevant yet, text length will always be over 0 right now
if (text.length > 0 || message.attachments.size > 0) {
const channel = client.channels.get(message.channelId);
const webhook = await wh.getOrCreateWebhook(client, channel).catch((e) =>{throw e});
const webhook = await webhookHelper.getOrCreateWebhook(client, channel);
const username = member.displayname ?? member.name;
if (text.length > 0) {
await webhook.send({content: text, username: username, avatar_url: member.propic}).catch(async(e) => {
const returnedBuffer = messageHelper.returnBufferFromText(text);
await webhook.send({content: returnedBuffer.text, username: username, avatar_url: member.propic, files: [{ name: 'text.txt', data: returnedBuffer.file }]
})
console.error(e);
});
if (text.length <= 2000) {
await webhook.send({content: text, username: username, avatar_url: member.propic})
}
else if (text.length > 2000) {
const returnedBuffer = messageHelper.returnBufferFromText(text);
await webhook.send({content: returnedBuffer.text, username: username, avatar_url: member.propic, files: [{ name: 'text.txt', data: returnedBuffer.file }]
})
}
if (message.attachments.size > 0) {
// Not implemented yet
}
await message.delete();
}
}
@@ -69,10 +68,10 @@ wh.replaceMessage = async function(client, message, text, member) {
* @returns {Webhook} A webhook object.
* @throws {Error} When no webhooks are allowed in the channel.
*/
wh.getOrCreateWebhook = async function(client, channel) {
webhookHelper.getOrCreateWebhook = async function(client, channel) {
// If channel doesn't allow webhooks
if (!channel?.createWebhook) throw new Error(enums.err.NO_WEBHOOKS_ALLOWED);
let webhook = await wh.getWebhook(client, channel).catch((e) =>{throw e});
let webhook = await webhookHelper.getWebhook(client, channel)
if (!webhook) {
webhook = await channel.createWebhook({name: name});
}
@@ -86,18 +85,12 @@ wh.getOrCreateWebhook = async function(client, channel) {
* @param {Channel} channel - The channel the message was sent in.
* @returns {Webhook} A webhook object.
*/
wh.getWebhook = async function(client, channel) {
webhookHelper.getWebhook = async function(client, channel) {
const channelWebhooks = await channel?.fetchWebhooks() ?? [];
if (channelWebhooks.length === 0) {
return;
}
let pf_webhook;
channelWebhooks.forEach((webhook) => {
if (webhook.name === name) {
pf_webhook = webhook;
}
})
return pf_webhook;
return channelWebhooks.find((webhook) => webhook.name === name);
}
export const webhookHelper = wh;
module.exports.webhookHelper = webhookHelper;

View File

@@ -0,0 +1,74 @@
const Member = require("../../database/entity/Member");
const { AppDataSource } = require("../../database/data-source");
const {ILike} = require("typeorm");
const members = AppDataSource.getRepository(Member.Member)
const memberRepo = {};
/**
* Gets a member based on the author and proxy tag.
*
* @async
* @param {string} authorId - The author of the message.
* @param {string} memberName - The member's name.
* @returns {Promise<Member | null>} The member object or null if not found.
*/
memberRepo.getMemberByName = async function (authorId, memberName) {
return await members.findOne({where: {userid: authorId, name: ILike(memberName)}});
}
/**
* Gets all members belonging to the author.
*
* @async
* @param {string} authorId - The author of the message
* @returns {Promise<Member[]>} The member object array.
*/
memberRepo.getMembersByAuthor = async function (authorId) {
return await members.findBy({userid: authorId});
}
/**
* Removes a member.
*
* @async
* @param {string} authorId - The author of the message
* @param {string} memberName - The name of the member to remove
* @returns {Promise<number>} Number of results removed.
*/
memberRepo.removeMember = async function (authorId, memberName) {
const deleted = await members.delete({ name: ILike(memberName), userid: authorId })
return deleted.affected;
}
/**
* Adds a member with full details.
*
* @async
* @param {{name: string, userid: string, displayname: (string|null), proxy: (string|null), propic: (string|null)}} createObj - Object with parameters in it
* @returns {Promise<Member>} A successful inserted object.
*/
memberRepo.createMember = async function (createObj) {
return await members.save({
name: createObj.name, userid: createObj.userid, displayname: createObj.displayname, proxy: createObj.proxy, propic: createObj.propic
});
}
/**
* Updates one fields for a member in the database.
*
* @async
* @param {string} authorId - The author of the message
* @param {string} memberName - The member to update
* @param {string} columnName - The column name to update.
* @param {string} value - The value to update to.
* @returns {Promise<number>} A successful update.
*/
memberRepo.updateMemberField = async function (authorId, memberName, columnName, value) {
const updated = await members.update({
name: ILike(memberName),
userid: authorId
}, {[columnName]: value})
return updated.affected;
}
module.exports.memberRepo = memberRepo;

300
tests/bot.test.js Normal file
View File

@@ -0,0 +1,300 @@
const env = require('dotenv').config({path: './.env.jest'})
const {enums} = require("../src/enums.js");
jest.mock('@fluxerjs/core', () => {
return {
Events: {
MessageCreate: jest.fn(),
Ready: jest.fn(),
GuildCreate: jest.fn(),
},
Client: jest.fn().mockImplementation(() => {
return {
on: jest.fn(),
intents: 0,
login: jest.fn()
}
}),
Message: jest.fn()
};
});
jest.mock("../src/helpers/messageHelper.js", () => {
return {
messageHelper: {
parseCommandArgs: jest.fn(),
prefix: "pf;"
}
}
});
jest.mock("../src/helpers/webhookHelper.js", () => {
return {
webhookHelper: {
sendMessageAsMember: jest.fn()
}
}
})
jest.mock("../src/helpers/utils.js", () => {
return {
utils: {
debounce: jest.fn()
}
}
})
jest.mock("../src/commands.js", () => {
return {
commands: {
commandsMap: {
get: jest.fn(),
},
aliasesMap: {
get: jest.fn()
}
}
}
})
jest.mock('../database/data-source.ts', () => {
return {
AppDataSource: {
isInitialized: false,
initialize: jest.fn().mockResolvedValue()
}
}
})
const {Client, Events} = require('@fluxerjs/core');
const {messageHelper} = require("../src/helpers/messageHelper.js");
const {commands} = require("../src/commands.js");
const {webhookHelper} = require("../src/helpers/webhookHelper.js");
const {utils} = require("../src/helpers/utils.js");
let {handleMessageCreate, client} = require("../src/bot.js");
const {login} = require("../src/bot");
describe('bot', () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
})
describe('handleMessageCreate', () => {
test('on message creation, if message is from bot, return', async () => {
// Arrange
const message = {
author: {
bot: true
}
}
// Act
const res = await handleMessageCreate(message);
expect(res).toBeUndefined();
})
test("if message doesn't start with bot prefix, call sendMessageAsMember", async () => {
// Arrange
webhookHelper.sendMessageAsMember.mockResolvedValue();
const message = {
content: "hello",
author: {
bot: false
}
}
// Act
const res = await handleMessageCreate(message);
// Assert
expect(webhookHelper.sendMessageAsMember).toHaveBeenCalledTimes(1);
expect(webhookHelper.sendMessageAsMember).toHaveBeenCalledWith(client, message)
})
test("if sendMessageAsMember returns error, catch and log error", async () => {
// Arrange
webhookHelper.sendMessageAsMember.mockRejectedValue(new Error("error"));
const message = {
content: "hello",
author: {
bot: false
}
}
jest.spyOn(global.console, 'error').mockImplementation(() => {});
// Act
await handleMessageCreate(message);
// Assert
expect(webhookHelper.sendMessageAsMember).toHaveBeenCalledTimes(1);
expect(webhookHelper.sendMessageAsMember).toHaveBeenCalledWith(client, message)
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(new Error('error'));
})
test("if no command after prefix, return correct enum", async () => {
// Arrange
const message = {
content: "pf;",
author: {
bot: false
},
reply: jest.fn()
}
// Act
await handleMessageCreate(message);
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith(enums.help.SHORT_DESC_PLURALFLUX);
expect(webhookHelper.sendMessageAsMember).not.toHaveBeenCalled();
})
test("if command after prefix, call parseCommandArgs and commandsMap.get", async () => {
// Arrange
const message = {
content: "pf;help",
author: {
bot: false
},
reply: jest.fn()
}
const command = {
execute: jest.fn().mockResolvedValue(),
}
commands.commandsMap.get = jest.fn().mockReturnValue(command);
// Act
await handleMessageCreate(message);
// Assert
expect(messageHelper.parseCommandArgs).toHaveBeenCalledTimes(1);
expect(messageHelper.parseCommandArgs).toHaveBeenCalledWith('pf;help', 'help');
expect(commands.commandsMap.get).toHaveBeenCalledTimes(1);
expect(commands.commandsMap.get).toHaveBeenCalledWith('help');
expect(webhookHelper.sendMessageAsMember).not.toHaveBeenCalled();
})
test('if commands.commandsMap.get returns undefined, call aliasesMap.get and commandsMap.get again with that value', async () => {
// Arrange
const message = {
content: "pf;m",
author: {
bot: false
},
reply: jest.fn()
}
const mockAlias = {
command: 'member'
}
commands.commandsMap.get = jest.fn().mockReturnValueOnce();
commands.aliasesMap.get = jest.fn().mockReturnValueOnce(mockAlias);
// Act
await handleMessageCreate(message);
// Assert
expect(commands.commandsMap.get).toHaveBeenCalledTimes(2);
expect(commands.commandsMap.get).toHaveBeenNthCalledWith(1, 'm');
expect(commands.commandsMap.get).toHaveBeenNthCalledWith(2, 'member');
expect(commands.aliasesMap.get).toHaveBeenCalledTimes(1);
expect(commands.aliasesMap.get).toHaveBeenCalledWith('m');
})
test('if aliasesMap.get returns undefined, do not call commandsMap again', async () => {
// Arrange
const message = {
content: "pf;m",
author: {
bot: false
},
reply: jest.fn()
}
const mockAlias = {
command: 'member'
}
commands.commandsMap.get = jest.fn().mockReturnValueOnce();
commands.aliasesMap.get = jest.fn().mockReturnValueOnce();
// Act
await handleMessageCreate(message);
// Assert
expect(commands.aliasesMap.get).toHaveBeenCalledTimes(1);
expect(commands.aliasesMap.get).toHaveBeenCalledWith('m');
})
test("if command exists, call command.execute", async () => {
// Arrange
const message = {
content: "pf;member test",
author: {
bot: false
},
reply: jest.fn()
}
const command = {
execute: jest.fn()
}
messageHelper.parseCommandArgs = jest.fn().mockReturnValue(['test']);
commands.commandsMap.get = jest.fn().mockReturnValue(command);
command.execute = jest.fn().mockResolvedValue();
// Act
await handleMessageCreate(message)
// Assert
expect(command.execute).toHaveBeenCalledTimes(1);
expect(command.execute).toHaveBeenCalledWith(message, ['test']);
expect(webhookHelper.sendMessageAsMember).not.toHaveBeenCalled();
});
})
test("if command.execute returns error, log error", async () => {
// Arrange
const command = {
execute: jest.fn()
}
commands.commandsMap.get = jest.fn().mockReturnValue(command);
command.execute.mockRejectedValue(new Error("error"));
const message = {
content: "pf;member test",
author: {
bot: false
},
reply: jest.fn()
}
jest.spyOn(global.console, 'error').mockImplementation(() => {
})
// Act
await handleMessageCreate(message);
// Assert
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(new Error('error'))
})
test("if command does not exist, return correct enum", async () => {
// Arrange
commands.commandsMap.get = jest.fn().mockReturnValue();
commands.aliasesMap.get = jest.fn().mockReturnValue();
const message = {
content: "pf;asdfjlas",
author: {
bot: false
},
reply: jest.fn()
}
// Act
await handleMessageCreate(message);
// Assert
expect(message.reply).toHaveBeenCalledWith(enums.err.COMMAND_NOT_RECOGNIZED);
expect(message.reply).toHaveBeenCalledTimes(1);
})
test('login calls client.login with correct argument', async () => {
// Arrange
client.login = jest.fn().mockResolvedValue();
// Act
await login();
// Assert
expect(client.login).toHaveBeenCalledTimes(1);
expect(client.login).toHaveBeenCalledWith(process.env.FLUXER_BOT_TOKEN)
})
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});
})

210
tests/commands.test.js Normal file
View File

@@ -0,0 +1,210 @@
import {enums} from "../src/enums.js";
jest.mock("../src/helpers/messageHelper.js", () => {
return {
messageHelper: {
returnBufferFromText: jest.fn(),
prefix: 'pf;'
}
}
})
jest.mock('../src/helpers/memberHelper.js', () => {
return {
memberHelper: {
parseMemberCommand: jest.fn()
}
}
})
jest.mock('../src/helpers/importHelper.js', () => {
return {
importHelper: {
pluralKitImport: jest.fn()
}
}
})
import {messageHelper} from "../src/helpers/messageHelper.js";
import {memberHelper} from "../src/helpers/memberHelper.js";
import {EmbedBuilder} from "@fluxerjs/core";
import {importHelper} from "../src/helpers/importHelper.js";
import {commands} from "../src/commands.js";
describe('commands', () => {
const authorId = '123';
const discriminator = '123';
const username = 'somePerson'
const attachmentUrl = 'oya.json';
const attachmentExpiration = new Date('2026-01-01').toDateString();
let message;
const args = ['new']
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
message = {
author: {
username: username,
id: authorId,
discriminator: discriminator,
},
attachments: {
size: 1,
first: jest.fn().mockImplementation(() => {
return {
url: attachmentUrl,
expires_at: attachmentExpiration
}
})
},
reply: jest.fn().mockResolvedValue(),
content: 'pf;import'
}
})
describe('memberCommand', () => {
test('calls parseMemberCommand with the correct arguments', async () => {
// Arrange
memberHelper.parseMemberCommand = jest.fn().mockResolvedValue("parsed command");
// Act
await commands.memberCommand(message, args)
// Assert
expect(memberHelper.parseMemberCommand).toHaveBeenCalledTimes(1);
expect(memberHelper.parseMemberCommand).toHaveBeenCalledWith(authorId, `${username}#${discriminator}`, args, attachmentUrl, attachmentExpiration);
});
})
test('if parseMemberCommand returns error, log error and reply with error', async () => {
// Arrange
memberHelper.parseMemberCommand = jest.fn().mockRejectedValue(new Error('error'));
// Act
await commands.memberCommand(message, args)
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith('error');
});
test('if parseMemberCommand returns embed, reply with embed', async () => {
// Arrange
const embed = new EmbedBuilder();
memberHelper.parseMemberCommand = jest.fn().mockResolvedValue(embed);
// Act
await commands.memberCommand(message, args);
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith({embeds: [embed]})
})
test('if parseMemberCommand returns object, reply with embed and content', async () => {
// Arrange
const reply = {
errors: ['error', 'error2'],
success: 'success',
embed: {title: 'hi'}
}
const expected = {
content: `success \n\n${enums.err.ERRORS_OCCURRED}\n- error\n- error2`,
embeds: [reply.embed]
}
console.log(expected)
memberHelper.parseMemberCommand = jest.fn().mockResolvedValue(reply);
// Act
await commands.memberCommand(message, args);
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith(expected)
})
describe('importCommand', () => {
test('if message includes --help and no attachmentURL, return help message', async () => {
// Arrange
const args = ["--help"];
message.content = "pf;import --help";
message.attachments.size = 0;
// Act
await commands.importCommand(message, args)
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith(enums.help.IMPORT);
expect(importHelper.pluralKitImport).not.toHaveBeenCalled();
})
test('if no args and no attachmentURL, return help message', async () => {
// Arrange
const args = [""];
message.content = 'pf;import'
message.attachments.size = 0;
// Act
await commands.importCommand(message, args)
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith(enums.help.IMPORT);
expect(importHelper.pluralKitImport).not.toHaveBeenCalled();
})
test('if attachment URL, call pluralKitImport with correct arguments', async () => {
// Arrange
const args = [""];
message.content = 'pf;import';
importHelper.pluralKitImport = jest.fn().mockResolvedValue('success');
// Act
await commands.importCommand(message, args);
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith('success');
expect(importHelper.pluralKitImport).toHaveBeenCalledTimes(1);
expect(importHelper.pluralKitImport).toHaveBeenCalledWith(authorId, attachmentUrl);
})
test('if pluralKitImport returns aggregate errors with length <= 2000, send errors.', async () => {
// Arrange
const args = [""];
message.content = 'pf;import'
importHelper.pluralKitImport = jest.fn().mockRejectedValue(new AggregateError(['error1', 'error2'], 'errors'));
// Act
await commands.importCommand(message, args);
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith(`errors.\n\n${enums.err.ERRORS_OCCURRED}\n\nerror1\nerror2`);
})
test('if pluralKitImport returns aggregate errors with length > 2000, call returnBufferFromText and message.reply.', async () => {
// Arrange
const args = [""];
const text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbb";
const file = Buffer.from(text, 'utf-8');
const returnedBuffer = {text: 'bbbb', file: file};
const expected = {content: returnedBuffer.text, files: [{name: 'text.txt', data: returnedBuffer.file}]};
importHelper.pluralKitImport = jest.fn().mockRejectedValue(new AggregateError([text, 'error2'], 'errors'));
messageHelper.returnBufferFromText = jest.fn().mockReturnValue(returnedBuffer);
// Act
await commands.importCommand(message, args);
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith(expected);
})
test('if pluralKitImport returns one error, reply with error and log it', async () => {
// Arrange
importHelper.pluralKitImport = jest.fn().mockRejectedValue(new Error('error'));
jest.spyOn(global.console, 'error').mockImplementation(() => {})
// Act
await commands.importCommand(message, args);
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith('error');
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(new Error('error'));
})
})
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});
})

View File

@@ -0,0 +1,103 @@
const {enums} = require('../../src/enums.js');
jest.mock('../../src/helpers/memberHelper.js', () => {
return {
memberHelper: {
addFullMember: jest.fn()
}
}
})
const {memberHelper} = require("../../src/helpers/memberHelper.js");
const {importHelper} = require('../../src/helpers/importHelper.js');
describe('importHelper', () => {
const authorId = '123';
const attachmentUrl = 'system.json';
const mockImportedMember = {
proxy_tags: [{
prefix: "SP{",
suffix: "}"
}],
display_name: "SomePerson",
avatar_url: 'oya.png',
name: 'somePerson'
}
const mockData = {
members: [mockImportedMember]
};
const mockAddReturnMember = {
proxy: "SP{text}",
displayname: "SomePerson",
propic: 'oya.png',
name: 'somePerson'
}
const mockAddReturn = {
member: mockAddReturnMember,
errors: []
}
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData)
})
})
describe('pluralKitImport', () => {
test('if no attachment URL, throws error', async () => {
await expect(importHelper.pluralKitImport(authorId)).rejects.toThrow(enums.err.NOT_JSON_FILE);
})
test('if attachment URL, calls fetch and addFullMember and returns value', async () => {
memberHelper.addFullMember.mockResolvedValue(mockAddReturn);
const result = await importHelper.pluralKitImport(authorId, attachmentUrl);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(attachmentUrl);
expect(memberHelper.addFullMember).toHaveBeenCalledWith(authorId, mockImportedMember.name, mockImportedMember.display_name, 'SP{text}', mockImportedMember.avatar_url);
expect(result).toEqual(`Successfully added members: ${mockAddReturnMember.name}`)
})
test('if fetch fails, throws error', async () => {
global.fetch = jest.fn().mockRejectedValue("can't get");
await expect(importHelper.pluralKitImport(authorId, attachmentUrl)).rejects.toThrow(enums.err.CANNOT_FETCH_RESOURCE, "can't get file");
})
test('if json conversion fails, throws error', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: () => Promise.reject("not json")
})
await expect(importHelper.pluralKitImport(authorId, attachmentUrl)).rejects.toThrow(enums.err.NOT_JSON_FILE, "not json");
})
test('if addFullMember returns nothing, return correct enum', async () => {
memberHelper.addFullMember.mockResolvedValue();
const promise = importHelper.pluralKitImport(authorId, attachmentUrl);
await expect(promise).rejects.toBeInstanceOf(AggregateError);
await expect(promise).rejects.toMatchObject(AggregateError([], enums.err.NO_MEMBERS_IMPORTED));
})
test('if addFullMember throws error, catch and return error', async () => {
memberHelper.addFullMember.mockRejectedValue(new Error('error'));
await expect(importHelper.pluralKitImport(authorId, attachmentUrl)).rejects.toMatchObject(new AggregateError(['error'], enums.err.NO_MEMBERS_IMPORTED));
});
test('if addFullMember returns member but also contains error, return member and error', async () => {
// Arrange
const memberObj = {errors: ['error'], member: mockAddReturnMember};
memberHelper.addFullMember.mockResolvedValue(memberObj);
await expect(importHelper.pluralKitImport(authorId, attachmentUrl)).rejects.toMatchObject(new AggregateError(['error'], `Successfully added members: ${mockAddReturnMember.name}`));
});
});
afterEach(() => {
// restore the spy created with spyOn
jest.clearAllMocks();
});
})

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,17 @@
const env = require('dotenv');
env.config();
const {memberHelper} = require("../../src/helpers/memberHelper.js");
const {Message} = require("@fluxerjs/core");
const {fs} = require('fs');
const {enums} = require('../../src/enums');
const {tmp, setGracefulCleanup} = require('tmp');
jest.mock('../../src/helpers/memberHelper.js', () => {
return {memberHelper: {
getMembersByAuthor: jest.fn()
}}
jest.mock('../../src/repositories/memberRepo.js', () => {
return {
memberRepo: {
getMembersByAuthor: jest.fn()
}
}
})
jest.mock('tmp');
jest.mock('fs');
jest.mock('@fluxerjs/core');
const {messageHelper} = require("../../src/helpers/messageHelper.js");
const {memberRepo} = require("../../src/repositories/memberRepo");
describe('messageHelper', () => {
@@ -28,11 +22,11 @@ describe('messageHelper', () => {
describe('parseCommandArgs', () => {
test.each([
['pk;member', ['']],
['pk;member add somePerson "Some Person"', ['add', 'somePerson', 'Some Person']],
['pk;member add \"Some Person\"', ['add', 'Some Person']],
['pk;member add somePerson \'Some Person\'', ['add', 'somePerson', 'Some Person']],
['pk;member add somePerson \"\'Some\' Person\"', ['add', 'somePerson', 'Some Person']],
['pf;member', ['']],
['pf;member add somePerson "Some Person"', ['add', 'somePerson', 'Some Person']],
['pf;member add \"Some Person\"', ['add', 'Some Person']],
['pf;member add somePerson \'Some Person\'', ['add', 'somePerson', 'Some Person']],
['pf;member add somePerson \"\'Some\' Person\"', ['add', 'somePerson', 'Some Person']],
])('%s returns correct arguments', (content, expected) => {
// Arrange
const command = "member";
@@ -60,7 +54,7 @@ describe('messageHelper', () => {
const attachmentUrl = "../oya.png"
beforeEach(() => {
memberHelper.getMembersByAuthor = jest.fn().mockImplementation((specificAuthorId) => {
memberRepo.getMembersByAuthor = jest.fn().mockImplementation((specificAuthorId) => {
if (specificAuthorId === "1") return membersFor1;
if (specificAuthorId === "2") return membersFor2;
if (specificAuthorId === "3") return membersFor3;
@@ -80,17 +74,16 @@ describe('messageHelper', () => {
['2', 'hello', null, undefined],
['2', '--hello', null, undefined],
['2', 'hello', attachmentUrl, undefined],
['2', '--hello', attachmentUrl,undefined],
['2', '--hello', attachmentUrl, undefined],
['3', 'hello', null, {}],
['3', '--hello', null, {}],
['3', 'hello', attachmentUrl, {}],
['3', '--hello', attachmentUrl,{}],
])('ID %s with string %s returns correct proxy', async(specificAuthorId, content, attachmentUrl, expected) => {
['3', '--hello', attachmentUrl, {}],
])('ID %s with string %s returns correct proxy', async (specificAuthorId, content, attachmentUrl, expected) => {
// Act
return messageHelper.parseProxyTags(specificAuthorId, content, attachmentUrl).then((res) => {
// Assert
expect(res).toEqual(expected);
})
const res = await messageHelper.parseProxyTags(specificAuthorId, content, attachmentUrl);
// Assert
expect(res).toEqual(expected);
});
})

View File

@@ -0,0 +1,90 @@
const {enums} = require("../../src/enums");
const {utils} = require("../../src/helpers/utils.js");
describe('utils', () => {
const attachmentUrl = 'oya.png';
const expirationString = new Date("2026-01-01").toDateString();
let blob;
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
blob = new Blob([JSON.stringify({attachmentUrl: attachmentUrl})], {type: 'image/png'});
global.fetch = jest.fn(() =>
Promise.resolve({
blob: () => Promise.resolve(blob),
})
);
})
describe('checkImageFormatValidity', () => {
test('calls fetch with imageUrl and returns true if no errors', async() => {
// Act
const res = await utils.checkImageFormatValidity(attachmentUrl);
// Assert
expect(res).toBe(true);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(attachmentUrl);
})
test('throws error if fetch returns error', async() => {
// Arrange
global.fetch = jest.fn().mockRejectedValue(new Error('error'));
// Act & Assert
await expect(utils.checkImageFormatValidity(attachmentUrl)).rejects.toThrow(`${enums.err.PROPIC_CANNOT_LOAD}: error`);
})
test('throws error if blob returns error', async() => {
// Arrange
global.fetch = jest.fn(() =>
Promise.resolve({
blob: () => Promise.reject(new Error('error'))
}))
// Act & Assert
await expect(utils.checkImageFormatValidity(attachmentUrl)).rejects.toThrow('error');
})
test('throws error if blob in wrong format', async() => {
// Arrange
blob = new Blob([JSON.stringify({attachmentUrl})], {type: 'text/html'});
global.fetch = jest.fn(() =>
Promise.resolve({
blob: () => Promise.resolve(blob),
})
);
// Act & Assert
await expect(utils.checkImageFormatValidity(attachmentUrl)).rejects.toThrow(enums.err.PROPIC_FAILS_REQUIREMENTS);
})
})
describe('setExpirationWarning', () => {
test('sets warning if image Url starts with Fluxer host', () => {
// Act
const result = utils.setExpirationWarning(`${enums.misc.FLUXER_ATTACHMENT_URL}${attachmentUrl}`);
// Assert
expect(result).toEqual(enums.misc.ATTACHMENT_EXPIRATION_WARNING);
})
test('sets warning if expiration string exists', () => {
const result = utils.setExpirationWarning(null, expirationString);
// Assert
expect(result).toEqual(`${enums.misc.ATTACHMENT_EXPIRATION_WARNING}. Expiration date: *${expirationString}*.`);
})
test('returns null if img url does not start iwth fluxer host and no expiration', () => {
// Act
const result = utils.setExpirationWarning(attachmentUrl);
// Assert
expect(result).toBeNull();
})
})
afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});
})

View File

@@ -14,10 +14,44 @@ const {webhookHelper} = require("../../src/helpers/webhookHelper.js");
const {enums} = require("../../src/enums");
describe('webhookHelper', () => {
const channelId = '123';
const authorId = '456';
const guildId = '789';
const text = "hello";
let client, member, attachments, message, webhook;
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
client = {
channels: {
get: jest.fn().mockReturnValue(channelId)
}
}
member = {proxy: "--text", name: 'somePerson', displayname: "Some Person", propic: 'oya.png'};
attachments = {
size: 1,
first: () => {return channelId;}
};
message = {
client,
channelId: channelId,
content: text,
attachments: attachments,
author: {
id: authorId
},
guild: {
guildId: guildId
},
reply: jest.fn().mockResolvedValue(),
delete: jest.fn().mockResolvedValue()
}
webhook = {
send: async() => jest.fn().mockResolvedValue()
}
})
describe(`sendMessageAsMember`, () => {
@@ -34,9 +68,7 @@ describe('webhookHelper', () => {
author: {
id: '123'
},
guild: {
guildId: '123'
},
guildId: '123',
reply: jest.fn()
}
const member = {proxy: "--text", name: 'somePerson', displayname: "Some Person"};
@@ -50,25 +82,24 @@ describe('webhookHelper', () => {
// Arrange
messageHelper.parseProxyTags.mockResolvedValue({});
// Act
return webhookHelper.sendMessageAsMember(client, message).then((res) => {
expect(res).toBeUndefined();
expect(messageHelper.parseProxyTags).toHaveBeenCalledTimes(1);
expect(messageHelper.parseProxyTags).toHaveBeenCalledWith(message.author.id, content, null);
expect(webhookHelper.replaceMessage).not.toHaveBeenCalled();
})
const res = await webhookHelper.sendMessageAsMember(client, message)
// Assert
expect(res).toBeUndefined();
expect(messageHelper.parseProxyTags).toHaveBeenCalledTimes(1);
expect(messageHelper.parseProxyTags).toHaveBeenCalledWith(message.author.id, content, null);
expect(webhookHelper.replaceMessage).not.toHaveBeenCalled();
})
test('calls parseProxyTags and returns if proxyMatch is undefined', async() => {
// Arrange
messageHelper.parseProxyTags.mockResolvedValue(undefined);
// Act
return webhookHelper.sendMessageAsMember(client, message).then((res) => {
// Assert
expect(res).toBeUndefined();
expect(messageHelper.parseProxyTags).toHaveBeenCalledTimes(1);
expect(messageHelper.parseProxyTags).toHaveBeenCalledWith(message.author.id, content, null);
expect(webhookHelper.replaceMessage).not.toHaveBeenCalled();
})
const res = await webhookHelper.sendMessageAsMember(client, message)
// Assert
expect(res).toBeUndefined();
expect(messageHelper.parseProxyTags).toHaveBeenCalledTimes(1);
expect(messageHelper.parseProxyTags).toHaveBeenCalledWith(message.author.id, content, null);
expect(webhookHelper.replaceMessage).not.toHaveBeenCalled();
})
test('calls parseProxyTags with attachmentUrl', async() => {
@@ -79,27 +110,22 @@ describe('webhookHelper', () => {
return {url: 'oya.png'}
}
}
// message.attachments.set('attachment', {url: 'oya.png'})
// message.attachments.set('first', () => {return {url: 'oya.png'}})
messageHelper.parseProxyTags.mockResolvedValue(undefined);
// Act
return webhookHelper.sendMessageAsMember(client, message).then((res) => {
// Assert
expect(res).toBeUndefined();
expect(messageHelper.parseProxyTags).toHaveBeenCalledTimes(1);
expect(messageHelper.parseProxyTags).toHaveBeenCalledWith(message.author.id, content, 'oya.png');
})
const res = await webhookHelper.sendMessageAsMember(client, message)
// Assert
expect(res).toBeUndefined();
expect(messageHelper.parseProxyTags).toHaveBeenCalledTimes(1);
expect(messageHelper.parseProxyTags).toHaveBeenCalledWith(message.author.id, content, 'oya.png');
})
test('if message matches member proxy but is not sent from a guild, throw an error', async() => {
// Arrange
message.guildId = null;
messageHelper.parseProxyTags.mockResolvedValue(proxyMessage);
// Act
return webhookHelper.sendMessageAsMember(client, message).catch((res) => {
// Assert
expect(res).toEqual(new Error(enums.err.NOT_IN_SERVER));
expect(webhookHelper.replaceMessage).not.toHaveBeenCalled();
})
// Act and Assert
await expect(webhookHelper.sendMessageAsMember(client, message)).rejects.toThrow(enums.err.NOT_IN_SERVER);
expect(webhookHelper.replaceMessage).not.toHaveBeenCalled();
})
test('if message matches member proxy and sent in a guild and has an attachment, reply to message with ping', async() => {
@@ -109,12 +135,11 @@ describe('webhookHelper', () => {
messageHelper.parseProxyTags.mockResolvedValue(proxyMessage);
const expected = `${enums.misc.ATTACHMENT_SENT_BY} ${proxyMessage.member.displayname}`
// Act
return webhookHelper.sendMessageAsMember(client, message).then((res) => {
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith(expected);
expect(webhookHelper.replaceMessage).not.toHaveBeenCalled();
})
await webhookHelper.sendMessageAsMember(client, message)
// Assert
expect(message.reply).toHaveBeenCalledTimes(1);
expect(message.reply).toHaveBeenCalledWith(expected);
expect(webhookHelper.replaceMessage).not.toHaveBeenCalled();
})
test('if message matches member proxy and sent in a guild channel and no attachment, calls replace message', async() => {
@@ -124,63 +149,26 @@ describe('webhookHelper', () => {
messageHelper.parseProxyTags.mockResolvedValue(proxyMessage);
jest.spyOn(webhookHelper, 'replaceMessage').mockResolvedValue();
// Act
return webhookHelper.sendMessageAsMember(client, message).then((res) => {
// Assert
expect(message.reply).not.toHaveBeenCalled();
expect(webhookHelper.replaceMessage).toHaveBeenCalledTimes(1);
expect(webhookHelper.replaceMessage).toHaveBeenCalledWith(client, message, proxyMessage.message, proxyMessage.member);
})
await webhookHelper.sendMessageAsMember(client, message);
// Assert
expect(message.reply).not.toHaveBeenCalled();
expect(webhookHelper.replaceMessage).toHaveBeenCalledTimes(1);
expect(webhookHelper.replaceMessage).toHaveBeenCalledWith(client, message, proxyMessage.message, proxyMessage.member);
})
test('if replace message throws error, throw same error', async() => {
test('if replace message throws error, throw same error and does not call message.reply', async () => {
// Arrange
message.guildId = '123';
messageHelper.parseProxyTags.mockResolvedValue(proxyMessage);
jest.spyOn(webhookHelper, 'replaceMessage').mockImplementation(() => {throw new Error("error")});
jest.spyOn(webhookHelper, 'replaceMessage').mockRejectedValue(new Error("error"));
// Act
return webhookHelper.sendMessageAsMember(client, message).catch((res) => {
// Assert
expect(message.reply).not.toHaveBeenCalled();
expect(webhookHelper.replaceMessage).toHaveBeenCalledTimes(1);
expect(webhookHelper.replaceMessage).toHaveBeenCalledWith(client, message, proxyMessage.message, proxyMessage.member);
expect(res).toEqual(new Error('error'));
})
await expect(webhookHelper.sendMessageAsMember(client, message)).rejects.toThrow("error");
// Assert
expect(message.reply).not.toHaveBeenCalled();
})
})
describe(`replaceMessage`, () => {
const channelId = '123';
const authorId = '456';
const guildId = '789';
const text = "hello";
const client = {
channels: {
get: jest.fn().mockReturnValue(channelId)
}
}
const member = {proxy: "--text", name: 'somePerson', displayname: "Some Person", propic: 'oya.png'};
const attachments= {
size: 1,
first: () => {return channelId;}
};
const message = {
client,
channelId: channelId,
content: text,
attachments: attachments,
author: {
id: authorId
},
guild: {
guildId: guildId
},
reply: jest.fn(),
delete: jest.fn()
}
const webhook = {
send: async() => jest.fn().mockResolvedValue()
}
test('does not call anything if text is 0 or message has no attachments', async() => {
// Arrange
@@ -192,13 +180,12 @@ describe('webhookHelper', () => {
message.attachments = noAttachments;
jest.spyOn(webhookHelper, 'getOrCreateWebhook').mockResolvedValue(webhook);
// Act
return webhookHelper.replaceMessage(client, message, emptyText, member).then(() => {
expect(webhookHelper.getOrCreateWebhook).not.toHaveBeenCalled();
expect(message.delete).not.toHaveBeenCalled();
})
await webhookHelper.replaceMessage(client, message, emptyText, member)
expect(webhookHelper.getOrCreateWebhook).not.toHaveBeenCalled();
expect(message.delete).not.toHaveBeenCalled();
})
test('calls getOrCreateWebhook and message.delete with correct arguments if text >= 0', async() => {
test('calls getOrCreateWebhook and message.delete with correct arguments if text > 0 & < 2000', async() => {
// Arrange
message.attachments = {
size: 0,
@@ -207,58 +194,108 @@ describe('webhookHelper', () => {
};
jest.spyOn(webhookHelper, 'getOrCreateWebhook').mockResolvedValue(webhook);
// Act
return webhookHelper.replaceMessage(client, message, text, member).then((res) => {
// Assert
expect(webhookHelper.getOrCreateWebhook).toHaveBeenCalledTimes(1);
expect(webhookHelper.getOrCreateWebhook).toHaveBeenCalledWith(client, channelId);
expect(message.delete).toHaveBeenCalledTimes(1);
expect(message.delete).toHaveBeenCalledWith();
})
await webhookHelper.replaceMessage(client, message, text, member);
// Assert
expect(webhookHelper.getOrCreateWebhook).toHaveBeenCalledTimes(1);
expect(webhookHelper.getOrCreateWebhook).toHaveBeenCalledWith(client, channelId);
expect(message.delete).toHaveBeenCalledTimes(1);
expect(message.delete).toHaveBeenCalledWith();
})
// TODO: flaky for some reason
test('calls getOrCreateWebhook and message.delete with correct arguments if attachments exist', async() => {
// TODO: Flaky for some reason. Skipping until attachments are implemented
test.skip('calls getOrCreateWebhook and message.delete with correct arguments if attachments exist', async() => {
// Arrange
const emptyText = ''
jest.spyOn(webhookHelper, 'getOrCreateWebhook').mockResolvedValue(webhook);
// Act
return webhookHelper.replaceMessage(client, message, emptyText, member).then((res) => {
// Assert
expect(webhookHelper.getOrCreateWebhook).toHaveBeenCalledTimes(1);
expect(webhookHelper.getOrCreateWebhook).toHaveBeenCalledWith(client, channelId);
expect(message.delete).toHaveBeenCalledTimes(1);
expect(message.delete).toHaveBeenCalledWith();
})
await webhookHelper.replaceMessage(client, message, emptyText, member);
// Assert
// expect(webhookHelper.getOrCreateWebhook).toHaveBeenCalledTimes(1);
// expect(webhookHelper.getOrCreateWebhook).toHaveBeenCalledWith(client, channelId);
expect(message.delete).toHaveBeenCalledTimes(1);
expect(message.delete).toHaveBeenCalledWith();
})
test('calls returnBufferFromText and console error if webhook.send returns error', async() => {
test('calls returnBufferFromText if text is more than 2000 characters', async() => {
// Arrange
const text = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb";
message.content = text;
const file = Buffer.from(text, 'utf-8');
const returnedBuffer = {text: text, file: file};
const expected2ndSend = {content: returnedBuffer.text, username: member.displayname, avatar_url: member.propic, files: [{name: 'text.txt', data: returnedBuffer.file}]};
jest.mock('console', () => ({error: jest.fn()}));
const returnedBuffer = {text: 'bbbb', file: file};
const expected = {content: returnedBuffer.text, username: member.displayname, avatar_url: member.propic, files: [{name: 'text.txt', data: returnedBuffer.file}]};
jest.spyOn(webhookHelper, 'getOrCreateWebhook').mockResolvedValue(webhook);
webhook.send = jest.fn().mockImplementationOnce(async() => {throw new Error('error')});
messageHelper.returnBufferFromText = jest.fn().mockResolvedValue(returnedBuffer);
webhook.send = jest.fn();
messageHelper.returnBufferFromText = jest.fn().mockReturnValue(returnedBuffer);
// Act
return webhookHelper.replaceMessage(client, message, text, member).catch((res) => {
// Assert
expect(messageHelper.returnBufferFromText).toHaveBeenCalledTimes(1);
expect(messageHelper.returnBufferFromText).toHaveBeenCalledWith(text);
expect(webhook.send).toHaveBeenCalledTimes(2);
expect(webhook.send).toHaveBeenNthCalledWith(2, expected2ndSend);
expect(console.error).toHaveBeenCalledTimes(1);
expect(console.error).toHaveBeenCalledWith(new Error('error'));
})
await webhookHelper.replaceMessage(client, message, text, member);
// Assert
expect(messageHelper.returnBufferFromText).toHaveBeenCalledTimes(1);
expect(messageHelper.returnBufferFromText).toHaveBeenCalledWith(text);
expect(webhook.send).toHaveBeenCalledTimes(1);
expect(webhook.send).toHaveBeenCalledWith(expected);
})
})
describe(`getOrCreateWebhook`, () => {
let channel;
beforeEach(async () => {
channel = {
createWebhook: jest.fn().mockResolvedValue()
}
jest.spyOn(webhookHelper, 'getWebhook').mockResolvedValue(webhook);
})
test('throws error if channel does not allow webhooks', async() => {
channel.createWebhook = false;
await expect(webhookHelper.getOrCreateWebhook(client, channel)).rejects.toThrow(enums.err.NO_WEBHOOKS_ALLOWED);
})
test('calls getWebhook if channel allows webhooks and returns webhook', async() => {
const res = await webhookHelper.getOrCreateWebhook(client, channel);
expect(webhookHelper.getWebhook).toHaveBeenCalledTimes(1);
expect(webhookHelper.getWebhook).toHaveBeenCalledWith(client, channel);
expect(res).toEqual(webhook);
})
test("calls createWebhook if getWebhook doesn't return webhook", async() => {
jest.spyOn(webhookHelper, 'getWebhook').mockResolvedValue();
await webhookHelper.getOrCreateWebhook(client, channel);
expect(channel.createWebhook).toHaveBeenCalledTimes(1);
expect(channel.createWebhook).toHaveBeenCalledWith({name: 'PluralFlux Proxy Webhook'});
})
})
describe(`getWebhook`, () => {
let webhook1, webhook2, channel;
beforeEach(() => {
webhook1 = {name: 'PluralFlux Proxy Webhook'};
webhook2 = {name: 'other webhook'};
channel = {
fetchWebhooks: jest.fn().mockResolvedValue([webhook1, webhook2])
}
})
test('calls fetchWebhooks and returns correct webhook', async() => {
// Act
const res = await webhookHelper.getWebhook(client, channel);
// Assert
expect(res).toEqual(webhook1);
expect(channel.fetchWebhooks).toHaveBeenCalledTimes(1);
expect(channel.fetchWebhooks).toHaveBeenCalledWith();
})
test('if fetchWebhooks returns no webhooks, return', async() => {
// Arrange
channel.fetchWebhooks = jest.fn().mockResolvedValue([]);
// Act
const res = await webhookHelper.getWebhook(client, channel);
// Assert
expect(res).toBeUndefined();
})
})

16
tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"lib": [
"es2021"
],
"target": "es2021",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./database/build",
"rootDir": "./database",
"esModuleInterop": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": true
}
}

7
variables.env Normal file
View File

@@ -0,0 +1,7 @@
FLUXER_BOT_TOKEN=<>
POSTGRES_PASSWORD=<>
POSTGRES_ENDPOINT=postgres
PGADMIN_DEFAULT_EMAIL: <>
PGADMIN_DEFAULT_PASSWORD: <>
PGADMIN_CONFIG_SERVER_MODE: 'False'
PGADMIN_CONFIG_MASTER_PASSWORD_REQUIRED: 'False'