調べ物した結果

現役SEが仕事と直接関係ないことを調べた結果とか感想とか

Slackを使って問題集作ってみる~Slack->API->GAS->スプレッドシート~

概要


社内でAWS勉強会を開催していたら、社内メンバーが有志でAWS問題集チャンネルを作ってくれた。(ありがたや)
そのままでも十分に使えるけどスプレッドシートに模擬試験が生成できないかやってみた。
Slackだと
gyazo.com
こういった形で、答えをスレッドにして書いておけば一覧で見れてGood。
覚えることに主眼を置きたくはないけど、悪くもないなーと思った。入門だし。

で、全部一括でやって点数出したいとかそういった要望があるかなーと思って作ってみることにした。

必要なステップ


サクサクのサク。というわけにはいかない。先に手順を整理する。大まかには
Slack側の実装
・Slack Apiで特定チャンネルにアクセスできるように口を開ける(アクセスキーを作る)
GAS側の実装
Google スプレッドシートを作る
・GASでApiを経由して特定チャンネルのメッセージを取ってくる。
・メッセージを加工して模試テキスト形式に落とし込む。
・落とし込んだテキストをスプレッドシートに張り付けるスクリプトを作る
スクリプトをキックするボタンをつくる。
とこんな感じ。ほとんどGAS。この手順で進めていく。

Slack側の実装

Slack Apiで特定チャンネルにアクセスできるように口を開ける(アクセスキーを作る)


一度ここを参照するのだ。(※注意:今回使うAPIはここで説明されているものと違う)
www.kwbtblog.com
ここを見るのが一番わかりやすかったのでここを参照してもらうのが一番かなーと思う。

手順(APIを使う)

まで進めてもらえばアクセスキーはゲットできると思う。
以降の手順は今回は使用しない(使うAPIが違うので手順が違う。)

ということでサクサクっと作ってキーをゲットする
APPを作って
gyazo.com
Permissionsを選択
gyazo.com
Scopesでスコープを追加する。(add an OAuth Scopeをクリックして追加)
gyazo.com
今回はチャンネルのテキストを読み込みたいだけなので
channels:read
channels:history
を追加すればいい。
gyazo.com
追加したらページの上部のほうに戻って、InstallAppTo~でインストールする
gyazo.com
そうするとOAutoのAccessTokenが作成される。
f:id:couraeg:20191219214502p:plain
これでSlack側の準備は完了。

GAS側の実装


GASのほうを実装していこう

Google スプレッドシートを作る


なんでもいいのでドライブを開いてスプレッドシートを作ろう
gyazo.com

GASでApiを経由して特定チャンネルのメッセージを取ってくる


スクリプトを作る。ツールメニューからスクリプトエディタを選んで選択する
gyazo.com
空っぽのスクリプトエディタが起動される。
gyazo.com

ここからはAPIのリファレンスのにらめっこしながら作る形になる。
Slack側のリファレンスと
https://api.slack.com/methods/channels.history
GAS側のリファレンス
https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app

で、こうなる。

/**
 * 模擬試験用のテキスト群を取得する
 * @return {Object} Request Json object
 */
function getExamItems()
{
  // SlackAPIのAccessKey
  var token = "******************************************************";
  // SlackのURLからチャンネルID取ってくる。
  var cannelID = "************************"
}

/**
 * APIリクエストを作って返す(tokenをヘッダーに埋め込む)
 * @param {string} Request Token. SlackAPIのAccessKey
 * @return {Json} Request Json object
 */
function createGetRequest(token)
{
  // Authorizationに埋め込むことでURLで渡さないようにする。https://api.slack.com/docs/oauth
  var headers =
  {
    "Authorization" : "Bearer" + " " + token    
  };
  var params =
  {
    "headers" : headers,
    "contentType": "application/json"
  };
  Logger.log("createGetRequest:{" + params + "}");
  return params;
}

/**
 * CannelHistoryのアクセスURLを返す.
 * @param {string} target cannel ID. スラックのURLから取得してね。
 * @return {string} access url
*/
function cannelHistoryURL(cannelID)
{
  var url = "https://slack.com/api/channels.history";
  url += "?";
  url += "channel=" + cannelID;
  Logger.log("cannelHistoryURL:{" + url + "}");
  return url;
}

/**
 * UrlFetchAppを使って結果を返す。
 * @param {string} Request URL
 * @param {Object} Request Option Parameters
 * @return {Object} Get Request Response Json Object
 */
function fetchApp(url, params)
{
  var response = UrlFetchApp.fetch(url, params);
  var parsedResponse = JSON.parse(response.getContentText());
  Logger.log("getRequest:{" + parsedResponse + "}");
  return parsedResponse;
}

必要っぽいところにはコメントは入れているので。察する形で。
アクセスキーをURLに埋め込まないようにしているとこがポイント。あとはGETメソッドをつかってFetchしているだけ。
APIからとってきた値はそのままではただの文字列だと扱いづらいのでJsonにコンバートかけている。

メッセージを加工して模試テキスト形式に落とし込む。


JSONにコンバートしてもまだまだ使いにく。
gyazo.com
こんなノリでづらづらあるとしんどいので以下の条件を加味して加工する
・いるのは「Messages」だけ。
・「Messages」のなかでも「Text」「ts(タイムスタンプ)」があればなんとかなる。
・問題の方のMessageには「replies」がついて、そこにリプライの「ts」が埋まっている。
・反対に「答え」には「replies」がない。(複数リプライをつけるとまずいが)
・書き込みの降順で取得してくるので「ts」を使って時系列に直す。

/**
 * 模擬試験用のテキスト群を取得する
 * @return {Object} Request Json object
 */
function getExamItems()
{
  // SlackAPIのAccessKey
  var token = "******************************************************";
  // SlackのURLからチャンネルID取ってくる。
  var cannelID = "************************"
  var response = fetchApp(cannelHistoryURL(cannelID), createGetRequest(token));
  var examItems = convertExamItems(response);
}

/**
 * APIリクエストを作って返す(tokenをヘッダーに埋め込む)
 * @param {string} Request Token. SlackAPIのAccessKey
 * @return {Json} Request Json object
 */
function createGetRequest(token)
{
  // Authorizationに埋め込むことでURLで渡さないようにする。https://api.slack.com/docs/oauth
  var headers =
  {
    "Authorization" : "Bearer" + " " + token    
  };
  var params =
  {
    "headers" : headers,
    "contentType": "application/json"
  };
  Logger.log("createGetRequest:{" + params + "}");
  return params;
}

/**
 * CannelHistoryのアクセスURLを返す.
 * @param {string} target cannel ID. スラックのURLから取得してね。
 * @return {string} access url
*/
function cannelHistoryURL(cannelID)
{
  var url = "https://slack.com/api/channels.history";
  url += "?";
  url += "channel=" + cannelID;
  Logger.log("cannelHistoryURL:{" + url + "}");
  return url;
}

/**
 * UrlFetchAppを使って結果を返す。
 * @param {string} Request URL
 * @param {Object} Request Option Parameters
 * @return {Object} Get Request Response Json Object
 */
function fetchApp(url, params)
{
  var response = UrlFetchApp.fetch(url, params);
  var parsedResponse = JSON.parse(response.getContentText());
  Logger.log("getRequest:{" + parsedResponse + "}");
  return parsedResponse;
}

/**
 試験データとして扱いやすいように加工して返却する。
 */
function convertExamItems(response)
{
  var messages = fillExamMessages(response);
  var sortedMessages = orderByTimeStampeAscending(messages);
  Logger.log("formatExamItem:sortedMessages{" + sortedMessages + "}");
  
  // timeStampをキーとして、問題(リプライがあるか)と回答で分ける。
  var questions = {};
  var answers = {};
  for(i in sortedMessages)
  {
    var message = sortedMessages[i];
    if(isQuestion(message))
    {
      questions[message["ts"]] = message;
    }
    else
    {
      answers[message["ts"]] = message;
    }        
  }
  
  var examItems = [];
  for(key in questions)
  {
    var question = questions[key];
    var replieKey = question["replies"][0]["ts"];
    question["answer"] = answers[replieKey];
    examItems.push(question);
  }
  return examItems;
}

function isQuestion(message)
{
  return "replies" in message;
}

/**
 * メッセージの部分だけ抽出して返却する。
 * @return {object[]} 
 */
function fillExamMessages(response)
{
  return response["messages"];
}

/**
 タイムスタンプでソートして返却する
 */
function orderByTimeStampeAscending(response)
{
  return response.sort(function(a,b){
        if( parseFloat(a["ts"]) < parseFloat(b["ts"]) ) return -1;
        if( parseFloat(a["ts"]) < parseFloat(b["ts"]) ) return 1;
        return 0;
  });
}

と。こんな感じになる。 tsで問題と答えを紐づけているところがポイントかな。

落とし込んだテキストをスプレッドシートに張り付けるスクリプトを作る。


スクリプト作っただけでは動かないので、新規のシート起こしてそこにいい感じに
転写するように設定していく。

/**
 * 模擬試験用のシートを作成する。
 */
function createNewExamSheet()
{
  // sheetのURL
  var sheetID = "******";
  var name = "new_sheet";
  
  // 試験データを読み込む。
  var examItems = getExamItems();
  // 書き込み用のシートを作る。
  createNewSheet(sheetID,name);  
  // で、書き込む。
  writeExam(sheetID, name, examItems);
}

/**
 * シートを作る
 */
function createNewSheet(sheetID, name)
{
  var spreadsheet = SpreadsheetApp.openById(sheetID);
  var existsSheet = spreadsheet.getSheetByName(name);
  if(existsSheet != null)
  {
    // 追加できないのであったら消す。
    spreadsheet.deleteSheet(existsSheet);
  }
  spreadsheet.insertSheet(name);
}

/**
 * シートに書き込む
 */
function writeExam(sheetID, name, examItems)
{
  var spreadsheet = SpreadsheetApp.openById(sheetID);
  var sheet = spreadsheet.getSheetByName(name);

  for(i in examItems)
  {
    var item  = examItems[i];
    var questionRange = "B" + (parseInt(i) + 1);
    var answerRange = "C" + (parseInt(i) + 1);

    sheet.getRange(questionRange).setValue(item["text"]);
    sheet.getRange(answerRange).setValue(item["answer"]["text"]);
    
    // 答えが見えちゃうから黒塗りする
    sheet.getRange(answerRange).setBackground('#000000');
  }
}

ヘッダーとか、もうちょっと加工してもよさそう。実行するとこうなる
gyazo.com
おおむねおっけいでしょう。
SheetIDはURLから取得できるので、適当に検索してもらえばすぐ場所はわかると思います。

スクリプトをキックするボタンをつくる。


もうここまできたらほとんどできたようなものなのだけれど。
後は使いやすいようにボタンを作る。
ぴゃぴゃっと図形を張り付けて
gyazo.com
クリックして「・・・」の縦表示ボタンからスクリプトの割り当てを選ぶ
gyazo.com
つくった関数を宣言する(引数つかえないので、どうしても引数が欲しい場合はセルの値読み込んだりとか工夫がいります。)
gyazo.com
おわり。
ぽちっとクリックしてみると・・・
見事スクリプトが実行されて無事問題が作成できる。やったぜ!

あとがき


ということで、そんな感じでスプレッドシートとSlackが連携できますので、試してみてはいかがだろうか。

最終的なソース全文はこちら。(呼び込み用に必要になるキーをまとめた)

/**
 * 模擬試験用のシートを作成する。
 */
function CreateExamSheet()
{
  var sheetID = "*************************************";
  var token = "*************************************";
  var channelID = "*************************************";
  createNewExamSheet(sheetID, "new_sheet", token, channelID);
}

/**
 * 模擬試験用のシートを作成する。
 */
function createNewExamSheet(sheetID, name, token, channelID)
{
  // 試験データを読み込む。
  var examItems = getExamItems(token, channelID);
  // 書き込み用のシートを作る。
  createNewSheet(sheetID, name);  
  // で、書き込む。
  writeExam(sheetID, name, examItems);
}

/**
 * シートを作る
 */
function createNewSheet(sheetID, name)
{
  var spreadsheet = SpreadsheetApp.openById(sheetID);
  var existsSheet = spreadsheet.getSheetByName(name);
  if(existsSheet != null)
  {
    // 追加できないのであったら消す。
    spreadsheet.deleteSheet(existsSheet);
  }
  spreadsheet.insertSheet(name);
}

/**
 * シートに書き込む
 */
function writeExam(sheetID, name, examItems)
{
  var spreadsheet = SpreadsheetApp.openById(sheetID);
  var sheet = spreadsheet.getSheetByName(name);

  for(i in examItems)
  {
    var item  = examItems[i];
    var questionRange = "B" + (parseInt(i) + 1);
    var answerRange = "C" + (parseInt(i) + 1);

    sheet.getRange(questionRange).setValue(item["text"]);
    sheet.getRange(answerRange).setValue(item["answer"]["text"]);
    
    // 答えが見えちゃうから黒塗りする
    sheet.getRange(answerRange).setBackground('#000000');
  }
}

/**
 * 模擬試験用のテキスト群を取得する
 * @return {Object} Request Json object
 */
function getExamItems(token, channelID)
{
  var response = fetchApp(cannelHistoryURL(channelID), createGetRequest(token));
  var examItems = convertExamItems(response);
  Logger.log("getExamItems:{" + getExamItems + "}");
  return examItems;
}

/**
 * APIリクエストを作って返す(tokenをヘッダーに埋め込む)
 * @param {string} Request Token. SlackAPIのAccessKey
 * @return {Json} Request Json object
 */
function createGetRequest(token)
{
  // Authorizationに埋め込むことでURLで渡さないようにする。https://api.slack.com/docs/oauth
  var headers =
  {
    "Authorization" : "Bearer" + " " + token    
  };
  var params =
  {
    "headers" : headers,
    "contentType": "application/json"
  };
  Logger.log("createGetRequest:{" + params + "}");
  return params;
}

/**
 * CannelHistoryのアクセスURLを返す.
 * @param {string} target cannel ID. スラックのURLから取得してね。
 * @return {string} access url
*/
function cannelHistoryURL(channelID)
{
  var url = "https://slack.com/api/channels.history";
  url += "?";
  url += "channel=" + channelID;
  Logger.log("cannelHistoryURL:{" + url + "}");
  return url;
}

/**
 * UrlFetchAppを使って結果を返す。
 * @param {string} Request URL
 * @param {Object} Request Option Parameters
 * @return {Object} Get Request Response Json Object
 */
function fetchApp(url, params)
{
  var response = UrlFetchApp.fetch(url, params);
  var parsedResponse = JSON.parse(response.getContentText());
  Logger.log("getRequest:{" + parsedResponse + "}");
  return parsedResponse;
}

/**
 試験データとして扱いやすいように加工して返却する。
 */
function convertExamItems(response)
{
  var messages = fillExamMessages(response);
  var sortedMessages = orderByTimeStampeAscending(messages);
  Logger.log("formatExamItem:sortedMessages{" + sortedMessages + "}");
  
  // timeStampをキーとして、問題(リプライがあるか)と回答で分ける。
  var questions = {};
  var answers = {};
  for(i in sortedMessages)
  {
    var message = sortedMessages[i];
    if(isQuestion(message))
    {
      questions[message["ts"]] = message;
    }
    else
    {
      answers[message["ts"]] = message;
    }        
  }
  
  var examItems = [];
  for(key in questions)
  {
    var question = questions[key];
    var replieKey = question["replies"][0]["ts"];
    question["answer"] = answers[replieKey];
    examItems.push(question);
  }
  return examItems;
}

function isQuestion(message)
{
  return "replies" in message;
}

/**
 * メッセージの部分だけ抽出して返却する。
 * @return {object[]} 
 */
function fillExamMessages(response)
{
  return response["messages"];
}

/**
 タイムスタンプでソートして返却する
 */
function orderByTimeStampeAscending(response)
{
  return response.sort(function(a,b){
        if( parseFloat(a["ts"]) < parseFloat(b["ts"]) ) return -1;
        if( parseFloat(a["ts"]) < parseFloat(b["ts"]) ) return 1;
        return 0;
  });
}