Compare commits

...

26 commits

Author SHA1 Message Date
Bee
53c7367acf Merge branch 'main' of https://github.com/padlocks/Compatiplural 2025-01-02 12:26:14 -08:00
Bee
b33335d18a fix: crash when custom front is activated 2025-01-02 12:22:37 -08:00
bee!
a764d7b21c
Update dataManager.js 2024-06-19 22:01:14 -07:00
bee!
0f00694d2e
Update .env_example 2024-06-19 22:00:50 -07:00
bee!
2983fb83e3
Update README.md 2024-06-19 21:59:49 -07:00
bee!
5e0fe8aa0c
docker: add dockerfile, format log 2023-10-22 16:31:10 -07:00
bee!
1564a110e0
allow for publishing entire front instead
Signed-off-by: bee! <pascalyoung03@gmail.com>
2023-09-24 13:32:14 -07:00
bee!
032f4fa753
Merge pull request #8 from padlocks/dev
fix customStatus events, option to silence spam messages
2022-05-13 09:41:45 -07:00
bee!
9d54038ec7
fix customStatus events, option to silence spam messages 2022-05-13 09:39:48 -07:00
bee!
39991119ab
Merge pull request #7 from padlocks/dev
v1.1.0
2022-05-09 22:51:26 -07:00
bee!
7e34599116
fixes and update simplyapi 2022-05-09 22:49:16 -07:00
bee!
4728714d72
queue system to fix data malformation with rapid events 2022-05-09 08:07:44 -07:00
bee!
7df8eedbea
fix api to the current subdomain of the rewrite 2022-03-23 15:05:20 -07:00
bee!
c8814c62e3
Merge pull request #6 from padlocks/dev
fix typos
2022-03-23 14:28:16 -07:00
bee!
e696fe9684
fix typos 2022-03-23 14:27:45 -07:00
bee!
1199790007
Merge pull request #5 from padlocks/dev
change default api route to production
2022-03-23 14:10:26 -07:00
bee!
ad400aeced
change default api route to production 2022-03-23 14:09:30 -07:00
bee!
8548088e20
Merge pull request #4 from padlocks/dev
rebrand
2022-03-23 14:04:29 -07:00
bee!
ec4f74e0cd
simplyapi is public now 2022-03-23 14:02:32 -07:00
bee!
3d7247f18c
add a datamanager to handle subprocesses 2022-03-23 14:01:47 -07:00
bee
ebaaa23553
update to current state of SimplyAPI 2022-03-06 03:02:52 -08:00
bee
888ec5a072
rebrand! 2022-03-02 12:03:25 -08:00
bee!
f36ab890ad
Merge pull request #3 from padlocks/dev
bug fix
2022-03-02 05:17:12 -08:00
bee
6acb591085
fix for diff sometimes including a change and a previous change and not completing its function 2022-03-02 05:15:04 -08:00
bee
64e8e7f188
Merge branch 'dev' of https://github.com/padlocks/SPPK into dev 2022-03-02 04:55:23 -08:00
bee
500c2b19be
hotfix for empty customStatus changes 2022-03-02 04:54:56 -08:00
17 changed files with 502 additions and 726 deletions

2
.dockerignore Normal file
View file

@ -0,0 +1,2 @@
node_modules
npm-debug.log

View file

@ -1,9 +1,13 @@
# SPPK # Compatiplural
url="https://devapi.apparyllis.com" url_override="https://api.apparyllis.com"
socket="wss://devapi.apparyllis.com/v1/socket" api_version="v1"
socket="wss://api.apparyllis.com/v1/socket"
pk_url="https://api.pluralkit.me/v2" pk_url="https://api.pluralkit.me/v2"
token="AAAAAAAAAAAAAAAAAAAA" token="SIMPLYPLURAL_TOKEN"
userId="AAAAAAAAAAAAAAAAAAA" userId="abcd1234"
pk_token= "AAAAAAAAAAAAAAAA" pk_token= "PLURALKIT_TOKEN"
pk_system="AAAAAAAAAAAAAAAA" heartbeat=4500000
heartbeat=4500000 max_workers=1
silence_connections=true
full_swap=false
primary_tag="primary "

2
.gitignore vendored
View file

@ -5,3 +5,5 @@ config.json
package-lock.json package-lock.json
.env .env
.vercel .vercel
docker_build.bat
docker_run.bat

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM node:18
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
# If you are building your code for production
# RUN npm ci --omit=dev
# Bundle app source
COPY . .
#EXPOSE 8080
CMD [ "node", "index.js" ]

View file

@ -1,5 +1,6 @@
# SPPK # Compatiplural
### SimplyPlural -> PluralKit Connectivity. ### SimplyPlural -> PluralKit Connectivity.
Compatiplural is a small application which enables the transfer of your SimplyPlural data to PluralKit.
### Fork this repository and simply deploy to Heroku! ### Fork this repository and simply deploy to Heroku!
This project already has a Procfile set up, so it's super easy to get started. Once you have forked / cloned this repo, you can connect it to Heroku and fill in your credentials as config variables. More info below. This project already has a Procfile set up, so it's super easy to get started. Once you have forked / cloned this repo, you can connect it to Heroku and fill in your credentials as config variables. More info below.
@ -8,11 +9,14 @@ This project already has a Procfile set up, so it's super easy to get started. O
These can be set either in the .env file, in terminal, or in the config vars section of Heroku. These can be set either in the .env file, in terminal, or in the config vars section of Heroku.
| Setting | Default | Description | | Setting | Default | Description |
| ---------| ------- | ------------------ | | ---------| ------- | ------------------ |
| url | https://devapi.apparyllis.com | The base URL for all SimplyPlural API requests. Unless you are running your own fork of Simply Plural, you shouldn't change this. | | url_override | https://api.apparyllis.com | The base URL for all SimplyPlural API requests. Unless you are running your own fork of Simply Plural, you shouldn't change this. |
| socket | wss://devapi.apparyllis.com/v1/socket | The socket URL for SimplyPlural. Unless you are running your own fork of Simply Plural, you shouldn't change this. | | api_version | v1 | The target SimplyPlural API version. Unless you are running your own fork of Simply Plural, you shouldn't change this. |
| socket | wss://api.apparyllis.com/v1/socket | The socket URL for SimplyPlural. Unless you are running your own fork of Simply Plural, you shouldn't change this. |
| pk_url | https://api.pluralkit.me/v2 | The base URL for all PluralKit API requests. Unless you are running your own fork of PluralKit, you shouldn't change this. | | pk_url | https://api.pluralkit.me/v2 | The base URL for all PluralKit API requests. Unless you are running your own fork of PluralKit, you shouldn't change this. |
| token | token_here | Your SimplyPlural account token. As of now, the only permission necessary is the Read permission. | | token | token_here | Your SimplyPlural account token. As of now, the only permission necessary is the Read permission. |
| userId | user_id | Your SimplyPlural account/system ID. You can find it in account info near the bottom. | | userId | user_id | Your SimplyPlural account/system ID. You can find it in account info near the bottom. |
| pk_token | pluralkit_token_here | Your PluralKit token. Get it by using `pk;token`. | | pk_token | pluralkit_token_here | Your PluralKit token. Get it by using `pk;token`. |
| pk_system | pluralkit_system_id | Your Pluralkit system ID. This can be either your Discord account ID or your 5 letter ID shown by using pk;system. |
| heartbeat | 4500000 | The time in miliseconds before the websocket client reconnects to the websocket server. | | heartbeat | 4500000 | The time in miliseconds before the websocket client reconnects to the websocket server. |
| max_workers | 1 | Max number of workers for processing enqueued tasks. This probably shouldn't be changed. |
| silence_connections | true | Whether or not to silence the websocket connection and authentication messages. |
| full_swap | false | Determines whether to completely overwrite PluralKit's front with SimplyPlural's. No individual changes. |

View file

@ -1,61 +0,0 @@
const config = require('./config.json')
const SAPI = require('./SimplyAPI.js')
const SimplyAPI = new SAPI(config)
let group = {
parent: "root",
color: "",
private: true,
preventTrusted: false,
name: "123",
desc: "test group",
emoji: "",
members: []
}
main = async () => {
getGroups()
findGroup("123")
createTestGroup(group)
deleteTestGroup("123")
}
getGroups = async () => {
SimplyAPI.getGroups()
.then((response) => {
console.log(response.data)
})
.catch(err => console.error(err))
}
findGroup = async (what) => {
SimplyAPI.findGroup(what, (group) => {
if (group) {
console.log(group)
}
})
}
createTestGroup = async (data) => {
SimplyAPI.createGroup(data)
.then((response) => {
console.log(response.data)
})
.catch(err => console.error(err))
}
deleteTestGroup = async (what) => {
await SimplyAPI.findGroup(what, async (group) => {
if (group) {
await SimplyAPI.deleteGroup(group.id)
.then(async (res) => {
if (res.status == 200) {
console.log(`group deleted: ${group.content.name}.`,)
}
})
.catch(err => console.error(err))
}
})
}
main()

View file

@ -1,58 +0,0 @@
const config = require('./config.json')
const SAPI = require('./lib/SimplyAPI.js')
const SimplyAPI = new SAPI(config)
let member = {
name: "Test",
desc: "a test member",
pronouns: "It/Its",
pkId: "",
color: "",
avatarUuid: "",
avatarUrl: "",
private: false,
preventTrusted: false,
preventFrontNotifs: false,
info: {
"Age": "19",
"Likes": "bread"
}
}
main = async () => {
findMember("Test")
createTestMember(member)
deleteTestMember("Test")
}
findMember = async (who) => {
SimplyAPI.findMemberCallback(who, (member) => {
if (member) {
console.log(member)
}
})
}
createTestMember = async (data) => {
SimplyAPI.createMember(data)
.then((response) => {
console.log(response.data)
})
.catch(err => console.error(err))
}
deleteTestMember = async (who) => {
await SimplyAPI.findMember(who, async (member) => {
if (member) {
await SimplyAPI.deleteMember(member.id)
.then((res) => {
if (res.status == 200) {
console.log(`member deleted: ${res.data.content.name}.`)
}
})
.catch(err => console.error(err))
}
})
}
main()

View file

@ -1,26 +0,0 @@
const config = require('./config.json')
const SAPI = require('./SimplyAPI.js')
const SimplyAPI = new SAPI(config)
main = async () => {
getSystem()
getCurrentFronters()
}
getSystem = async () => {
SimplyAPI.getSystem()
.then((response) => {
console.log(response.data)
})
.catch(err => console.error(err))
}
getCurrentFronters = async () => {
SimplyAPI.getFronters()
.then((response) => {
console.log(response)
})
.catch(err => console.error(err))
}
main()

View file

@ -1,6 +0,0 @@
const SimplyAPI = require('./lib/SimplyAPI')
SimplyAPI.Validate = require('./lib/Validate')
SimplyAPI.Schemas = require('./lib/Schemas')
module.exports = SimplyAPI;

View file

@ -1,100 +0,0 @@
const memberSchema = {
type: "object",
properties: {
name: { type: "string" },
desc: { type: "string" },
pronouns: { type: "string" },
pkId: { type: "string" },
color: { type: "string" },
avatarUuid: { type: "string" },
avatarUrl: { type: "string" },
private: { type: "boolean" },
preventTrusted: { type: "boolean" },
preventFrontNotifs: { type: "boolean" },
info: {
type: "object",
properties: {
"*": { type: "string" }
}
}
},
nullable: false,
additionalProperties: false,
};
const groupSchema = {
type: "object",
properties: {
parent: { type: "string" },
color: { type: "string" },
private: { type: "boolean" },
preventTrusted: { type: "boolean" },
name: { type: "string" },
desc: { type: "string" },
emoji: { type: "string" },
members: { type: "array", items: { type: "string" } },
},
nullable: false,
additionalProperties: false,
dependencies: {
private: { required: ["preventTrusted"] },
preventTrusted: { required: ["private"] },
}
};
const customFrontSchema = {
type: "object",
properties: {
name: { type: "string" },
desc: { type: "string" },
avatarUrl: { type: "string" },
avatarUuid: { type: "string" },
color: { type: "string" },
preventTrusted: { type: "boolean" },
private: { type: "boolean" },
},
nullable: false,
additionalProperties: false,
}
const commentSchema = {
type: "object",
properties: {
time: { type: "number" },
text: { type: "string" },
documentId: { type: "string" },
collection: { type: "string" }
},
nullable: false,
additionalProperties: false,
required: ["time", "text", "documentId", "collection"]
}
const commentPatchSchema = {
type: "object",
properties: {
text: { type: "string" },
},
nullable: false,
additionalProperties: false,
required: ["text"]
}
const automatedTimerSchema = {
type: "object",
properties: {
name: { type: "string" },
message: { type: "string" },
action: { type: "number" },
delayInHours: { type: "number" },
type: { type: "number" },
},
nullable: false,
additionalProperties: false,
};
module.exports = {
memberSchema,
groupSchema
}

View file

@ -1,170 +0,0 @@
const { resolveRef } = require('ajv/dist/compile')
const axios = require('axios')
const schemas = require('./Schemas')
const validate = require('./Validate')
/**
* @param {Object} config
*/
class SimplyAPI {
constructor(config) {
this.url = config.url_override || 'https://devapi.apparyllis.com'
this.userId = config.userId
this.system = config.userId
this.token = config.token
this.header = {
'Content-Type': 'application/json',
'Authorization': this.token
}
}
getSystem = async () => {
let system = await axios.get(`${this.url}/v1/members/${this.system}`, {
headers: this.header
})
return system.data
//.then((response) => response)
//.catch(err => console.error(err.toJSON().message));
}
getGroups = async () => {
return axios.get(`${this.url}/v1/groups/${this.system}`, {
headers: this.header
})
.then((response) => response)
.catch(err => console.error(err.toJSON().message));
}
/**
* @param {string} group
* @param {function} callback
*/
findGroup = async (group, callback) => {
await this.getGroups()
.then((groups) => {
for (let i in groups.data) {
if (groups.data[i].content.name.includes(group)) {
callback(groups.data[i])
return
}
}
})
}
createGroup = async (group) => {
let valid = await validate.validateSchema(schemas.groupSchema, group)
if (valid) {
return axios.post(`${this.url}/v1/group/`, JSON.stringify(group), {
headers: this.header,
})
.then((response) => response)
.catch(err => console.error(err.toJSON().message));
} else {
let response = {}
response.data = {status: 'error', message: 'Invalid group schema'}
return response
}
}
deleteGroup = async (group) => {
return await axios.delete(`${this.url}/v1/group/${group}`, {
headers: this.header,
})
.then((response) => response)
.catch(err => console.error(err.toJSON().message));
}
/**
* @param {string} id
*/
findMemberById = async (id) => {
let found = false
let system = await this.getSystem()
return new Promise(async (resolve) => {
await asyncForEach(system, async (m) => {
if (m.id == id) {
found = true
resolve(m.content)
}
})
if (!found) resolve({ "name": "Unknown member" })
})
}
/**
* @param {string} member
*/
findMember = async (member) => {
let found = false
let system = await this.getSystem()
return new Promise(async (resolve) => {
await asyncForEach(system, async (m) => {
if (m.content.name.includes(member)) {
found = true
resolve(m)
}
})
if (!found) resolve({"name": "Unknown member"})
})
}
/**
* @param {string} member
* @param {function} callback
*/
findMemberCallback = async (member, callback) => {
await this.getSystem()
.then(async (system) => {
for (let i in system) {
if (system[i].content.name.includes(member)) {
await callback(system[i])
return
}
}
})
}
createMember = async (member) => {
let valid = await validate.validateSchema(schemas.memberSchema, member)
if (valid) {
return axios.post(`${this.url}/v1/member/`, JSON.stringify(member), {
headers: this.header,
})
.then((response) => response)
.catch(err => console.error(err.toJSON().message));
} else {
let response = {}
response.data = { status: 'error', message: 'Invalid group schema' }
return response
}
}
deleteMember = async (member) => {
return await axios.delete(`${this.url}/v1/member/${member}`, {
headers: this.header,
})
.then((response) => response)
.catch(err => console.error(err.toJSON().message));
}
getFronters = async () => {
return await axios.get(`${this.url}/v1/fronters/`, {
headers: this.header,
})
.then((response) => response.data)
.catch(err => console.error(err.toJSON().message));
}
}
asyncForEach = async (array, callback) => {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array);
}
}
module.exports = SimplyAPI

View file

@ -1,11 +0,0 @@
const Ajv = require('ajv')
const ajv = new Ajv()
class Validate {
static validateSchema = async (schema, body) => {
const validate = ajv.compile(schema)
return validate(body)
}
}
module.exports = Validate

View file

@ -1,20 +0,0 @@
{
"name": "simplyapi",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "padlocks",
"license": "ISC",
"optionalDependencies": {
"bufferutil": "^4.0.6",
"utf-8-validate": "^5.0.8"
},
"dependencies": {
"ajv": "^8.10.0",
"axios": "^0.26.0",
"ws": "^8.5.0"
}
}

View file

@ -7,7 +7,7 @@ function WebSocketClient(url) {
let connecting = false let connecting = false
let backoff = 250 let backoff = 250
const init = () => { const init = () => {
console.error(`::SimplyWS:: [${timestamp()}] connecting`) if (!process.env.silence_connections) console.error(`::SimplyWS:: [${timestamp()}] connecting`)
connecting = false connecting = false
if (client !== undefined) { if (client !== undefined) {
client.removeAllListeners() client.removeAllListeners()
@ -21,14 +21,14 @@ function WebSocketClient(url) {
timeout = setTimeout(() => client.terminate(), process.env.heartbeat || 350000) timeout = setTimeout(() => client.terminate(), process.env.heartbeat || 350000)
} }
client.on('ping', () => { client.on('ping', () => {
console.log(`::SimplyWS:: [${timestamp()}] pinged`) if (!process.env.silence_connections) console.log(`::SimplyWS:: [${timestamp()}] pinged`)
heartbeat() heartbeat()
}) })
client.on('open', (e) => { client.on('open', (e) => {
if (typeof this.onOpen === 'function') { if (typeof this.onOpen === 'function') {
this.onOpen() this.onOpen()
} else { } else {
console.log(`::SimplyWS:: [${timestamp()}] opened`) if (!process.env.silence_connections) console.log(`::SimplyWS:: [${timestamp()}] opened`)
console.log(e) console.log(e)
} }
heartbeat() heartbeat()
@ -37,7 +37,7 @@ function WebSocketClient(url) {
if (typeof this.onMessage === 'function') { if (typeof this.onMessage === 'function') {
this.onMessage(e) this.onMessage(e)
} else { } else {
console.log(`::SimplyWS:: [${timestamp()}] messaged`) if (!process.env.silence_connections) console.log(`::SimplyWS:: [${timestamp()}] messaged`)
} }
heartbeat() heartbeat()
}) })

359
dataManager.js Normal file
View file

@ -0,0 +1,359 @@
const axios = require('axios')
const { Config, System, Util } = require('simplyapi')
const pkUrl = Config.pk_url
const pkHeader = {
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': Config.pk_token
}
let cache = {}
async function initializeCache() {
let system = new System(Config)
cache.frontHistory = await system.getFronters()
}
function unknownTarget(target) {
console.log('::SimplyWS:: Unknown update target: ' + target + '\n::SimplyWS:: Full message: ' + e)
}
function unrecognizedMessage(msg) {
console.log('::SimplyWS:: Unrecognized message: ' + msg + '\n::SimplyWS:: Full message: ' + e)
}
async function getPKFronters() {
let members = []
let fronters = await axios.get(`${pkUrl}/systems/@me/fronters`, {
headers: pkHeader
})
.catch((err) => {
if (err.toJSON().status == 429)
// Too many requests
setTimeout(async () => {
return await getPKFronters()
}, 1500)
})
if (fronters != undefined) {
fronters.data.members.forEach((key, value) => {
members.push(key.id)
})
}
return members
}
async function findPrimary() {
let found = false
let system = new System(Config)
let fronters = await system.getFronters()
return new Promise(async (resolve) => {
await Util.asyncForEach(fronters, async (fronter) => {
if (fronter.content.customStatus) {
if (fronter.content.customStatus.toLowerCase().includes(Config.primary_tag)) {
let member = await system.getMemberById(fronter.content.member)
resolve({ name: member.content.name, pkId: member.content.pkId })
found = true
}
}
})
if (!found)
resolve(false)
})
}
async function determineAction(eventData, frontData = []) {
if (frontData.length == 0)
return 'remove'
let action = ''
// check for cache
if (!cache.frontHistory) {
let system = new System(Config)
let frontHistory = await system.getFronters()
cache.frontHistory = frontHistory
}
// get the difference between cached history and current front
let diff = await calculateDiff(cache.frontHistory, frontData)
// we handle one thing at a time, although this should be expanded since you can modify multiple custom statuses at once
if (diff.length >= 1) {
if (diff[0].content.customStatus || eventData.content.customStatus || diff[0].content.customStatus == "" || eventData.content.customStatus == "") {
// check if customStatus value is in cache
let foundInCache = Object.keys(cache.frontHistory).filter((key) => {
return cache.frontHistory[key] === diff[0].content.customStatus
})
// if value is unique, publish action
if (foundInCache.length == 0) {
action = 'customStatus'
}
}
else {
if (eventData.content.customStatus == '')
return 'customStatus'
console.error('::SimplyWS:: Unrecognized diff: ' + JSON.stringify(diff))
}
}
else {
// if there's an endTime, it was a removal event
if (eventData.content.endTime && !eventData.content.live) {
action = 'remove'
}
}
return action
}
async function swapFront() {
let system = new System(Config)
let front = await system.getFronters()
// start forming new front list
let newFront = []
let frontNames = []
for (member of front) {
let m = await system.getMemberById(member.content.member)
if (m.content && m.content.pkId) {
// fronting member pkID has been found
newFront.push(m.content.pkId)
frontNames.push(m.content.name)
}
else {
console.warn('::SimplyWS:: System member not found, this may be a custom front which is unsupported.')
}
}
// shift primary fronter to first in list
let primary = await findPrimary()
let primaryPK = primary.pkId
let primaryName = primary.name
if (primaryPK) {
if (newFront.indexOf(primaryPK) > 0) {
newFront.splice(newFront.indexOf(primaryPK), 1)
newFront.unshift(primaryPK)
}
if (frontNames.indexOf(primaryName) > 0) {
frontNames.splice(frontNames.indexOf(primaryName), 1)
frontNames.unshift(primaryName)
}
}
// post the new switch
let url = `${pkUrl}/systems/@me/switches`
await axios.post(url, JSON.stringify({ "members": newFront }), {
headers: pkHeader
})
.then(async (res) => {
// check if current front equals the new front
let front = await getPKFronters()
var equal = (front.length == newFront.length) && front.every(function(element, index) {
return element === newFront[index];
})
if (!equal) {
console.log('::SimplyWS:: Failed to swap front: ' + newFront)
await swapFront()
return
} else {
let formattedNames = frontNames.toString().replace(',',', ')
console.log(`::SimplyWS:: SimplyPlural -> PluralKit: ${formattedNames}`)
}
})
.catch(async err => {
let status = err.status || err.toJSON().status
if (status == 400) {
// if the fronter is already in the front, do nothing
return
}
else if (status == 404) {
return
}
else if (status == 429) {
// Too many requests
console.warn("::SimplyWS:: Too many requests, waiting to try again.")
setTimeout(async function () {
await swapFront()
}, 1000)
return
}
})
}
async function insertFront(member) {
// get current fronters and add new fronter
let fronters = await getPKFronters()
if (!fronters.includes(member.content.pkId)) {
fronters.push(member.content.pkId)
} else {
console.warn('::SimplyWS:: Member already in fronters: ' + member.content.pkId)
return
}
// find the "primary" fronter to move to the first element in the list
let primary = await findPrimary().pkId
if (primary) {
if (fronters.indexOf(primary) > 0) {
fronters.splice(fronters.indexOf(primary), 1)
fronters.unshift(primary)
}
}
// post the new switch
let url = `${pkUrl}/systems/@me/switches`
await axios.post(url, JSON.stringify({ "members": fronters }), {
headers: pkHeader
})
.then(async (res) => {
let front = await getPKFronters()
if (!front.includes(member.content.pkId)) {
console.log('::SimplyWS:: Failed to insert fronter: ' + member.content.pkId)
await insertFront(member)
return
} else {
console.log('::SimplyWS:: ' + member.content.name + ' was added to the front.')
}
})
.catch(async err => {
let status = err.status || err.toJSON().status
if (status == 400) {
// if the fronter is already in the front, do nothing
return
}
else if (status == 404) {
// member not found
console.error("::SimplyWS:: Could not find member: " + member.content.pkId)
let index = fronters.indexOf(member.content.pkId)
fronters.splice(index, 1)
return
}
else if (status == 429) {
// Too many requests
console.warn("::SimplyWS:: Too many requests, waiting to try again.")
let index = fronters.indexOf(member.content.pkId)
fronters.splice(index, 1)
setTimeout(async function () {
await insertFront(member)
}, 1000)
return
}
})
}
async function removeFront(member) {
let fronters = await getPKFronters()
if (fronters.includes(member.content.pkId)) {
let index = fronters.indexOf(member.content.pkId)
fronters.splice(index, 1)
} else {
console.warn('::SimplyWS:: Member is not in front: ' + member.content.pkId)
return
}
// find the "primary" fronter to move to the first element in the list
let primary = await findPrimary().pkId
if (primary) {
if (fronters.indexOf(primary) > 0) {
fronters.splice(fronters.indexOf(primary), 1)
fronters.unshift(primary)
}
}
let url = `${pkUrl}/systems/@me/switches`
await axios.post(url, JSON.stringify({ "members": fronters }), {
headers: pkHeader
})
.then(async (res) => {
let front = await getPKFronters()
if (front.includes(member.content.pkId)) {
console.log('::SimplyWS:: Failed to remove fronter: ' + member.content.pkId)
await removeFront(member)
return
} else {
console.log('::SimplyWS:: ' + member.content.name + ' was removed from the front.')
}
})
.catch(async err => {
let status = err.status || err.toJSON().status
if (status == 400) {
// fronter is already not in front
console.warn("::SimplyWS:: " + member.content.name + " is not in the front.")
return
}
else if (status == 429) {
// Too many requests
console.warn("::SimplyWS:: Too many requests, waiting to try again.")
fronters.push(member.content.pkId)
setTimeout(async function () {
await removeFront(member)
}, 1000)
return
}
})
}
async function updateCustomStatus(member) {
// find the "primary" fronter to move to the first element in the list
let fronters = await getPKFronters()
let primary = await findPrimary().pkId
if (primary && fronters.length > 1 && (member.content.pkId == primary)) {
if (fronters.indexOf(primary) >= 0) {
fronters.splice(fronters.indexOf(primary), 1)
fronters.unshift(primary)
// post the new switch
axios.post(`${pkUrl}/systems/@me/switches`, JSON.stringify({ "members": fronters }), {
headers: pkHeader
})
.catch(async err => {
if (err.toJSON().status == 429)
//Too many requests
console.warn("::SimplyWS:: Too many requests, waiting to try again.")
setTimeout(function () {
updateCustomStatus(member)
}, 1000)
return
})
console.log('::SimplyWS:: ' + member.content.name + ' is now the primary fronter.')
}
}
else {
console.log('::SimplyWS:: ' + member.content.name + ' changed custom status.')
}
}
const transform = require('lodash.transform')
const isEqual = require('lodash.isequal')
const isObject = require('lodash.isobject')
async function calculateDiff(origObj, newObj) {
return new Promise(function (resolve) {
changes = (newObj, origObj) => {
let arrayIndexCounter = 0
return transform(newObj, function (result, value, key) {
if (!isEqual(value, origObj[key])) {
let resultKey = Array.isArray(origObj) ? arrayIndexCounter++ : key
result[resultKey] = (isObject(value) && isObject(origObj[key])) ? changes(value, origObj[key]) : value
}
})
}
resolve(changes(newObj, origObj))
})
}
module.exports = {
initializeCache,
unknownTarget,
unrecognizedMessage,
getPKFronters,
findPrimary,
determineAction,
swapFront,
insertFront,
removeFront,
updateCustomStatus,
calculateDiff
}

345
index.js
View file

@ -1,290 +1,129 @@
const dotenv = require('dotenv') const dotenv = require('dotenv')
dotenv.config() dotenv.config()
const config = process.env
const axios = require('axios') const { Config, System } = require('simplyapi')
const SAPI = require('./SimplyAPI') const { Util } = require('simplyapi')
const SimplyAPI = new SAPI(config) const { initializeCache, determineAction, swapFront, insertFront, removeFront, updateCustomStatus } = require('./dataManager')
const pkUrl = config.pk_url const {
const pkHeader = { isMainThread,
'Content-Type': 'application/json', BroadcastChannel,
'Authorization': config.pk_token Worker
} } = require('node:worker_threads')
let e let e
let cache = {} main = () => {
main = async () => { initiateWorkerPool()
openWebSocket()
} }
openWebSocket = async () => { // Queue
const async = require('async')
const queue = async.queue((task, completed) => {
let error = { status: false, message: '' }
update(task.data)
.catch(err => {
error.status = true
error.message = err
})
completed(error, task)
}, Config.max_workers)
initiateWorkerPool = () => {
// Worker Pool
const bc = new BroadcastChannel('plural')
if (isMainThread) {
openWebSocket()
bc.onmessage = (event) => {
//console.log('::SimplyWS:: received message from worker')
queue.push(event.data, (error, task) => {
// task completed
if (error.status) {
console.log(`An error occurred while processing task ${error.message}`)
}
})
}
for (let n = 0; n < Config.max_workers; n++)
new Worker(__filename)
}
}
// Socket
openWebSocket = () => {
const WebSocketClient = require('./WebsocketClient') const WebSocketClient = require('./WebsocketClient')
const wss = new WebSocketClient(config.socket); const wss = new WebSocketClient(Config.socket)
let initialPacket = { "op": "authenticate", "token": config.token } let initialPacket = { "op": "authenticate", "token": Config.token }
wss.onOpen = (_) => { wss.send(JSON.stringify(initialPacket)); } wss.onOpen = (_) => { wss.send(JSON.stringify(initialPacket)); }
wss.onClose = (e) => { console.log('SimplyWS/onClose :: %s', e); e = '' } wss.onClose = (e) => { console.log('SimplyWS/onClose :: %s', e); e = '' }
wss.onError = (e) => { console.log('SimplyWS/onError :: %s', e) } wss.onError = (e) => { console.log('SimplyWS/onError :: %s', e) }
wss.onMessage = async (raw) => { const bc = new BroadcastChannel('plural')
let first_auth = true
wss.onMessage = (raw) => {
e = raw e = raw
let data = JSON.parse(e) let data = JSON.parse(e)
if (Object.keys(data).length === 0) return if (Object.keys(data).length === 0) return
switch (data.msg) { switch (data.msg) {
case "Successfully authenticated": case "Successfully authenticated":
console.log('::SimplyWS:: authenticated') if (!process.env.silence_connections || first_auth) console.log('::SimplyWS:: authenticated')
first_auth = false
// cache current front // cache current front
cache.frontHistory = await SimplyAPI.getFronters() initializeCache()
break; break
case "Authentication violation: Token is missing or invalid. Goodbye :)": case "Authentication violation: Token is missing or invalid. Goodbye :)":
console.log('::SimplyWS:: invalid token, exiting..') console.error('::SimplyWS:: invalid token, exiting..')
process.exit(1) process.exit(1)
case "update": case "update":
let response = await generateResponse(data.target, data); bc.postMessage({data: data})
if (response) console.log('::SimplyWS:: ' + response) break
break;
default: default:
unrecognizedMessage(data.msg) //unrecognizedMessage(data.msg)
break; break
} }
} }
} }
generateResponse = async (target, data) => { // Data Processing
let response = '' update = async (data) => {
let target = data.target
switch (target) { switch (target) {
case 'frontHistory': case 'frontHistory':
//response += 'Front has changed!' //response += 'Front has changed!'
await asyncForEach(data.results, async (o) => { await Util.asyncForEach(data.results, async (o) => {
await SimplyAPI.findMemberById(o.content.member) let system = new System(Config)
.then(async (member) => { let member = await system.getMemberById(o.content.member)
if (o.operationType == "insert") { let swap = Config.full_swap
// get current fronters and add new fronter // insert
let fronters = await getPKFronters() if (swap) {
fronters.push(member.pkId) swapFront()
}
else if (o.operationType == "insert") {
insertFront(member)
}
else {
// get current fronters and patch the list
let frontData = await system.getFronters()
let action = await determineAction(o, frontData)
// if delete operation, remove the member from the list
switch (action) {
case "remove":
removeFront(member)
break
// find the "primary" fronter to move to the first element in the list case "customStatus":
let primary = findPrimary() updateCustomStatus(member)
if (primary) { break
if (fronters.indexOf(primary) > 0) { }
fronters.splice(fronters.indexOf(primary), 1) }
fronters.unshift(primary)
}
}
// cache front
cache.frontHistory = await SimplyAPI.getFronters()
// post the new switch
axios.post(`${pkUrl}/systems/${config.pk_system}/switches`, JSON.stringify({"members": fronters}), {
headers: pkHeader
})
.catch(err => {
if (err.toJSON().status == 400) unknownError400()
else console.error(err.message)
})
response += '' + member.name + ' was added to the front.'
return
}
else {
// get current fronters and patch the list
let fronters = await getPKFronters()
let frontData = await SimplyAPI.getFronters()
let action = await determineAction(o, frontData)
// if delete operation, remove the member from the list
switch (action) {
case "remove":
let index = fronters.indexOf(member.pkId)
fronters.splice(index, 1)
// find the "primary" fronter to move to the first element in the list
let p = findPrimary()
if (p) {
if (fronters.indexOf(p) > 0) {
fronters.splice(fronters.indexOf(p), 1)
fronters.unshift(p)
}
}
// cache front
cache.frontHistory = await SimplyAPI.getFronters()
// post the new switch
axios.post(`${pkUrl}/systems/${config.pk_system}/switches`, JSON.stringify({ "members": fronters }), {
headers: pkHeader
})
.catch(err => {
if (err.toJSON().status == 400) unknownError400()
else console.error(err.message)
})
response += '' + member.name + ' was removed from the front.'
break;
case "customStatus":
// find the "primary" fronter to move to the first element in the list
let primary = await findPrimary()
if (primary && fronters.length > 1) {
if (fronters.indexOf(primary) > 0) {
fronters.splice(fronters.indexOf(primary), 1)
fronters.unshift(primary)
// cache front
cache.frontHistory = await SimplyAPI.getFronters()
// post the new switch
axios.post(`${pkUrl}/systems/${config.pk_system}/switches`, JSON.stringify({ "members": fronters }), {
headers: pkHeader
})
.catch(err => {
if (err.toJSON().status == 400) unknownError400()
else console.error(err.message)
})
response += '' + member.name + ' is now the primary fronter.'
}
}
else {
response += '' + member.name + ' changed custom status.'
}
break;
}
return
}
})
.catch(err => {
console.log('::SimplyWS:: Error finding member: ' + err)
})
}) })
break; break
default: default:
unknownTarget(data.target) //unknownTarget(data.target)
break; break
} }
return response
}
unknownError400 = () => {
return
}
unknownTarget = (target) => {
console.log('::SimplyWS:: Unknown update target: ' + target + '\n::SimplyWS:: Full message: ' + e)
}
unrecognizedMessage = (msg) => {
console.log('::SimplyWS:: Unrecognized message: ' + msg + '\n::SimplyWS:: Full message: ' + e)
}
findMember = (who) => {
return new Promise(function (resolve, reject) {
SimplyAPI.findMember(who, (member) => {
if (member) {
resolve(member)
} else {
reject({"name": "Unknown member"})
}
})
})
}
getPKFronters = async () => {
let members = []
let fronters = await axios.get(`${pkUrl}/systems/${config.pk_system}/fronters`, {
headers: pkHeader
})
.catch(err => console.error("An error occured while getting current fronters: " + err.message))
fronters.data.members.forEach((key, value) => {
members.push(key.id)
})
return members
}
findPrimary = async () => {
let found = false
let fronters = await SimplyAPI.getFronters()
return new Promise(async (resolve) => {
await asyncForEach(fronters, async (fronter) => {
if (fronter.content.customStatus) {
if (fronter.content.customStatus.toLowerCase().includes("primary")) {
let member = await SimplyAPI.findMemberById(fronter.content.member)
resolve(member.pkId)
found = true
}
}
})
if (!found) resolve(false)
})
}
determineAction = async (eventData, frontData = []) => {
if (frontData.length == 0) return 'remove'
let action = ''
// check for cache
if (!cache.frontHistory) {
let frontHistory = await SimplyAPI.getFronters()
cache.frontHistory = frontHistory
}
// get the difference between cached history and current front
let diff = await calculateDiff(cache.frontHistory, frontData)
// we handle one thing at a time, although this should be expanded since you can modify multiple custom statuses at once
if (diff.length == 1) {
if (diff[0].content.customStatus) {
// check if customStatus value is in cache
let foundInCache = Object.keys(cache.frontHistory).filter((key) => {
return cache.frontHistory[key] === diff[0].content.customStatus
})
// if value is unique, publish action
if (foundInCache.length == 0) {
action = 'customStatus'
}
}
else {
console.error('::SimplyWS:: Unrecognized diff: ' + JSON.stringify(diff))
}
}
else {
// if there's an endTime, it was a removal event
if (eventData.content.endTime && !eventData.content.live) {
action = 'remove'
}
}
return action
}
asyncForEach = async (array, callback) => {
for (let index = 0; index < array.length; index++) {
await callback(array[index], index, array);
}
}
const { inspect } = require('util')
const transform = require('lodash.transform')
const isEqual = require('lodash.isequal')
const isArray = require('lodash.isarray')
const isObject = require('lodash.isobject')
const { PassThrough } = require('stream')
calculateDiff = async (origObj, newObj) => {
return new Promise(function (resolve) {
changes = (newObj, origObj) => {
let arrayIndexCounter = 0
return transform(newObj, function (result, value, key) {
if (!isEqual(value, origObj[key])) {
let resultKey = isArray(origObj) ? arrayIndexCounter++ : key
result[resultKey] = (isObject(value) && isObject(origObj[key])) ? changes(value, origObj[key]) : value
}
})
}
resolve(changes(newObj, origObj))
})
} }
main() main()

View file

@ -1,22 +1,24 @@
{ {
"name": "sppk", "name": "Compatiplural",
"version": "1.0.0", "version": "1.1.0",
"description": "A tool to automatically update PluralKit with SimplyPlural data.", "description": "SimplyPlural -> PluralKit Connectivity",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node .", "start": "node .",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"author": "padlocks", "author": "padlocks",
"license": "ISC", "license": "MIT",
"repository": "github:padlocks/Compatiplural",
"dependencies": { "dependencies": {
"ajv": "^8.10.0", "ajv": "^8.10.0",
"async": "^3.2.3",
"axios": "^0.26.0", "axios": "^0.26.0",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"lodash.isarray": "^4.0.0",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lodash.isobject": "^3.0.2", "lodash.isobject": "^3.0.2",
"lodash.transform": "^4.6.0", "lodash.transform": "^4.6.0",
"simplyapi": "^0.1.4",
"ws": "^8.5.0" "ws": "^8.5.0"
}, },
"optionalDependencies": { "optionalDependencies": {