├── .circleci ├── Dockerfile ├── config.yml └── deploy-ecs.sh ├── .dockerignore ├── .env_example ├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── bitcoind ├── .lock ├── Dockerfile ├── config.js └── webhook.js ├── doc ├── english_readme.md └── env.md ├── docker-compose.test.yml ├── docker-compose.yml ├── okimochi ├── .dockerignore ├── Dockerfile ├── config.js ├── index.js ├── locale │ ├── english.js │ └── japanese.js ├── package-lock.json ├── package.json ├── src │ ├── conversations.js │ ├── db.js │ ├── handlers │ │ ├── generateKeys.js │ │ └── help.js │ ├── lib.js │ ├── logger.js │ └── smartpay.js ├── static │ └── images │ │ ├── logo.png │ │ ├── okimochi_explanation.png │ │ ├── sketch_image.png │ │ ├── slack_icon2.png │ │ └── small_logo.png └── test │ ├── integration │ ├── test_db.js │ └── test_smartpay.js │ ├── unit │ ├── test_handler.js │ └── test_locale.js │ └── util.js ├── test.sh └── userdb └── .gitkeep /.circleci/Dockerfile: -------------------------------------------------------------------------------- 1 | from node:7.10 2 | MAINTAINER Joe Miyamoto 3 | 4 | RUN mkdir -p /usr/src/app 5 | WORKDIR /usr/src/app 6 | 7 | 8 | RUN set -e \ 9 | && apt-get update && apt-get upgrade -y 10 | 11 | RUN set -e \ 12 | && apt-get install --yes \ 13 | --no-install-recommends \ 14 | build-essential g++ python2.7 python2.7-dev unzip curl jq 15 | 16 | RUN mkdir -p /tmp \ 17 | && cd /tmp \ 18 | && curl -O https://bootstrap.pypa.io/get-pip.py \ 19 | && python get-pip.py \ 20 | && pip install awscli \ 21 | && rm -f /tmp/get-pip.py 22 | 23 | RUN curl -fsSLO https://get.docker.com/builds/Linux/x86_64/docker-17.04.0-ce.tgz \ 24 | && tar xzvf docker-17.04.0-ce.tgz \ 25 | && mv docker/docker /usr/local/bin \ 26 | && rm -r docker docker-17.04.0-ce.tgz 27 | 28 | RUN set -e \ 29 | && apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* 30 | 31 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | working_directory: /home/circleci/okimochi 5 | docker: 6 | - image: joemphilips/node7-awscli-docker:latest 7 | steps: 8 | - checkout 9 | - restore_cache: 10 | key: dependency-cache-{{ checksum "okimochi/package.json" }} 11 | - run: 12 | name: install npm packages 13 | command: 'cd okimochi && npm install' 14 | - save_cache: 15 | key: dependency-cache-{{ checksum "okimochi/package.json" }} 16 | paths: 17 | - okimochi/.node_modules 18 | - run: 'cd okimochi && npm test' 19 | 20 | - run: 21 | name: Install Docker Compose 22 | command: | 23 | set -x 24 | curl -L https://github.com/docker/compose/releases/download/1.11.2/docker-compose-`uname -s`-`uname -m` > /usr/local/bin/docker-compose 25 | chmod +x /usr/local/bin/docker-compose 26 | 27 | - setup_remote_docker 28 | 29 | - run: 30 | name: Run integration test by docker compose 31 | command: | 32 | ./test.sh 33 | 34 | - run: 35 | name: build docker image for okimochi only. 36 | command: | 37 | docker build okimochi -f okimochi/Dockerfile --tag okimochi:temp 38 | 39 | # deploy 40 | - run: 41 | name: register to ecr and deploy to ecs 42 | command: | 43 | if [ "${CIRCLE_BRANCH}" == "master" ]; then 44 | ./.circleci/deploy-ecs.sh 45 | fi 46 | 47 | -------------------------------------------------------------------------------- /.circleci/deploy-ecs.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -u 3 | 4 | readonly VERSION="1.0" 5 | 6 | AWS_DEFAULT_REGION=ap-northeast-1 7 | AWS_ECS_TASKDEF_NAME=okimochi 8 | AWS_ECS_CLUSTER_NAME=okimochi-cluster 9 | AWS_ECS_SERVICE_NAME=okimochi-service 10 | AWS_ECR_REP_NAME=okimochi 11 | AWS_ACCOUNT_ID=894559805305 12 | 13 | # Create Task Definition 14 | 15 | make_task_def(){ 16 | task_template='[ 17 | { 18 | "name": "%s", 19 | "image": "%s.dkr.ecr.%s.amazonaws.com/%s:%s", 20 | "essential": true, 21 | "memory": 1500, 22 | "cpu": 500, 23 | "portMappings": [ 24 | { 25 | "containerPort": 3000, 26 | "hostPort": 80 27 | } 28 | ], 29 | "links": [ 30 | "mongo:mongo" 31 | ], 32 | "environment": [{ 33 | "name": "TOKEN", 34 | "value": "%s" 35 | }, 36 | { 37 | "name": "NODE_ENV", 38 | "value": "production" 39 | }, 40 | { 41 | "name": "SLACK_CLIENT_ID", 42 | "value": "%s" 43 | }, 44 | { 45 | "name": "SLACK_CLIENT_SECRET", 46 | "value": "%s" 47 | }, 48 | { 49 | "name": "EMOJI", 50 | "value": "%s" 51 | }, 52 | { 53 | "name": "WEBHOOK_URL", 54 | "value": "%s" 55 | }, 56 | { 57 | "name": "DEFAULT_CHANNEL", 58 | "value": "%s" 59 | }, 60 | { 61 | "name": "PLOTLY_API_KEY", 62 | "value": "%s" 63 | }, 64 | { 65 | "name": "PLOTLY_API_USER", 66 | "value": "%s" 67 | }, 68 | { 69 | "name": "BITCOIND_NETWORK", 70 | "value": "mainnet" 71 | }, 72 | { 73 | "name": "BITCOIND_URI", 74 | "value": "54.92.98.67" 75 | }, 76 | { 77 | "name": "BITCOIND_USERNAME", 78 | "value": "%s" 79 | }, 80 | { 81 | "name": "BITCOIND_PASSWORD", 82 | "value": "%s" 83 | }, 84 | { 85 | "name": "MESSAGE_LANG", 86 | "value": "ja" 87 | }, 88 | { 89 | "name": "MINIMUM_TX", 90 | "value": "0.0025" 91 | } 92 | ], 93 | "logConfiguration": { 94 | "logDriver": "awslogs", 95 | "options": { 96 | "awslogs-group": "okimochi-loggroup", 97 | "awslogs-region": "ap-northeast-1", 98 | "awslogs-stream-prefix": "okimochi-log" 99 | } 100 | } 101 | }, 102 | 103 | 104 | { 105 | "memory": 800, 106 | "portMappings": [ 107 | { 108 | "hostPort": 27017, 109 | "containerPort": 27017, 110 | "protocol": "tcp" 111 | }, 112 | { 113 | "hostPort": 28017, 114 | "containerPort": 28017, 115 | "protocol": "tcp" 116 | } 117 | ], 118 | "essential": true, 119 | "entryPoint": [ 120 | "mongod", 121 | "--dbpath=/data/db" 122 | ], 123 | "mountPoints": [ 124 | { 125 | "containerPath": "/data/db", 126 | "sourceVolume": "userdb" 127 | } 128 | ], 129 | "name": "mongo", 130 | "image": "mongo:3.4.5", 131 | "cpu": 300, 132 | "logConfiguration": { 133 | "logDriver": "awslogs", 134 | "options": { 135 | "awslogs-group": "okimochi-loggroup", 136 | "awslogs-region": "ap-northeast-1", 137 | "awslogs-stream-prefix": "mongo-log" 138 | } 139 | } 140 | } 141 | 142 | ]' 143 | 144 | task_def=$(printf "$task_template" ${AWS_ECS_TASKDEF_NAME} \ 145 | $AWS_ACCOUNT_ID ${AWS_DEFAULT_REGION} ${AWS_ECR_REP_NAME} \ 146 | $CIRCLE_SHA1 ${TOKEN} ${SLACK_CLIENT_ID} ${SLACK_CLIENT_SECRET} \ 147 | ${EMOJI} ${WEBHOOK_URL} ${DEFAULT_CHANNEL} \ 148 | ${PLOTLY_API_KEY} ${PLOTLY_API_USER} \ 149 | ${BITCOIND_USERNAME} ${BITCOIND_PASSWORD} ) 150 | volume_def='[ 151 | { 152 | "host": { 153 | "sourcePath": "/data/db" 154 | }, 155 | "name": "userdb" 156 | } 157 | ]' 158 | 159 | 160 | } 161 | 162 | # more bash-friendly output for jq 163 | JQ="jq --raw-output --exit-status" 164 | 165 | configure_aws_cli(){ 166 | aws --version 167 | aws configure set default.region ${AWS_DEFAULT_REGION} 168 | aws configure set default.output json 169 | } 170 | 171 | deploy_cluster() { 172 | 173 | make_task_def 174 | register_definition 175 | if [[ $(aws ecs update-service --cluster ${AWS_ECS_CLUSTER_NAME} --service ${AWS_ECS_SERVICE_NAME} \ 176 | --task-definition $revision | \ 177 | $JQ '.service.taskDefinition') != $revision ]]; then 178 | echo "Error updating service." 179 | return 1 180 | fi 181 | 182 | # wait for older revisions to disappear 183 | # not really necessary, but nice for demos 184 | for attempt in {1..30}; do 185 | if stale=$(aws ecs describe-services --cluster ${AWS_ECS_CLUSTER_NAME} --services ${AWS_ECS_SERVICE_NAME} | \ 186 | $JQ ".services[0].deployments | .[] | select(.taskDefinition != \"$revision\") | .taskDefinition"); then 187 | echo "Waiting for stale deployments:" 188 | echo "$stale" 189 | sleep 5 190 | else 191 | echo "Deployed!" 192 | return 0 193 | fi 194 | done 195 | echo "Service update took too long." 196 | return 1 197 | } 198 | 199 | 200 | push_ecr_image(){ 201 | eval $(aws ecr get-login --region ${AWS_DEFAULT_REGION}) 202 | docker tag okimochi:temp $AWS_ACCOUNT_ID.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${AWS_ECR_REP_NAME}:$CIRCLE_SHA1 203 | docker push $AWS_ACCOUNT_ID.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${AWS_ECR_REP_NAME}:$CIRCLE_SHA1 204 | } 205 | 206 | register_definition() { 207 | 208 | if revision=$(aws ecs register-task-definition --container-definitions "$task_def" --volumes "$volume_def" --family ${AWS_ECS_TASKDEF_NAME} | $JQ '.taskDefinition.taskDefinitionArn'); then 209 | echo "Revision: $revision" 210 | else 211 | echo "Failed to register task definition" 212 | return 1 213 | fi 214 | 215 | } 216 | 217 | configure_aws_cli 218 | push_ecr_image 219 | deploy_cluster 220 | 221 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.env_example: -------------------------------------------------------------------------------- 1 | APP_NAME=@okimochi 2 | SLACK_CLIENT_ID=0000000000.0000000000 3 | SLACK_CLIENT_SECRET=deadbeef000000000000000 4 | VERIFICATION_TOKEN=24longVerificationtoken0 5 | EMOJI=:moneybag: 6 | TOKEN=xoxb-000000000000-hogehogehogehoge 7 | WEBHOOK_URL=https://hooks.slack.com/services/hogehogehogehoge 8 | DEFAULT_CHANNEL=#your-default-channel-name 9 | ADMIN_USERNAME=@myname 10 | SECURE_MODE=true 11 | BITCOIND_HOST_DIR=~/.bitcoin 12 | BITCOIND_URI=172.0.0.3 13 | BITCOIND_USERNAME=rpcusernameToConnectBitcoind 14 | BITCOIND_PASSWORD=rpcpasswordToConnectBitcoind 15 | BITCOIND_NETWORK=testnet 16 | MONGO_URI=172.0.0.4 17 | HOST_PORT=80 18 | DEBUG=okimochi 19 | PLOTLY_API_KEY= 20 | PLOTLY_API_USER=YourPlotlyUsername 21 | MESSAGE_LANG=ja 22 | MINIMUM_TX=0.003 23 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.c diff=cpp 2 | *.h diff=cpp 3 | *.cpp diff=cpp 4 | *.hpp diff=cpp 5 | *.m diff=objc 6 | *.java diff=java 7 | *.html diff=html 8 | *.pl diff=perl 9 | *.pm diff=perl 10 | *.t diff=perl 11 | *.php diff=php 12 | *.py diff=python 13 | *.rb diff=ruby 14 | *.js diff=java 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .*swp 3 | userdb/* 4 | !userdb/.gitkeep 5 | okimochi.log 6 | .env 7 | .nyc_output 8 | .DS_Store 9 | bitcoind/regtest 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT © CAMPFIRE and Joe Miyamoto 2 | 3 | Copyright 2017 Joe Miyamoto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OKIMOCHI (see [here](doc/english_readme.md) for English manual) 2 | 日頃の感謝の気持ちを、少額のビットコインという形にして、Slack上の従業員同士で送り合えるbotアプリケーション。 3 | 4 | ## OKIMOCHI開発の背景 5 | 事務所への配送業者の来訪に席の配置の関係で特定の社員が応対をする。給湯室の片付けを気付いた社員が行う。特別な事ではなく業務とも言えないけれど、従業員のささやかな善意や気遣いで企業や事務所の運営が成り立っているケースが多々あります。そのような善意へのありがとうという感謝の気持ちに加え、少額のビットコインの送付ができるSlackbotが「OKIMOCHI」です。 6 | 7 | ## 使い方イメージ 8 | 9 | ![image](okimochi/static/images/sketch_image.png) 10 | 11 | 1. 送付の原資となるビットコインをメインウォレットに入金する(会社の福利厚生用の資金を纒めて入金するような形を想定しています)※ 原資となるビットコインは利用者の誰から入金されても同じウォレットに入金されます。 12 | 2. 送付のトリガーとなる指定のスタンプや指定コマンドを入力して送金する。 13 | 3. 一定のビットコインが貯まったら、指定した外部のビットコインウォレットへ引き出す。※その他の詳しい使い方はヘルプコマンドにて参照頂けます。 14 | 15 | ### 利用上の注意事項 16 | __このアプリケーションは各利用者の善意に基づいております。二人以上の利用者が結託した場合、メインウォレット内の資金を盗むことができます。(自分から自分に対する送金は不可だが、お互いに送金し合うことでメインウォレット内の資金を盗むことが可能)預け金の合計は小額にとどめ、信頼できる仲間内のチームで使用してください!__ 17 | 18 | 19 | 20 | ## 導入方法 21 | - Slack Appsの作成/Webhook URLを発行 22 | Slack AppsとWebhook URLを slack APIにて作成 23 | Slack Botの管理ページ からAdd Configurationをクリックし、 `API Token` を発行します。 24 | `Client ID` `Client Secret` `Verification Token` `Webhook URL` `API Token` をメモ 25 | - Protlyのアカウント作成・API keyの発行 26 | ビットコインの獲得ランキングなどを図化するための作図アプリ Plotly のアカウントを作成し、API Keyを発行する。 27 | `API Key` `username` をメモ 28 | - インフラ準備 29 | - Gitをinstallし、Repositoryをclone 30 | - 環境変数を更新 31 | `.env_example`をコピーし、環境変数を設定 32 | 33 | ``` 34 | cp .env_example .env # modify .env with your own slack bot token 35 | ``` 36 | 37 | | 変数名 | 内容 | 38 | | :------------------- | :------------------------------------------------------------------------- | 39 | | SLACK_CLIENT_ID | Slack `Client ID` を入力 | 40 | | SLACK_CLIENT_SECRET | Slack `Client Secret` を入力 | 41 | | VERIFICATION_TOKEN | Slack `Verification Token` を入力 | 42 | | EMOJI | Slack Botのアイコンを設定 | 43 | | TOKEN | Slack Botの`API Token` を入力 | 44 | | WEBHOOK_URL | Slack `Webhook URL` を入力 | 45 | | DEFAULT_CHANNEL | デフォルトのSlackチャンネルを入力 | 46 | | ADMIN_USERNAME | 管理者となるSlack ユーザ名を入力 | 47 | | BITCOIND_HOST_DIR | ブロックチェーンや秘密鍵などの格納先。デフォルトは `~/.bitcoin` | 48 | | BITCOIND_URI | 既にbitcoindサーバをお持ちの場合、URIを入力 | 49 | | BITCOIND_USERNAME | 既にbitcoindサーバをお持ちの場合、USERNAMEを入力。新規の場合、任意の文字列を入力(十分長くて推測できないものを推奨) | 50 | | BITCOIND_PASSWORD | 既にbitcoindサーバをお持ちの場合、PASSWORDを入力。新規の場合、任意の文字列を入力(十分長くて推測できないものを推奨) | 51 | | BITCOIND_NETWORK | 本番用の場合は `mainnet` 、試験用の場合は `testnet` | 52 | | PLOTLY_API_KEY | Plotlyの `API Key` を入力 | 53 | | PLOTLY_API_USER | Plotlyの `username` を入力 | 54 | | MESSAGE_LANG | 日本語と英語を用意。日本語は `ja` 、英語は `en` | 55 | | MINIMUM_TX | この値に達したらトランザクションを初めて発行する(支払いごとに発行しないのは、手数料節約のため) | 56 | 57 | - Docker環境の構築、dokcer-compose up(bitcoindの同期に約10時間程度必要) 58 | ``` 59 | docker network create -d bridge --subnet 172.0.0.0/24 --gateway 172.0.0.1 okimochi-network 60 | COMPOSE_HTTP_TIMEOUT=45000 docker-compose up --build 61 | ``` 62 | - 稼働確認 63 | ``` 64 | # Slack チャンネルにて@okimochiを招待 65 | /invite @okimochi 66 | # 使い方をリクエスト 67 | @okimochi help 68 | ``` 69 | 70 | ## その他設定項目 71 | 72 | ### 反応する対象のボタンとその金額 73 | 74 | `okimochi/locale/*.js` の `emoji_to_BTC_map` を編集してください。 75 | 76 | ### 最低トランザクション金額の設定 77 | 78 | `okimochi/config.js` の `minimumTxAmount` を設定してください。 79 | 80 | ### `tip` 時の最大金額 81 | 82 | `okimochi/config.js` の `MAX_TIP_AMOUNT ` を設定してください。 83 | 84 | ## ホストマシンの推奨スペック 85 | 86 | * Memory ... 3GB 87 | * Storage ... 160GB( `BITCOIND_HOST_DIR` で指定するブロックチェーンデータ容量) + 2GB (ユーザーデータベース) 88 | 89 | ## CAMPFIREでの試験導入結果 90 | CAMPFIREでは社内における試験を実施し、1週間で約100件の「OKIMOCHI」が送られました。 91 | 試験概要は以下の通りです。 92 | - 試験期間 : 2017年7月25日 〜 2017年8月11日 93 | - 参加従業員数 : 約40名 94 | - tip数(トランザクション数):200件 95 | - 1度の送付金額(botの設定で可変) : 約30円 96 | - ビットコインの原資 : 約60,000円 97 | 98 | ## 開発への参加方法 99 | ソースコードのコメント、GitHub のイシューやプルリクエストでは、日本語か英語を使用して下さい。 100 | - ローカルでのセットアップ 101 | ``` 102 | cp .env_example .env 103 | # 環境変数を設定後 104 | docker network create -d bridge --subnet 172.0.0.0/24 --gateway 172.0.0.1 okimochi-network # 3つのdockerコンテナが走るネットワークの作成 105 | docker-compose up -d bitcoind mongo # アプリの後ろで走るコンテナをバックグラウンドで走らせる。 106 | # bitcoindの初回同期にはかなり時間がかかるので、同期状況を見たい場合は-dオプションなしでフォアグラウンドでの実行をおすすめします。 107 | COMPOSE_HTTP_TIMEOUT=45000 docker-compose up --build okimochi # アプリ本体を立ち上げる 108 | ``` 109 | - 今後の想定ToDo 110 | 作者側で想定しているToDoは、以下となります。 111 | [TODO](https://github.com/campfire-inc/OKIMOCHI/issues/1) 112 | 113 | ### テストの実行 114 | 115 | * ユニットテスト ... `cd okimochi && npm test` 116 | * 結合テスト&ユニットテスト ... `./test.sh` 117 | 118 | ## ライセンス 119 | [MIT](./LICENSE) © CAMPFIRE and Joe Miyamoto 120 | -------------------------------------------------------------------------------- /bitcoind/.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/campfire-inc/OKIMOCHI/7b9b7aa3f8f824017a22dfc87adea497c5df9e63/bitcoind/.lock -------------------------------------------------------------------------------- /bitcoind/Dockerfile: -------------------------------------------------------------------------------- 1 | from seegno/bitcoind:latest 2 | MAINTAINER Joe Miyamoto 3 | 4 | COPY . /home/bitcoin/.bitcoin/ 5 | 6 | -------------------------------------------------------------------------------- /bitcoind/config.js: -------------------------------------------------------------------------------- 1 | okimochi/config.js -------------------------------------------------------------------------------- /bitcoind/webhook.js: -------------------------------------------------------------------------------- 1 | // This script is used for bitcoind to send -walletnotify info to slack 2 | 3 | const request = require('request'); 4 | const path = require('path'); 5 | const config = require(path.join( __dirname, 'config' )); 6 | 7 | const headers = { 8 | 'Content-type': 'application/json' 9 | }; 10 | 11 | const data = { 12 | "text": process.argv[2], 13 | "channel": config.default_channel, 14 | "username": config.botUsername, 15 | "icon_emoji": config.icon_emoji 16 | } 17 | 18 | console.log("data is \n") 19 | console.log(data) 20 | 21 | const options = { 22 | url: process.argv[3] || config.webhook_url, 23 | method: 'POST', 24 | headers: headers, 25 | json: data 26 | } 27 | 28 | 29 | 30 | request(options, (err, response, body) => { 31 | if (err) throw err; 32 | if (response.statusCode == 400) throw new Error(body); 33 | console.log("body was ------------ \n") 34 | console.log (body) 35 | }); 36 | -------------------------------------------------------------------------------- /doc/english_readme.md: -------------------------------------------------------------------------------- 1 | # OKIMOCHI 2 | 3 | An user-friendly micro payment app working as a slack bot. 4 | 5 | OKIMOCHI stands for *gratitude* in japanese. 6 | 7 | This bot is still in alpha state, use as your own responsibility. 8 | 9 | Especially be careful to use only in trusted members since it is possible to steal almost whole deposited balance 10 | if more than two people have conspired. So it is recommended to not deposit too much amount at once. 11 | 12 | ## What is it? 13 | 14 | It is a bot for tipping the bitcoin to each other on Slack. You must first deposit to shared pot by `deposit` command. 15 | And you can tip to others by reacting by slack button(or use `tip` command to manually tip). type `help` to this bot in direct message for more detail. 16 | 17 | ## How it works? 18 | 19 | It consists of 3 containers 20 | 21 | 1. `okimochi` ... app itself 22 | 2. `mongo` ... mongodb which contains user information 23 | 3. `bitcoind` ... bitcoind 24 | 25 | 2 and 3 are optional, you can specify your own bitcoind instance by configuring `BITCOIND_URI` environment variable in `.env` 26 | 27 | 28 | ## How to run all 3 containers in local 29 | 30 | ``` 31 | cp .env_example .env # modify .env with your own slack bot token 32 | ``` 33 | 34 | there are lots of environment variable you can configure, but the one most importtant is `TOKEN`, 35 | which you can get when you register bot uesr into your team. 36 | Or otherwise you can get when you register your app from `Authentication` in the buttom of [this page](https://api.slack.com/web). 37 | 38 | see document for [detailed explanation](./doc/env.md) about environment variable. 39 | 40 | ``` 41 | docker network create -d bridge --subnet 172.0.0.0/24 --gateway 172.0.0.1 okimochi-network 42 | docker-compose --build up 43 | ``` 44 | 45 | ## use remote bitcoind. 46 | 47 | please edit `BITCOIND_URI`, `BITCOIND_USERNAME`, `BITCOIND_PASSWORD`, in `.env`, 48 | check there is no inconsistency in `docker-compose.yml`, 49 | and run 50 | 51 | ``` 52 | docker-compose up --build mongo okimochi 53 | ``` 54 | 55 | ## how to run test. 56 | 57 | `./test.sh` for running test. 58 | test suite is far from complete and may contain bugs. We appreciate for PR adding new tests. 59 | 60 | ## How to contribute 61 | 62 | see [TODO](https://github.com/campfire-inc/OKIMOCHI/issues/22) if you are looking for something thing to hack. 63 | -------------------------------------------------------------------------------- /doc/env.md: -------------------------------------------------------------------------------- 1 | list of environment variable and it's explanation. 2 | These are the one only which is not obvious from its name. 3 | 4 | | variable name | explanation | 5 | | :---: | :---: | 6 | | `APP_NAME` | name for bot (starts from @) | 7 | | `TOKEN` | token for slack bot to connect your team | 8 | | `DEVELOP_TOKEN` | if you want to run 2 bots (probably for the sake of development of bot itself.), you can set this variable. It will be used when you specify `NODE_ENV=development`. 9 | | | 10 | | `DEFAULT_CHANNEL` | the channel which the bot sends logging messages | 11 | | `EMOJI` | icon emoji which the bot uses. | 12 | | `BITCOIND_HOST_DIR` | directory which will be specified by `-datadir` option of the bitcoind, specify this is when you want to existing blockchain data in your host machine. 13 | | `MESSAGE_LANG` | The language that bot speaks. specify either `ja` (for Japanese) or `en` (for english) | 14 | 15 | 16 | -------------------------------------------------------------------------------- /docker-compose.test.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | okimochi: 4 | build: ./okimochi 5 | image: joemphilips/okimochi:latest 6 | command: npm start 7 | extra_hosts: 8 | - "bitcoind:182.0.0.3" 9 | - "mongo:182.0.0.4" 10 | environment: 11 | - NODE_ENV=test 12 | - MONGO_URI=182.0.0.4 13 | - BITCOIND_URI=182.0.0.3 14 | - BITCOIND_USERNAME=testuser 15 | - BITCOIND_PASSWORD=testpassword 16 | - BITCOIND_NETWORK=regtest 17 | networks: 18 | ci: 19 | ipv4_address: 182.0.0.2 20 | tty: true 21 | stdin_open: true 22 | depends_on: 23 | - bitcoind 24 | - mongo 25 | bitcoind: 26 | image: seegno/bitcoind:0.14.2-alpine 27 | networks: 28 | ci: 29 | ipv4_address: 182.0.0.3 30 | ports: 31 | - "8332:8332" 32 | - "8333:8333" 33 | - "18332:18332" 34 | - "18333:18333" 35 | - "18444:18444" 36 | command: 37 | -printtoconsole 38 | -rest 39 | -txconfirmtarget=20 40 | -regtest 41 | -server 42 | -rpcuser=testuser 43 | -rpcpassword=testpassword 44 | -rpcallowip=182.0.0.0/8 45 | volumes: 46 | - "./bitcoind/:/home/bitcoin/.bitcoin" 47 | mongo: 48 | restart: always 49 | image: mongo 50 | networks: 51 | ci: 52 | ipv4_address: 182.0.0.4 53 | command: 54 | --dbpath=/data/db 55 | --rest 56 | volumes: 57 | - "./userdb:/data/db" 58 | 59 | networks: 60 | ci: 61 | external: true 62 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | okimochi: 4 | build: ./okimochi 5 | image: joemphilips/okimochi:latest 6 | env_file: .env 7 | command: npm start 8 | extra_hosts: 9 | - "mongo:${MONGO_URI}" 10 | - "bitcoind:${BITCOIND_URI}" 11 | restart: on-failure 12 | environment: 13 | - NODE_ENV 14 | networks: 15 | okimochi-network: 16 | ipv4_address: 172.0.0.2 17 | tty: true 18 | stdin_open: true 19 | depends_on: 20 | - bitcoind 21 | - mongo 22 | bitcoind: 23 | image: seegno/bitcoind:0.14.2-alpine 24 | volumes: 25 | - "${BITCOIND_HOST_DIR}:/home/bitcoin/.bitcoin" 26 | networks: 27 | okimochi-network: 28 | ipv4_address: 172.0.0.3 29 | ports: 30 | - "8332:8332" 31 | - "8333:8333" 32 | - "18332:18332" 33 | - "18333:18333" 34 | - "18444:18444" 35 | command: 36 | -printtoconsole 37 | -rest 38 | -txconfirmtarget=20 39 | -${BITCOIND_NETWORK} 40 | -server 41 | -rpcuser=${BITCOIND_USERNAME} 42 | -rpcpassword=${BITCOIND_PASSWORD} 43 | -rpcallowip=172.0.0.0/8 44 | -datadir=/home/bitcoin/.bitcoin 45 | -dbcache=500 46 | -prune=600 47 | -rpcthreads=8 48 | -assumevalid=00000000000000000026c661d175ef328e415e834e62f3d316382e9f3d24e44e 49 | mongo: 50 | restart: always 51 | image: mongo:3.4 52 | networks: 53 | okimochi-network: 54 | ipv4_address: 172.0.0.4 55 | command: 56 | --dbpath=/data/db 57 | --rest 58 | volumes: 59 | - "./userdb:/data/db" 60 | 61 | networks: 62 | okimochi-network: 63 | external: true 64 | -------------------------------------------------------------------------------- /okimochi/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | -------------------------------------------------------------------------------- /okimochi/Dockerfile: -------------------------------------------------------------------------------- 1 | from node:8 2 | MAINTAINER Joe Miyamoto 3 | 4 | RUN mkdir -p /usr/src/app 5 | WORKDIR /usr/src/app 6 | 7 | COPY package.json /usr/src/app/ 8 | RUN npm install --no-progress && npm cache verify 9 | 10 | COPY . /usr/src/app/ 11 | 12 | EXPOSE 3000 13 | 14 | CMD ["npm", "start"] 15 | -------------------------------------------------------------------------------- /okimochi/config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | let mongoBaseUri 3 | const BitcoindClient = require('bitcoin-core'); 4 | const network = process.env.BITCOIND_NETWORK || 'testnet'; 5 | const bitcoinjs = require('bitcoinjs-lib') 6 | const bitcoinjsNetwork = network === 'mainnet' ? bitcoinjs.networks.mainnet : bitcoinjs.networks.testnet; 7 | 8 | if (process.env.MONGO_PORT_27017_TCP_ADDR){ 9 | mongoBaseUri = `mongodb://${process.env.MONGO_PORT_27017_TCP_ADDR}:27017/` 10 | } else if (process.env.mongo) { 11 | mongoBaseUri = `mongodb://${process.env.mongo}:27017/` 12 | } else if(process.env.MONGO_URI ){ 13 | mongoBaseUri = `mongodb://${process.env.MONGO_URI}:27017/` 14 | } else { 15 | mongoBaseUri = `mongodb://localhost:27017/` 16 | } 17 | 18 | const mongoUri = mongoBaseUri + network 19 | 20 | let TOKEN = ""; 21 | if (process.env.NODE_ENV === "development") { 22 | TOKEN = process.env.DEVELOP_TOKEN ? process.env.DEVELOP_TOKEN : process.env.TOKEN; 23 | } else { 24 | TOKEN = process.env.TOKEN 25 | } 26 | 27 | const SLACK_DEBUG = (process.env.NODE_ENV === "development") ? true : false 28 | 29 | let minimumTxAmount = process.env.MINIMUM_TX || 0.003 30 | minimumTxAmount = Number(minimumTxAmount) 31 | 32 | // import message object according to lang setting. 33 | const lang = process.env.MESSAGE_LANG || "en" 34 | 35 | let locale_message 36 | if (lang === "en") { 37 | locale_message = require("./locale/english") 38 | } else if (lang === "ja") { 39 | locale_message = require('./locale/japanese') 40 | } else { 41 | throw new Error("must specify MESSAGE_LANG environment variable either to `en` or `ja` !!") 42 | } 43 | 44 | module.exports = { 45 | adminPassword: "hoge", 46 | mongoBaseUri: mongoBaseUri, 47 | mongoUri: mongoUri, 48 | 49 | botconfig: { 50 | clientId: process.env.SLACK_CLIENT_ID ||"2154447482.200385943586", 51 | clientSecret: process.env.SLACK_CLIENT_SECRET, 52 | scopes: ['bot'] 53 | }, 54 | TOKEN: TOKEN, 55 | SLACK_DEBUG: SLACK_DEBUG, 56 | APP_NAME: process.env.APP_NAME || "@okimochi-bitcoin", 57 | iconUrl: "http://3.bp.blogspot.com/-LE-WPdZd5j4/UzoZuyc49QI/AAAAAAAAesw/4EU0zMlH_E4/s800/gold_kinkai_nobebou.png", 58 | icon_emoji: process.env.EMOJI || ":moneybag:", 59 | webhook_url: process.env.WEBHOOK_URL, 60 | default_channel: process.env.DEFAULT_CHANNEL || "#okimochi-test", 61 | 62 | locale_message: locale_message, 63 | 64 | bitcoinjsNetwork: bitcoinjsNetwork, 65 | btc_network: network, 66 | bitcoindclient: new BitcoindClient({ 67 | network: network, 68 | username: process.env.BITCOIND_USERNAME || 'slackbot', 69 | password: process.env.BITCOIND_PASSWORD || 'bitcoin-tipper', 70 | host: process.env.BITCOIND_PORT_8333_TCP_ADDR || 71 | process.env.BITCOIND_URI || "localhost", 72 | timeout: 30000 73 | }), 74 | 75 | MAX_TIP_AMOUNT: 0.01, 76 | minimumTxAmount: minimumTxAmount, 77 | 78 | plotly: { 79 | api_key: process.env.PLOTLY_API_KEY, 80 | account_name: process.env.PLOTLY_API_USER 81 | }, 82 | } 83 | -------------------------------------------------------------------------------- /okimochi/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | require('dotenv').config({path: '../.env'}); 3 | const Botkit = require("botkit"); 4 | const config = require("./config"); 5 | const debug = require('debug')('okimochi'); 6 | const plotly = require('plotly')(config.plotly.account_name, config.plotly.api_key); 7 | const QRCode = require('qrcode'); 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const util = require('util'); 11 | const MyConvos = require(path.join(__dirname, "src", "conversations")) 12 | const lib = require(path.join(__dirname, "src", "lib")) 13 | const getRateJPY = lib.getRateJPY 14 | const formatUser = lib.formatUser 15 | const { User, PromiseSetAddressToUser, promisegetPendingSum } = require(path.join(__dirname, 'src', 'db')) 16 | const smartPay = require(path.join(__dirname, 'src', 'smartpay')) 17 | 18 | 19 | // logger 20 | require(path.join(__dirname, "src", "logger.js")); 21 | const winston = require('winston'); 22 | const logger = winston.loggers.get('okimochi'); 23 | 24 | const locale_message = config.locale_message 25 | console.log("config is", config) 26 | 27 | // bitcoin 28 | const bitcoindclient = config.bitcoindclient 29 | 30 | 31 | function PromiseGetAllUsersDeposit(){ 32 | return new Promise((resolve, reject) => { 33 | User.find({} , ["id", "totalPaybacked"], {sort: {'id': 1}}, (err, ids) => { 34 | if (err) reject(err); 35 | if (ids === undefined) reject(new Error("couldn't find undefined! ")); 36 | debug("ids are ", ids) 37 | let ps = []; 38 | for (let i = 0, size = ids.length; i { 44 | let result = []; 45 | for (let i = 0, size = ids.length; i reject(err)) 60 | }) 61 | }) 62 | } 63 | 64 | 65 | function makeTraceForPlotly(userinfo, hue){ 66 | debug("makeing trace from", userinfo) 67 | return { 68 | x: userinfo.balance, 69 | y: userinfo.totalPaybacked, 70 | text: [userinfo.name], 71 | mode: "markers", 72 | name: userinfo.name, 73 | marker: { 74 | color: userinfo.color, 75 | size: 20, 76 | line: { 77 | color: "white", 78 | width: 0.5 79 | } 80 | }, 81 | type: 'scatter' 82 | } 83 | } 84 | 85 | 86 | async function PromisePlotRankingChart(){ 87 | let userinfos; 88 | try { 89 | userinfos = await PromiseGetAllUsersDeposit() 90 | } catch(e) { 91 | throw e 92 | } 93 | 94 | let u; 95 | let data = []; 96 | for (u of userinfos){ 97 | data.push(makeTraceForPlotly(u)); 98 | } 99 | 100 | const layout = { 101 | title: 'OKIMOCHI ranking', 102 | xaxis: { 103 | title: locale_message.ranking.xaxis, 104 | showgrid: false, 105 | zeroline: false 106 | }, 107 | yaxis: { 108 | title: locale_message.ranking.yaxis, 109 | showline: false 110 | }, 111 | autosize: false, 112 | width: 960, 113 | height: 540 114 | }; 115 | 116 | const opts = { 117 | layout: layout, 118 | filename: 'okimochi-ranking', 119 | fileopt: 'new' 120 | }; 121 | 122 | return new Promise((resolve, reject) => { 123 | plotly.plot(data, opts, (err, msg) => { 124 | if (err) reject(err); 125 | else resolve(msg); 126 | }) 127 | }) 128 | } 129 | 130 | 131 | function PromiseFindUser(userid){ 132 | return new Promise((resolve, reject) => { 133 | User.findOne({id: userid}, (err, content) => { 134 | if (err) { 135 | reject(err) 136 | // if user has not registered yet. 137 | } else if (content === null || content === undefined ){ 138 | const replyMessage = formatUser(userid) + 139 | " is not in db. \nplease register or deposit first!"; 140 | reject( new Error(replyMessage)) 141 | } else { 142 | resolve(content) 143 | } 144 | }) 145 | }); 146 | } 147 | 148 | 149 | /* 150 | * @param userid {string} to retreive the balance 151 | * @return {Promise} Which will resolves to Integer amount of BTC that the user has deposited. 152 | */ 153 | function PromisegetUserBalance(userid){ 154 | return PromiseFindUser(userid) 155 | .then((content) => { 156 | debug("content is ", content) 157 | 158 | content = content.toObject() 159 | return content.depositAddresses 160 | .map((a) => bitcoindclient.validateAddress(a)) 161 | }) 162 | .then((ps) => { 163 | return Promise.all(ps) 164 | .then((results) => { 165 | debug("results were ", results) 166 | return results.filter((r) => r.isvalid) 167 | }) 168 | .then((validAddresses) => { 169 | debug("validAddresses were ", validAddresses); 170 | return Promise.all( 171 | validAddresses.map((a) => { 172 | debug("address validation result is ", a); 173 | return bitcoindclient.getReceivedByAddress(a.address, 0) 174 | }) 175 | ) 176 | }) 177 | 178 | .then((amounts) => { 179 | debug("amounts are", amounts); 180 | debug(Object.prototype.toString.call(amounts)) 181 | return amounts.reduce((a, b) => a + b, 0) 182 | }) 183 | }) 184 | } 185 | 186 | 187 | const message_to_BTC_map = locale_message.message_to_BTC_map; 188 | const thxMessages = Object.keys(message_to_BTC_map); 189 | const userIdPattern = /<@([A-Z\d]+)>/ig; 190 | const amountPattern = /([\d\.]*)/ig; 191 | 192 | 193 | // lackbot settings. 194 | let controller = Botkit.slackbot({ 195 | clientId: config.botconfig.clientId, 196 | clientSecret: config.botconfig.clientSecret, 197 | scopes: ['bot'], 198 | logger: winston.loggers.get('botkit') 199 | }).configureSlackApp( 200 | config.botconfig 201 | ); 202 | 203 | 204 | let bot = controller.spawn({ 205 | token: config.TOKEN, 206 | debug: config.SLACK_DEBUG, 207 | retry: 1000 208 | }).startRTM((err) => { 209 | if (err) { 210 | throw new Error(err); 211 | } 212 | }); 213 | 214 | bot.configureIncomingWebhook({ 215 | url: config.webhook_url 216 | }); 217 | 218 | controller.on('rtm_reconnect_failed',function(bot) { 219 | console.log('\n\n*** '+moment().format() + ' ** Unable to automatically reconnect to rtm after a closed conection.') 220 | }) 221 | 222 | // from id (e.g. U2FG58SDR) => to information used in this bot (mostly by plotting ranking) 223 | let UserInfoMap = {}; 224 | 225 | bot.api.users.list({}, (err, res) => { 226 | if (err) throw err; 227 | if (!res.ok) {throw new Error("failed to call slack `users_list` api")} 228 | res = res.members; 229 | for (let i = 0, size = res.length; i < size; ++i){ 230 | if (res[i]["is_bot"] || res[i]["id"] === "USLACKBOT") continue; 231 | if (i === 1){ 232 | console.log("first user's info is ", res[i]) 233 | } 234 | UserInfoMap[res[i]["id"]] = { "name": res[i]["name"], 235 | "team": res[i]["team_id"], 236 | "color": res[i]["color"] || "#000000", 237 | } 238 | } 239 | }); 240 | 241 | 242 | if (process.env.NODE_ENV === "production"){ 243 | bot.sendWebhook({ 244 | text: "OKIMOCHI has been updated !", 245 | channel: config.default_channel, 246 | icon_emoji: config.icon_emoji 247 | }, (err, res) => { 248 | if (err) throw err; 249 | }) 250 | } 251 | 252 | 253 | // deposit 254 | controller.hears(`deposit`, ["direct_mention", "direct_message", "mention"], (bot, message) => { 255 | debug("heard deposit") 256 | bitcoindclient.getNewAddress() 257 | .then((address) => { 258 | debug("going to show following address \n", address) 259 | const tmpfile = path.join('/tmp', address + ".png") 260 | QRCode.toFile(tmpfile, address, (err) => { 261 | if (err) throw err; 262 | 263 | bot.api.files.upload({ 264 | file: fs.createReadStream(tmpfile), 265 | filename: "please_pay_to_this_address" + ".png", 266 | title: address + ".png", 267 | initial_comment: locale_message.deposit.file_comment, 268 | channels: message.channel 269 | }, (err, res) => { 270 | if (err) bot.reply(err) 271 | debug("result is ", res); 272 | }) 273 | 274 | bot.reply(message, locale_message.deposit.msg_with_qrcode) 275 | bot.reply(message, address) 276 | }) 277 | return address 278 | }) 279 | .then((address) => PromiseSetAddressToUser(message.user, address, User)) 280 | .catch((err) => {bot.reply(message, err)}) 281 | }) 282 | 283 | // register 284 | controller.hears('register', ["direct_mention", "direct_message"], (bot, message) => { 285 | bot.startConversation(message, (err, convo) => { 286 | if (err) { 287 | throw err 288 | } 289 | convo.ask(util.format(locale_message.register.beg, config.btc_network), (response, convo) => { 290 | let ps = []; 291 | for (let address of response.text.split("\n")) { 292 | ps.push(PromiseSetAddressToUser(message.user, address, User)) 293 | } 294 | Promise.all(ps) 295 | .then(() => convo.say(util.format(locale_message.register.success, formatUser(message.user)))) 296 | .then(() => convo.next()) 297 | .catch((err) => {convo.say(err.toString())}).then(() => {convo.next()}) 298 | }) 299 | }) 300 | }) 301 | 302 | // tip by reaction 303 | controller.on(['reaction_added'], (bot, message) => { 304 | debug("reaction added !") 305 | debug("and message object is " + JSON.stringify(message)); 306 | const emoji = ":" + message.reaction + ":" 307 | if (thxMessages.some((p) => p === emoji)) { 308 | 309 | // 支払い 310 | const amount = message_to_BTC_map[emoji] 311 | smartPay(message.user, message.item_user, amount, emoji, User) 312 | .then((msg) => { 313 | PromiseOpenPrivateChannel(message.item_user) 314 | .then((channel) => { 315 | message.channel = channel 316 | bot.reply(message, msg) 317 | 318 | debug("msg was " + msg) 319 | }) 320 | }) 321 | .catch((err) => { 322 | PromiseOpenPrivateChannel(message.user) 323 | .then((channel) => { 324 | message.channel = channel 325 | bot.reply(message, err.toString()) 326 | 327 | debug("err was " + err) 328 | }) 329 | }) 330 | } 331 | }) 332 | 333 | 334 | 335 | function PromiseOpenPrivateChannel(user){ 336 | return new Promise((resolve,reject) => { 337 | bot.api.im.open({"user": user}, (err, res) => { 338 | if (err) reject(err); 339 | logger.info("result for im.open is " + JSON.stringify(res)); 340 | if (!res.ok) reject(new Error("could not open private channel by api!")); return 341 | if (!res.channel) reject(new Error("there was no private channel to open!")); return 342 | resolve(res.channel.id); 343 | }) 344 | }); 345 | } 346 | 347 | // tip intentionally 348 | controller.hears(`tip ${userIdPattern.source} ${amountPattern.source}(.*)`, ["direct_mention", "direct_message"], (bot, message) => { 349 | controller.logger.debug("whole match pattern was " + message.match[0]); 350 | const toPayUser = message.match[1]; 351 | const amount = Number(message.match[2]); 352 | const Txmessage = message.match[3] || "no message"; 353 | if (isNaN(amount)){ 354 | return bot.reply(message, "please give amount of BTC in number !"); 355 | } else if (config.MAX_TIP_AMOUNT < amount){ 356 | return bot.reply(message, "amount must be equal to or lower than " + config.MAX_TIP_AMOUNT); 357 | } 358 | if (message.user === toPayUser){ 359 | return bot.reply(message, "can not pay to your self !!") 360 | } 361 | smartPay(message.user, toPayUser, amount, Txmessage, User) 362 | .then((msg) => { 363 | PromiseOpenPrivateChannel(toPayUser) 364 | .then((channel) => { 365 | bot.reply(message, locale_message.tell_payment_success_to_tipper) 366 | message.channel = channel 367 | bot.reply(message, msg) 368 | }) 369 | }) 370 | .catch((err) => { 371 | PromiseOpenPrivateChannel(message.user) 372 | .then((channel) => { 373 | message.channel = channel 374 | bot.reply(message, err.toString()) 375 | }) 376 | .catch(() => bot.reply(message, err.toString())) 377 | }) 378 | }) 379 | 380 | // show pending balance 381 | controller.hears(`pendingBalance`, ["direct_mention", "direct_message"], (bot, message) => { 382 | User.findOneAndUpdate({id: message.user}, {id: message.user}, (err, content) => { 383 | if (err) throw err; 384 | if (!content) { 385 | bot.reply(message, locale_message.noDBEntry) 386 | } else { 387 | bot.reply(message, util.format(locale_message.pendingBalance, content.pendingBalance)) 388 | } 389 | }) 390 | }) 391 | 392 | 393 | // show total balance 394 | controller.hears(`totalBalance`, ["direct_mention", "direct_message"], (bot, message) => { 395 | Promise.all([promisegetPendingSum(User), bitcoindclient.getBalance()]) 396 | .then((sums) => sums[1] - sums[0]) 397 | .then((balance) => bot.reply(message, util.format(locale_message.totalBalance, balance))) 398 | }) 399 | 400 | 401 | // withdraw from pendingBalance 402 | controller.hears(`withdraw`, ["direct_mention", "direct_message"], (bot, message) => { 403 | bot.startConversationInThread(message, (err, convo) => { 404 | if (err) throw err; 405 | User.findOneAndUpdate({id: message.user}, 406 | {id: message.user}, 407 | { upsert: true, new: true, runValidators: true}, 408 | (err, content) => { 409 | if (err) throw err; 410 | convo.ask(locale_message.withdraw.Ask, MyConvos.getwithdrawConvo(content)); 411 | }) 412 | }) 413 | }) 414 | 415 | 416 | // ranking 417 | controller.hears(`ranking`, ['mention', 'direct_mention', 'direct_message'], (bot, message) => { 418 | PromisePlotRankingChart() 419 | .then((msg) => { 420 | console.log("msg was \n") 421 | bot.reply(message, msg.url) 422 | console.log("finished plotting!") 423 | }) 424 | .catch(err => bot.reply(message, err.stack)) 425 | }) 426 | 427 | // rate 428 | controller.hears('^rate$', ['direct_mention', 'direct_message'], (bot, message) => { 429 | let rate = getRateJPY(); 430 | if (rate) { 431 | bot.reply(message, `1BTC is now worth ${rate}JPY!`); 432 | } else { 433 | bot.reply(message, 'cannot get the rate somehow :pensive:'); 434 | } 435 | }); 436 | 437 | // help 438 | require('./src/handlers/help')(controller) 439 | require('./src/handlers/generateKeys')(controller) 440 | -------------------------------------------------------------------------------- /okimochi/locale/english.js: -------------------------------------------------------------------------------- 1 | 2 | // This module should not require config (to avoid circular dependency problem) 3 | 4 | const path = require('path'); 5 | const lib = require(path.join(__dirname, '..', 'src', 'lib')); 6 | 7 | const APP_NAME = process.env.APP_NAME || '@okimochi' 8 | let minimumTxAmount = process.env.MINIMUM_TX || 0.003 9 | minimumTxAmount = Number(minimumTxAmount) 10 | 11 | const inSatoshi = lib.inSatoshi; 12 | const btc2jpy = lib.btc2jpy; 13 | 14 | const gratitude_to_BTC_map = { 15 | "awesome": 0.0001, 16 | "thanks": 0.0001, 17 | "thx": 0.0001, 18 | }; 19 | 20 | const emoji_to_BTC_map = { 21 | ":pray:": 0.0001, 22 | ":bow:": 0.0001, 23 | ":okimochi:": 0.001, 24 | ":+1:": 0.00001, 25 | ":bitcoin:": 0.0001, 26 | }; 27 | 28 | 29 | let emoji_info = [] 30 | for (k of Object.keys(emoji_to_BTC_map)){ 31 | let btc = emoji_to_BTC_map[k] 32 | emoji_info.push([k, btc + "BTC", inSatoshi(btc) + "Satoshi"]) 33 | } 34 | 35 | 36 | module.exports = { 37 | help: ` 38 | \`\`\` 39 | # What is this bot? 40 | This is a bot to enable team members to tip each other. 41 | And to visualize 42 | 1. who has deposited most to this bot (investor) 43 | 2. who got most honored from others. 44 | Currently it is based on the assumption that some members 45 | won't conspire with another member to pay only in between them. 46 | So be careful to use! 47 | 48 | 49 | # show this help 50 | - ${APP_NAME} help 51 | 52 | # show @users bitcoin deposit address 53 | - ${APP_NAME} deposit 54 | 55 | # show users received amount pending inside this bot. 56 | - ${APP_NAME} pendingBalance 57 | 58 | # withdrow from your pending balance. 59 | - ${APP_NAME} withdrow 60 | 61 | # register the address for getting paied automatically. 62 | - ${APP_NAME} register 63 | 64 | # show BTC-JPY rate 65 | - ${APP_NAME} rate 66 | 67 | # tip intentionally ... message will be included as Tx message for bitcoin 68 | - ${APP_NAME} tip @user 69 | 70 | # show ranking for deposited amount and the amount payed to registered Address 71 | - ${APP_NAME} ranking 72 | 73 | this bot will tip automatically when someone showed his gratitude by 74 | action button. 75 | below is a list of actions that okimochi will react. 76 | \`\`\` 77 | ${emoji_info.join("\n")} 78 | 79 | `, 80 | 81 | message_to_BTC_map: Object.assign(gratitude_to_BTC_map, emoji_to_BTC_map), 82 | cannot_pay: `you got tip from %s. The amount is %s BTC. 83 | but you had no registered address, so the tip will be in \`pendingBalance\`, 84 | Please do the following! 85 | 1. check your \`pendingBalance\` by \`${APP_NAME} pendingBalance\` 86 | 2. withdraw your balance to your address by \`${APP_NAME} withdraw\` 87 | 3. (optional) register your own address by \`${APP_NAME} register\` to automatically pay to this address from next time. 88 | * It is possible to register multiple address by \`register\` by separating by \\\n 89 | 90 | try \`${APP_NAME} help\` to see detailed info. 91 | `, 92 | 93 | allPaybackAddressUsed: "warning: all addresses has been used.\n" + 94 | "So using the one we used before!\n" + 95 | "Please register the new address for the sake of security! \n", 96 | pendingSmallTx: `you got tip from %s, amount is %sBTC. 97 | It was too small amount to send Tx, so payment has been pending inside this bot. 98 | if you have been registered your address by \`${APP_NAME} register\`, this bot will automatically send Tx when your pendingBalance has exceed the threshold. 99 | The threshold is now set to ${minimumTxAmount}, and the pendingBalance can be seen by \`${APP_NAME} pendingBalance\` . 100 | `, 101 | needMoreDeposit: `There is no way to pay since OKIMOCHI is now empty :( 102 | waiting for someone to deposit :)`, 103 | totalBalance: "%s BTC left as deposited amount!", 104 | ranking:{ 105 | xaxis: "deposit amount", 106 | yaxis: "payback amount", 107 | }, 108 | 109 | pendingBalance: "the amount you can withdraw is %s BTC", 110 | tell_payment_success_to_tipper: "tipped!", 111 | deposit: { 112 | msg_with_qrcode: "Please deposit to this address (or QRcode if you prefer)", 113 | file_comment: "this is a same address with the one shown above." 114 | }, 115 | register: { 116 | beg: "please paste your bitcoin address separated by \\n of %s", 117 | success: "successfully registered address as %s's!", 118 | notValid: 'please enter valid address !' 119 | }, 120 | withdraw: { 121 | Ask: "How much do you want to retrieve?", 122 | amountMustBeNumber: "amount must be Number (BTC) !", 123 | notEnoughPendingBalance: `you can'not withdraw more than your pending balance! 124 | please check it by \`${APP_NAME} pendingBalance\` `, 125 | pasteAddress: "please paste your bitcoin address to send.", 126 | successfulPayment: 'sent!', 127 | sent: "accepted! I will try to send Tx in a moment ... ", 128 | amountLessThanThreshold: `Sorry! Tx have to be bigger than ${minimumTxAmount}! wait until you gather more!` 129 | }, 130 | generateKeys: { 131 | explain: 'generating yoru private key and corresponding address', 132 | warn: `caution!: this is just helper for you playing with $\`{APP_NAME}\` ! be aware that you must generate your own private key in your safe environment if you want to make sure your wallet safe!`, 133 | mnemonic: 'your bip39 mnemonic code for master private key', 134 | base58: 'base58 of your master private key', 135 | wif: 'WIF format for the same private key', 136 | address: 'address generated from the private key above' 137 | }, 138 | noDBEntry: 'You have no entry in DB! You must first received tip from someone or deposit BTC' 139 | } 140 | -------------------------------------------------------------------------------- /okimochi/locale/japanese.js: -------------------------------------------------------------------------------- 1 | 2 | // This module should not require config (to avoid circular dependency problem) 3 | 4 | const path = require('path'); 5 | const util = require('util'); 6 | const lib = require(path.join(__dirname, '..', 'src', 'lib')); 7 | 8 | const APP_NAME = process.env.APP_NAME || '@okimochi' 9 | let minimumTxAmount = process.env.MINIMUM_TX || 0.003 10 | minimumTxAmount = Number(minimumTxAmount) 11 | 12 | const inSatoshi = lib.inSatoshi; 13 | const btc2jpy = lib.btc2jpy; 14 | 15 | const gratitude_to_BTC_map = { 16 | "感謝": 0.0001, 17 | "気持ち": 0.0001, 18 | "きもち": 0.0001, 19 | "ありがと": 0.0001, 20 | "thanks": 0.0001, 21 | "どうも": 0.0001 22 | }; 23 | 24 | const emoji_to_BTC_map = { 25 | ":bitcoin:": 0.0002, 26 | ":pray:": 0.0002, 27 | ":okimochi:": 0.0008, 28 | ":thankyou1:": 0.0002, 29 | ":+1:": 0.0001, 30 | ":bow:": 0.0002, 31 | ":congratulations:": 0.0002, 32 | ":100:": lib.jpy2btc(100), 33 | ":azaas:": 0.0002, 34 | ":bitoko:": 0.0002, 35 | ":dekiru:": 0.0001, 36 | ":ga-sas:": 0.0001, 37 | ":hayai:": 0.0001, 38 | ":kami-taiou:": 0.0002, 39 | ":kawaii:": 0.0001, 40 | ":kekkon:": 0.0001, 41 | ":kawaii:": 0.0001, 42 | ":sugoi:": 0.0001, 43 | ":suki:": 0.0001, 44 | ":suko:": 0.0001, 45 | ":yoi:": 0.0001, 46 | ":you_know:": 0.0002, 47 | ":yuunou:": 0.0001, 48 | ":zass:": 0.0002, 49 | }; 50 | 51 | let emoji_info = [] 52 | for (k of Object.keys(emoji_to_BTC_map)){ 53 | let btc = emoji_to_BTC_map[k] 54 | emoji_info.push([k, btc + "BTC", inSatoshi(btc) + "Satoshi", Math.round(btc2jpy(btc)) + "円"]) 55 | } 56 | 57 | 58 | module.exports = { 59 | help: ` 60 | \`\`\` 61 | # このhelpメッセージを表示 62 | - ${APP_NAME} help 63 | 64 | # ビットコインをbotにデポジットするためのアドレスを表示 65 | ## (※ユーザーごとにデポジット額をカウントするので、自分で呼び出したアドレスに振り込むと良い) 66 | - ${APP_NAME} deposit 67 | 68 | # 支払い保留中のビットコインの額を確認(registerすれば不要) 69 | - ${APP_NAME} pendingBalance 70 | 71 | # 支払い保留中のビットコインの引き出し(registerすれば不要) 72 | - ${APP_NAME} withdraw 73 | 74 | # 自身のアドレスの登録 75 | ## このコマンドの後にbotが振込先アドレスを聞いてくるので、貼り付けると自動で振り込まれる 76 | ## 改行を挟めば複数登録できるので多めに登録しておくと吉 77 | - ${APP_NAME} register 78 | 79 | # depositされ、まだ誰にも支払われていない額の合計 80 | - ${APP_NAME} totalBalance 81 | 82 | # 現在のBTC-JPYの換算レートを表示 83 | - ${APP_NAME} rate 84 | 85 | # 金額を指定してtip 86 | ## メッセージはなくても良い。(bitcoinのトランザクションメッセージになる) 87 | - ${APP_NAME} tip @user 88 | 89 | # ランキングの表示 90 | ## 1. このbotにデポジットした額 91 | ## 2. そこから受け取った額 92 | ## を、チームごとに色分けして表示する。 93 | - ${APP_NAME} ranking 94 | 95 | # (おまけ)実験用秘密鍵とアドレスの生成 96 | 97 | - ${APP_NAME} generateKeys 98 | 99 | :bitcoin: や :okimochi: などのリアクションを押すと自動的に支払われるよ! 100 | 反応するボタンは以下 101 | \`\`\` 102 | 103 | ${emoji_info.join("\n")} 104 | `, 105 | 106 | message_to_BTC_map: Object.assign(gratitude_to_BTC_map, emoji_to_BTC_map), 107 | cannot_pay: `%sから%sBTCのOKIMOCHIをもらいました!` + 108 | `が、ビットコインアドレスを登録していないので、支払いはOKIMOCHI内で保留しています。 109 | ビットコインを本当にあなたのものにしたい場合は以下の手順を踏んでください。 110 | 1. \`${APP_NAME} pendingBalance \` で保留されているビットコインの額を確認 111 | 2. \`${APP_NAME} withdraw\` で自身のビットコインアドレスに送金 112 | 3. \`${APP_NAME} register\` でビットコインアドレスを登録することで、次回から自動でここに送金(任意) 113 | * \`register\` で登録する場合は、可能なら改行で区切って複数登録しておくことをお勧めします。 114 | 115 | \`${APP_NAME} help\` でより詳しい情報が手に入ります。 116 | `, 117 | allPaybackAddressUsed: `注意: registerされたアドレスが全て使用済みになりました。 118 | セキュリティ確保のため、アドレスはトランザクションごとに使い分けることが奨励されています。 119 | 気が向いたら、多めに登録しておきましょう。 120 | `, 121 | pendingSmallTx: `%sから%sBTCのOKIMOCHIをもらいました! 122 | が、小額の支払いだったので、支払いはOKIMOCHI内で保留しています。 123 | \`${APP_NAME} register\` してあれば、保留している額が域値を超えた時に自動で発行されます。 124 | あなたの保留中の額は \`${APP_NAME} pendingBalance\` で確認できます。 125 | 域値は${minimumTxAmount}です。 126 | `, 127 | needMoreDeposit: `depositされた額が底をついたため、支払えません :( 128 | \`${APP_NAME} deposit\` でデポジットしてくれると嬉しいな :) `, 129 | totalBalance: "壺に残っている額は現在%s BTCです。", 130 | ranking:{ 131 | xaxis: "depositした量", 132 | yaxis: "受け取った量", 133 | }, 134 | tell_payment_success_to_tipper: "tipしました!", 135 | pendingBalance: "あなたが引き出せる金額は %s BTCです", 136 | deposit: { 137 | msg_with_qrcode: "このアドレスに振り込んでください(QRコードも同じアドレスです)", 138 | file_comment: "同じアドレスのQRコード表現です" 139 | }, 140 | register: { 141 | beg: "%sのBTCアドレスを入力してください(改行を入れて複数可)", 142 | success: "%sの支払先アドレスを登録しました!", 143 | notValid: "有効なビットコインアドレスを入力してください!" 144 | }, 145 | withdraw: { 146 | Ask: "いくら引き出しますか?", 147 | amountMustBeNumber: "BTCの額を半角数字で入力してください!", 148 | notEnoughPendingBalance: `あなたが引き出せる額を超えています! 149 | 最大額は \`${APP_NAME} pendingBalance\` で確認できます!`, 150 | pasteAddress: "送金先ビットコインアドレスを貼り付けてください", 151 | successfulPayment: "送金しました!", 152 | sent: "送金を受け付けました。自動で送金を試みます。", 153 | amountLessThanThreshold: `トランザクションの最低金額は${minimumTxAmount} 154 | に設定されています!もう少し貯まるまで待ちましょう :) ` 155 | }, 156 | generateKeys: { 157 | explain: 'あなたの%sの秘密鍵とアドレスを生成します...', 158 | warn: `注意!: この機能はあくまで \`${APP_NAME}\` の機能の実験用です!実際は手元で安全に生成した秘密鍵を利用してください!`, 159 | mnemonic: 'あなたの秘密鍵のリカバリーコード', 160 | base58: '秘密鍵のbase58エンコーディング', 161 | wif: '秘密鍵のwifフォーマット', 162 | address: '上の秘密鍵に対応するアドレス' 163 | }, 164 | noDBEntry: 'データベースにあなたのデータがありません!誰かからtipを受け取るかdepositしましょう' 165 | } 166 | -------------------------------------------------------------------------------- /okimochi/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "okimochi", 3 | "version": "1.0.0", 4 | "description": "slack bot for bitcoin micropayment", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/campfire-inc/OKIMOCHI" 9 | }, 10 | "scripts": { 11 | "test": "echo -e 'running unit test \n ' && nyc mocha --recursive test/unit", 12 | "start": "if test \"$NODE_ENV\" = \"test\"; then nyc mocha --recursive; else node index.js; fi" 13 | }, 14 | "keywords": [ 15 | "slack", 16 | "bitcoin" 17 | ], 18 | "author": "joemphilips", 19 | "license": "MIT", 20 | "dependencies": { 21 | "babel": "^6.23.0", 22 | "bip39": "^2.4.0", 23 | "bitcoin": "^3.0.1", 24 | "bitcoin-core": "^1.2.0", 25 | "bitcoinjs-lib": "^3.1.1", 26 | "botkit": "^0.5.7", 27 | "date-utils": "^1.2.21", 28 | "dotenv": "^4.0.0", 29 | "express": "^4.15.4", 30 | "mongoose": "^4.11.8", 31 | "mysql": "^2.14.1", 32 | "plotly": "^1.0.6", 33 | "qrcode": "^0.9.0", 34 | "sync-request": "^4.1.0", 35 | "winston": "^2.3.1" 36 | }, 37 | "devDependencies": { 38 | "botkit-mock": "^0.1.2", 39 | "chai": "^4.1.1", 40 | "mocha": "^3.5.0", 41 | "mockgoose": "^7.3.3", 42 | "nyc": "^11.1.0", 43 | "power-assert": "^1.4.4", 44 | "should": "^13.0.1" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /okimochi/src/conversations.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require("path") 3 | const config = require(path.join(__dirname, "..", "config")); 4 | const logger = require("winston"); 5 | const bitcoindclient = config.bitcoindclient; 6 | 7 | const locale_message = config.locale_message 8 | 9 | 10 | module.exports = { 11 | getwithdrawConvo: (content) => {return [ 12 | { 13 | default: true, 14 | callback: (response, convo) => { 15 | convo.say(locale_message.withdraw.amountMustBeNumber); 16 | convo.repeat(); 17 | convo.next(); 18 | } 19 | }, 20 | { 21 | pattern: /[\.\d]+/, 22 | callback: (response, convo) => { 23 | let amount = Number(response.text); 24 | if (amount < config.minimumTxAmount) { 25 | convo.say(locale_message.withdraw.amountLessThanThreshold) 26 | convo.next() 27 | } else if (amount > content.pendingBalance) { 28 | convo.say(locale_message.withdraw.notEnoughPendingBalance) 29 | convo.repeat(); 30 | convo.next(); 31 | } else { 32 | convo.next() 33 | convo.ask(locale_message.withdraw.pasteAddress, (response, convo) => { 34 | bitcoindclient.sendToAddress(response.text, amount.toFixed(8), "", "", true) 35 | .then((response) => { 36 | content.pendingBalance -= amount; 37 | content.save(); 38 | convo.say(locale_message.withdraw.successfulPayment) 39 | }) 40 | .catch((err) => convo.say(err.toString())) 41 | .then(() =>convo.next()); 42 | convo.say(locale_message.withdraw.sent); 43 | }) 44 | } 45 | } 46 | } 47 | ]} 48 | } 49 | -------------------------------------------------------------------------------- /okimochi/src/db.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const config = require('../config') 3 | const debug = require('debug') 4 | const bitcoindclient = config.bitcoindclient 5 | const locale_message = config.locale_message 6 | 7 | process.on('unhandledRejection', (reason, p) => { 8 | console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); 9 | // application specific logging, throwing an error, or other logic here 10 | }); 11 | 12 | // database initialization 13 | const mongoose = require("mongoose"); 14 | mongoose.Promise = global.Promise 15 | mongoose.connect(config.mongoUri, { 16 | useMongoClient: true, 17 | autoReconnect: true 18 | }) 19 | .then((db) => {return db.once('open', () => { 20 | console.log("db is open!") 21 | })}) 22 | .catch((err) => {throw err}) 23 | 24 | 25 | const BTCaddressValidator = { 26 | validator: (v) => { 27 | return new Promise((resolve, reject) => { 28 | bitcoindclient.validateAddress(v) 29 | .then((res) => { 30 | if (res.isvalid) { 31 | resolve(true) 32 | } else { 33 | resolve(false) 34 | } 35 | }) 36 | }) 37 | }, 38 | message: 'btc address is not correct!' 39 | } 40 | 41 | const Schema = mongoose.Schema, 42 | ObjectId = Schema.ObjectId; 43 | 44 | let UserSchema = new Schema({ 45 | // _id: ObjectId, // comment out since this causes 'document must have an _id before saving' error. 46 | id: String, 47 | depositAddresses: [{ type: String, validate: BTCaddressValidator }], 48 | paybackAddresses: [ 49 | { 50 | address: { type: String, validate: BTCaddressValidator }, 51 | used: { type: Boolean, default: false }, 52 | } 53 | ], 54 | totalPaybacked: {type: Number, default: 0}, // pendingBalance + amount payed directry. 55 | pendingBalance: {type: Number, default: 0} 56 | }) 57 | 58 | const User = mongoose.model('User', UserSchema); 59 | 60 | mongoose.connection.on( 'connected', function(){ 61 | console.log('connected.'); 62 | }); 63 | 64 | mongoose.connection.on( 'error', function(err){ 65 | console.log( 'failed to connect a mongo db : ' + err ); 66 | }); 67 | 68 | // mongoose.disconnect() を実行すると、disconnected => close の順番でコールされる 69 | mongoose.connection.on( 'disconnected', function(){ 70 | console.log( 'disconnected.' ); 71 | }); 72 | 73 | mongoose.connection.on( 'close', function(){ 74 | console.log( 'connection closed.' ); 75 | }); 76 | 77 | function PromiseSetAddressToUser(userId, address, UserModel){ 78 | return new Promise((resolve, reject) => { 79 | bitcoindclient.validateAddress(address, (err, result) => { 80 | if (err){ 81 | reject(err) 82 | } 83 | if (result && result.isvalid){ 84 | UserModel.update( {id: userId}, 85 | {$push: {paybackAddresses: {address: address, used: false}}}, 86 | {upsert: true, 'new': true}, (res) => {resolve(res)}) 87 | resolve() 88 | } else { 89 | reject(new Error(locale_message.register.notValid)) 90 | } 91 | }) 92 | }) 93 | } 94 | 95 | 96 | function PromiseGetAllUserPayback(UserModel){ 97 | return new Promise((resolve, reject) => { 98 | UserModel.find({}, ["id", "totalPaybacked"], { sort: { 'id': 1 }}, (err, contents) => { 99 | if (err) reject(err); 100 | let result = []; 101 | for (let c of contents){ 102 | result.push(c.toObject().totalPaybacked) 103 | } 104 | resolve(result); 105 | }) 106 | }) 107 | } 108 | 109 | 110 | /** 111 | * Promise to return the total amount of pendingBalance 112 | * for all users 113 | * */ 114 | module.exports.promisegetPendingSum = async function promisegetPendingSum(UserModel){ 115 | const PendingList = await PromiseGetAllUserPayback(UserModel); 116 | const res = PendingList.reduce((a, b) => a + b, 0); 117 | return res 118 | } 119 | 120 | module.exports.User = User 121 | module.exports.UserSchema = UserSchema 122 | module.exports.PromiseSetAddressToUser = PromiseSetAddressToUser 123 | -------------------------------------------------------------------------------- /okimochi/src/handlers/generateKeys.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const util =require('util') 3 | const path = require('path') 4 | const config = require('../../config') 5 | const locale_message = config.locale_message 6 | const network = config.bitcoinjsNetwork 7 | const networkString = config.btc_network 8 | const debug = require('debug') 9 | const bitcoin = require('bitcoinjs-lib') 10 | const bip39 = require('bip39') 11 | 12 | module.exports = (controller) => { 13 | controller.hears('generateKeys', ['direct_mention', 'direct_message'], (bot, message) => { 14 | const mnemonic = bip39.generateMnemonic() 15 | const seed = bip39.mnemonicToSeed(mnemonic) 16 | const node = bitcoin.HDNode.fromSeedBuffer(seed, network) 17 | const xprv = node.toBase58() 18 | const wif = node.keyPair.toWIF() 19 | const replytMessage = [ 20 | util.format(locale_message.generateKeys.explain, networkString), 21 | locale_message.generateKeys.warn, 22 | locale_message.generateKeys.mnemonic, 23 | mnemonic, 24 | locale_message.generateKeys.base58, 25 | xprv, 26 | locale_message.generateKeys.wif, 27 | wif, 28 | locale_message.generateKeys.address, 29 | node.getAddress() 30 | ].join('\n') 31 | bot.reply(message, replytMessage) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /okimochi/src/handlers/help.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const locale_message = require('../../config').locale_message 4 | const debug = require('debug') 5 | 6 | module.exports = (controller) => { 7 | controller.hears('help', ['direct_mention', 'direct_message'], (bot, message) => { 8 | bot.reply(message, locale_message.help); 9 | const exp_file = "okimochi_explanation.png"; 10 | bot.api.files.upload({ 11 | file: fs.createReadStream(path.join(__dirname, '..', '..', "static", "images", exp_file)), 12 | filename: exp_file, 13 | title: exp_file, 14 | channels: message.channel 15 | }, (err, res) => { 16 | if (err) bot.reply(err) 17 | debug("result is ", res); 18 | }) 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /okimochi/src/lib.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const moment = require('moment'); 3 | const sync_request = require('sync-request'); 4 | 5 | let lastRequestTime = moment().unix() 6 | let responseCache; 7 | 8 | const formatUser = (user) => `<@${user}>` 9 | 10 | function getRateJPY() { 11 | const rate_api_url = 'https://coincheck.com/api/exchange/orders/rate?order_type=buy&pair=btc_jpy&amount=1'; 12 | let now = moment().unix(); 13 | let response; 14 | if ((lastRequestTime < now - 30) || (!responseCache)) { 15 | response = sync_request('GET', rate_api_url); 16 | lastRequestTime = now; 17 | responseCache = response 18 | } else { 19 | response = responseCache; 20 | } 21 | let rate; 22 | if (response.statusCode == 200) { 23 | rate = Math.round(JSON.parse(response.body).rate); 24 | return rate; 25 | } 26 | } 27 | 28 | function jpy2btc(jpyAmount) { 29 | let rate = getRateJPY(); 30 | return jpyAmount * 1.0 / rate; 31 | } 32 | 33 | function btc2jpy(btcAmount) { 34 | let rate = getRateJPY(); 35 | return btcAmount * rate; 36 | } 37 | 38 | function inBTC(satoshi) { 39 | return (satoshi / 100000000.0).toFixed(4); 40 | } 41 | 42 | function inSatoshi(btc) { 43 | return parseFloat((btc * 100000000).toFixed(0)); 44 | } 45 | 46 | module.exports = { getRateJPY: getRateJPY, 47 | jpy2btc: jpy2btc, 48 | btc2jpy: btc2jpy, 49 | inBTC: inBTC, 50 | inSatoshi: inSatoshi, 51 | formatUser: formatUser 52 | } 53 | 54 | -------------------------------------------------------------------------------- /okimochi/src/logger.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const winston = require("winston"); 3 | 4 | const logLevel = (process.env.NODE_ENV === "development") ? 'debug' : 'info'; 5 | 6 | winston.loggers.add("okimochi", { 7 | console: { 8 | level: logLevel, 9 | colorize: true, 10 | label : "okimochi-logger" 11 | } 12 | }) 13 | 14 | winston.loggers.add('botkit', { 15 | console: { 16 | level: "info", 17 | colorize: false, 18 | } 19 | }) 20 | 21 | -------------------------------------------------------------------------------- /okimochi/src/smartpay.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require('../config') 4 | const bitcoindclient = config.bitcoindclient 5 | const locale_message = config.locale_message 6 | const debug = require('debug') 7 | const util = require('util') 8 | const lib = require('./lib') 9 | const formatUser = lib.formatUser 10 | const { promisegetPendingSum } = require('./db') 11 | 12 | 13 | /** 14 | * from users information. choose unused paybackAddress Preferentially. 15 | * And mark that Address as "used". and returns updated info and address to use. 16 | * @param {Object} userContent 17 | * @return {Array} 18 | * 1. first is the address for using as paying back tx.(null if no address registered.) 19 | * 2. Second is updated user info. 20 | * 3. And third is String for bot to speak 21 | */ 22 | function extractUnusedAddress(userContent){ 23 | let paybackAddresses = userContent.paybackAddresses 24 | let address; 25 | let replyMessage = ""; 26 | let addressIndex; 27 | if (!paybackAddresses || paybackAddresses.length === 0){ 28 | address = null 29 | } else if (paybackAddresses.every((a) => a.used)){ 30 | replyMessage += locale_message.allPaybackAddressUsed 31 | address = paybackAddresses.pop().address 32 | } else { 33 | addressIndex = paybackAddresses.findIndex((e) => !e.used) 34 | address = paybackAddresses[addressIndex].address 35 | userContent.paybackAddresses[addressIndex].used = true; 36 | } 37 | replyMessage += "Sending Tx to " + address + "\n" 38 | return [address, userContent, replyMessage]; 39 | } 40 | 41 | 42 | /** 43 | * function to mangae all payments done by this bot. 44 | * throws error when the bot has to reply to sender. 45 | * returns string when the bot has to reply to receiver 46 | */ 47 | module.exports = async function smartPay(fromUserID, toUserID, amount, Txmessage, UserModel) { 48 | debug("paying from ", fromUserID); 49 | debug("paying to ", toUserID); 50 | amount = Number(amount.toFixed(8)) // since 1 satoshi is the smallest amount bitcoind can recognize 51 | console.log('amount is ', amount) 52 | 53 | // can not pay to yourself 54 | if (fromUserID === toUserID){ 55 | throw new Error("tried to send to yourself!"); 56 | } 57 | 58 | let pendingSum; 59 | let totalBitcoindBalance; 60 | try { 61 | pendingSum = await promisegetPendingSum(UserModel); 62 | totalBitcoindBalance = await bitcoindclient.getBalance(); 63 | } catch (e) { 64 | console.log(e) 65 | console.log("pendingSum and totalBitcoindBalance are", pendingSum, totalBitcoindBalance) 66 | throw e 67 | } 68 | const ableToPayInPot = totalBitcoindBalance - pendingSum 69 | if (ableToPayInPot < amount){ 70 | throw new Error(locale_message.needMoreDeposit); 71 | }; 72 | 73 | let returnMessage = ""; 74 | const toUserContent = await UserModel.findOneAndUpdate({id: toUserID}, 75 | {id: toUserID}, 76 | { upsert: true, runValidators: true, new: true, setDefaultsOnInsert: true}) 77 | 78 | // check if all paybackAddresses has been used. 79 | let [address, updatedContent, replyMessage] = 80 | extractUnusedAddress(toUserContent); 81 | console.log("result of extractUnusedAddress was ", address, updatedContent, replyMessage); 82 | 83 | // pend payment when it does not satisfy condifitons 84 | if (!address || amount + toUserContent.pendingBalance < config.minimumTxAmount){ 85 | console.log("not going to send Tx") 86 | toUserContent.pendingBalance = toUserContent.pendingBalance + amount; 87 | toUserContent.totalPaybacked = toUserContent.totalPaybacked + amount 88 | await toUserContent.save() 89 | 90 | if (!address){ 91 | console.log("because there were no address registered") 92 | return util.format(locale_message.cannot_pay, formatUser(fromUserID), amount) 93 | } else if (amount + toUserContent.pendingBalance < config.minimumTxAmount) { 94 | console.log("because amount to send is less than minimumTxAmount") 95 | return util.format(locale_message.pendingSmallTx, formatUser(fromUserID), amount) 96 | } else { 97 | throw new Error("unreachable!") 98 | } 99 | 100 | // when it satisfies the criteria to send Tx. 101 | } else { 102 | const amountToPay = amount + Number(toUserContent.pendingBalance.toFixed(8)) 103 | console.log("going to send Tx to " + address); 104 | console.log("of user " + updatedContent); 105 | 106 | returnMessage = replyMessage + 107 | " payed to " + formatUser(toUserID) 108 | try { 109 | const result = await bitcoindclient.sendToAddress(address, amountToPay, Txmessage, "this is comment.", true); 110 | } catch (e) { 111 | throw e 112 | } 113 | updatedContent.totalPaybacked = updatedContent.totalPaybacked + amount 114 | updatedContent.pendingBalance = updatedContent.totalPaybacked - amountToPay 115 | updatedContent.save((err) => {if (err) throw err;}) 116 | return returnMessage 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /okimochi/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/campfire-inc/OKIMOCHI/7b9b7aa3f8f824017a22dfc87adea497c5df9e63/okimochi/static/images/logo.png -------------------------------------------------------------------------------- /okimochi/static/images/okimochi_explanation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/campfire-inc/OKIMOCHI/7b9b7aa3f8f824017a22dfc87adea497c5df9e63/okimochi/static/images/okimochi_explanation.png -------------------------------------------------------------------------------- /okimochi/static/images/sketch_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/campfire-inc/OKIMOCHI/7b9b7aa3f8f824017a22dfc87adea497c5df9e63/okimochi/static/images/sketch_image.png -------------------------------------------------------------------------------- /okimochi/static/images/slack_icon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/campfire-inc/OKIMOCHI/7b9b7aa3f8f824017a22dfc87adea497c5df9e63/okimochi/static/images/slack_icon2.png -------------------------------------------------------------------------------- /okimochi/static/images/small_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/campfire-inc/OKIMOCHI/7b9b7aa3f8f824017a22dfc87adea497c5df9e63/okimochi/static/images/small_logo.png -------------------------------------------------------------------------------- /okimochi/test/integration/test_db.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert') 2 | const { UserSchema, PromiseSetAddressToUser } = require('../../src/db') 3 | const Mongoose = require('mongoose').Mongoose 4 | const mongoose = new Mongoose() 5 | const config = require("../../config") 6 | const mongoBaseUri = config.mongoBaseUri 7 | 8 | 9 | const testUser = mongoose.model('User', UserSchema) 10 | 11 | describe('User Database entry', () => { 12 | 13 | before((done) => { 14 | mongoose.connect(mongoBaseUri + 'testDB', { useMongoClient: true }, (err) => {if (err) throw err}) 15 | const db = mongoose.connection 16 | db.once('open', () => {done()}) 17 | }) 18 | 19 | it('can be created from id', () => { 20 | const validUser = new testUser({id: "hoge user ID"}) 21 | return validUser.save() 22 | }) 23 | 24 | it('can not be created with invalid address', (done) => { 25 | invalidUser = new testUser({id: "hoge user ID", depositAddresses: ["invalid address format"]}) 26 | invalidUser.save(err => { 27 | if (err) {return done()} 28 | throw new Error('User should return Error when address is invalid!') 29 | }) 30 | }) 31 | 32 | it('can set regtest address to user', (done) => { 33 | PromiseSetAddressToUser('TOUSERID', 'mufX2qkNPLFWXSrXha9uEK94rTwJKV6mA9', testUser) 34 | .then(() => {done()}) 35 | .catch((err) => {throw err}) 36 | }) 37 | 38 | after((done) => { 39 | mongoose.connection.db.dropDatabase(() => { 40 | mongoose.connection.close(done); 41 | }); 42 | }); 43 | }) 44 | 45 | 46 | -------------------------------------------------------------------------------- /okimochi/test/integration/test_smartpay.js: -------------------------------------------------------------------------------- 1 | const smartPay = require('../../src/smartpay') 2 | const { UserSchema, PromiseSetAddressToUser } = require('../../src/db') 3 | const Mongoose = require('mongoose').Mongoose 4 | const mongoose = new Mongoose() 5 | const config = require("../../config") 6 | const mongoBaseUri = config.mongoBaseUri 7 | const bitcoindclient = config.bitcoindclient 8 | 9 | const testUser = mongoose.model('User', UserSchema, 'User') 10 | const assert = require('power-assert') 11 | const should = require('should') 12 | const failTest = require('../util').failTest 13 | 14 | describe('smartPay', () => { 15 | before((done) => { 16 | mongoose.connect(mongoBaseUri + 'testDB', { useMongoClient: true }, (err) => {if (err) throw err}) 17 | const db = mongoose.connection 18 | db.dropDatabase(); 19 | db.once('open', () => {done()}) 20 | }) 21 | 22 | 23 | describe('When depisited balance is zero', () => { 24 | it('throws error when deposited balance are zero.', (done) => { 25 | smartPay("FROMUSERID", "TOUSERID", 0.001, "test message", testUser) 26 | .then(failTest) 27 | .catch((err) => done()) 28 | }) 29 | }) 30 | 31 | 32 | describe("has enough balance to pay and then", () => { 33 | before(function() { // not using anonymous function since it has no `this` binding 34 | this.timeout(10000); 35 | return bitcoindclient.generate(450) 36 | }) 37 | 38 | it('creates a new user with Pendging Balance when paying for the first time .', (done) => { 39 | smartPay("FROMUSERID", "TOUSERID2", 0.001, "test message", testUser) 40 | .then((retMessage) => { 41 | testUser.findOne({id: 'TOUSERID2'}, (err, content) => { 42 | if (err) {throw err}; 43 | assert.equal(content.pendingBalance, 0.001) 44 | done() 45 | }) 46 | }) 47 | .catch((err) => {throw err}) 48 | }) 49 | 50 | describe('when the receiver has registered address', () => { 51 | before((done) => { 52 | PromiseSetAddressToUser('TOUSERID3', 'mufX2qkNPLFWXSrXha9uEK94rTwJKV6mA9', testUser) 53 | .then(() => {done()}) 54 | .catch((err) => {throw err}) 55 | }) 56 | 57 | it('will send Tx to address when has enough balance', () => { 58 | return smartPay("FROMUSERID", "TOUSERID3", 0.9, "test message", testUser) 59 | .then((retMessage) => { 60 | console.log('retMessage is', retMessage) 61 | testUser.findOne({id: 'TOUSERID2'}, (err, content) => { 62 | if (err) {throw err}; 63 | assert.equal(content.pendingBalance, 0) 64 | assert.equal(bitcoindclient.getReceivedByAddress('mufX2qkNPLFWXSrXha9uEK94rTwJKV6mA9'), 0.9) 65 | }) 66 | }) 67 | .catch((err) => {throw err}) 68 | }) 69 | 70 | it('can pay even when precision is too small', () => { 71 | return smartPay('FROMUSERID', 'TOUSERID3', 0.99999999999999999, 'test message',testUser) 72 | .then((retMessage) => { 73 | console.log('retMessage is', retMessage) 74 | testUser.findOne({id: 'TOUSERID3'}, (err, content) => { 75 | if (err) {throw err}; 76 | assert.equal(content.pendingBalance, 0) 77 | }) 78 | }) 79 | .catch((err) => {throw err}) 80 | }) 81 | 82 | it('will save to pendingBalance of the receiver when payment is small.', () => { 83 | return smartPay('FROMUSERID', 'TOUSERID4', 0.0000001, 'test message', testUser) 84 | .then((retmessage) => { 85 | testUser.findOne({id: 'TOUSERID4'}, (err, content) => { 86 | if (err) {throw err}; 87 | assert.equal(content.pendingBalance, 0.0000001) 88 | }) 89 | }) 90 | .catch((err) => {throw err}) 91 | }) 92 | }) 93 | 94 | }) 95 | 96 | after((done) => { 97 | mongoose.connection.db.dropDatabase(() => { 98 | mongoose.connection.close(done); 99 | }); 100 | }); 101 | }) 102 | -------------------------------------------------------------------------------- /okimochi/test/unit/test_handler.js: -------------------------------------------------------------------------------- 1 | const Botmock = require('botkit-mock') 2 | const helpHandler = require('../../src/handlers/help') 3 | const assert = require('assert') 4 | const locale_message = require('../../config').locale_message 5 | 6 | describe('Help Controller Tests', () => { 7 | beforeEach(() => { 8 | this.controller = Botmock({}) 9 | this.bot = this.controller.spawn({type: 'slack'}) 10 | helpHandler(this.controller) 11 | }) 12 | 13 | it('bot should return help message when user asked for help', () => { 14 | return this.bot.usersInput( 15 | [ 16 | { 17 | user: 'someUserId', 18 | channel: 'someChannel', 19 | messages: [ 20 | { 21 | text: 'help', isAssertion: true 22 | } 23 | ] 24 | } 25 | ] 26 | ) 27 | .then((message) => { 28 | return assert.equal(message.text, locale_message.help) 29 | }) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /okimochi/test/unit/test_locale.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const assert = require('assert') 3 | const locale_en = require(path.join(__dirname, '..', '..', 'locale', "english")) 4 | const locale_ja = require(path.join(__dirname, '..', '..', 'locale', "japanese")) 5 | 6 | function compareKeys(a, b) { 7 | var aKeys = Object.keys(a).sort(); 8 | var bKeys = Object.keys(b).sort(); 9 | return JSON.stringify(aKeys) === JSON.stringify(bKeys); 10 | } 11 | 12 | 13 | assert(compareKeys(locale_en, locale_ja)) 14 | -------------------------------------------------------------------------------- /okimochi/test/util.js: -------------------------------------------------------------------------------- 1 | 2 | module.exports.failTest = () => { 3 | throw new Error('Expect Promise to be rejected but its fullfilled!') 4 | } 5 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ux 3 | docker-compose down 4 | docker network create -d bridge --subnet 182.0.0.0/24 --gateway 182.0.0.1 ci 5 | docker-compose -f docker-compose.test.yml -p ci build okimochi 6 | docker-compose -f docker-compose.test.yml -p ci run --rm okimochi 7 | docker-compose -f docker-compose.test.yml -p ci down 8 | docker network rm ci 9 | -------------------------------------------------------------------------------- /userdb/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/campfire-inc/OKIMOCHI/7b9b7aa3f8f824017a22dfc87adea497c5df9e63/userdb/.gitkeep --------------------------------------------------------------------------------