├── docs └── assets │ ├── icon.png │ ├── screenshot01.png │ ├── screenshot02.png │ └── screenshot03.png ├── src ├── __test__ │ ├── index.test.js │ └── adaptors │ │ └── slack.test.js ├── views │ ├── add_to_slack.ejs │ ├── add_to_slack_result.ejs │ ├── register.ejs │ ├── register_result.ejs │ ├── layout.ejs │ └── index.ejs ├── adaptors │ ├── express-middlewares │ │ ├── slack.js │ │ └── hostVerifier.js │ ├── passport.js │ ├── routes │ │ ├── actions.js │ │ └── auth.js │ ├── redis.js │ └── slack.js ├── index.js └── application │ ├── patrol-service.js │ ├── app-mention-service.js │ ├── conversation-service.js │ ├── patrol.js │ └── message │ └── msg.js ├── config ├── test.js ├── production.js └── default.js ├── app.json ├── package.json ├── LICENSE ├── .gitignore ├── README.md └── public └── css └── index.css /docs/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rockymanobi/dm-keisatsu/HEAD/docs/assets/icon.png -------------------------------------------------------------------------------- /docs/assets/screenshot01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rockymanobi/dm-keisatsu/HEAD/docs/assets/screenshot01.png -------------------------------------------------------------------------------- /docs/assets/screenshot02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rockymanobi/dm-keisatsu/HEAD/docs/assets/screenshot02.png -------------------------------------------------------------------------------- /docs/assets/screenshot03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rockymanobi/dm-keisatsu/HEAD/docs/assets/screenshot03.png -------------------------------------------------------------------------------- /src/__test__/index.test.js: -------------------------------------------------------------------------------- 1 | // てすとのてすとw 2 | describe('yes', ()=>{ 3 | it('hoge', ()=>{ 4 | console.log('hey') 5 | }); 6 | }) 7 | -------------------------------------------------------------------------------- /config/test.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | slack:{ 3 | testToken: process.env.SLACK_TEST_TOKEN, 4 | } 5 | } 6 | 7 | if(process.env.NODE_ENV === 'test'){ 8 | describe('', () => { 9 | it('', ()=>{}); 10 | }); 11 | } 12 | -------------------------------------------------------------------------------- /src/views/add_to_slack.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 |

インストール

4 | 5 | DM警察をSlackにインストールする 6 | 7 | 13 |
14 | 15 | -------------------------------------------------------------------------------- /src/views/add_to_slack_result.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 |

4 | DM警察のインストールが<%= success ? '成功' : '失敗' %>したよ。 5 |

6 | <% if( success ){ %> 7 |

8 | @dm-keisatsu というbotが追加されているのを確認したら、次は 自分のDMのパトロールを依頼 しよう。 9 |

10 | <% } %> 11 | 12 | <% if( !success ){ %> 13 | もう一度インストールを試みる 14 | <% } %> 15 |
16 | -------------------------------------------------------------------------------- /src/views/register.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 |

自分のDMのパトロールを依頼

4 | 5 | 15 |
16 | 17 | -------------------------------------------------------------------------------- /config/production.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | slack:{ 3 | clientId: process.env.SLACK_APP_CLIENT_ID, 4 | clientSecret: process.env.SLACK_APP_CLIENT_SECRET, 5 | verificationToken: process.env.SLACK_APP_VERIFICATION_TOKEN, 6 | appLevelToken: process.env.SLACK_APP_LEVEL_TOKEN, 7 | }, 8 | redis: { 9 | url: process.env.REDIS_URL || 'redis://localhost:6379', 10 | }, 11 | envName: 'production', 12 | host: process.env.HOST_URL, 13 | timeSpanForIgnoreMsec: 10 * 60 * 1000 14 | }; 15 | -------------------------------------------------------------------------------- /config/default.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | slack:{ 3 | clientId: process.env.SLACK_APP_CLIENT_ID, 4 | clientSecret: process.env.SLACK_APP_CLIENT_SECRET, 5 | verificationToken: process.env.SLACK_APP_VERIFICATION_TOKEN, 6 | appLevelToken: process.env.SLACK_APP_LEVEL_TOKEN, 7 | }, 8 | redis: { 9 | url: process.env.REDIS_URL || 'redis://localhost:6379', 10 | }, 11 | envName: 'local-development', 12 | host: process.env.HOST_URL || 'hppt://localhost:4000', 13 | timeSpanForIgnoreMsec: 1 * 60 * 1000 14 | }; 15 | -------------------------------------------------------------------------------- /src/__test__/adaptors/slack.test.js: -------------------------------------------------------------------------------- 1 | 2 | const { slack } = require('config'); 3 | const { revokeToken, getChannelInfo } = require('../../adaptors/slack'); 4 | 5 | describe('', () => { 6 | it('', async () => { 7 | const token = slack.testToken; 8 | const res = await getChannelInfo(token, 'GJK5G9LE7'); 9 | console.log(res); 10 | }); 11 | }); 12 | describe('', () => { 13 | it('', async () => { 14 | const token = slack.testToken; 15 | const res = await revokeToken(token, 1); // TESTモードで実行 16 | console.log(res); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/adaptors/express-middlewares/slack.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | function ensureRequestFromSlack(req, res, next){ 3 | if( req.body.token !== config.slack.verificationToken ){ 4 | console.error('invalid verification token'); 5 | return res.send('ng'); 6 | } 7 | next(); 8 | } 9 | 10 | function challengeRequestHandler(req, res, next){ 11 | if( req.body.challenge ){ 12 | return res.send(req.body.challenge); 13 | } 14 | next(); 15 | } 16 | 17 | module.exports = { 18 | ensureRequestFromSlack, 19 | challengeRequestHandler, 20 | }; 21 | -------------------------------------------------------------------------------- /src/adaptors/express-middlewares/hostVerifier.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const url = require('url'); 3 | 4 | // これを利用することで... 5 | function hostVerifier(req, res, next){ 6 | const requestHost = req.headers.host; 7 | const configHost = url.parse(config.host).host; 8 | if( configHost !== requestHost) { 9 | const msg = `Env value HOST_URL did not match request.headers.host: ${requestHost} vs ${configHost}`; 10 | console.error(msg); 11 | return res.send(msg); 12 | } 13 | next(); 14 | } 15 | 16 | module.exports = { 17 | hostVerifier 18 | }; 19 | -------------------------------------------------------------------------------- /src/views/register_result.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 | 結果 : <%= success ? '成功' : '失敗' %> 4 | 5 | <% if( !success ){ %> 6 | <% } %> 7 | 8 | 9 | 10 |

11 | パトロールの依頼が<%= success ? '成功' : '失敗' %>したよ。 12 |

13 | <% if( success ){ %> 14 |

15 | 自分が送信したDMも監視対象なので、誰かにDMしてみると試せます(あんまりやると本末転倒だけど) 16 |

17 | <% } %> 18 | 19 | <% if( !success ){ %> 20 | 21 | @dm-keisatsuというbotがいるかどうかを調べよう
22 | いない場合は[slackにDM警察をインストール]を試してみよう
23 | いる場合はもう一度依頼する 24 | <% } %> 25 |
26 | -------------------------------------------------------------------------------- /src/views/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 |

DM警察<%= process.env.WARN_TEXT %>

9 |
10 | <%- body %> 11 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dm-keisatsu", 3 | "description": "DM警察です", 4 | "website": "https://dm-keisatsu.herokuapp.com/", 5 | "repository": "https://github.com/rockymanobi/dm-keisatsu", 6 | "logo": "https://user-images.githubusercontent.com/1561249/69445654-17933400-0d96-11ea-8297-a140515d3cfe.png", 7 | "success_url": "/", 8 | "env": { 9 | "SLACK_APP_CLIENT_ID": { 10 | "description": "Slack AppのCLIENT_ID" 11 | }, 12 | "SLACK_APP_CLIENT_SECRET": { 13 | "description": "Slack AppのCLIENT_SECRET" 14 | }, 15 | "SLACK_APP_VERIFICATION_TOKEN": { 16 | "description": "Slack APPのverification token" 17 | }, 18 | "HOST_URL": { 19 | "description": "Service Host Url (`https://${app-name}.herokuapp.com`)" 20 | } 21 | }, 22 | "addons": [ 23 | { 24 | "plan": "heroku-redis" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dm-keisatsu", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node src/index.js", 8 | "test": "jest" 9 | }, 10 | "jest": { 11 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(ts?|jsx?|tsx?)$", 12 | "moduleFileExtensions": [ 13 | "ts", 14 | "tsx", 15 | "js", 16 | "jsx", 17 | "json", 18 | "node" 19 | ], 20 | "collectCoverage": true, 21 | "collectCoverageFrom": [ 22 | "src/**/*.js" 23 | ] 24 | }, 25 | "author": "Takanori Koroki (https://github.com/rockymanobi/)", 26 | "license": "MIT", 27 | "dependencies": { 28 | "body-parser": "^1.19.0", 29 | "config": "^3.1.0", 30 | "ejs": "^2.6.1", 31 | "express": "^4.17.0", 32 | "express-ejs-layouts": "^2.5.0", 33 | "ioredis": "^4.9.5", 34 | "passport": "^0.4.0", 35 | "passport-slack-oauth2": "^1.0.2", 36 | "redis": "^2.8.0", 37 | "request": "^2.88.0", 38 | "request-promise": "^4.2.4" 39 | }, 40 | "devDependencies": { 41 | "jest": "^24.9.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2018 Takanori Koroki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/adaptors/passport.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const CLIENT_ID = config.slack.clientId; 3 | const CLIENT_SECRET = config.slack.clientSecret; 4 | const SlackStrategy = require('passport-slack-oauth2').Strategy; 5 | const passport = require('passport'); 6 | 7 | //////////////////////////////////// 8 | // DMパトロール依頼 9 | passport.use(new SlackStrategy({ 10 | name:"slack-personal", 11 | skipUserProfile: true, 12 | clientID: CLIENT_ID, 13 | clientSecret: CLIENT_SECRET, 14 | callbackURL: `${config.host}/auth/slackPersonal/callback`, 15 | scope: [ 'im:history', 'mpim:history','chat:write:bot', 'im:read'] 16 | }, (accessToken, refreshToken, params , profile, done) => { 17 | // optionally persist profile data 18 | done(null, params); 19 | } 20 | )); 21 | 22 | //////////////////////////////////// 23 | // アプリのインストール 24 | passport.use(new SlackStrategy({ 25 | name:"slack-admin", 26 | skipUserProfile: true, 27 | clientID: CLIENT_ID, 28 | clientSecret: CLIENT_SECRET, 29 | callbackURL: `${config.host}/auth/slack/callback`, 30 | scope: [ 'bot', 'commands', 'incoming-webhook'] 31 | }, (accessToken, refreshToken, params,profile, done) => { 32 | // optionally persist profile data 33 | done(null, params); 34 | } 35 | )); 36 | 37 | module.exports = passport; 38 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const config = require("config"); 2 | const express = require("express"); 3 | const bodyParser = require('body-parser'); 4 | const passport = require('./adaptors/passport'); 5 | const store = require('./adaptors/redis'); 6 | const expressLayouts = require('express-ejs-layouts'); 7 | const { hostVerifier } = require('./adaptors/express-middlewares/hostVerifier') 8 | const app = express(); 9 | 10 | console.log( `========= env=${config.envName} で起動します=========` ); 11 | 12 | app.use(hostVerifier); 13 | app.use(passport.initialize()); 14 | app.use(require('body-parser').json()); 15 | app.use(require('body-parser').urlencoded({ extended: true })); 16 | 17 | app.set('views', __dirname + '/views'); 18 | app.set('view engine', 'ejs'); 19 | app.use(expressLayouts); 20 | 21 | app.use(express.static('public')); 22 | app.use(function(req, res, next) { 23 | res.header("Access-Control-Allow-Origin", "*"); 24 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); 25 | next(); 26 | }); 27 | 28 | app.get('/', (req, res)=>{ 29 | res.render('index', {}); 30 | }); 31 | 32 | app.use('/auth', require('./adaptors/routes/auth')); 33 | app.use('/slackEvents', require('./adaptors/routes/actions')); 34 | 35 | const server = app.listen(process.env.PORT || 4000, function(){ 36 | console.log("Node.js is listening to PORT:" + server.address().port); 37 | }); 38 | -------------------------------------------------------------------------------- /src/application/patrol-service.js: -------------------------------------------------------------------------------- 1 | const store = require('../adaptors/redis'); 2 | const slack = require('../adaptors/slack'); 3 | const Patrol = require('./patrol'); 4 | const msg = require('./message/msg'); 5 | 6 | async function handleMessage(reqBody) { 7 | const patrol = new Patrol(reqBody, store); 8 | if( await patrol.shouldIgnore() ){ return; } 9 | 10 | const latestWarnLog = await store.getLatestWarnLog(reqBody.team_id, reqBody.event.channel); 11 | const recentEnough = !!latestWarnLog && new Date().getTime() - latestWarnLog.ts < 20000; 12 | 13 | setTimeout(()=>{ 14 | const authedUsers = patrol.getAuthedUsers().then((authedUsers)=>{ 15 | const receiverUserId = patrol.pickReceiverUserId().then((receiverUserId)=>{ 16 | const token = store.getUserAccessToken( reqBody.team_id, receiverUserId).then((token)=>{ 17 | const body = (recentEnough)? 18 | msg.getMessageBody('ignored',{}) : 19 | msg.getMessageBody('hello', { userIds: authedUsers, tokenHolder: receiverUserId }); 20 | body.channel = reqBody.event.channel; 21 | slack.sendMessage( token, body); 22 | }); 23 | }); 24 | }); 25 | },1000); 26 | 27 | store.addWarnLog(reqBody.team_id, reqBody.event.channel, reqBody.event.user); 28 | return; 29 | } 30 | 31 | async function reActivatePatrol(teamId, channelId) { 32 | return store.deleteReejctedLog(teamId, channelId) 33 | } 34 | 35 | module.exports = { handleMessage, reActivatePatrol }; 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | 90 | .DS_Store 91 | dump.rdb 92 | -------------------------------------------------------------------------------- /src/application/app-mention-service.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const store = require('../adaptors/redis'); 3 | const slack = require('../adaptors/slack'); 4 | 5 | async function handleAppMention({ 6 | sendUserId, 7 | channelId, 8 | teamId, 9 | text, 10 | }) { 11 | try { 12 | const replyText = await reaction({ text, sendUserId, teamId }); 13 | // REPLY 14 | const botToken = await store.getBotToken(teamId); 15 | const body = { 16 | text: replyText, 17 | channel: channelId, 18 | } 19 | slack.sendMessage( botToken, body); 20 | } catch (e) { 21 | console.error(e); 22 | } 23 | } 24 | 25 | async function reaction({text, sendUserId, teamId}){ 26 | try { 27 | if(text.includes('パトロールよろしく')){ 28 | return `<@${sendUserId}>ありがとうございます。<${config.host}/auth/register|コチラ> から登録してください!` 29 | } else if (text.includes('パトロールおしまい')) { 30 | // await REVOKE 31 | const token = await store.getUserAccessToken(teamId, sendUserId) 32 | const result = await slack.revokeToken(token); 33 | return `<@${sendUserId}>ありがとうございました!再度パトロールをご要望の際は「パトロールよろしく」とメンションください!\n\n` + 34 | `注意 : アプリをインストールした人コマンドを実行すると、@DM警察 がチャネルからいなくなってしまいます。\n` + 35 | `もしも\`removed an integration from this channel: dm-keisatsu\`と表示されている場合は、再度 ${config.host} からDMをワークスペースにインストールし直してください(消去する必要はありません)` 36 | } else if (text.includes('実績')){ 37 | return `実装したいね` 38 | } else { 39 | return `<@${sendUserId}> すみません。まだ勉強中につき「パトロールよろしく」か「パトロールおしまい」しか理解できないんです。` 40 | } 41 | } catch(e) { 42 | console.error(e); 43 | return `<@${sendUserId}> 失敗した... Twitterで@rocky_manobi とかに聞いてみてください` 44 | } 45 | } 46 | 47 | 48 | module.exports = { handleAppMention }; 49 | // <${config.host}/auth/register|コチラ> 50 | -------------------------------------------------------------------------------- /src/application/conversation-service.js: -------------------------------------------------------------------------------- 1 | const store = require('../adaptors/redis'); 2 | const msg = require('./message/msg'); 3 | 4 | function handleButtonActions(payload) { 5 | 6 | const action = payload.actions[0]; 7 | 8 | const reaction = { 9 | userSelection: null, 10 | actionValue: null, 11 | }; 12 | if(action.type === 'button'){ 13 | const button = payload.actions[0]; 14 | reaction.actionValue = JSON.parse(button.value); 15 | reaction.userSelection = button.name; 16 | }else if(action.type === 'select'){ 17 | const selected = payload.actions[0].selected_options[0]; 18 | reaction.actionValue = JSON.parse(selected.value); 19 | reaction.userSelection = `理由 : 「${reaction.actionValue.text}」`; 20 | } 21 | 22 | if(reaction.actionValue.storyKey === 'rejected'){ 23 | store.addRejectedLog(payload.team.id, payload.channel.id, payload.user.id, reaction.actionValue); 24 | } 25 | 26 | // update selected value 27 | const lastAttachment = payload.original_message.attachments[payload.original_message.attachments.length -1]; 28 | lastAttachment.actions = []; 29 | lastAttachment.color = '#aaaaaa'; 30 | lastAttachment.fields = [{value: `:slightly_smiling_face: <@${payload.user.id}>\n${reaction.userSelection}`}]; 31 | 32 | let newFields = []; 33 | payload.original_message.attachments.forEach((at)=>{ 34 | newFields = newFields.concat(at.fields); 35 | }); 36 | payload.original_message.attachments = [{ fields: newFields}]; 37 | 38 | // next anser 39 | const n = msg.getAttachments(reaction.actionValue.storyKey,{}); 40 | payload.original_message.attachments = payload.original_message.attachments.concat(n); 41 | 42 | 43 | return payload.original_message; 44 | } 45 | 46 | module.exports = { handleButtonActions }; 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Deploy](https://www.herokucdn.com/deploy/button.png)](https://heroku.com/deploy?template=https://github.com/rockymanobi/dm-keisatsu) 3 | 4 | # DM警察です 5 | 6 | * [公式サイト](https://dm-keisatsu.herokuapp.com) からインストールして使えます 7 | * 独自環境を構築したい場合は[こちらの記事](https://rocky-dev.kibe.la/shared/entries/4d1c1fd8-c2cb-4399-ab28-42380f812839) を参考にどうぞ。 8 | * 紹介記事は[こちら](https://blog.rocky-manobi.com/entry/2019/12/09/235739) 9 | 10 | # 使い方 11 | 12 | * インストールは公式サイトを参考に 13 | * 自分のDMパトロールをお願いしたい場合は「パトロールよろしく」とBotにメンション 14 | * 自分のDMパトロールを解除したい場合は「パトロールおしまい」とBotにメンション 15 | * 「10分黙る」状態になったとき「`/dm-keisatsu`」とすると再び喋るモードになる 16 | 17 | # ローカルで動かす 18 | 19 | * 一度[Herokuボタンを使った環境構築](https://rocky-dev.kibe.la/shared/entries/4d1c1fd8-c2cb-4399-ab28-42380f812839)をやってみると、設定項目などの感覚がわかって良いと思います。 20 | 21 | ### 注意 22 | 23 | ### requirement 24 | 25 | * node.js v10.0 or later 26 | * redis 27 | * slackアプリのトークン(client_id, client_secret, verification_token) 28 | * ngrockなど、グローバルIPでローカルPCにアクセスできるような環境 29 | 30 | ### 動かし方 31 | 32 | #### 依存関係のインストール 33 | 34 | ``` 35 | npm i 36 | ``` 37 | 38 | #### 起動 39 | 40 | ``` 41 | REDIS_URL=${redisのURL} \n 42 | SLACK_APP_CLIENT_ID=${slack app の client id} \n 43 | SLACK_APP_CLIENT_SECRET=${slack app の client secret} \n 44 | SLACK_APP_VERIFICATION_TOKEN=${slack app の verification token} \n 45 | npm start 46 | ``` 47 | 48 | 49 | **デフォルトのlocalhost:4000以外で動かしたい場合** 50 | 51 | ``` 52 | 53 | HOST_URL=${そのまま。 例 : https://testtest.herokuapp.com } \n 54 | REDIS_URL=${redisのURL} \n 55 | SLACK_APP_CLIENT_ID=${slack app の client id} \n 56 | SLACK_APP_CLIENT_SECRET=${slack app の client secret} \n 57 | SLACK_APP_VERIFICATION_TOKEN=${slack app の verification token} \n 58 | npm start 59 | ``` 60 | 61 | # Contribution 62 | 63 | * Super Welcom! 64 | * なのですが、勢いで書いてるから、しばらくは大変だと思います。闇をみる覚悟を。 65 | * 永続化機構がRedisオンリー。すでに辛いので移行したい。 66 | * テストはコンソールデバッグ機としての用途でしか書いてないので気をつけて 67 | 68 | # License 69 | 70 | [MIT](https://github.com/rockymanobi/dm-keisatsu/blob/master/LICENSE) 71 | 72 | -------------------------------------------------------------------------------- /src/adaptors/routes/actions.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const config = require('config'); 3 | const router = express.Router(); 4 | const { handleMessage, reActivatePatrol } = require('../../application/patrol-service'); 5 | const { handleButtonActions } = require('../../application/conversation-service'); 6 | const { handleAppMention } = require('../../application/app-mention-service'); 7 | 8 | /** 9 | * Slack Event Subscription を捌く 10 | */ 11 | router.post('/', async (req, res)=>{ 12 | if( req.body.challenge ){ return res.send(req.body.challenge); } 13 | if( req.body.token !== config.slack.verificationToken ){ console.error('invalid verification token'); return res.send('ng'); } 14 | // 応答は非同期 15 | res.send('ok'); 16 | 17 | if(req.body.event.type ==='app_mention' ){ 18 | // DM以外でメンションされた 19 | return handleAppMention({ 20 | teamId: req.body.team_id, 21 | channelId: req.body.event.channel, 22 | sendUserId: req.body.event.user, 23 | text: req.body.event.text, 24 | }); 25 | }else{ 26 | // それ以外(DM) 27 | return handleMessage(req.body) 28 | } 29 | }); 30 | 31 | /** 32 | * Slack Button Actions を捌く 33 | */ 34 | router.post('/buttonActions', (req, res)=>{ 35 | const payload = JSON.parse(req.body.payload); 36 | if( payload.token !== config.slack.verificationToken ){ 37 | console.error('invalid verification token'); return res.send('ng'); 38 | } 39 | const responsePayload = handleButtonActions(payload); 40 | return res.send(responsePayload); 41 | }); 42 | 43 | 44 | /** 45 | * Slack Slush Commandを捌く 46 | */ 47 | router.post('/slush', async (req, res)=>{ 48 | if( req.body.token !== config.slack.verificationToken ){ 49 | console.error('invalid verification token'); return res.send('ng'); 50 | } 51 | const teamId = req.body.team_id; 52 | const channelId = req.body.channel_id; 53 | await reActivatePatrol(teamId, channelId); 54 | res.send('ok'); 55 | }); 56 | 57 | module.exports = router; 58 | -------------------------------------------------------------------------------- /src/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |

dmを受け取ると
あなたに代わって
オープンチャネルに
誘導してくれるやつです

5 |
dm警察をslackにインストールする
6 |
7 | 8 | 9 |
10 |
11 |
12 |
13 |
14 | 15 |
16 |
17 |
18 |
19 |

注意

20 |
    21 |
  • 22 | 利用するには、slackワークスペースへのインストール、その後に個別のアカウントからdm警察にパトロール依頼をする必要があります。 23 |
  • 24 |
  • 25 | パトロールを依頼した人宛のdmを検知必要があるため、このslackアプリは強めの権限を要求します。dmの内容は一切ログを取らないように作っていますが、不安な場合は作者に連絡いただき、 heroku buttonで独自の環境を作成することをお勧めします。詳しくはコチラ 26 |
  • 27 |
28 |
29 |
30 |

使ってみる

31 |
32 |
33 |
34 |

step1 slackにdm警察をインストール

35 |
36 |

あなたのslackワークスペースにdm警察をインストールします。インストールをすると@dm警察というappおよびbotが作成されます。

37 |
38 |
39 |
40 | -> 41 |
42 |
43 |
44 |

step2 自分宛てのdmのパトロールを依頼

45 |
46 |

あなた宛のdmのパトロールをdm警察を依頼します。このリンクから、または@dm警察に「パトロールよろしく」と話しかけることで登録することができます。パトロールを停止するには、 @dm警察 あてに「パトロールおしまい」と話しかけます。

47 |
48 |
49 |
50 |
51 | 59 | 60 | 61 |
62 | -------------------------------------------------------------------------------- /src/application/patrol.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const slack = require('../adaptors/slack'); 3 | 4 | class Patrol { 5 | 6 | constructor(params, store){ 7 | this.store = store; 8 | 9 | this.sendUserId = params.event.user; // メッセージを送信したユーザ 10 | this.authedUsers_ = null; // DM内にいるDM警察パトロール対象のユーザ 11 | this.event_context = params.event_context 12 | this.channelId = params.event.channel; 13 | this.teamId = params.team_id; 14 | this.eventType = params.event.type; 15 | this.eventSubType = params.event.subtype; 16 | } 17 | 18 | async getAuthedUsers(){ 19 | if (this.authedUsers_ !== null) return this.authedUsers_; 20 | const auths_list = await slack.getAuthorizationsList(this.event_context); 21 | this.authedUsers_ = auths_list.authorizations.map(au => au.user_id); 22 | return this.authedUsers_; 23 | } 24 | 25 | async shouldIgnore(){ 26 | if(this.eventType !== 'message' ){ return true; } 27 | if(false && (await this.getAuthedUsers()).includes( this.sendUserId ) ){ 28 | console.log("自分のメッセージなので無視しました"); 29 | return true; 30 | } 31 | if( this.eventSubType === 'bot_message'){ 32 | console.log("bot messageなので無視しました"); 33 | return true; 34 | } 35 | if( this.eventSubType === 'message_changed'){ 36 | console.log("change イベントは無視です"); 37 | return true; 38 | } 39 | 40 | const rejectedLog = await this.store.getLatestRejectedLog( this.teamId, this.channelId); 41 | if(rejectedLog){ 42 | const pastTimeMsec = new Date().getTime() - rejectedLog.ts; 43 | if(pastTimeMsec < config.timeSpanForIgnoreMsec){ 44 | console.log('猶予期間なので無視') 45 | return true; 46 | } 47 | } 48 | 49 | const isSelfDM = await ( async ()=>{ 50 | const token = await this.store.getUserAccessToken( this.teamId, this.sendUserId) 51 | .catch(printAndIgnoreError); 52 | if(!token){ return false } 53 | 54 | const channelInfo = await slack.getChannelInfo(token, this.channelId); 55 | if(!channelInfo.channel){ return false; } // Channel情報取得に失敗 -> 無視しないに倒す 56 | 57 | return channelInfo.channel.user === this.sendUserId; 58 | })(); 59 | if(isSelfDM) { 60 | console.log('自分宛のDMは許可'); 61 | return true; 62 | } 63 | console.log("介入しますNE"); 64 | return false; 65 | } 66 | 67 | async pickReceiverUserId(){ 68 | const authedUsers = await this.getAuthedUsers(); 69 | // TODO メソッド名と実態が全然違うので変えよう 70 | const primaryUserId = authedUsers.find( au => au === this.sendUserId); 71 | if( primaryUserId ){ return primaryUserId; } 72 | 73 | const userId = authedUsers.find( au => au !== this.sendUserId); 74 | return (userId)? userId : this.sendUserId; // 上記の場合どのみち失敗するのだが 75 | } 76 | } 77 | 78 | function printAndIgnoreError(e){ 79 | console.error(e); 80 | } 81 | 82 | module.exports = Patrol; 83 | -------------------------------------------------------------------------------- /public/css/index.css: -------------------------------------------------------------------------------- 1 | 2 | /* ------ */ 3 | /* Common */ 4 | /* ------ */ 5 | body { 6 | min-width: 780px; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | .contents-main { 12 | padding: 20px 20px; 13 | background: #f8f8f8; 14 | } 15 | 16 | .contents-main .section{ 17 | max-width: 950px; 18 | margin: auto; 19 | } 20 | 21 | h1, h2 { 22 | margin: 0; 23 | padding: 0; 24 | } 25 | 26 | h2 { 27 | margin-top: 20px; 28 | } 29 | 30 | 31 | /* ------ */ 32 | /* Layout */ 33 | /* ------ */ 34 | 35 | .header{ 36 | padding: 10px 20px; 37 | } 38 | 39 | .header { 40 | border-bottom: solid 1px #eeeeee; 41 | background: white; 42 | } 43 | .header h1{ 44 | margin: 0; 45 | } 46 | .link-to-top { 47 | color: black; 48 | text-decoration: none; 49 | } 50 | .footer { 51 | color: white; 52 | font-size: 12px; 53 | background: #45BED9; 54 | padding: 30px 20px; 55 | } 56 | .footer .credit { 57 | text-align: right; 58 | } 59 | 60 | /* ------ */ 61 | /* Index */ 62 | /* ------ */ 63 | .screenshots { 64 | display: flex; 65 | justify-content: space-evenly; 66 | align-items: center; 67 | 68 | width: 100%; 69 | font-size: 16px; 70 | box-sizing: border-box; 71 | padding: 80px 0px; 72 | position: relative; 73 | background: #45BED9; 74 | overflow: hidden; 75 | z-index: 1; 76 | 77 | border-bottom: solid 1px #eeeeee; 78 | border-top: solid 1px #eeeeee; 79 | } 80 | .screenshots::before { 81 | content: ""; 82 | position: absolute; 83 | top: 0; 84 | right: 0; 85 | bottom: 0; 86 | left: 0; 87 | z-index: -1; 88 | background: white; 89 | transform: translate(-313px, 0px) scale(1,2) rotate(60deg); 90 | } 91 | .screenshots img{ 92 | max-width: 100%; 93 | width: 225px; 94 | height: auto; 95 | } 96 | .catch-text { 97 | font-size: 36px; 98 | line-height: 1em; 99 | margin-bottom: 30px; 100 | } 101 | .install-button { 102 | display: inline-block; 103 | text-decoration: none; 104 | box-sizing: border-box; 105 | padding: 15px 20px; 106 | font-size: 16px; 107 | font-weight: bold; 108 | background: #FF9700; 109 | color: white; 110 | margin-bottom: 80px; 111 | } 112 | 113 | .step { 114 | display: flex; 115 | justify-content: space-evenly; 116 | } 117 | 118 | .step-item { 119 | display: flex; 120 | align-items: center; 121 | } 122 | .step-box { 123 | width: 320px; 124 | min-height: 400px; 125 | } 126 | .step-title { 127 | font-size: 14px; 128 | color: white; 129 | } 130 | .step-title a { 131 | display: block; 132 | color: white; 133 | text-decoration: none; 134 | text-align: center; 135 | background: #45BED9; 136 | padding: 10px 15px;; 137 | border-radius: 40px; 138 | } 139 | .step-visual { 140 | display: flex; 141 | align-items: center; 142 | border: 5px solid gray; 143 | width: 150px; 144 | height: 150px; 145 | border-radius: 500px; 146 | margin: auto; 147 | overflow: hidden; 148 | } 149 | .step-visual img { 150 | width: 150px; 151 | } 152 | .step .arrow { 153 | color: #FF9700; 154 | font-size: 48px; 155 | } 156 | 157 | -------------------------------------------------------------------------------- /src/adaptors/routes/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const passport = require('../passport'); 4 | const store = require('../redis'); 5 | const slack = require('../slack'); 6 | const config = require('config'); 7 | 8 | // あんまりたくさん修正入らないので、 9 | // このままロジックもここに置いておくことにする 10 | router.get('/add_to_slack', (req, res)=>{ 11 | res.render('add_to_slack', {}); 12 | }); 13 | 14 | router.get('/add_to_slack/success', (req, res)=>{ 15 | res.render('add_to_slack_result', {success: true}); 16 | }); 17 | router.get('/add_to_slack/fail', (req, res)=>{ 18 | res.render('add_to_slack_result', {success: false} ); 19 | }); 20 | 21 | router.get('/register/success', (req, res)=>{ 22 | res.render('register_result', {success: true}); 23 | }); 24 | router.get('/register/fail', (req, res)=>{ 25 | res.render('register_result', {success: false}); 26 | }); 27 | 28 | router.get('/register', (req, res)=>{ 29 | res.render('register', {}); 30 | }); 31 | 32 | router.get('/slackPersonal', passport.authorize('slack-personal')); 33 | router.get('/slack', passport.authorize('slack-admin')); 34 | 35 | router.get('/slack/callback', 36 | passport.authorize('slack-admin', { failureRedirect: '/auth/add_to_slack/fail' }), 37 | (req, res)=>{ 38 | const account = req.account; 39 | // console.log( account ); 40 | store.createOrUpdateWorkspace( 41 | account.team_id, 42 | account.team_name, 43 | account.bot.bot_user_id, 44 | account.bot.bot_access_token, 45 | account.incoming_webhook, 46 | account.user_id 47 | ).then((e)=>{ 48 | sendWebhook(account.team_id, welcomMessageBody(config.host, account.user_id,)); 49 | res.redirect('/auth/add_to_slack/success'); 50 | }).catch((e)=>{ 51 | res.redirect('/auth/add_to_slack/fail'); 52 | console.log(e); 53 | }); 54 | } 55 | ); 56 | 57 | router.get('/slackPersonal/callback', 58 | passport.authorize('slack-personal', { failureRedirect: '/auth/register/fail' }), 59 | (req, res) => { 60 | const account = req.account; 61 | store.addOrUpdateUserToken( 62 | account.team_id, 63 | account.user_id, 64 | account.access_token 65 | ).then(()=>{ 66 | res.redirect("/auth/register/success"); 67 | }).catch((e)=>{ 68 | console.log(e); 69 | res.redirect("/auth/register/fail"); 70 | }); 71 | } 72 | ); 73 | 74 | async function sendWebhook(teamId, body){ 75 | const webhookUrl = await store.getWebhookUrl(teamId); 76 | return slack.sendWebhook(webhookUrl, body); 77 | } 78 | 79 | function welcomMessageBody(host, userId){ 80 | return { 81 | text: 'SlackBot DM警察がインストールされました', 82 | attachments: [ 83 | { 84 | title: `DM警察`, 85 | title_link: `${host}`, 86 | fields: [ 87 | { 88 | value: `<@${userId}>さんによってDM警察がチームにインストールされました。`, 89 | }, 90 | { 91 | value: `DMパトロールをご希望の方は<${host}/auth/register|コチラ>より登録いただけます`, 92 | }, 93 | { 94 | value: `使い方はをご覧ください`, 95 | }, 96 | { 97 | value: `身に覚えのない方はアプリの設定画面からこのアプリを決してください`, 98 | } 99 | 100 | ], 101 | }, 102 | ], 103 | footer: 'オープンチャネル利用推進委員会の提供でお送りいたします', 104 | } 105 | } 106 | 107 | module.exports = router; 108 | -------------------------------------------------------------------------------- /src/adaptors/redis.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const client = new (require("ioredis"))(config.redis.url); 3 | client.on("error", function (err) { 4 | console.log("Error " + err); 5 | }); 6 | 7 | function getRejectedLogKey(teamId, channelId){ 8 | const REJECTED_LOG_PREFIX = 'REJECTED:'; 9 | return `${REJECTED_LOG_PREFIX}${teamId}-${channelId}`; 10 | } 11 | 12 | async function addRejectedLog(teamId, channelId, actionUserId, actionValue){ 13 | const ts = new Date().getTime(); 14 | const key = getRejectedLogKey(teamId, channelId); 15 | const body = JSON.stringify({ teamId, channelId, actionUserId, ts, actionValue}); 16 | await client.lpush(key, body); 17 | } 18 | 19 | async function getLatestRejectedLog(teamId, channelId){ 20 | const key = getRejectedLogKey(teamId, channelId); 21 | const logs = await client.lrange(key,0,0); 22 | return (logs[0])? JSON.parse(logs[0]) : null; 23 | } 24 | 25 | function getWarnLogKey(teamId, channelId){ 26 | const WARN_LOG_PREFIX = 'WARN:'; 27 | return `${WARN_LOG_PREFIX}${teamId}-${channelId}`; 28 | } 29 | 30 | async function addWarnLog(teamId, channelId, actionUserId){ 31 | const ts = new Date().getTime(); 32 | const key = getWarnLogKey(teamId, channelId); 33 | const body = JSON.stringify({ teamId, channelId, actionUserId, ts}); 34 | await client.lpush(key, body); 35 | } 36 | 37 | async function getLatestWarnLog(teamId, channelId){ 38 | const key = getWarnLogKey(teamId, channelId); 39 | const logs = await client.lrange(key,0,0); 40 | return (logs[0])? JSON.parse(logs[0]) : null; 41 | } 42 | 43 | async function createOrUpdateWorkspace( teamId, teamName, botUserId, botAccessToken, incoming_webhook, lastRegisterUserId ){ 44 | const key = teamId; 45 | const team = await getTeamObject(key); 46 | if(!team){ 47 | await client.set(key, JSON.stringify({ teamId, teamName, botUserId, botAccessToken, lastRegisterUserId, incoming_webhook,users:[] })); 48 | }else{ 49 | team.incoming_webhook = incoming_webhook; 50 | team.botAccessToken = botAccessToken; 51 | team.botUserId = botUserId; 52 | team.lastRegisterUserId = lastRegisterUserId; 53 | await client.set(key, JSON.stringify( team )); 54 | } 55 | } 56 | 57 | async function getTeamObject( teamId ){ 58 | const key = teamId; 59 | const team = await client.get(key); 60 | if(!team){ 61 | return null; 62 | }else{ 63 | return JSON.parse(team); 64 | } 65 | }; 66 | 67 | async function addOrUpdateUserToken( teamId, userId, userAccessToken ){ 68 | const key = teamId; 69 | const team = await getTeamObject(key); 70 | if(!team){ 71 | throw new Error('no application installed'); 72 | }else{ 73 | const user = team.users.find(u => u.userId === userId); 74 | if( user ){ 75 | user.userAccessToken = userAccessToken; 76 | user.ts = new Date().getTime(); 77 | }else{ 78 | team.users.push({userId, userAccessToken, ts: new Date().getTime()}); 79 | } 80 | await client.set(key, JSON.stringify( team )); 81 | } 82 | } 83 | 84 | async function deleteReejctedLog(teamId, channelId){ 85 | const key = getRejectedLogKey(teamId, channelId); 86 | return client.del(key) 87 | } 88 | 89 | async function getBotToken( teamId ) { 90 | const key = teamId; 91 | const team = await getTeamObject(key); 92 | if(!team) { return null; } 93 | return team.botAccessToken; 94 | } 95 | 96 | async function getWebhookUrl(teamId) { 97 | const team = await getTeamObject(teamId); 98 | if(!team){ throw new Error('no team fond'); } 99 | if(!team.incoming_webhook){ throw new Error('the team does not webhook scope'); } 100 | return team.incoming_webhook.url; 101 | } 102 | async function getUserAccessToken(teamId, userId) { 103 | const team = await getTeamObject(teamId); 104 | if(!team){ throw new Error('no team fond'); } 105 | 106 | const user = team.users.find(u => u.userId === userId); 107 | if(!user){ throw new Error('not registered'); } 108 | 109 | return user.userAccessToken; 110 | } 111 | 112 | module.exports = { 113 | createOrUpdateWorkspace, 114 | addOrUpdateUserToken, 115 | getUserAccessToken, 116 | getWebhookUrl, 117 | getBotToken, 118 | deleteReejctedLog, 119 | addWarnLog, 120 | getLatestWarnLog, 121 | addRejectedLog, 122 | getLatestRejectedLog, 123 | }; 124 | -------------------------------------------------------------------------------- /src/adaptors/slack.js: -------------------------------------------------------------------------------- 1 | 2 | const request = require("request-promise"); 3 | const config = require('config'); 4 | 5 | async function sendWebhook(webhookUrl, body){ 6 | const requestOptions = { 7 | method: 'POST', 8 | url: webhookUrl, 9 | json: true, 10 | body: body, 11 | }; 12 | 13 | return request(requestOptions) 14 | .then((e)=>{ console.log(e.ok); console.log("webhook sent."); return e; }) 15 | .catch((e)=>{ console.log("fail"); console.log(e); return e; }) 16 | } 17 | 18 | 19 | function sendMessage(token, body){ 20 | const requestOptions = { 21 | method: 'POST', 22 | url: "https://slack.com/api/chat.postMessage", 23 | json: true, 24 | headers: { 25 | Authorization: `Bearer ${token}`, 26 | }, 27 | body: body 28 | }; 29 | 30 | return request(requestOptions) 31 | .then((e)=>{ console.log(e.ok); console.log("message sent."); return e;}) 32 | .catch((e)=>{ console.log("fail"); console.log(e); return e;}) 33 | } 34 | 35 | 36 | function revokeToken(token, testMode) { 37 | const test = testMode? 1 : 0; 38 | const requestOptions = { 39 | method: 'GET', 40 | url: `https://slack.com/api/auth.revoke?token=${token}&test=${test}`, 41 | json: false, 42 | headers: { 43 | Authorization: `Bearer ${token}`, 44 | }, 45 | }; 46 | 47 | return request(requestOptions) 48 | .then((e)=>{ return JSON.parse(e);}) 49 | .catch((e)=>{ console.log("Fail on getChannelInfo"); console.log(e); return e;}) 50 | 51 | } 52 | 53 | function getChannelInfo(token, channelId){ 54 | const requestOptions = { 55 | method: 'GET', 56 | url: `https://slack.com/api/conversations.info?channel=${channelId}&token=${token}`, 57 | json: false, 58 | headers: { 59 | Authorization: `Bearer ${token}`, 60 | }, 61 | }; 62 | 63 | return request(requestOptions) 64 | .then((e)=>{ return JSON.parse(e);}) 65 | .catch((e)=>{ console.log("Fail on getChannelInfo"); console.log(e); return e;}) 66 | } 67 | 68 | function getAuthorizationsList(event_context){ 69 | const requestOptions = { 70 | method: 'POST', 71 | url: "https://slack.com/api/apps.event.authorizations.list", 72 | json: true, 73 | headers: { 74 | Authorization: `Bearer ${config.slack.appLevelToken}`, 75 | }, 76 | body: {event_context: event_context}, 77 | }; 78 | 79 | return request(requestOptions) 80 | .then((e)=>{ return e;}) 81 | .catch((e)=>{ console.log("Fail on getAuthorizationsList"); console.log(e); return e;}) 82 | } 83 | 84 | module.exports = { 85 | sendWebhook, 86 | sendMessage, 87 | getChannelInfo, 88 | revokeToken, 89 | getAuthorizationsList, 90 | }; 91 | 92 | // is_mpim 93 | //// COnversation : multiple 94 | //{ 95 | // "ok": true, 96 | // "channel": { 97 | // "id": "GJK5G9LE7", 98 | // "name": "mpdm-tak.koroki--jdwfg704g--takanori.koroki-1", 99 | // "is_channel": false, 100 | // "is_group": true, 101 | // "is_im": false, 102 | // "created": 1558510071, 103 | // "is_archived": false, 104 | // "is_general": false, 105 | // "unlinked": 0, 106 | // "name_normalized": "mpdm-tak.koroki--jdwfg704g--takanori.koroki-1", 107 | // "is_shared": false, 108 | // "parent_conversation": null, 109 | // "creator": "U81GRA5FY", 110 | // "is_ext_shared": false, 111 | // "is_org_shared": false, 112 | // "shared_team_ids": [ 113 | // "T81HEV46T" 114 | // ], 115 | // "pending_shared": [], 116 | // "pending_connected_team_ids": [], 117 | // "is_pending_ext_shared": false, 118 | // "is_member": true, 119 | // "is_private": true, 120 | // "is_mpim": true, 121 | // "last_read": "1558594010.001900", 122 | // "is_open": true, 123 | // "topic": { 124 | // "value": "Group messaging", 125 | // "creator": "U81GRA5FY", 126 | // "last_set": 1558510071 127 | // }, 128 | // "purpose": { 129 | // "value": "Group messaging with: @tak.koroki @jdwfg704g @takanori.koroki", 130 | // "creator": "U81GRA5FY", 131 | // "last_set": 1558510071 132 | // } 133 | // } 134 | //} 135 | // 136 | // 137 | // 138 | // 139 | // 140 | // 141 | // SELF 142 | //{ 143 | // "ok": true, 144 | // "channel": { 145 | // "id": "D81NQBWET", 146 | // "created": 1510749349, 147 | // "is_archived": false, 148 | // "is_im": true, 149 | // "is_org_shared": false, 150 | // "user": "U81GRA5FY", 151 | // "is_starred": true, 152 | // "last_read": "1574223550.000300", 153 | // "latest": { 154 | // "type": "message", 155 | // "subtype": "bot_message", 156 | // "text": "お疲れ様です!私DM警察と申します!試験運用中です。\n現在 <@U81GRA5FY>さん 宛てのDMが *オープンチャネル利用推進強化のためのパトロール* の対象となっておりまして、DMを送られた方にお声かけさせて頂いております。\n\n *試験運用中* : 監視方式、メッセージなど含めてFeedbackをもらいながら改善中です ", 157 | // "ts": "1574223550.000300", 158 | // "username": "dm-keisatsu", 159 | // "bot_id": "BQTN29X9U", 160 | // "attachments": [ 161 | // { 162 | // "callback_id": "1574222954632", 163 | // "fallback": "Adding this command requires an official Slack client.", 164 | // "id": 1, 165 | // "color": "ff3300", 166 | // "actions": [ 167 | // { 168 | // "id": "1", 169 | // "name": "DM警察って何?", 170 | // "text": "DM警察って何?", 171 | // "type": "button", 172 | // "value": "{\"version\":\"v1\",\"storyKey\":\"what_is_dm_keisatsu\"}", 173 | // "style": "default" 174 | // }, 175 | // { 176 | // "id": "2", 177 | // "name": "あ、オープンチャネル行きます", 178 | // "text": "あ、オープンチャネル行きます", 179 | // "type": "button", 180 | // "value": "{\"version\":\"v1\",\"storyKey\":\"go_open_channel\"}", 181 | // "style": "primary" 182 | // }, 183 | // { 184 | // "id": "3", 185 | // "name": "いや、これはDMじゃないと", 186 | // "text": "いや、これはDMじゃないと", 187 | // "type": "button", 188 | // "value": "{\"version\":\"v1\",\"storyKey\":\"keep_dm_light\"}", 189 | // "style": "danger" 190 | // }, 191 | // { 192 | // "id": "4", 193 | // "name": "これはただの雑談なので...", 194 | // "text": "これはただの雑談なので...", 195 | // "type": "button", 196 | // "value": "{\"version\":\"v1\",\"storyKey\":\"rejected\",\"reasonKey\":\"just_a_chat_talk\"}", 197 | // "style": "default" 198 | // } 199 | // ] 200 | // } 201 | // ] 202 | // }, 203 | // "unread_count": 0, 204 | // "unread_count_display": 0, 205 | // "is_open": true, 206 | // "priority": 0 207 | // } 208 | //} 209 | -------------------------------------------------------------------------------- /src/application/message/msg.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const tripleQuart = '```'; 3 | 4 | function getMessageBody(key, options){ 5 | const bodyGenerater = { 6 | 'hello' : helloBody, 7 | 'ignored' : ignoredBody, 8 | }[key]; 9 | if(!bodyGenerater){ throw new Error('no such message body template:' + key); } 10 | const body = bodyGenerater(options || {}); 11 | body.attachments = body.attachments.map(at => Object.assign({}, baseAttachment, at) ); 12 | return body; 13 | } 14 | 15 | const helloBody = (options)=>{ 16 | if(!options.userIds || options.userIds.length <= 0){ throw new Error('userIds must be passed'); } 17 | const users = options.userIds.map(uid => `<@${uid}>さん`).join('、'); 18 | 19 | return { 20 | text: `お疲れ様です!私DM警察と申します!試験運用中です。\n現在 ${users} 宛てのDMが *オープンチャネル利用推進強化のためのパトロール* の対象となっておりまして、DMを送られた方にお声かけさせて頂いております。`, 21 | attachments: [ 22 | { 23 | actions: [ 24 | button('DM警察って何?', buttonValue('what_is_dm_keisatsu')), 25 | button('あ、オープンチャネル行きます', buttonValue('go_open_channel'), 'primary'), 26 | button('いや、これはDMじゃないと', buttonValue('keep_dm_light'), 'danger'), 27 | button('これはただの雑談なので...', buttonValue({ storyKey: 'rejected', reasonKey: 'just_a_chat_talk'}), 'default'), 28 | ], 29 | } 30 | ] 31 | } 32 | }; 33 | 34 | const ignoredBody = (options)=>{ 35 | return { 36 | text: `ちょっとだけで良いですので、お話聞いていただけませんか??(試験運用中です)`, 37 | attachments: [ 38 | { 39 | actions: [ 40 | button('DM警察って何?', buttonValue('what_is_dm_keisatsu')), 41 | button('はいはい、違うとこでやります', buttonValue('go_open_channel'), 'primary'), 42 | button('いや、これはDMじゃないと', buttonValue('keep_dm_light'), 'danger'), 43 | button('これはただの雑談なので...', buttonValue({ storyKey: 'rejected', reasonKey: 'just_a_chat_talk'}), 'default'), 44 | ], 45 | } 46 | ] 47 | } 48 | }; 49 | 50 | function getAttachments(key, options){ 51 | const attachmentGenerater = { 52 | 'keep_dm_light' : forKeepDmLight, 53 | 'what_is_dm_keisatsu' : whatIsDmKeisatsu, 54 | 'go_open_channel' : thankYouForYourCooperation, 55 | 'keep_dm_strong' : forKeepDmStrong, 56 | 'rejected' : rejected, 57 | }[key]; 58 | if(!attachmentGenerater){ throw new Error('no such atachemnt template:' + key); } 59 | return attachmentGenerater(options || {}).map( m => Object.assign({}, baseAttachment, m) ); 60 | } 61 | 62 | const baseAttachment = { 63 | callback_id: new Date().getTime(), 64 | attachment_type: 'default', 65 | color: '#ff3300', 66 | // title: 'オープンチャネル推進委員会', 67 | // title_link: config.host, 68 | fallback: 'Adding this command requires an official Slack client.', 69 | }; 70 | 71 | function button(text, value, style){ 72 | if( !value || !JSON.parse(value).version ){ throw new Error('button value has invalid format: '+ value); } 73 | const name = text; 74 | return { 75 | name, text, value, style: style || 'default', type: 'button' 76 | }; 77 | } 78 | function buttonValue(value){ 79 | const _value = typeof value === 'string' ? { storyKey: value }:value; 80 | const base = { 81 | version: 'v1', 82 | }; 83 | return JSON.stringify(Object.assign({}, base, _value)); 84 | } 85 | 86 | const whatIsDmKeisatsu = (options)=>{ 87 | return [ 88 | { 89 | fields : [ 90 | { 91 | title: `:male-police-officer: DM警察`, 92 | value: `ありがとうございます。我々こういうものです。`, 93 | }, 94 | { 95 | title: '', 96 | value: `${tripleQuart} # オープンチャネル推進委員会 - DM警察\nより多くの情報がオープンな場所でやりとりされるSlackワークスペースを目指して活動しています。\n\n` 97 | + `パトロール希望者にDMが送られたとき、その方に代わってオープンチャネルでコミュニケーションを取っていただけるようお声かけをしています。` 98 | + `\n\nパトロールのお申し込みは<${config.host}/auth/register|コチラ>からどうぞ!\n${config.host}/auth/register${tripleQuart}` 99 | , 100 | }, 101 | ], 102 | }, 103 | { 104 | actions: [ 105 | button('なるほど、オープンチャネル行きますね', buttonValue('go_open_channel'), 'primary'), 106 | button('いや、やっぱりこれはDMで', buttonValue('keep_dm_light'), 'danger'), 107 | ], 108 | } 109 | ]; 110 | }; 111 | 112 | const rejected = (options)=>{ 113 | return [ 114 | { 115 | color: '#ff3300', 116 | fields : [ 117 | { 118 | title: `:male-police-officer: DM警察`, 119 | value: `ありがとうございます。\nでは私は他のチャネルのパトロールに行ってきますので、その間にご要件お済ませください。`, 120 | }, 121 | { 122 | value: `${tripleQuart}10分程度でこのチャネルにおけるパトロールが再開されます。${tripleQuart}`, 123 | }, 124 | ], 125 | }, 126 | ]; 127 | }; 128 | 129 | 130 | const thankYouForYourCooperation = (options)=>{ 131 | return [ 132 | { 133 | fields : [ 134 | { 135 | title: `:male-police-officer: DM警察`, 136 | value: `ご協力ありがとうございます!\nパトロールの申請も受け付けておりますので、よろしければご利用ください!`, 137 | }, 138 | ], 139 | color: '#88cc99', 140 | }, 141 | { 142 | title: 'オープンチャネル推進委員会', 143 | title_link: config.host, 144 | fields : [ 145 | { 146 | value: `より多くの情報がオープンな場所でやりとりされるSlackワークスペースを目指し、活動しています。`, 147 | }, 148 | { 149 | value: `パトロールのお申し込みは<${config.host}/auth/register|コチラ>からどうぞ!`, 150 | }, 151 | ], 152 | color: '#88cc99', 153 | } 154 | ]; 155 | }; 156 | 157 | 158 | const forKeepDmLight = (options)=>{ 159 | return [ 160 | { 161 | fields : [ 162 | { 163 | title: `:male-police-officer: DM警察`, 164 | value: `そうおっしゃる方も多いのですが、我々としてもこのような考え方に基づいてパトロールを行っておりまして...ご協力いただけますでしょうか。`, 165 | }, 166 | { 167 | value: `${tripleQuart}#本当にDMが最適ですか?\n\n` + 168 | `* ここに居ない人の力や情報を頼りたくなったとき、やり取りをシェアしたくなったときなど、ダイレクトメッセージよりもオープンチャネルに情報があった方が何かと便利です\n` + 169 | `* 通知を飛ばしたい場合は、DMでなくともオープンチャネルにメンション(@rocky等)をつけてメッセージを投稿する\n` + 170 | `* 複数人DMを多用すると、あとでどこで話したのかわからなくなるので注意です\n` + 171 | `* 適切なチャネルがわからないならチャネルを立ててしまったり、どこで話すべきかオープンチャネルで聞いてしまうのも良い手です\n` + 172 | `* いきなりオープンなところに投稿することに抵抗がある場合は、チャネルを作ってその人だけを招待しましょう。そのあと、必要あらばそのチャネルの存在を皆に知らせるなどしてシェアすることができます。\n` + 173 | `* 内容を秘密にしたい場合、やり取りが恒久的に発生する場合はprivateチャネルを立てましょう\n\n` + 174 | `* そもそも秘密にすべき情報は思ったより少ないはずです。情報の非対称性を作った時点で情報が少ない人には能動的な行動を期待することが難しくなります。\n` + 175 | tripleQuart, 176 | }, 177 | ], 178 | }, 179 | { 180 | actions: [ 181 | button('オープンチャネルに行く', buttonValue('go_open_channel'), 'primary'), 182 | button('いや、やっぱりDMがいい', buttonValue('keep_dm_strong'), 'danger'), 183 | ], 184 | } 185 | ]; 186 | }; 187 | 188 | const forKeepDmStrong = (options)=>{ 189 | return [ 190 | { 191 | fields : [ 192 | { 193 | title: `:male-police-officer: DM警察`, 194 | value: `わかりました。最後に理由だけお答えください!そしたら黙ります!`, 195 | }, 196 | ], 197 | }, 198 | { 199 | fields: [{ 200 | text: 'DMにする理由を教えてください' 201 | }], 202 | actions: [ 203 | { 204 | name: 'reason_list', 205 | text: 'DMを利用する理由', 206 | type: 'select', 207 | options: [ 208 | { 209 | text: '業務関係ない単なる雑談だから', 210 | value: buttonValue({ storyKey: 'rejected', text: '業務関係ない単なる雑談だから', reasonKey: 'just_a_chat_talk'}), 211 | }, 212 | { 213 | text: '内密に話をしたい', 214 | value: buttonValue({ storyKey: 'rejected', text: '内密に話をしたい', reasonKey: 'to_keep_it_secret'}), 215 | }, 216 | { 217 | text: '通知を飛ばしたい', 218 | value: buttonValue({ storyKey: 'rejected', text: '通知を飛ばしたい', reasonKey: 'to_mention'}), 219 | }, 220 | { 221 | text: '確実に読んでほしい', 222 | value: buttonValue({ storyKey: 'rejected', text: '確実に読んでほしい', reasonKey: 'to_make_sure_to_read'}), 223 | }, 224 | { 225 | text: '適切なチャネルが存在しない', 226 | value: buttonValue({ storyKey: 'rejected', text: '適切なチャネルが存在しない', reasonKey: 'no_channel_for_the_topic'}), 227 | }, 228 | { 229 | text: '面倒臭い', 230 | value: buttonValue({ storyKey: 'rejected', text: '面倒臭い', reasonKey: 'be_too_lazy'}), 231 | }, 232 | { 233 | text: 'その他', 234 | value: buttonValue({ storyKey: 'rejected', text: 'その他', reasonKey: 'other'}), 235 | } 236 | ] 237 | }, 238 | button('やっぱりオープンチャネル行きます', buttonValue('go_open_channel'), 'primary'), 239 | ] 240 | 241 | } 242 | ]; 243 | }; 244 | 245 | module.exports = { 246 | getAttachments, 247 | getMessageBody, 248 | }; 249 | 250 | --------------------------------------------------------------------------------