Files
habitica/website/server/libs/webhook.js
Phillip Thelen 8150fef993 Database Access optimisations (#14544)
* Optimize database access during spell casting

* load less data when casting spells

* Begin migrating update calls to updateOne and updateMany

* Only update user objects that don’t have notification yet

* fix test

* fix spy

* Don’t unnecessarily update user when requesting invalid guild

* fix sort order for middlewares to not load user twice every request

* fix tests

* fix integration test

* fix skill usage not always deducting mp

* addtest case for blessing spell

* fix healAll

* fix lint

* Fix error for when some spells are used outside of party

* Add check to not run bulk spells in web client

* fix(tags): change const to let

---------

Co-authored-by: SabreCat <sabe@habitica.com>
2023-05-16 12:21:45 -05:00

213 lines
5.0 KiB
JavaScript

import got from 'got';
import { isURL } from 'validator';
import nconf from 'nconf';
import moment from 'moment';
import logger from './logger';
import { // eslint-disable-line import/no-cycle
model as User,
} from '../models/user';
const IS_PRODUCTION = nconf.get('IS_PROD');
function sendWebhook (webhook, body, user) {
const { url, lastFailureAt } = webhook;
got.post(url, {
json: body,
timeout: 30000, // wait up to 30s before timing out
retry: 3, // retry the request up to 3 times
// Not calling .json() to parse the response because we simply ignore it
}).catch(webhookErr => {
// Log the error
logger.error(webhookErr, {
extraMessage: 'Error while sending a webhook request.',
userId: user._id,
webhookId: webhook.id,
});
let _failuresReset = false;
// Reset failures if the last one happened more than 1 month ago
const oneMonthAgo = moment().subtract(1, 'months');
if (!lastFailureAt || moment(lastFailureAt).isBefore(oneMonthAgo)) {
webhook.failures = 0;
_failuresReset = true;
}
// Increase the number of failures
webhook.failures += 1;
webhook.lastFailureAt = new Date();
// Disable a webhook with too many failures
if (webhook.failures >= 10) {
webhook.enabled = false;
webhook.failures = 0;
webhook.lastFailureAt = undefined;
_failuresReset = true;
}
const update = {
$set: {
'webhooks.$.lastFailureAt': webhook.lastFailureAt,
'webhooks.$.enabled': webhook.enabled,
},
};
if (_failuresReset) {
update.$set['webhooks.$.failures'] = webhook.failures;
} else {
update.$inc = {
'webhooks.$.failures': 1,
};
}
return User.updateOne({
_id: user._id,
'webhooks.id': webhook.id,
}, update).exec();
}).catch(err => logger.error(err)); // log errors that might have happened in the previous catch
}
function isValidWebhook (hook) {
return hook.enabled && isURL(hook.url, {
require_tld: !!IS_PRODUCTION, // eslint-disable-line camelcase
});
}
export class WebhookSender {
constructor (options = {}) {
this.type = options.type;
this.transformData = options.transformData || WebhookSender.defaultTransformData;
this.webhookFilter = options.webhookFilter || WebhookSender.defaultWebhookFilter;
}
static defaultTransformData (data) {
return data;
}
static defaultWebhookFilter () {
return true;
}
attachDefaultData (user, body) {
body.webhookType = this.type;
body.user = body.user || {};
body.user._id = user._id;
}
send (user, data) {
const { webhooks } = user;
const hooks = webhooks.filter(hook => {
if (!isValidWebhook(hook)) return false;
if (hook.type === 'globalActivity') return true;
return this.type === hook.type && this.webhookFilter(hook, data);
});
if (hooks.length < 1) {
return; // prevents running the body creation code if there are no webhooks to send
}
const body = this.transformData(data);
this.attachDefaultData(user, body);
hooks.forEach(hook => {
sendWebhook(hook, body, user);
});
}
}
export const taskScoredWebhook = new WebhookSender({
type: 'taskActivity',
webhookFilter (hook) {
const scored = hook.options && hook.options.scored;
return scored;
},
transformData (data) {
const {
user, task, direction, delta,
} = data;
const extendedStats = User.addComputedStatsToJSONObj(user.stats.toJSON(), user);
const userData = {
// _id: user._id, added automatically when the webhook is sent
_tmp: user._tmp,
stats: extendedStats,
};
const dataToSend = {
type: 'scored',
direction,
delta,
task,
user: userData,
};
return dataToSend;
},
});
export const taskActivityWebhook = new WebhookSender({
type: 'taskActivity',
webhookFilter (hook, data) {
const { type } = data;
return hook.options[type];
},
});
export const userActivityWebhook = new WebhookSender({
type: 'userActivity',
webhookFilter (hook, data) {
const { type } = data;
return hook.options[type];
},
});
export const questActivityWebhook = new WebhookSender({
type: 'questActivity',
webhookFilter (hook, data) {
const { type } = data;
return hook.options[type];
},
transformData (data) {
const { group, quest, type } = data;
const dataToSend = {
type,
group: {
id: group.id,
name: group.name,
},
quest: {
key: quest.key,
questOwner: group.quest.leader,
},
};
return dataToSend;
},
});
export const groupChatReceivedWebhook = new WebhookSender({
type: 'groupChatReceived',
webhookFilter (hook, data) {
return hook.options.groupId === data.group.id;
},
transformData (data) {
const { group, chat } = data;
const dataToSend = {
group: {
id: group.id,
name: group.name,
},
chat,
};
return dataToSend;
},
});