Posted at

[黒い画面不要] ブラウザだけで始めるSlack Bot開発(2) - 実装編


はじめに



  • 前回の記事でSlack Botが動くようになったと思います

  • 今回はスラッシュコマンドからQiitaのAPIを叩いて記事をキーワードで検索していきます

  • 更にボタンを使ったインタラクティブな操作もできるようにします


Qiita APIの仕様確認



  • Qiita API v2を見てみるとGET /api/v2/itemsが使えそうです

  • しかしソート順を制御するパラメータが見つからず作成日時の降順でしか返ってこないようです


    • いいねやストック数の多い順に記事を検索したかったのですが今回は諦めます



  • 検索クエリのみを指定してAPIを叩くことにします

  • 最終的にそこそこ複雑になってしまうので段階的に実装と挙動の確認をやっています


実装(1)


  • まずは単純にQiita APIを叩いてSlackに返すところまでを実装します

  • skillsディレクトリにファイルを作り以下のようなコードを記述します


    • これだけでSlackから/qiita reactといったqiita検索コマンドを作ることができます




skills/slash_commands.js

const request = require("request");

module.exports = function (controller) {
// slash command
controller.on('slash_command', function (bot, message) {
switch (message.command) {
case '/qiita':

search_qiita(bot, message);
break;
}
});

function search_qiita(bot, message) {

const base_url = "https://qiita.com/api/v2/items"
const query=`query=${message.text}`
const query_url =base_url + "?" + query
console.log(query_url);

request({
url: query_url,
method: 'GET'
}, function (error, response, body) {
console.log("error", error);
console.log("response", response);
console.log("body", body);
console.log("message", message);
bot.replyPrivate(message, body);
});
}
}



  • bodyにAPIのレスポンスボディが入っておりbot.replyPrivate(message, body);でslackに結果を返しています


    • replyPrivateというメソッドを使うとコマンド実行を行った人にだけ結果が見えるようになります

    • messageにはチャンネルIDやらコマンド実行者やらメッセージの情報(今回はスラックコマンド自体の情報)もろもろが入っています



  • 上記のコードだとSlack上ではJSONがそのまま表示されて見にくいので記事URL、タイトル、本文だけを抽出するようにします

Screenshot from 2018-12-01 00-49-27.png


実装(2)


  • ここではタイトル(+URLリンク)とボタンを表示できるようにします

  • 複数記事のタイトルと本文の全てが一度に表示されたら結構な文字数になってしまうやはり見にくいと思います

  • それでは困るのでSlack Attachmentsとして本文はボタンをクリックした場合のみ表示できるようにしたいと思います

  • AttachmentsにするにはJSONを作って上げる必要があります

  • 1記事1アタッチメントとしてJSONを作っていきます


    • Attachmentsについて詳細が知りたければ上のリンクを読んでください




skills/slash_commands.js

const request = require("request");

module.exports = function (controller) {
// slash command
controller.on('slash_command', function (bot, message) {
switch (message.command) {
case '/qiita':

search_qiita(bot, message);
break;
}
});

function search_qiita(bot, message) {

const base_url = "https://qiita.com/api/v2/items"
const query=`query=${message.text}`
const query_url =base_url + "?" + query
console.log(query_url);

request({
url: query_url,
method: 'GET'
}, function (error, response, body) {
let res_message = {};
//res_message.text = '_Searching for ' + message.text + '_';
// FIXME?
res_message.text = message.text;
//res_message.text = JSON.parse(JSON.stringify(message.text));

let actions = [];
let attachments = [];

//console.log(body);
const articles = JSON.parse(body);
if (articles === undefined) {
bot.replyPrivate(message, "みつからないよ");
return;
}
//console.log(articles);

articles.forEach(function (article) {
const article_id = article.id;
const article_title = article.title;
const article_url = article.url;

let article_content;
if (article.body === undefined) {
article_content = "";
} else {
article_content = "```" + article.body.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, '').slice(0, 1024) + "```";
}

let attachment = {};
attachment.fallback = "fallback string";
attachment.title = article_title;
attachment.title_link = article_url;

let btn_name = "expand";
let btn_text = "Expand";

attachment.text = "";
btn_name = "expand";
btn_text = "Expand";

attachment.callback_id = "qiita";
attachment.color = "#333388";
attachment.attachment_type = "default";
attachment.actions = [{
"name": btn_name,
"text": btn_text,
"type": "button",
"style": "primary",
//"value": article_id
"value": message.text + "|" + article_id
}]
attachments.push(attachment);
});

res_message.attachments = attachments;
res_message.replace_original = true;
console.log(res_message.attachments);

bot.replyPrivate(message, res_message);
});
}
}



  • 上記コードで以下のような結果を返せるようになりました

Screenshot from 2018-12-01 01-17-55.png


  • タイトルは記事へのリンクとなっておりクリックするとブラウザで記事ページが開きます

  • Expandボタンをクリックしても今の段階では何も起きません

  • このボタンの挙動ですが実はクリックするとHTTPSリクエストが飛ぶようになっています

  • クリックのコールバック処理をサーバ側に実装する必要があります


実装(3)


  • interactive callback処理を追加していきます

  • Expandボタンをクリックしたら本文を表示しボタンのラベルをCollapseに変更する処理を書きます

  • 更にCollapseボタンをクリックしたら本文を非表示にしボタンのラベルをExpandに戻す処理を書きます


skills/slash_commands.js

var request = require("request");

module.exports = function (controller) {
// slash command
controller.on('slash_command', function (bot, message) {
switch (message.command) {
case '/qiita':

search_qiita(bot, message, null, null);
break;
}
});

// interactive callback
controller.on('interactive_message_callback', function (bot, message) {
const btn_name = message.actions[0].name;
const btn_value = message.actions[0].value;
console.log(message.channel)
console.log(btn_value)

if (message.callback_id == "qiita") {
switch (btn_name) {

case 'collapse':
case 'expand':
//const texts = message.text.split("|")
const texts = message.text.split("|");
message.text = texts[0];
const article_id = texts[1];
//console.log(texts[0]);
//console.log(texts[1]);
search_qiita(bot, message, btn_name, article_id)
break;
}
}
});

function search_qiita(bot, message, clicked_btn_name, clicked_article_id) {

const base_url = "https://qiita.com/api/v2/items"
const query=`query=${message.text}`
const query_url =base_url + "?" + query
console.log(query_url);

request({
url: query_url,
method: 'GET'
}, function (error, response, body) {
let res_message = {};
//res_message.text = '_Searching for ' + message.text + '_';
// FIXME?
res_message.text = message.text;
//res_message.text = JSON.parse(JSON.stringify(message.text));

let actions = [];
let attachments = [];

//console.log(body);
const articles = JSON.parse(body);
if (articles === undefined) {
bot.replyPrivate(message, "みつからないよ");
return;
}
//console.log(articles);

articles.forEach(function (article) {
const article_id = article.id;
const article_title = article.title;
const article_url = article.url;

let article_content;
if (article.body === undefined) {
article_content = "";
} else {
article_content = "```" + article.body.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, '').slice(0, 1024) + "```";
}

let attachment = {};
attachment.fallback = "fallback string";
attachment.title = article_title;
attachment.title_link = article_url;

let btn_name = "expand";
let btn_text = "Expand";

if (article_id === clicked_article_id && 'expand' === clicked_btn_name) {
attachment.text = article_content;
btn_name = "collapse";
btn_text = "Collapse";
} else {
attachment.text = "";
btn_name = "expand";
btn_text = "Expand";
}

attachment.callback_id = "qiita";
attachment.color = "#333388";
attachment.attachment_type = "default";
attachment.actions = [{
"name": btn_name,
"text": btn_text,
"type": "button",
"style": "primary",
//"value": article_id
"value": message.text + "|" + article_id
}]
attachments.push(attachment);
});

res_message.attachments = attachments;
res_message.replace_original = true;
console.log(res_message.attachments);
![Screenshot from 2018-12-01 01-46-41.png](https://qiita-image-store.s3.amazonaws.com/0/3507/2749b5e1-5302-8b93-f7e2-e8d009fdab6e.png)

switch (message.type) {
case 'slash_command':
bot.replyPrivate(message, res_message);
break;
case 'interactive_message_callback':
bot.replyPrivateDelayed(message, res_message);
break;
}
});
}
}


Screenshot from 2018-12-01 01-46-41.png


  • これでようやくボタンが機能するようになりました

  • 実装が割としんどかったのでやっつけで書いてしまいましたが、もっと書き方があるような気がします


実装(4)


  • これまでのSlackに返ってきて結果はコマンドを叩いた人にしか見えていません

  • 最後の実装として任意の記事をチャンネルに共有する処理を書いていきます

  • Postボタンを追加してクリック時にチャンネルの他の人に見える形式でSlackに記事のURLを返し、これまでの検索結果をクリアする処理を書きます


    • Qiitaの記事はSlackでOGPのタイトルと記事本文の一部が表示されるのでURLだけをポストすればOKだと思います




skills/slash_commands.js

var request = require("request");

module.exports = function (controller) {
// slash command
controller.on('slash_command', function (bot, message) {
switch (message.command) {
case '/qiita':

search_qiita(bot, message, null, null);
break;
}
});

// interactive callback
controller.on('interactive_message_callback', function (bot, message) {
const btn_name = message.actions[0].name;
const btn_value = message.actions[0].value;
console.log(message.channel)
console.log(btn_value)

if (message.callback_id == "qiita") {
switch (btn_name) {

case 'collapse':
case 'expand':
//const texts = message.text.split("|")
const texts = message.text.split("|");
message.text = texts[0];
const article_id = texts[1];
//console.log(texts[0]);
//console.log(texts[1]);
search_qiita(bot, message, btn_name, article_id)
break;
case 'post':
message.delete_original = true;
bot.replyPrivateDelayed(message, "_Postボタンが押されたため以下情報を共有しました_");
bot.say({ text: btn_value, channel: message.channel });
break;
}
}
});

function search_qiita(bot, message, clicked_btn_name, clicked_article_id) {

const base_url = "https://qiita.com/api/v2/items"
const query=`query=${message.text}`
const query_url =base_url + "?" + query
console.log(query_url);

request({
url: query_url,
method: 'GET'
}, function (error, response, body) {
let res_message = {};
//res_message.text = '_Searching for ' + message.text + '_';
// FIXME?
res_message.text = message.text;
//res_message.text = JSON.parse(JSON.stringify(message.text));

let actions = [];
let attachments = [];

//console.log(body);
const articles = JSON.parse(body);
if (articles === undefined) {
bot.replyPrivate(message, "みつからないよ");
return;
}
//console.log(articles);

articles.forEach(function (article) {
const article_id = article.id;
const article_title = article.title;
const article_url = article.url;

let article_content;
if (article.body === undefined) {
article_content = "";
} else {
article_content = "```" + article.body.replace(/<("[^"]*"|'[^']*'|[^'">])*>/g, '').slice(0, 1024) + "```";
}

let attachment = {};
attachment.fallback = "fallback string";
attachment.title = article_title;
attachment.title_link = article_url;

let btn_name = "expand";
let btn_text = "Expand";

if (article_id === clicked_article_id && 'expand' === clicked_btn_name) {
attachment.text = article_content;
btn_name = "collapse";
btn_text = "Collapse";
} else {
attachment.text = "";
btn_name = "expand";
btn_text = "Expand";
}

attachment.callback_id = "qiita";
attachment.color = "#333388";
attachment.attachment_type = "default";
attachment.actions = [{
"name": btn_name,
"text": btn_text,
"type": "button",
"style": "primary",
//"value": article_id
"value": message.text + "|" + article_id
}, {
"name": "post",
"text": "Post to this Channel",
"type": "button",
"style": "danger",
//"value": "*" + article_title + "*" + " " + article_url + " " + article_content
"value": article_url
}]
attachments.push(attachment);
});

res_message.attachments = attachments;
res_message.replace_original = true;
console.log(res_message.attachments);

switch (message.type) {
case 'slash_command':
bot.replyPrivate(message, res_message);
break;
case 'interactive_message_callback':
bot.replyPrivateDelayed(message, res_message);
break;
}
});
}
}



  • 上記のコードで新たにPostボタンが表示されるようになります

Screenshot from 2018-12-01 02-05-56.png


  • これをクリックすると以下のように検索結果がクリアされて記事のURLがチャンネルにポストされます

    Screenshot from 2018-12-01 02-06-54.png


  • これで一通りの機能が実装できました


  • 今回作成したGlitchのプロジェクトはこちらに公開状態で置いてあります https://glitch.com/~somber-forest


  • これをRemixしてSlackとBotkitの.envファイルを設定すれば自分の環境でも動かせると思います



さいごに


  • SlackとBotをいったり来たりするインタラクティブ処理だったのでそこそこ複雑になってしまいました


    • 普通にちょっと面倒なWebアプリを開発している気分になってきます(実際Webアプリですが)

    • もっと良い実装方法があれば知りたいです



  • いいねやストック数も検索結果に表示できると良さそうです

  • またExpand/Collapseボタンのレスポンスがあまり良くないで改良したいですね

  • 最後にQiita APIが投稿日時の降順でしか返せないのが使いにくいと思います


    • どうにかしていいねやストック数順に返すことができるのでしょうか??

    • プログラムでソートしなおすことも可能ですがあまりやりたくはないですね、、、



  • ここまで出来れば他のサイトのAPIに差し替えたりアタッチメントを変えたりすることも難しくないと思うのでより便利な機能を作っていきたいと思います