78 Commits

Author SHA1 Message Date
4550a911f0 3.1.0 2019-04-16 12:52:54 +01:00
d1580560b5 Update kredits-contracts 2019-04-16 12:51:49 +01:00
92c50ac69e Merge pull request #30 from 67P/feature/29-date_time
Add contribution date
2019-04-13 16:07:46 +00:00
fca991c685 Add date to mediawiki edits
Use the day before, as we collect them at 7am every day.
2019-04-13 14:18:47 +01:00
626712356a Add date and time for GitHub issues and PRs 2019-04-13 14:04:41 +01:00
65e34ee4e1 Update kredits-contracts 2019-04-13 12:44:10 +01:00
63057191d6 3.0.0 2019-04-08 15:02:01 +02:00
768c24da8e Merge pull request #27 from 67P/feature/26-contributions
Create contributions instead of proposals
2019-04-08 15:01:08 +02:00
0fdd7d0f1c Fix accidental commenting of cron schedule 2019-04-08 15:00:24 +02:00
1e0d637c26 "Fix" nonce issue for Mediawiki as well
See previous commit (dcbce66) for details.
2019-04-06 11:55:02 +02:00
dcbce66796 Hacky fix for nonce issues
This waits until a tx has likely been confirmed, before trying to send
the next one, because nonce management is broken in the ethers.js
wallet and it tries to replace a previous tx if two are sent quickly in
succession.

This will most likely break, when there are multiple independent
contrbution issuance calls (e.g. two PRs merged quickly in succession).
But at least it fixes issues/PRs with multiple assignees for most cases.
2019-04-06 11:41:19 +02:00
16b62a2545 Print actual tx error instead of guessing 2019-04-05 20:07:30 +02:00
f39bd41098 Create contributions instead of proposals 2019-04-05 19:52:48 +02:00
8cfbc222fc Merge pull request #25 from 67P/update-kredits-contracts
Update kredits contracts
2019-04-05 19:20:17 +02:00
21aaf2f3bf Use kredits-contracts from npm, update provider setup 2019-04-05 17:02:33 +02:00
7056772066 Fix function name for logger warning 2019-04-05 16:03:48 +02:00
d854b4e0fb Update npm packages 2019-03-31 17:17:16 +02:00
321fe95a27 Operator is now Proposal 2019-03-31 17:16:55 +02:00
b38e6faf8a Prepare for kredits-contracts update
Also updates ethers.js to v4.x
2019-03-31 17:09:58 +02:00
bfaf26cc9a Use robot logger 2019-03-13 12:21:48 +07:00
3ac2774c4f 2.1.3 2018-09-09 15:36:52 +08:00
83990a36dc Merge pull request #23 from 67P/bugfix/mediawiki_cron_syntax
Fix mediawiki polling cron
2018-09-09 15:36:25 +08:00
c427d7c313 Fix mediawiki polling cron
Was doing every minute after 7am, instead of every day once at 7am.
2018-09-09 15:35:09 +08:00
8b1977b53e 2.1.2 2018-06-14 15:03:30 +02:00
189f7fe4ee Fix amount calculation
Was using the wrong object as argument.
2018-06-14 15:02:20 +02:00
a95bf0141e 2.1.1 2018-05-07 14:04:15 +02:00
b4f6fb7ef7 Add missing require 2018-05-07 14:03:50 +02:00
98f4516217 2.1.0 2018-05-06 21:15:04 +02:00
149bc0c052 Use new wrapper function for finding contributor
Fixes a bug with the find algo as well.
2018-05-06 21:14:04 +02:00
c190490d9b Add context to contribution description
It didn't actually say where the pages were edited, i.e. the wiki.
2018-05-06 21:12:57 +02:00
eb767a90f2 Deduplicate title of pages edited/created 2018-05-06 21:12:37 +02:00
156ea44f0d Use configured wiki URL for contribution details 2018-05-06 21:12:06 +02:00
ec7f4a4d33 2.0.1 2018-05-03 13:32:15 +02:00
5f9e237f36 Add missing keywords to package config 2018-05-03 13:31:13 +02:00
38db976c29 2.0.0 2018-05-03 13:27:11 +02:00
36e73beb3d Update README 2018-05-03 13:24:53 +02:00
8d067d221f Update kredits-contracts to latest release 2018-05-03 13:21:00 +02:00
35222456a4 Merge pull request #19 from 67P/docs/9-readme
Add README/documentation
2018-04-22 14:36:54 +00:00
7636e08e8f Merge pull request #20 from 67P/dev/cleanup
Remove obsolete lib files
2018-04-22 14:36:05 +00:00
b4229c7879 Document wallet creation, rename npm script 2018-04-22 13:47:32 +02:00
201c982466 Remove obsolete lib files
We're using the npm module now
2018-04-22 13:42:59 +02:00
bba5407c7d Fix typo, link kredits wiki page 2018-04-22 13:39:29 +02:00
417eb42905 Add README/documentation 2018-04-22 13:20:08 +02:00
b852bd24af Merge pull request #16 from 67P/feature/6-mediawiki_integration
MediaWiki integration & integration architecture improvements
2018-04-21 09:31:29 +00:00
581b15da69 Cron job for checking mediawiki changes 2018-04-19 19:21:05 +02:00
c97cc82817 Only fetch wiki changes since last processing 2018-04-19 19:04:20 +02:00
d4a3a9c9df Add hardcoded amounts for wiki edits 2018-04-19 18:00:19 +02:00
6c3070b43b Increase kredits amounts for GitHub labels 2018-04-19 17:54:59 +02:00
9d8b2c08dc [WIP] Add proposals for wiki changes 2018-04-19 17:11:02 +02:00
2780c87aaa Last GitHub fix 2018-04-19 17:10:51 +02:00
7904aa8c84 Remove robot stub from mediawiki 2018-04-19 14:39:02 +02:00
3d810287c0 Implement GitHub repo blacklist
closes #17
2018-04-19 14:38:09 +02:00
22b480ae92 Fix typos 2018-04-19 14:37:55 +02:00
48a42d4f2c Improve logging 2018-04-19 14:37:21 +02:00
aab5b58bab Add missing module imports 2018-04-19 14:35:26 +02:00
c0c2f97dae Add MediaWiki integration basics 2018-04-19 12:32:03 +02:00
74429feb9d Use contracts from master 2018-04-19 12:31:36 +02:00
ed5d127b4c Add missing variables to GitHub integration 2018-04-19 12:18:12 +02:00
e8da47db70 Use relative path for integration module 2018-04-19 12:10:44 +02:00
462efcefbd Use robot.logger 2018-04-19 12:10:34 +02:00
5259b56e53 Split out integrations, add code section comments 2018-04-19 11:50:59 +02:00
33cefac88e Add node-cron 2018-04-19 11:39:33 +02:00
5a2ebb323d Basic MediaWiki changes integration 2018-04-19 11:38:01 +02:00
eecfa7e8f2 Improve variable assignment 2018-04-19 11:35:09 +02:00
5d76b2dead Revert "minor refactorings"
This reverts commit 8c481179b4.
2018-04-19 11:34:47 +02:00
22cb49df2d Add missing network id documentation 2018-04-19 01:08:03 +02:00
8c481179b4 minor refactorings 2018-04-19 01:01:33 +02:00
2be3d4e8e3 Improve room messages a bit
And don't notify everyone for every contribution.
2018-04-19 00:44:12 +02:00
f4b484ed5a Improve logging
Add prefix, some formatting
2018-04-19 00:42:50 +02:00
e4fb97c0c9 Better message for new proposal notifications 2018-04-19 00:21:48 +02:00
3fe6dfb2ce properly handle github webhooks 2018-04-19 00:13:42 +02:00
9dc1ece94f Working kredits-contract integration 2018-04-18 22:54:41 +02:00
e99addf37e Use kredits-contracts from truffle-kredits 2018-04-18 20:50:43 +02:00
050e8ce79f WIP update to use new kredits library
Using the new kredits library to load contract data and create
proposals.
currently the kredits lib is only copied - which needs to be removed
asap.
2018-04-18 16:27:22 +02:00
821543056b Use ethers.js to create a new wallet
ethers.js is smaller and nicer than web3
2018-04-18 16:25:46 +02:00
ce87aaa3fd 1.7.0 2017-07-12 14:48:20 +02:00
fa86d0a3c0 Merge pull request #12 from 67P/feature/events
Notify channel from ProposalCreated event
2017-07-12 14:47:17 +02:00
94baf62c51 Notify channel from ProposalCreated event
This handles incoming events (for now only ProposalCreated, but easy to
extend), and moves the channel message for new proposals to the event
handler, so that they're being announced for all proposals, including
those created manually from kredits-web for example.
2017-06-20 18:59:58 -07:00
7 changed files with 2690 additions and 350 deletions

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
[![npm](https://img.shields.io/npm/v/hubot-kredits.svg)](https://www.npmjs.com/package/hubot-kredits)
# Hubot Kredits
This repository provides scripts for integrating [Kosmos
Kredits](https://wiki.kosmos.org/Kredits) in [Hubot](http://hubot.github.com/)
chatbots. The bot will watch for project-related things happening on the
Internet and automatically create ERC721 tokens for issuing kredits for project
contributions.
## Setup
You will need an Ethereum wallet for your bot, so it can interact with the
Ethereum smart contracts. `npm run create-wallet` will do the job for you.
## Configuration
As usual in Hubot, you can add all config as environment variables.
| Key | Description |
| --- | --- |
| `KREDITS_WEBHOOK_TOKEN` | A string for building your secret webhook URLs |
| `KREDITS_ROOM` | The bot will talk to you in this room |
| `KREDITS_WALLET_PATH` | Path to an Etherum wallet JSON file (default: `./wallet.json`) |
| `KREDITS_WALLET_PASSWORD` | Wallet password |
| `KREDITS_PROVIDER_URL` | Ethereum JSON-RPC URL (default: `http://localhost:7545`) |
| `KREDITS_NETWORK_ID` | The ethereum network ID to use (default: 100 = local) |
## Integrations
### GitHub
The GitHub integration will watch for closed issues and merged pull requests,
which carry a kredits label: `kredits-1`, `kredits-2`, `kredits-3` for small,
medium and large contributions. If there are multiple people assigned, it will
issue contribution tokens for all of them.
#### Setup
Point a GitHub organization webhook to the following URL:
https://your-hubot.example.com/incoming/kredits/github/{webhook_token}
#### Config
| Key | Description |
| --- | --- |
| `KREDITS_GITHUB_REPO_BLACKLIST` | Repos which you do not want to issue kredits for. Format: `orgname/reponame`, e.g. `67P/test-one-two` |
### MediaWiki
The MediaWiki integration will periodically check for wiki page creations and
edits. It will create kredits contribution tokens based on amount of text added.
#### Setup
No setup needed, except for configuring the wiki URL. The bot will poll your
wiki's API on its own.
#### Config
| Key | Description |
| --- | --- |
| `KREDITS_MEDIAWIKI_URL` | Your wiki URL, e.g. `https://wiki.kosmos.org/` |

460
index.js
View File

@@ -1,363 +1,157 @@
// Description:
// Kosmos Kredits chat integration
//
// Configuration:
// KREDITS_WEBHOOK_TOKEN: A string for building your secret webhook URL
// KREDITS_ROOM: Kredit proposals are posted to this chatroom
// KREDITS_WALLET_PATH: Path to a etherum wallet JSON file
// KREDITS_WALLET_PASSWORD: Wallet password
// KREDITS_CONTRACT_ADDRESS: Address of Kredits contract
// KREDITS_PROVIDER_URL: Ethereum JSON-RPC URL (default 'http://localhost:8545')
// IPFS_API_HOST: Host/domain (default 'localhost')
// IPFS_API_PORT: Port number (default '5001')
// IPFS_API_PROTOCOL: Protocol, e.g. 'http' or 'https' (default 'http')
//
const fs = require('fs');
const util = require('util');
const fetch = require('node-fetch');
const kreditsContracts = require('kredits-contracts');
const ProviderEngine = require('web3-provider-engine');
const Wallet = require('ethereumjs-wallet');
const WalletSubprovider = require('ethereumjs-wallet/provider-engine');
const Web3Subprovider = require('web3-provider-engine/subproviders/web3.js');
const Web3 = require('web3');
const ipfsAPI = require('ipfs-api');
const schemas = require('kosmos-schemas');
const tv4 = require('tv4');
const ethers = require('ethers');
const Kredits = require('kredits-contracts');
(function() {
"use strict";
const walletPath = process.env.KREDITS_WALLET_PATH || './wallet.json';
const walletJson = fs.readFileSync(walletPath);
const providerUrl = process.env.KREDITS_PROVIDER_URL;
const networkId = parseInt(process.env.KREDITS_NETWORK_ID || 100);
//
// Instantiate ethereum client and wallet
//
let engine = new ProviderEngine();
const ipfsConfig = {
host: process.env.IPFS_API_HOST || 'localhost',
port: process.env.IPFS_API_PORT || '5001',
protocol: process.env.IPFS_API_PROTOCOL || 'http'
};
let walletPath = process.env.KREDITS_WALLET_PATH || './wallet.json';
let walletJson = fs.readFileSync(walletPath);
let wallet = Wallet.fromV3(JSON.parse(walletJson), process.env.KREDITS_WALLET_PASSWORD);
let providerUrl = process.env.KREDITS_PROVIDER_URL || 'http://localhost:8545';
let hubotWalletAddress = '0x' + wallet.getAddress().toString('hex');
module.exports = async function(robot) {
engine.addProvider(new WalletSubprovider(wallet, {}));
engine.addProvider(new Web3Subprovider(new Web3.providers.HttpProvider(providerUrl)));
// TODO only start engine if providerURL is accessible
engine.start();
let web3 = new Web3(engine);
web3.eth.defaultAccount = hubotWalletAddress;
//
// Instantiate contracts
//
let contractConfig = {};
if (process.env.KREDITS_CONTRACT_ADDRESS) {
contractConfig = { Kredits: { address: process.env.KREDITS_CONTRACT_ADDRESS }};
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
let contracts = kreditsContracts(web3, contractConfig);
let kredits = contracts['Kredits'];
//
// Instantiate IPFS API client
// Ethereum wallet setup
//
let ipfsConfig = {};
if (process.env.IPFS_API_HOST) {
ipfsConfig = {
host: process.env.IPFS_API_HOST,
port: process.env.IPFS_API_PORT,
protocol: process.env.IPFS_API_PROTOCOL
};
let wallet;
try {
wallet = await ethers.Wallet.fromEncryptedJson(walletJson, process.env.KREDITS_WALLET_PASSWORD);
} catch(error) {
robot.logger.warning('[hubot-kredits] Could not load wallet:', error);
process.exit(1);
}
let ipfs = ipfsAPI(ipfsConfig);
module.exports = function(robot) {
//
// Ethereum provider/node setup
//
robot.logger.info('[hubot-kredits] Wallet address: ' + hubotWalletAddress);
let ethProvider;
if (providerUrl) {
ethProvider = new ethers.providers.JsonRpcProvider(providerUrl);
} else {
ethProvider = new ethers.getDefaultProvider('rinkeby');
}
const signer = wallet.connect(ethProvider);
getBalance().then(balance => {
if (balance <= 0) {
messageRoom(`Yo gang, I\'m broke! Please drop me some ETH to ${hubotWalletAddress}. kthxbai.`);
}
//
// Kredits contracts setup
//
let kredits;
try {
kredits = await new Kredits(signer.provider, signer, {
// TODO support local devchain custom address
apm: 'open.aragonpm.eth',
ipfsConfig
}).init();
} catch(error) {
robot.logger.warning('[hubot-kredits] Could not set up kredits:', error);
process.exit(1);
}
const Contributor = kredits.Contributor;
const Proposal = kredits.Proposal;
const Contribution = kredits.Contribution;
robot.logger.info('[hubot-kredits] Wallet address: ' + wallet.address);
//
// Check robot's wallet balance and alert when it's broke
//
ethProvider.getBalance(wallet.address).then(balance => {
robot.logger.info('[hubot-kredits] Wallet balance: ' + ethers.utils.formatEther(balance) + 'ETH');
if (balance.lt(ethers.utils.parseEther('0.0001'))) {
messageRoom(`Yo gang, I\'m broke! Please drop me some ETH to ${wallet.address}. kthxbai.`);
}
});
//
// Robot chat commands/interaction
//
robot.respond(/got ETH\??/i, res => {
ethProvider.getBalance(wallet.address).then((balance) => {
res.send(`My wallet contains ${ethers.utils.formatEther(balance)} ETH`);
});
});
function getBalance() {
return new Promise((resolve, reject) => {
web3.eth.getBalance(hubotWalletAddress, function (err, balance) {
if (err) {
robot.logger.error('[hubot-kredits] Error checking balance');
reject(err);
return;
}
resolve(balance);
});
});
}
robot.respond(/propose (\d*)\s?\S*\s?to (\S+)(?:\sfor (.*))?$/i, res => {
let [_, amount, githubUser, description] = res.match;
let url = null;
createProposal(githubUser, amount, description, url).then((result) => {
messageRoom('Sounds good! Will be listed on https://kredits.kosmos.org in a bit...');
});
});
function getValueFromContract(contractMethod, ...args) {
return new Promise((resolve, reject) => {
kredits[contractMethod](...args, (err, data) => {
if (err) { reject(err); }
resolve(data);
});
});
}
function loadProfileFromIPFS(contributor) {
let promise = new Promise((resolve, reject) => {
return ipfs.cat(contributor.ipfsHash, { buffer: true }).then(res => {
let content = res.toString();
let profile = JSON.parse(content);
contributor.name = profile.name;
contributor.kind = profile.kind;
let accounts = profile.accounts;
let github = accounts.find(a => a.site === 'github.com');
let wiki = accounts.find(a => a.site === 'wiki.kosmos.org');
if (github) {
contributor.github_username = github.username;
contributor.github_uid = github.uid;
}
if (wiki) {
contributor.wiki_username = wiki.username;
}
resolve(contributor);
}).catch((err) => {
console.log(err);
reject(err);
});
});
return promise;
}
function getContributorData(i) {
let promise = new Promise((resolve, reject) => {
getValueFromContract('contributorAddresses', i).then(address => {
robot.logger.debug('address', address);
getValueFromContract('contributors', address).then(person => {
robot.logger.debug('person', person);
let c = {
address: address,
name: person[1],
id: person[0],
ipfsHash: person[2]
};
if (c.ipfsHash) {
loadProfileFromIPFS(c).then(contributor => {
robot.logger.debug('[kredits] contributor', contributor);
resolve(contributor);
}).catch(() => console.log('[kredits] error fetching contributor info from IPFS for '+c.name));
} else {
resolve(c);
}
robot.respond(/list open proposals/i, res => {
Proposal.all().then((proposals) => {
proposals.forEach((proposal) => {
if (!proposal.executed) {
Contributor.getById(proposal.contributorId).then((contributor) => {
messageRoom(`* ${proposal.amount} kredits to ${contributor.name} for ${proposal.description}`);
});
}).catch(err => reject(err));
});
return promise;
}
function getContributors() {
return getValueFromContract('contributorsCount').then(contributorsCount => {
let contributors = [];
for(var i = 0; i < contributorsCount.toNumber(); i++) {
contributors.push(getContributorData(i));
}
return Promise.all(contributors);
});
}
messageRoom('https://kredits.kosmos.org');
});
});
function getContributorByGithubUser(username) {
let promise = new Promise((resolve, reject) => {
getContributors().then(contributors => {
let contrib = contributors.find(c => {
return c.github_username === username;
});
if (contrib) {
resolve(contrib);
} else {
reject();
}
});
});
return promise;
}
//
// Smart contract events
//
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
function watchContractEvents() {
ethProvider.getBlockNumber().then((blockNumber) => {
// current block is the last mined one, thus we check from the next
// mined one onwards to prevent getting previous events
let nextBlock = blockNumber + 1;
robot.logger.debug(`[hubot-kredits] Watching events from block ${nextBlock} onward`);
ethProvider.resetEventsBlock(nextBlock);
function amountFromIssueLabels(issue) {
let kreditsLabel = issue.labels.map(l => l.name)
.filter(n => n.match(/^kredits/))[0];
// No label, no kredits
if (typeof kreditsLabel === 'undefined') { return 0; }
Proposal.on('ProposalCreated', handleProposalCreated);
Contribution.on('ContributionAdded', handleContributionAdded);
});
}
// TODO move to config maybe?
let amount;
switch(kreditsLabel) {
case 'kredits-1':
amount = 50;
break;
case 'kredits-2':
amount = 150;
break;
case 'kredits-3':
amount = 500;
break;
}
return amount;
}
function createContributionDocument(contributor, url, description, details) {
let contribution = {
"@context": "https://schema.kosmos.org",
"@type": "Contribution",
contributor: {
ipfs: contributor.ipfsHash
},
kind: 'dev',
url: url,
description: description,
details: details
};
if (! tv4.validate(contribution, schemas["contribution"])) {
console.log('[kredits] invalid contribution data: ', util.inspect(contribution));
return Promise.reject('invalid contribution data');
}
return ipfs.add(new ipfs.Buffer(JSON.stringify(contribution)))
.then(res => { return res[0].hash; })
.catch(err => console.log(err));
}
function createProposal(recipient, amount, url, description, details) {
robot.logger.debug(`Creating proposal to issue ${amount}₭S to ${recipient} for ${url}...`);
return new Promise((resolve, reject) => {
// Get contributor details for GitHub user
getContributorByGithubUser(recipient).then(c => {
// Create document containing contribution data on IPFS
createContributionDocument(c, url, description, details).then(ipfsHash => {
// Create proposal on ethereum blockchain
kredits.addProposal(c.address, amount, url, ipfsHash, (e/* , d */) => {
if (e) { reject(e); return; }
messageRoom(`Let's give ${recipient} some kredits for ${url}: https://kredits.kosmos.org`);
});
});
}, () => {
messageRoom(`I wanted to propose giving kredits to ${recipient} for ${url}, but I can't find their contact data. Please add them as a contributor: https://kredits.kosmos.org`);
});
resolve();
});
}
function handleGitHubIssueClosed(data) {
return new Promise((resolve/*, reject*/) => {
// fs.writeFileSync('tmp/github-issue.json', JSON.stringify(data, null, 4));
let recipients;
let issue = data.issue;
let assignees = issue.assignees.map(a => a.login);
let web_url = issue.html_url;
let amount = amountFromIssueLabels(issue);
if (amount === 0) { resolve(); return; }
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [issue.user.login];
}
let repoName = issue.repository_url.match(/.*\/(.+\/.+)$/)[1];
let description = `${repoName}: ${issue.title}`;
recipients.forEach(recipient => {
createProposal(recipient, amount, web_url, description, issue);
});
resolve();
});
}
function handleGitHubPullRequestClosed(data) {
return new Promise((resolve, reject) => {
// fs.writeFileSync('tmp/github-pr.json', JSON.stringify(data, null, 4));
let recipients;
let pull_request = data.pull_request;
let assignees = pull_request.assignees.map(a => a.login);
let web_url = pull_request._links.html.href;
let pr_issue_url = pull_request.issue_url;
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [pull_request.user.login];
}
fetch(pr_issue_url)
.then(response => {
if (response.status >= 400) {
reject('Bad response from fetching PR issue');
}
return response.json();
})
.then(issue => {
// fs.writeFileSync('tmp/github-pr-issue.json', JSON.stringify(data, null, 4));
let amount = amountFromIssueLabels(issue);
if (amount === 0) { resolve(); return; }
let repoName = pull_request.base.repo.full_name;
let description = `${repoName}: ${pull_request.title}`;
recipients.forEach(recipient => {
createProposal(recipient, amount, web_url, description, pull_request);
});
resolve();
});
});
}
robot.respond(/(got ETH)|(got gas)\?/i, res => {
getBalance().then(balance => {
if (balance <= 0) {
res.send(`HALP, I\'m totally broke! Not a single wei in my pocket.`);
}
else if (balance >= 1e+17) {
res.send(`my wallet contains ${web3.fromWei(balance, 'ether')} ETH`);
}
else {
res.send(`I\'m almost broke! Only have ${web3.fromWei(balance, 'ether')} ETH left in my pocket. :(`);
}
function handleProposalCreated(proposalId, creatorAccount, contributorId, amount) {
Contributor.getById(contributorId).then((contributor) => {
Proposal.getById(proposalId).then((proposal) => {
robot.logger.debug(`[hubot-kredits] Proposal created (${proposal.description})`);
// messageRoom(`Let's give ${contributor.name} some kredits for ${proposal.url} (${proposal.description}): https://kredits.kosmos.org`);
});
});
}
robot.router.post('/incoming/kredits/github/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => {
let evt = req.header('X-GitHub-Event');
let data = req.body;
// For some reason data is contained in a payload property on one
// machine, but directly in the root of the object on others
if (data.payload) { data = JSON.parse(data.payload); }
robot.logger.info(`Received GitHub hook. Event: ${evt}, action: ${data.action}`);
if (evt === 'pull_request' && data.action === 'closed') {
handleGitHubPullRequestClosed(data).then(() => res.send(200));
}
else if (evt === 'issues' && data.action === 'closed') {
handleGitHubIssueClosed(data).then(() => res.send(200));
} else {
res.send(200);
}
function handleContributionAdded(contributionId, contributorId, amount) {
Contributor.getById(contributorId).then((contributor) => {
Contribution.getById(contributionId).then((contribution) => {
robot.logger.debug(`[hubot-kredits] Contribution #${contribution.id} added (${contribution.description})`);
});
});
}
};
}());
watchContractEvents();
//
// Integrations
//
require('./integrations/github')(robot, kredits);
if (typeof process.env.KREDITS_MEDIAWIKI_URL !== 'undefined') {
require('./integrations/mediawiki')(robot, kredits);
}
};

189
integrations/github.js Normal file
View File

@@ -0,0 +1,189 @@
const util = require('util');
const fetch = require('node-fetch');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = async function(robot, kredits) {
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
robot.logger.debug('[hubot-kredits] Loading GitHub integration...');
const repoBlackList = [];
if (process.env.KREDITS_GITHUB_REPO_BLACKLIST) {
repoBlackList = process.env.KREDITS_GITHUB_REPO_BLACKLIST.split(',');
robot.logger.debug('[hubot-kredits] Ignoring GitHub actions from ', util.inspect(repoBlackList));
}
const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution;
function getContributorByGithubUser(username) {
return Contributor.all().then(contributors => {
const contrib = contributors.find(c => {
return c.github_username === username;
});
if (!contrib) {
throw new Error(`No contributor found for ${username}`);
} else {
return contrib;
}
});
}
function createContribution(githubUser, date, time, amount, description, url, details) {
return getContributorByGithubUser(githubUser).then(contributor => {
robot.logger.debug(`[hubot-kredits] Creating contribution token for ${amount}₭S to ${githubUser} for ${url}...`);
const contributionAttr = {
contributorId: contributor.id,
contributorIpfsHash: contributor.ipfsHash,
date,
time,
amount,
url,
description,
details,
kind: 'dev'
};
return Contribution.addContribution(contributionAttr).catch(error => {
robot.logger.error(`[hubot-kredits] Error:`, error);
messageRoom(`I tried to add a contribution for ${githubUser} for ${url}, but I encountered an error when submitting the tx:`);
messageRoom(error.message);
});
});
}
function amountFromIssueLabels(issue) {
const kreditsLabel = issue.labels.map(l => l.name)
.filter(n => n.match(/^kredits/))[0];
// No label, no kredits
if (typeof kreditsLabel === 'undefined') { return 0; }
// TODO move to config maybe?
let amount;
switch(kreditsLabel) {
case 'kredits-1':
amount = 500;
break;
case 'kredits-2':
amount = 1500;
break;
case 'kredits-3':
amount = 5000;
break;
}
return amount;
}
async function handleGitHubIssueClosed(data) {
let recipients;
const issue = data.issue;
const assignees = issue.assignees.map(a => a.login);
const web_url = issue.html_url;
[date, time] = issue.closed_at.split('T');
const amount = amountFromIssueLabels(issue);
const repoName = issue.repository_url.match(/.*\/(.+\/.+)$/)[1];
const description = `${repoName}: ${issue.title}`;
if (amount === 0) {
robot.logger.info('[hubot-kredits] Kredits amount from issue label is zero; ignoring');
return Promise.resolve();
} else if (repoBlackList.includes(repoName)) {
robot.logger.debug(`[hubot-kredits] ${repoName} is on black list; ignoring`);
return Promise.resolve();
}
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [issue.user.login];
}
for (const recipient of recipients) {
try {
await createContribution(recipient, date, time, amount, description, web_url, issue);
await sleep(60000);
}
catch (err) { robot.logger.error(err); }
}
return Promise.resolve();
}
function handleGitHubPullRequestClosed(data) {
let recipients;
const pull_request = data.pull_request;
const assignees = pull_request.assignees.map(a => a.login);
const web_url = pull_request._links.html.href;
const pr_issue_url = pull_request.issue_url;
[date, time] = pull_request.merged_at.split('T');
if (assignees.length > 0) {
recipients = assignees;
} else {
recipients = [pull_request.user.login];
}
return fetch(pr_issue_url)
.then(response => {
if (response.status >= 400) {
throw new Error('Bad response from fetching PR issue');
}
return response.json();
})
.then(async (issue) => {
const amount = amountFromIssueLabels(issue);
const repoName = pull_request.base.repo.full_name;
const description = `${repoName}: ${pull_request.title}`;
if (amount === 0) {
robot.logger.info('[hubot-kredits] Kredits amount from issue label is zero; ignoring');
return Promise.resolve();
} else if (repoBlackList.includes(repoName)) {
robot.logger.debug(`[hubot-kredits] ${repoName} is on black list; ignoring`);
return Promise.resolve();
}
for (const recipient of recipients) {
try {
await createContribution(recipient, date, time, amount, description, web_url, pull_request);
await sleep(60000);
}
catch (err) { robot.logger.error(err); }
}
return Promise.resolve();
});
}
robot.router.post('/incoming/kredits/github/'+process.env.KREDITS_WEBHOOK_TOKEN, (req, res) => {
const evt = req.header('X-GitHub-Event');
const data = req.body;
// For some reason data is contained in a payload property on one
// machine, but directly in the root of the object on others
if (data.payload) { data = JSON.parse(data.payload); }
robot.logger.info(`Received GitHub hook. Event: ${evt}, action: ${data.action}`);
if (evt === 'pull_request' && data.action === 'closed' && data.pull_request.merged) {
handleGitHubPullRequestClosed(data);
res.send(200);
}
else if (evt === 'issues' && data.action === 'closed') {
handleGitHubIssueClosed(data);
res.send(200);
} else {
res.send(200);
}
});
};

186
integrations/mediawiki.js Normal file
View File

@@ -0,0 +1,186 @@
const url = require('url');
const util = require('util');
const fetch = require('node-fetch');
const groupArray = require('group-array');
const cron = require('node-cron');
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = async function(robot, kredits) {
function messageRoom(message) {
robot.messageRoom(process.env.KREDITS_ROOM, message);
}
robot.logger.debug('[hubot-kredits] Loading MediaWiki integration...')
const Contributor = kredits.Contributor;
const Contribution = kredits.Contribution;
const wikiURL = process.env.KREDITS_MEDIAWIKI_URL;
const apiURL = wikiURL + 'api.php';
function getContributorByWikiUser(username) {
let account = {
site: url.parse(process.env.KREDITS_MEDIAWIKI_URL).hostname,
username: username
}
return Contributor.findByAccount(account).then(contributor => {
robot.logger.debug('CONTRIBUTOR: ', contributor)
if (contributor) { return contributor; } else { throw new Error(); }
});
}
function createContribution(username, date, amount, description, url, details={}) {
return getContributorByWikiUser(username).then(contributor => {
robot.logger.debug(`[hubot-kredits] Creating contribution token for ${amount}₭S to ${contributor.name} for ${url}...`);
let contribution = {
contributorId: contributor.id,
contributorIpfsHash: contributor.ipfsHash,
date,
amount: amount,
url,
description,
details,
kind: 'docs'
};
return Contribution.addContribution(contribution).catch(error => {
robot.logger.error(`[hubot-kredits] Adding contribution failed:`, error);
});
}).catch(() => {
robot.logger.info(`[hubot-kredits] No contributor found for ${username}`);
messageRoom(`I wanted to propose giving kredits to wiki user ${username}, but I cannot find their info. Please add them as a contributor: https://kredits.kosmos.org`);
});
}
function fetchChanges () {
const params = [
'action=query',
'format=json',
'list=recentchanges',
'rctype=edit|new',
'rcshow=!minor|!bot|!anon|!redirect',
'rclimit=max',
'rcprop=ids|title|timestamp|user|sizes|comment|flags'
];
let endTime = robot.brain.get('kredits:mediawiki:last_processed_at');
if (endTime) {
robot.logger.debug(`[hubot-kredits] Fetching wiki edits since ${endTime}`);
params.push(`rcend=${endTime}`);
}
const url = `${apiURL}?${params.join('&')}`;
return fetch(url).then(res => {
if (res.status === 200) {
return res.json();
} else {
robot.logger.info(`Fetching ${url} returned HTTP status ${res.status}:`);
robot.logger.info(res.body);
throw Error('Unexpected response from '+url);
}
}).then(res => {
return res.query.recentchanges;
}).catch(res => {
robot.logger.error(`[hubot-kredits] Failed to fetch ${url} (likely due to a network issue)`);
});
}
function groupChangesByUser (changes) {
return Promise.resolve(groupArray(changes, 'user'));
}
function analyzeUserChanges (user, changes) {
// robot.logger.debug(`Analyzing ${changes.length} edits from ${user} ...`);
const results = {};
results.pagesCreated = changes.filter(c => c.type === 'new');
results.pagesChanged = changes.filter(c => c.type === 'edit');
results.charsAdded = changes
.map(c => { return (c.oldlen < c.newlen) ? (c.newlen - c.oldlen) : 0; })
.reduce((a, b) => a + b);
// robot.logger.debug(`Created ${results.pagesCreated.length} pages`);
// robot.logger.debug(`Edited ${results.pagesChanged.length} pages`);
// robot.logger.debug(`Added ${results.charsAdded} lines of text\n`);
return results;
}
async function createContributions (changes) {
let promises = [];
for (const user of Object.keys(changes)) {
await createContributionForUserChanges(user, changes[user]);
await sleep(60000);
}
return Promise.resolve();
}
function pageTitlesFromChanges(changes) {
return [...new Set(changes.map(c => `"${c.title}"`))].join(', ');
}
function calculateAmountForChanges(details) {
let amount;
if (details.charsAdded < 280) {
// less than a tweet
amount = 500;
} else if (details.charsAdded < 2000) {
amount = 1500;
} else {
amount = 5000;
}
return amount;
}
function createContributionForUserChanges (user, changes) {
const dateNow = new Date();
const dateYesterday = dateNow.setDate(dateNow.getDate() - 1);
const date = (new Date(dateYesterday)).toISOString().split('T')[0];
const details = analyzeUserChanges(user, changes);
const amount = calculateAmountForChanges(details);
let desc = `Added ${details.charsAdded} characters of text.`;
if (details.pagesChanged.length > 0) {
desc = `Edited ${pageTitlesFromChanges(details.pagesChanged)}. ${desc}`;
}
if (details.pagesCreated.length > 0) {
desc = `Created ${pageTitlesFromChanges(details.pagesCreated)}. ${desc}`;
}
desc = `Wiki contributions: ${desc}`;
let url;
if (changes.length > 1) {
url = `${wikiURL}Special:Contributions/${user}?hideMinor=1`;
} else {
rc = changes[0];
url = `${wikiURL}index.php?title=${rc.title}&diff=${rc.revid}&oldid=${rc.old_revid}`;
}
return createContribution(user, date, amount, desc, url, details);
}
function updateTimestampForNextFetch () {
robot.logger.debug(`[hubot-kredits] Set timestamp for wiki changes fetch`);
robot.brain.set('kredits:mediawiki:last_processed_at', new Date().toISOString());
}
function processWikiChangesSinceLastRun () {
fetchChanges()
.then(res => groupChangesByUser(res))
.then(res => createContributions(res))
.then(() => updateTimestampForNextFetch());
}
cron.schedule('0 7 * * *', processWikiChangesSinceLastRun);
};

2106
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,32 @@
{
"name": "hubot-kredits",
"version": "1.6.0",
"version": "3.1.0",
"description": "Kosmos Kredits functionality for chat bots",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"bin": {
"kredits-wallet-create": "scripts/create-wallet.js"
"create-wallet": "scripts/create-wallet.js"
},
"dependencies": {
"ethereumjs-wallet": "mvayngrib/ethereumjs-wallet",
"ipfs-api": "^14.0.3",
"eth-provider": "^0.2.2",
"ethers": "^4.0.27",
"group-array": "^0.3.3",
"kosmos-schemas": "^1.1.2",
"kredits-contracts": "2.0.0",
"node-fetch": "^1.6.3",
"prompt": "^1.0.0",
"tv4": "^1.3.0",
"web3": "^0.18.4",
"web3-provider-engine": "^12.0.3"
"kredits-contracts": "^5.1.1",
"node-cron": "^2.0.3",
"node-fetch": "^2.3.0",
"prompt": "^1.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/67P/hubot-kredits.git"
},
"keywords": [
"kosmos"
"kosmos",
"kredits",
"hubot-scripts"
],
"author": "Kosmos Developers <mail@kosmos.org>",
"contributors": [

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env node
const fs = require('fs');
const Wallet = require('ethereumjs-wallet');
const ethers = require('ethers');
const userPrompt = require('prompt');
let schema = {
@@ -24,10 +24,10 @@ userPrompt.start();
userPrompt.get(schema, (err, result) => {
if (err) { throw(err); }
let wallet = Wallet.generate();
let content = JSON.stringify(wallet.toV3(result.password));
let wallet = ethers.Wallet.createRandom();
wallet.encrypt(result.password).then((walletJSON) => {
fs.writeFileSync(result.path, walletJSON);
console.log(`\nWrote encrypted wallet config to ${result.path}`);
});
fs.writeFileSync(result.path, content);
console.log(`\nWrote encrypted wallet config to ${result.path}`);
});