Skip to content

Commit

Permalink
Merge pull request #1153 from opencollective/10backers
Browse files Browse the repository at this point in the history
tweet milestones
  • Loading branch information
xdamman authored Feb 28, 2018
2 parents 3d27ef1 + 2c6b2fc commit e4c002b
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 12 deletions.
188 changes: 188 additions & 0 deletions cron/10mn/milestones.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/usr/bin/env node

process.env.PORT = 3066;

import config from 'config';
import Promise from 'bluebird';
import debugLib from 'debug';
import models, { sequelize } from '../../server/models';
import twitter from '../../server/lib/twitter';
import slackLib from '../../server/lib/slack';
import { pluralize } from '../../server/lib/utils';
import _, { pick, get, set } from 'lodash';

const TenMinutesAgo = new Date;
TenMinutesAgo.setMinutes(TenMinutesAgo.getMinutes() - 10);

if (process.env.NODE_ENV !== 'production') {
TenMinutesAgo.setDate(TenMinutesAgo.getDate() - 40);
}

const debug = debugLib('milestones');
const startTime = new Date;

const init = () => {
models.Member.findAll({
attributes: [ [sequelize.fn('COUNT', sequelize.col('Member.id')), 'count'], 'CollectiveId' ],
where: {
createdAt: { $gte: TenMinutesAgo },
role: 'BACKER'
},
limit: 30,
group: ['CollectiveId', 'collective.id'],
include: [ { model: models.Collective, as: 'collective' } ]
})
.tap(transactionsGroups => {
console.log(`${transactionsGroups.length} different collectives got new backers since ${TenMinutesAgo}`);
})
.map(processNewMembersCount)
.then(() => {
const timeLapsed = new Date - startTime;
console.log(`Total run time: ${timeLapsed}ms`);
process.exit(0)
});
}

const notifyCollective = async (CollectiveId, milestone, collective) => {
const twitterAccount = await models.ConnectedAccount.findOne({ where: { service: "twitter", CollectiveId } });
const slackAccount = await models.Notification.findOne({ where: { channel: "slack", CollectiveId } });

const tweet = await compileTweet(collective, milestone, twitterAccount);

if (!twitterAccount) {
debug(`${collective.slug}: the collective id ${CollectiveId} doesn't have a twitter account connected, skipping`);
await postToSlack(tweet, slackAccount);
return;
}
if (!get(twitterAccount, `settings.${milestone}.active`)) {
debug(`${collective.slug}: the collective id ${CollectiveId} hasn't activated the ${milestone} milestone notification, skipping`);
await postToSlack(tweet, slackAccount);
return;
}
if (process.env.TWITTER_CONSUMER_SECRET) {
const res = await sendTweet(tweet, twitterAccount, milestone);
return await postToSlack(res.url, slackAccount);
}
}

/**
* Process a milestone and send a notification to
* - slack.opencollective.com
* - slack of the host (if any)
* - slack of the collective (if any)
* @param {*} milestone
* @param {*} collective
*/
const processMilestone = async (milestone, collective) => {
set(collective, `data.milestones.${milestone}`, startTime);
collective.save();
const HostCollectiveId = await collective.getHostCollectiveId();
return Promise.all([
notifyCollective(HostCollectiveId, milestone, collective),
notifyCollective(collective.id, milestone, collective)
]);
};

const processNewMembersCount = async (newMembersCount) => {
const { collective, dataValues: { count } } = newMembersCount;
const backersCount = await collective.getBackersCount();
if (backersCount < 10) {
debug(`${collective.slug} only has ${backersCount} ${pluralize('backer', backersCount)}, skipping`);
return;
}

// If the collective just passed the number of x backers (could be that they reached > x within the last time span)
const hasPassedMilestone = (numberOfBackers) => (backersCount - count < numberOfBackers && backersCount >= numberOfBackers);

if (hasPassedMilestone(1000)) {
console.log(`🎉 ${collective.slug} just passed the 1,000 backers milestone with ${backersCount} backers`);
return await processMilestone('oneThousandBackers', collective);
}
if (hasPassedMilestone(100)) {
console.log(`🎉 ${collective.slug} just passed the 100 backers milestone with ${backersCount} backers`);
return await processMilestone('oneHundredBackers', collective);
}
if (hasPassedMilestone(10)) {
console.log(`🎉 ${collective.slug} got ${count} new ${pluralize('backer', count)} and just passed the 10 backers milestone with ${backersCount} backers`);
return await processMilestone('tenBackers', collective);
}

debug(`${collective.slug} got ${count} new ${pluralize('backer', count)} for a total of ${backersCount} backers, skipping`);
};

const compileTwitterHandles = (userCollectives, total, limit) => {
const twitterHandles = userCollectives.map(backer => backer.twitterHandle).filter(handle => Boolean(handle));
const limitToShow = Math.min(twitterHandles.length, limit);
let res = _.uniq(twitterHandles).map(handle => `@${handle}`).slice(0, limitToShow).join(', ');
if (limitToShow < total) {
res += `, +${total-limitToShow}`;
}
return res;
}

const compileTweet = async (collective, template, twitterAccount) => {
const replacements = {
collective: collective.twitterHandle ? `@${collective.twitterHandle}` : collective.name
}

if (template === 'tenBackers') {
const topBackers = await collective.getTopBackers(null, null, 10);
const backers = topBackers.map(b => pick(b.dataValues, ['twitterHandle']));
replacements.topBackersTwitterHandles = compileTwitterHandles(backers, 10, 10)
}

let tweet = await twitter.compileTweet(template, replacements, get(twitterAccount, `settings.${template}.tweet`));
tweet += `\nhttps://opencollective.com/${collective.slug}`;
return tweet;
}

const postSlackMessage = async (message, webhookUrl, options = {}) => {
if (!webhookUrl) {
return console.warn(`slack> no webhookUrl to post ${message}`);
}
try {
console.log(`slack> posting ${message} to ${webhookUrl}`);
return await slackLib.postMessage(message, webhookUrl, options);
} catch (e) {
console.warn(`Unable to post to slack`, e);
}
}

const postToSlack = async (message, slackAccount) => {
// post to slack.opencollective.com (bug: we send it twice if both `collective` and `host` have set up a Slack webhook)
await postSlackMessage(message, config.slack.webhookUrl, { channel: config.slack.publicActivityChannel, linkTwitterMentions: true });

if (!slackAccount) {
return console.warn(`No slack account to post ${message}`);
}

await postSlackMessage(message, slackAccount.webhookUrl, { linkTwitterMentions: true });
}

const sendTweet = async (tweet, twitterAccount, template) => {

console.log(">>> sending tweet:", tweet.length, tweet);
if (process.env.NODE_ENV === 'production') {

try {
// We thread the tweet with the previous milestone
const in_reply_to_status_id = get(twitterAccount, `settings.milestones.lastTweetId`);
const res = await twitter.tweetStatus(twitterAccount, tweet, null, { in_reply_to_status_id });

set(twitterAccount, `settings.milestones.tweetId`, res.id_str);
set(twitterAccount, `settings.milestones.tweetSentAt`, new Date(res.created_at));
set(twitterAccount, `settings.${template}.tweetId`, res.id_str);
set(twitterAccount, `settings.${template}.tweetSentAt`, new Date(res.created_at));
await twitterAccount.save();
if (process.env.DEBUG) {
console.log(">>> twitter response: ", JSON.stringify(res));
}
res.url = `https://twitter.com/${res.user.screen_name}/status/${res.id_str}`;
return res;
} catch (e) {
console.error("Unable to tweet", tweet, e);
}
}
}

init();
39 changes: 32 additions & 7 deletions cron/monthly/collective-tweet.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ if (process.env.NODE_ENV === 'production' && today.getDate() !== 1) {

process.env.PORT = 3066;

import config from 'config';
import moment from 'moment';
import Promise from 'bluebird';
import debugLib from 'debug';
import models from '../../server/models';
import slackLib from '../../server/lib/slack';
import twitter from '../../server/lib/twitter';
import _, { pick, get } from 'lodash';
import _, { pick, get, set } from 'lodash';
const d = new Date;
d.setMonth(d.getMonth() - 1);
const month = moment(d).format('MMMM');
Expand All @@ -26,6 +28,15 @@ console.log("startDate", startDate,"endDate", endDate);

const debug = debugLib('monthlyreport');


async function publishToSlack(message, webhookUrl, options) {
try {
return slackLib.postMessage(message, webhookUrl, options);
} catch (e) {
console.warn(`Unable to post to slack`, e);
}
}

const init = () => {

const startTime = new Date;
Expand Down Expand Up @@ -155,13 +166,27 @@ const sendTweet = async (twitterAccount, data) => {
const template = stats.totalReceived === 0 ? 'monthlyStatsNoNewDonation' : 'monthlyStats';
let tweet = await twitter.compileTweet(template, replacements);
tweet += `\nhttps://opencollective.com/${data.collective.slug}`;
const res = await twitter.tweetStatus(twitterAccount, tweet);
console.log(">>> sending tweet:", tweet.length);
if (process.env.DEBUG) {
console.log(">>> twitter response: ", JSON.stringify(res));

// We thread the tweet with the previos monthly stats
const in_reply_to_status_id = get(twitterAccount, `settings.monthlyStats.lastTweetId`);
try {
const res = await twitter.tweetStatus(twitterAccount, tweet, null, { in_reply_to_status_id });
const tweetUrl = `https://twitter.com/${res.user.screen_name}/status/${res.id_str}`;
// publish to slack.opencollective.com
await publishToSlack(tweetUrl, config.slack.webhookUrl, { channel: config.slack.publicActivityChannel });

set(twitterAccount, `settings.monthlyStats.lastTweetId`, res.id_str);
set(twitterAccount, `settings.monthlyStats.lastTweetSentAt`, new Date(res.created_at));
twitterAccount.save();
console.log(">>> sending tweet:", tweet.length);
if (process.env.DEBUG) {
console.log(">>> twitter response: ", JSON.stringify(res));
}
debug(replacements);
console.log(tweet);
} catch (e) {
console.error("Unable to tweet", tweet, e);
}
debug(replacements);
console.log(tweet);
}

init();
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@
"report:monthly": "babel-node cron/monthly/slack-report.js",
"check:transactions": "node scripts/check_transactions_on_stripe",
"script": "node scripts/execute",
"cron:10mn": "PG_MAX_CONNECTIONS=1 ./scripts/cron.sh 10mn",
"cron:daily": "PG_MAX_CONNECTIONS=1 ./scripts/cron.sh daily",
"cron:weekly": "PG_MAX_CONNECTIONS=1 ./scripts/cron.sh weekly",
"cron:monthly": "PG_MAX_CONNECTIONS=1 ./scripts/cron.sh monthly",
Expand Down
7 changes: 6 additions & 1 deletion server/lib/slack.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export default {
if (!options) {
options = {};
}

if (options.linkTwitterMentions) {
msg = msg.replace(/@([a-z\d_]+)/ig, '<http://twitter.com/$1|@$1>');
}

const slackOptions = {
text: msg,
username: 'OpenCollective Activity Bot',
Expand All @@ -54,7 +59,7 @@ export default {

return new Promise((resolve, reject) => {
// production check
if (process.env.NODE_ENV !== 'production') {
if (process.env.NODE_ENV !== 'production' && !process.env.TEST_SLACK) {
return resolve();
}

Expand Down
12 changes: 11 additions & 1 deletion server/lib/twitter.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,16 @@ const tweetStatus = (twitterAccount, status, url, options = {}) => {
}
}

const compileTweet = (template, data) => {
const compileTweet = (template, data, message) => {

const messages = {
'en-US': {
tenBackers: `🎉 {collective} just reached 10 backers! Thank you {topBackersTwitterHandles} 🙌
Support them too!`,
oneHundred: `🎉 {collective} just reached 100 backers!! 🙌
Support them too!`,
oneThousandBackers: `🎉 {collective} just reached 1,0000 backers!!! 🙌
Support them too!`,
updatePublished: `Latest update from the collective: {title}`,
monthlyStats: `In {month}, {totalNewBackers, select,
0 {no new backer joined. 😑}
Expand All @@ -125,6 +131,10 @@ Become a backer! 😃`
}
}

if (message) {
messages['en-US'][template] = message;
}

if (!messages['en-US'][template]) {
console.error("Invalid tweet template", template);
return;
Expand Down
10 changes: 7 additions & 3 deletions server/models/Collective.js
Original file line number Diff line number Diff line change
Expand Up @@ -1149,9 +1149,13 @@ export default function(Sequelize, DataTypes) {

const where = { role: roles.HOST, CollectiveId: this.ParentCollectiveId || this.id };
return models.Member.findOne({
attributes: ['MemberCollectiveId'],
where
}).then(member => member && member.MemberCollectiveId);
attributes: ['MemberCollectiveId'],
where
})
.then(member => {
this.HostCollectiveId = member && member.MemberCollectiveId;
return this.HostCollectiveId;
});
};

Collective.prototype.getHostStripeAccount = function() {
Expand Down

0 comments on commit e4c002b

Please sign in to comment.