きっかけ
Teamsでチーム内コミュニケーションをとり、Plannerでチームのタスク管理をしている場合に、「いちいち両方の画面開くのが面倒だなー。TeamsからPlannerへ直接タスク登録とかできないの?」という意見があったので、Botを作ってみることにしました!
環境
- Microsoft Bot Framework
- TeamsのBotを作るので、Bot Frameworkを使います。
- Teams以外にも、SkypeやSlackなども対応していて便利!
- 詳細はSecretaryBot 開発チーム Blogを参照してください。詳細に説明されています!!
- Heroku
- Bot本体を動かすWebサーバーとして使います。
- Microsoft Azure App Serviceを使って構築することが多いようですが、今回はHerokuで構築します。
- Node.js
- Microsoft Bot FrameworkではC#とNode.jsのSDKが用意されています。
- 今回はNode.jsで作ります。
- ここからインストールしてください。
- GraphAPI
- PlannerへのアクセスはGraphAPIを使います。
- その他開発環境
- MacBook Air (macOS Sierra)
- Terminal
- Atom
構成イメージ
環境構築
Microsoft Bot Framework
1.Microsoftアカウントを使って、Sing inします。
Microsoft Bot Framework
2.[My bots]メニューの右上画面にある[Create a bot]ボタンをクリック。
3.[Create]をクリック。
4.[Register an existing bot built using Bot Builder SDK.]を選択し、[Ok]をクリック。
5.設定を入れていきます。
- Bot profile
- Display name:35文字以内。
- Bot handle:英数字とアンダースコアのみ。一度登録すると変更できません。あと重複も不可です。
- Long description:Botの説明文を入力。
- Configuration
- Analytics
- 空白で大丈夫です。
- Admin
- Owners:電子メールアドレスが入っていると思います。
6.全て設定が終わったら、[I agree to the Terms of Use, Privacy Statement, and Code of Conduct for the Microsoft Bot Framework (Preview).] にチェックを入れ、[Register]をクリックします。
Heroku
Heroku環境構築
Herokuは日本語の資料がありましたので、こちらのサイトを参考にして環境構築をしてください。
この時点では、「ステップ 3: ログイン」まで実施すれば大丈夫です。
Herokuアプリケーション作成
1.ローカルの任意のフォルダにて今回のプロジェクトフォルダを作成します。
$ mkdir plannerbot
$ cd plannerbot
2.Node.jsのパッケージを初期化し、package.jsonを作成します。
[entry point:]は、server.jsとします。それ以外は全部Enterで問題ありません。
$ npm init
3.BotBuilderとRestifyをインストール
$ npm install --save botbuilder
$ npm i --save restify@4.1.1
Restifyは最新のVer5だと問題があるため、4.1.1をバージョン指定しインストールします。
restify.acceptParser is not a function;
4.まずは基本的なコードを書きます。下記の内容をコピペで大丈夫です。
const MICROSOFT_APP_ID = process.env.MICROSOFT_APP_ID;
const MICROSOFT_APP_PASSWORD = process.env.MICROSOFT_APP_PASSWORD;
//Import Modules
var restify = require('restify');
var builder = require('botbuilder');
//=========================================================
// Bot Setup
//=========================================================
// Setup Restify Server
var server = restify.createServer();
server.listen(process.env.port || process.env.PORT || 3978, function () {
console.log('%s listening to %s', server.name, server.url);
});
// Create chat bot
var connector = new builder.ChatConnector({
appId: MICROSOFT_APP_ID,
appPassword: MICROSOFT_APP_PASSWORD
});
var bot = new builder.UniversalBot(connector);
server.post('/api/messages', connector.listen());
//=========================================================
// Bots Dialogs
//=========================================================
bot.dialog('/', function (session) {
session.send("Hello World");
});
5.HerokuCLIを使ってHerokuにログインします。
$ heroku login
Enter your Heroku credentials.
Email: <your email address>
Password (typing will be hidden):
Logged in as <your email address>
6.Herokuでアプリケーションを作成します。
アプリケーション名は、Herokuの中で一意である必要があります。
$ heroku apps:create <アプリケーション名>
Creating ⬢ <アプリケーション名>... done
https://<アプリケーション名>.herokuapp.com/ | https://git.heroku.com/<アプリケーション名>.git
7.Gitリポジトリを初期化します。
$ git init
8.リモートリポジトリとしてHerokuを追加します。
$ git remote add heroku https://git.heroku.com/<アプリケーション名>.git
9.Git管理対象外ファイル(.gitignore)を作成します。
.DS_Store
.gitignore
npm-debug.log
node_modules
10.Herokuがプログラム起動する際に実行するファイル(Procfile)を作成します。
web: node server.js
Herokuアプリケーションへ環境変数をセット
プログラムを書く前に、利用する資格情報などを全て環境変数としてセットしておきます。
セットする資格情報は下記の通りです。
KEY | 補足 |
---|---|
MICROSOFT_APP_ID | 環境構築の5.で作成したアプリケーションのID |
MICROSOFT_APP_PASSWORD | 環境構築の5.で作成したアプリケーションのPASSWORD |
Herokuへ環境変数を登録するコマンドは下記です。
$ heroku config:set <KEY>="<VALUE>"
Herokuデプロイ
1.Herokuに、作成したコードをデプロイします。
$ git add . && git commit -m "first commit" && git push heroku master
2.Herokuのログを下記コマンドで確認します。
$ heroku logs --tail
BotにWebhookを設定
1.Microsoft Bot Framworkへログインし、[Mybot]より登録したBotを選択します。
2.[Messaging endpoint]のURLにHerokuのアプリのURLを登録して、変更を保存します。
https://<アプリケーション名>.herokuapp.com/api/messages
TeamsからBotへ話しかける
1.MicrosoftBotFrameworkのMyBotの[CHANNELS]の[Microsoft Teams]をクリックしTeamsへBotを登録する
2.Botに話しかけてみましょう。HelloWorldと応答が返ってきました!!
実装
環境準備ができたので、メインのTeamsからPlannerへアクセスし、Plannを参照するというプログラムを書きます。
Microsoft Bot FramworkのBot Builder SDK for Node.jsを使います。
Office365認証をする
BotがユーザーのPlannerへアクセスするために、ユーザーに対し認証画面を表示する必要があります。
ユーザーは認証画面から認証を行なったあと、Botは認証結果(AcceessToken,RefreshToken)を受け取り、
GraphAPIを使ってPlannerへアクセスします。
Botで認証する方法については、何パターンかあります。下記の素晴らしいページが参考になります!
今回は、上記記事の「Pattern B」に近い形で実装してみました。Botが取得した認証情報はStoratgeではなく、Bot FrameworkのState機能を利用して保存しています。
Stateサービスについてはこちらを参照してください。
上記記事を参考にしながら、私は下記のように組み立ててみました。
初心者が書いたコードなので、、、おかしなところがあったらご指摘ください!!(多分いっぱいある・・・)
const MICROSOFT_APP_ID = process.env.MICROSOFT_APP_ID;
const MICROSOFT_APP_PASSWORD = process.env.MICROSOFT_APP_PASSWORD;
const AZUREAD_APP_CLIENT_ID = process.env.AZUREAD_APP_CLIENT_ID;
const AZUREAD_APP_REDIRECT_URI = "https://<アプリケーション名>.herokuapp.com/api/oauthcallback";
const RESOURCE = "https://graph.microsoft.com";
const DOMAIN = "<Office365の独自ドメイン>";
//Import Modules
const restify = require('restify');//REST WEBサービスのフレームワーク
const builder = require('botbuilder');//MicrosoftBotFramework
const Promise = require('bluebird'); // Promiseの仕組みを提供するモジュール
const plannerService = require('./planner-service');//Plannerモジュール
const azureADService = require('./azureAD-service');//azureADモジュール
const qs = require('querystring');
const http = require('http');
const https = require('https');
const moment = require("moment");//時刻操作モジュール
const crypto = require('crypto');
const expressSession = require('express-session');
//=========================================================
// Auth Setup
//=========================================================
//state情報の暗号化用パスコードを設定
const passcode = crypto.randomBytes(20).toString('hex');
server.use(restify.queryParser());
server.use(restify.bodyParser());
server.use(expressSession({ secret: 'keyboard cat', resave: true, saveUninitialized: false }));
server.get('/api/oauthcallback', function(req, res, next){
const authcode = req.query.code;
//accessTokenとrefreshTokenを取得する関数を呼ぶ
azureADService.azureAdAuth(authcode, function(response){
const accessToken = response[0];
const refreshToken = response[1];
const address = JSON.parse(decrypt(req.query.state, passcode));
const magicCode = crypto.randomBytes(4).toString('hex');
const messageData = { magicCode: magicCode, accessToken: accessToken, refreshToken: refreshToken, userId: address.user.id, name: address.user.name };
const continueMsg = new builder.Message().address(address).text(JSON.stringify(messageData));
bot.receive(continueMsg.toMessage());
res.send('Welcome ' + address.user.name + '! Please copy this number and paste it back to your chat so your authentication can complete: ' + magicCode);
});
});
//=========================================================
// Bots Dialogs
//=========================================================
bot.dialog('/', [
(session, args, next) => {
if (!(session.userData.userName && session.userData.accessToken && session.userData.refreshToken)) {
session.send("あなたの代わりにoffice365に接続するため最初に認証してください。");
session.beginDialog('/azureadauth');
} else {
next();
}
},
(session, results, next) => {
if (session.userData.userName && session.userData.accessToken && session.userData.refreshToken) {
session.send("ようこそ " + session.userData.userName + "!ログインが完了しました。");
builder.Prompts.choice(session,"ご用件を教えてください。",["Plan作成・参照","終了","ログアウト"],{listStyle: builder.ListStyle.button, retryPrompt:"選択肢から選んでください",maxRetries:'2'});
} else {
session.endConversation("ログイン情報が確認できませんでした。");
}
},
(session, results, next) => {
const resp = results.response.entity;
if (resp === 'Plan作成・参照') {
session.beginDialog('/getPlan');
} else if (resp === '終了') {
session.endConversation("作業を終了します。");
} else if (resp === 'ログアウト') {
session.userData.loginData = null;
session.userData.userName = null;
session.userData.accessToken = null;
session.userData.refreshToken = null;
session.endConversation("ログアウトしました。またのご利用をお待ちしてます!");
} else {
next();
}
},
(session, results) => {
session.endConversation("作業を終了します。再開する場合は話しかけてください。");
}
]);
//AzureAD認証
bot.dialog('/azureadauth', [
function(session) {
const address = encrypt(JSON.stringify(session.message.address), passcode);
const AZUREAD_AUTH_URL = "https://login.microsoftonline.com/common/oauth2/authorize?client_id=" + AZUREAD_APP_CLIENT_ID + "&response_type=code&redirect_uri=" + qs.escape(AZUREAD_APP_REDIRECT_URI) + "&response_mode=query&resource=" + qs.escape(RESOURCE) + "&state=" + qs.escape(address) + "&domain_hint=" + DOMAIN;
const msg = new builder.Message(session);
const att = new builder.HeroCard(session)
.title("Office365 Authorize")
.text("Sign in ボタンをクリックしてログインしてください。")
.buttons([
builder.CardAction.openUrl(session, AZUREAD_AUTH_URL, 'Sing in')
]);
msg.addAttachment(att);
builder.Prompts.text(session, msg);
}, function(session, results){
session.userData.loginData = JSON.parse(results.response);
if (session.userData.loginData && session.userData.loginData.magicCode && session.userData.loginData.accessToken) {
session.beginDialog('/validateCode');
} else {
session.replaceDialog('/azureadauth', { invalid: true });
}
}, function(session, results){
if (results.response) {
session.userData.userName = session.userData.loginData.name;
session.endDialogWithResult({ response: true });
} else {
session.endDialogWithResult({ response: false });
}
}
]);
//magicCode認証
bot.dialog('/validateCode', [
(session) => {
builder.Prompts.text(session, "画面に表示されるコードをコピーして入力してください。中止する場合は「quit」と入力してください。");
},
(session, results) => {
const code = results.response;
if (code === 'quit') {
session.endDialogWithResult({ response: false });
} else {
if (code === session.userData.loginData.magicCode) {
session.userData.accessToken = session.userData.loginData.accessToken;
session.userData.refreshToken = session.userData.loginData.refreshToken;
session.endDialogWithResult({ response: true });
} else {
session.send("コードが不正なようです。もう1度お試しください。");
session.replaceDialog('validateCode');
}
}
}
]);
//=========================================================
// Utilities
//=========================================================
function encrypt(text,password){
var cipher = crypto.createCipher('aes-256-ctr',password);
var crypted = cipher.update(text,'utf8','hex');
crypted += cipher.final('hex');
return crypted;
}
function decrypt(text,password){
var decipher = crypto.createDecipher('aes-256-ctr',password);
var dec = decipher.update(text,'hex','utf8');
dec += decipher.final('utf8');
return dec;
}
'use strict';
const http = require('http');
const https = require('https');
const qs = require('querystring');
const request = require('request');
//graphAPI
const AZUREAD_APP_CLIENT_ID = process.env.AZUREAD_APP_CLIENT_ID;
const AZUREAD_APP_CLIENT_SECRET = process.env.AZUREAD_APP_CLIENT_SECRET;
const AZUREAD_APP_REDIRECT_URI = "https://<アプリケーション名>.herokuapp.com/api/oauthcallback";
const RESOURCE = "https://graph.microsoft.com";
const domain = "<Office365の独自ドメイン>";
//AuthCodeを使ったAccessToken、RefreshToken取得関数
function getAccessToken(authcode, callback) {
const postdata = qs.stringify({
'grant_type' : 'authorization_code',
'code' : authcode ,
'client_id' : AZUREAD_APP_CLIENT_ID,
'client_secret' : AZUREAD_APP_CLIENT_SECRET,
'redirect_uri' : AZUREAD_APP_REDIRECT_URI,
'resource' : RESOURCE
});
const opt = {
host : 'login.microsoftonline.com',
port : null,
path : '/common/oauth2/token',
method : 'POST',
headers : {
'Content-Type' : 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postdata)
}
};
let body = '';
const authreq = https.request(opt, function(authres) {
authres.setEncoding('utf8');
authres.on('data', (chunk) => {
body += chunk;
}).on('end', () => {
const jsonobj = JSON.parse(body);
const AccessToken = jsonobj.access_token;
const RefreshToken = jsonobj.refresh_token;
callback([AccessToken,RefreshToken]);
});
});
authreq.write(postdata);
authreq.end();
}
//RefreshTokenを使ったAccessToken取得関数
function refreshAccessToken(refreshtoken, callback) {
const postdata = qs.stringify({
'grant_type' : 'refresh_token',
'refresh_token' : refreshtoken ,
'client_id' : AZUREAD_APP_CLIENT_ID,
'client_secret' : AZUREAD_APP_CLIENT_SECRET,
'resource' : RESOURCE
});
const opt = {
host : 'login.microsoftonline.com',
port : null,
path : '/common/oauth2/token',
method : 'POST',
headers : {
'Content-Type' : 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postdata)
}
};
let body = '';
const authreq = https.request(opt, function(authres) {
authres.setEncoding('utf8');
authres.on('data', (chunk) => {
body += chunk;
}).on('end', () => {
const jsonobj = JSON.parse(body);
const AccessToken = jsonobj.access_token;
const RefreshToken = jsonobj.refresh_token;
callback([AccessToken,RefreshToken]);
});
});
authreq.write(postdata);
authreq.end();
}
exports.azureAdAuth = function (authcode, callback){
getAccessToken(authcode, (res) => {
const accesstoken = res[0];
const refreshtoken = res[1];
callback(res);
});
};
exports.refreshToken = function (refreshtoken, callback){
refreshAccessToken(refreshtoken, (res) => {
const accesstoken = res[0];
const refreshtoken = res[1];
callback(res);
});
};
Plannerからプラン一覧とそのタスクを取得する
認証が成功したら、session.userDataに記録したAccessToken、RefreshTokenを使ってPlannerへアクセスします。
今回はサンプルでログインしたユーザーが所属しているPlanのリストを提示しユーザーに選択を促します。
その後、選択されたPlanに登録された各タスクのうち期限切れのものの一覧を表示します。
//Plan取得
let plan = {};
bot.dialog('/getPlan', [
function (session) {
plannerService.getPlan(session.userData.refreshToken, function (res){
session.userData.accessToken = res[0];
session.userData.refreshToken = res[1];
plan = res[2];
builder.Prompts.choice(session,"プランを選んでください", plan, {listStyle: builder.ListStyle.button, retryPrompt:"プランを選んでください"});
});
},
function(session, results){
session.privateConversationData.planid = ("%(id)s", plan[results.response.entity]);
session.privateConversationData.planowner = ("%(owner)s", plan[results.response.entity]);
session.replaceDialog('/judgeQuestion');
}
]);
bot.dialog('/judgeQuestion', [
function (session) {
builder.Prompts.choice(session,"何をしますか?",["タスクの確認","タスクの作成"],{listStyle: builder.ListStyle.button, retryPrompt:"どちらかを選んでください"});
},
function (session, results){
if(results.response.entity){
switch (results.response.entity) {
case 'タスクの確認':
if(session.privateConversationData.planid !== undefined){
session.beginDialog('/getTasks');
} else {
session.replaceDialog('/getPlan');
}
break;
case 'タスクの作成':
if(session.privateConversationData.planid !== undefined){
session.beginDialog('/createTask');//本記事ではタスク作成に関しては記載していません。
} else {
session.replaceDialog('/getPlan');
}
break;
}
}
},
function(session){
builder.Prompts.choice(session,"他の操作をしますか?",["はい","いいえ"],{listStyle: builder.ListStyle.button, retryPrompt:"どちらかを選んでください"});
},
function (session, results){
if(results.response.entity){
switch (results.response.entity) {
case 'はい':
session.replaceDialog('/getPlan');
break;
case 'いいえ':
session.endDialog();
break;
}
}
}
]);
//期限切れのタスク取得
bot.dialog('/getTasks',[
function(session){
session.send("期限切れ&未完了のタスクを表示します。");
plannerService.getTasks(session.userData.accessToken, session.privateConversationData.planid.id, function (res){
session.send(res);
session.endDialog();
});
//処理済みのplanidは破棄
session.privateConversationData.planid = undefined;
}
]);
'use strict';
const http = require('http');
const https = require('https');
const qs = require('querystring');
const fs = require('fs');//ファイルシステム
const FormData = require('form-data');
const request = require('request');
const azureADService = require('./azureAD-service');//azureADモジュール
const async = require('async');
//アクセス権があるプラン名一覧取得関数
function getAllPlans(accesstoken, callback) {
if (typeof accesstoken != "undefined"){
const opt = {
host : 'graph.microsoft.com',
port : null,
path : '/v1.0/me/plans',
method : 'GET',
headers : {
'Authorization' : 'Bearer ' + accesstoken
}
};
let body = '';
const authreq = https.request(opt, function(authres) {
authres.setEncoding('utf8');
authres.on('data', (chunk) => {
body += chunk;
}).on('end', () => {
const resData = JSON.parse(body);
let resarray = {};
for (let i = 0; i < (resData.value).length; i++){
resarray[resData.value[i].title] = {'id':resData.value[i].id,'owner':resData.value[i].owner};
}
callback(resarray);
});
});
authreq.end();
}else{
callback("AccessTokenError");
}
}
//期限切れタスク一覧取得関数
function getExpiredTasks(accesstoken, planid, callback) {
const opt = {
host : 'graph.microsoft.com',
port : null,
path : '/v1.0/planner/plans/' + planid + '/tasks',
method : 'GET',
headers : {
'Authorization' : 'Bearer ' + accesstoken
}
};
let body = '';
const authreq = https.request(opt, function(authres) {
authres.setEncoding('utf8');
authres.on('data', (chunk) => {
body += chunk;
}).on('end', () => {
const resData = JSON.parse(body);
let resarray = [];
for (let i = 0; i < (resData.value).length; i++){
let task = {'assignuser':'','title':'','dueDateTime':'','percentComplete':''};
task.assignuser = Object.keys(resData.value[i].assignments);
task.title = resData.value[i].title;
task.dueDateTime = resData.value[i].dueDateTime;
task.percentComplete = resData.value[i].percentComplete;
resarray.push(task);
}
callback(resarray);
});
});
authreq.end();
}
exports.getPlan = function (refreshtoken, callback){
azureADService.refreshToken(refreshtoken, (res) => {
const accesstoken = res[0];
const refreshtoken = res[1];
getAllPlans(accesstoken, (entries) =>{
callback([accesstoken, refreshtoken, entries]);
});
});
};
exports.getTasks = function (accesstoken, planid, callback){
getExpiredTasks(accesstoken, planid, (entries) => {
let tasks = "";
async.eachSeries(entries, function(value, next){
//日付処理
const today = new Date();
const due = new Date(Date.parse(value.dueDateTime));
const y = due.getFullYear();
const m = due.getMonth() + 1;
const d = due.getDate();
//期限切れかつ未完了のみ表示表示
if(due < today && value.percentComplete != 100){
getUsername(accesstoken, value.assignuser, (name) => {
tasks = tasks + name + ":" + value.title + ":" + y + "年" + m + "月" + d + "日" + "\n\n";
next();
});
} else {
next();
}
},function(err){
callback(tasks);
});
});
};
結果
下記フォーマットで、期限切れのタスクで未完了のタスクを一覧表示します。
<名前>:<タスク名(タスクのタイトル)>:期限
実際の画像が、伏字だらけになってしまってわかり辛くてすみません。
最後に
今回は、情シス業務に携わっていた頃に、社員の業務効率化のために作ったBOTを紹介しました。
見ての通り、、プログラミングは初心者で、なんとか動くというレベルですが・・・
記事にして投稿することで、GraphAPIを使ってこんなことができるんだ!というのが少しでも同じ立場の人が参考になるといいなーと思ってます。
次回は、BOTからBoxへアクセスするというところを記事にしてみたいと思います。