🍱

Slack App + GASで社内のシャッフルランチBotをつくってみた! 〜世界一やさしく丁寧な解説付き〜

2021/12/06に公開

目的

社内横断のつながりづくり・メンバー間の相互理解の促進

背景

メンバー増加してきた〜〜〜!
という中で、なかなか接点を持てないメンバーとも気軽なきっかけでつながりができるといいなというのがきっかけ。
誰でも自由に"シャッフルランチくん"を呼び出して、いろんなメンバーとランチして欲しいなというもの。

使い方/How to use

① shuffle_lunchをメンションする
 ※メンションするのみでOK
② みんなが🍱スタンプを押す!
③ スタンプが押された後、スレッドに表示されるボタンをクリックするとシャッフルが実行される
 ※人数によって、3人か4人を選択してね!


Slack Appの設定(前半)

まずは、SlackAppから新しくアプリを作成をしましょう

From Scratchを選択!(どちらでも開発できますが、一旦こっちに)

App Name(アプリの名前)を決めて、開発をするチャンネルを選んで、Createを押す

Basic Informationにいきます

Botの追加

App HomeのYour App’s Presence in SlackからEditを選択しましょう。

その後、アプリの名前を設定します。

パーミッションの設定

Slack Appに持たせる権限を設定します。Appのメニューの Features > OAuth&Permissionsを選択、Select Permission Scopesで下記の9種を追加しておきましょう。

OAuth Tokens for Your Workspaceから、Install to Workspaceをクリックしてください。

次のような画面になるので、許可するを押してください。

そうすると、Bot User OAuth Tokenというのができるので、これをコピーしておきます。

ここまでで一旦Slack側の準備はOKです。

Webアプリの作成

(スピードを最優先でつくっているので、コードの内容は大目に見ていただけると助かります)

slackUrlを取得

下記のようなURLが取得できると思います。
https://hogehoge.slack.com/archives/CXXXXXXXXX/p1638758992000300

~archives/までをコピーしてください。

URLの解説

  • https://hogehoge.slack.com/archives/ この部分がslackのコミュニティ自体の情報です
  • CXXXXXXXXX この部分がslackのチャンネルIDです
  • p1638758992000300 pを除いて、下6桁に.をつけたもの(1638758992.000300)が、timestampです

シャッフルランチくんが呼びかけるアプリ: アプリ①

スプレッドシートからApp Scriptを作成

  1. スプレッドシートを作成し、URLを取得してください。
  2. ~/spreadsheets/d/fugafuga/d/スプレッドシートのID/edit からスプレッドシートのIDの部分をコピーしてください。
  3. 拡張機能からApp Scriptを押して、アプリを作成してください。

code.gs

それぞれの関数に関しては、解説は省略します。

var botToken = "先ほどコピーしたトークン"
var slackUrl = "先ほどコピーした~archives/までのURL(https://hogehoge.slack.com/archives/)"

function user_endpoint(userId) {
    const response = UrlFetchApp.fetch(
    "https://slack.com/api/users.info?user=" + userId + "&pretty=1",
    {
      method: "get",
      contentType: "application/x-www-form-urlencoded",
      headers: {'Authorization': 'Bearer '+ botToken},
    }
  );
  return response;
}

function reaction_endpoint(channel, timestamp) {
  const response = UrlFetchApp.fetch(
    "https://slack.com/api/reactions.get?channel=" + channel + "&timestamp=" + timestamp + "&pretty=1",
    {
      method: "get",
      contentType: "application/x-www-form-urlencoded",
      headers: {'Authorization': 'Bearer '+ botToken}
    }
  );

  return response;
}

function doPost(e) {
  var postData = JSON.parse(e.postData.getDataAsString());
  var res = {};

  if(postData.type == 'url_verification') {
    res = {'challenge':postData.challenge}
    return ContentService.createTextOutput(JSON.stringify(res)).setMimeType(ContentService.MimeType.JSON);
  }

  var channel = postData.event.channel;
  var user = postData.event.user;
  var text = postData.event.text;
  var ts = postData.event.ts;
  var timestamp = getTimestampInUrlText(text);

  var cache = CacheService.getScriptCache();
  var cacheKey = channel + ':' + ts;
  var cached = cache.get(cacheKey);
  if (cached != null) {
    return;
  }
  cache.put(cacheKey, true, 1800); // 30分キャッシュする

  if (text.indexOf('使い方') != -1) {
    var hot_to_use = ":bento:スタンプを集めてシャッフルをするアプリだよ。";
    postToThread(hot_to_use, channel, ts, user);
    return;
  } else {
    var thread_ts = replyToSlackForEveryone(channel, user, getAnnounceText());
    postToThreadWithButton("スタンプが集まったら下記のボタンを押してね!", channel, thread_ts);
    // なにも返さなくても200 statusが返却されるので放置
    res = {'ok': true};

    return ContentService.createTextOutput(JSON.stringify(res)).setMimeType(ContentService.MimeType.JSON);
  } 
}

function getTimestampInUrlText(text) {
  const matches = text.match(/https?:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+/g);

  if(matches != null) {
      var timestamp= matches[0].substr(-16,10) + "." + matches[0].substr(-6,6);
      return timestamp
  } else {
      return ''
  }
}

function getAnnounceText(){
  var text = [
    '今日一緒にランチしませんか:flushed:??\n・参加したいメンバーは:bento:スタンプ押してください!\n・11:30に締切ってシャッフルします。'];

  return text;
}

function postToThread(message, channel, thread_ts, user) {
  var url = 'https://slack.com/api/chat.postMessage'

  var data = {
    'channel' : channel,
    'text' : '<@'+user+'> '+ '\n' + message,
    'as_user' : true,
    'thread_ts' : thread_ts
  };

  var options = {
    'method' : 'post',
    'contentType' : 'application/json; charset=UTF-8',
    'headers' : {'Authorization': 'Bearer '+ botToken},
    'payload' : JSON.stringify(data)
  };

  var response = UrlFetchApp.fetch(url, options);
  const dates = JSON.parse(response.getContentText('utf-8'));
  // thread_ts を返す
  return dates['ts'];
}

function postToThreadWithButton(message, channel, thread_ts) {
  var url = 'https://slack.com/api/chat.postMessage'

  var data = {
    'channel' : channel,
    'text' : '',
    'as_user' : true,
    'thread_ts' : thread_ts,
    "attachments": [
      {
        "text": message,
        "fallback": "Sorry, no support for buttons.",
        "callback_id": "ButtonResponse",
        "color": "#3AA3E3",
        "attachment_type": "default",
        "actions": [
          {
                "name": "three",
                "text": "3人でシャッフル",
                "style": "primary",
                "type": "button",
                "value": "3人"
          },
          {
                "name": "four",
                "text": "4人でシャッフル",
                "style": "danger",
                "type": "button",
                "value": "4人"
          }
        ]
      }
    ],
  };

  var options = {
    'method' : 'post',
    'contentType' : 'application/json; charset=UTF-8',
    'headers' : {'Authorization': 'Bearer '+ botToken},
    'payload' : JSON.stringify(data)
  };

  var response = UrlFetchApp.fetch(url, options);
  const dates = JSON.parse(response.getContentText('utf-8'));

  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getActiveSheet();
  var lastRow = sheet.getLastRow();
  sheet.getRange(lastRow+1, 1).setValue(slackUrl + channel + "/p" + thread_ts)
  // thread_ts を返す
  return dates['ts'];
}

function replyToSlackForEveryone(channel, user, message){
  var url = 'https://slack.com/api/chat.postMessage'

  var data = {
    'channel' : channel,
    'text' : '<!channel>'+ '\n' + message,
    'as_user' : true
  };

  var options = {
    'method' : 'post',
    'contentType' : 'application/json; charset=UTF-8',
    'headers' : {'Authorization': 'Bearer '+ botToken},
    'payload' : JSON.stringify(data)
  };

  var response = UrlFetchApp.fetch(url, options);
  const dates = JSON.parse(response.getContentText('utf-8'));
  // thread_ts を返す
  return dates['ts'];
}

シャッフルランチくんがシャッフルして連絡するアプリ: アプリ②

同じくそれぞれの関数に関しては、解説は省略します。

var botToken = "先ほどコピーしたトークン"
var ssId = "先ほどコピーしたスプレッドシートのID"
var ssName = "対象のシート名"
var ssRange = "A"

function doPost(e) {
  var parameter=e.parameter;
  var data = parameter.payload;
  var json = JSON.parse(decodeURIComponent(data));

  var ss = SpreadsheetApp.openById(ssId);
  var sheet = ss.getSheetByName(ssName);
  var lastRow = sheet.getLastRow();
  var range = sheet.getRange(ssRange + lastRow);
  var url = range.getValue();

  var channel = getChannelInUrlText(url);
  var timestamp = getTimestampInUrlText(url);

  var cache = CacheService.getScriptCache();
  var cacheKey = channel + ':' + timestamp;
  var cached = cache.get(cacheKey);
  if (cached != null) {
    return;
  }
  cache.put(cacheKey, true, 1800); // 30分キャッシュする

  var members = shuffle(getReactionMembers(channel, timestamp));
  // ユーザの名前を取得
  var memberMentions = getMemberMentions(members);
  var chunk = getChunk(json.actions[0].value);
  var texts = textsOfGroup(memberMentions, chunk);
  var messages = [];
  messages.push(chunk+'人ずつシャッフル!!');
  messages = messages.concat(texts);
  var thread_ts = replyToSlackForEveryone(channel, messages.join("\n"));
  var to_the_participants = "参加するみなさまへ:speaking_head_in_silhouette:\n◆調整したい場合\n時間を少しづらしたい、メンバー調整したい(いつもと同じメンバー!、新しいメンバーばかり同じ組み合わせになった等)など、もし要望があれば、本スレッドでコメントしあって参加者間で調整お願いします!";
  postToThread(to_the_participants, channel, thread_ts);

  // なにも返さなくても200 statusが返却されるので放置
  res = 'シャッフル完了!!';

  return ContentService.createTextOutput(JSON.stringify(res)).setMimeType(ContentService.MimeType.JSON);   
}

function user_endpoint(userId) {
    const response = UrlFetchApp.fetch(
    "https://slack.com/api/users.info?user=" + userId + "&pretty=1",
    {
      method: "get",
      contentType: "application/x-www-form-urlencoded",
      headers: {'Authorization': 'Bearer '+ botToken},
    }
  );
  return response;
}

function reaction_endpoint(channel, timestamp) {
  const response = UrlFetchApp.fetch(
    "https://slack.com/api/reactions.get?channel=" + channel + "&timestamp=" + timestamp + "&pretty=1",
    {
      method: "get",
      contentType: "application/x-www-form-urlencoded",
      headers: {'Authorization': 'Bearer '+ botToken}
    }
  );

  return response;
}

function getTimestampInUrlText(text) {
  const matches = text.match(/https?:\/\/[-_.!~*\'()a-zA-Z0-9;\/?:\@&=+\$,%#]+/g);

  if(matches != null) {
      var timestamp= matches[0].substr(-17,10) + "." + matches[0].substr(-6,6);
      return timestamp
  } else {
      return ''
  }
}

function getChannelInUrlText(text) {
  const matches = text.split('/');

  if(matches != null) {
      var channel= matches[4];
      return channel
  } else {
      return ''
  }
}

function getChunk(text) {
  var result = text.match(/([0-9-]+)/);
  // chunkの指定がない場合は3人ずつにする
  if (!result) {
    return 3;
  }
  // 人数の入力が全角だった場合半角にする
  var number = result[1].replace(/[---]/g, function(s) {
    return String.fromCharCode(s.charCodeAt(0) - 65248);
  });
  return parseInt(number);
}

function shuffle(a) {
    var j, x, i;
    for (i = a.length - 1; i > 0; i--) {
        j = Math.floor(Math.random() * (i + 1));
        x = a[i];
        a[i] = a[j];
        a[j] = x;
    }
    return a;
}

function textsOfGroup(array, chunk) {
  var textsOfGroup = [];
  var i,j;
  for (i = 0, j = array.length; i < j; i += chunk) {
    var group = array.slice(i, i+chunk);
    var alphabet = columnToLetter((textsOfGroup.length+1));
    var text = alphabet + ':' + group.join('、');
    if (group.length == 1) {
      text = text + ' ← 一人なのでどこかのグループにマージしてね';
    }
    textsOfGroup.push(text);
  }
  return textsOfGroup;
}

function getReactionMembers(channel, timestamp) {
  var response = reaction_endpoint(channel, timestamp);
  var json = JSON.parse(response.getContentText());

  for(var i = 0; i < json.message.reactions.length; i++ ) {
    var reaction = json.message.reactions[i];
    if (reaction.name == "bento") {
      var reactionUsers = reaction.users;
    }
  }

  return reactionUsers;
}

function getMemberMentions(members) {
    // ユーザの名前を取得
  var memberIds = []
  for (var i = 0; i < members.length; i++ ) {
    var member = members[i];
    var response = user_endpoint(member);

    var json = JSON.parse(response.getContentText());
    // bot, 削除ユーザは無視
    if (json.user.is_bot || json.user.deleted ) {
      continue;
    }
    memberIds.push("<@" + json.user.id + ">");
  }

  return memberIds;
}

function postToThread(message, channel, thread_ts) {
  var url = 'https://slack.com/api/chat.postMessage'

  var data = {
    'channel' : channel,
    'text' : message,
    'as_user' : true,
    'thread_ts' : thread_ts
  };

  var options = {
    'method' : 'post',
    'contentType' : 'application/json; charset=UTF-8',
    'headers' : {'Authorization': 'Bearer '+ botToken},
    'payload' : JSON.stringify(data)
  };

  var response = UrlFetchApp.fetch(url, options);
  const dates = JSON.parse(response.getContentText('utf-8'));
  // thread_ts を返す
  return dates['ts'];
}

function replyToSlackForEveryone(channel, message){
  var url = 'https://slack.com/api/chat.postMessage'

  var data = {
    'channel' : channel,
    'text' : '<!channel>'+ '\n' + message,
    'as_user' : true
  };

  var options = {
    'method' : 'post',
    'contentType' : 'application/json; charset=UTF-8',
    'headers' : {'Authorization': 'Bearer '+ botToken},
    'payload' : JSON.stringify(data)
  };

  var response = UrlFetchApp.fetch(url, options);
  const dates = JSON.parse(response.getContentText('utf-8'));
  // thread_ts を返す
  return dates['ts'];
}

// 1 -> A のようにアルファベットにする
function columnToLetter(column) {
  var temp, letter = '';
  while (column > 0)
  {
    temp = (column - 1) % 26;
    letter = String.fromCharCode(temp + 65) + letter;
    column = (column - temp - 1) / 26;
  }
  return letter;
}

Webアプリの公開

アプリ①とアプリ②それぞれ行ってください。

  1. コード画面を開く
  2. 右上の「デプロイ」をクリック
  3. 下記画像の通りに、歯車のマークを押してwebアプリを選択し、実行は自分・アクセスできるユーザーは全員でデプロイをする

アプリ①とアプリ②それぞれの、Cuurent web app URL:に表示されたURLをコピーしてください。
https://script.google.com/macros/s/hogehogehogehogehoge/exec のような形で得られるはずです。

Slackと連携

Event Subscriptionsを設定(アプリ①)

Enable EventsをOFFからONにする

Request URLに、先ほどデプロイした、アプリ①のCuurent web app URLをRequest URLに貼り付けてSave Changesを押して保存してください。

Interactivity & Shortcutsを設定(アプリ②)

InteractivityをOFFからONにする

Request URLに、先ほどデプロイした、アプリ②のCuurent web app URLをRequest URLに貼り付けてSave Changesを押して保存してください。

最後に

お好みの画像を設定

Basic Informationを開いてDisplay Informationにお好みの名前と画像を設定

ハマったこと

  1. G Suite(有料アカウント)でスクリプトを作成して、権限の問題でスクリプト起動できなかった
  2. スクリプトの実行者をUserとすると起動できなかった ⇒ Meを選択

実行!!


フィードバックやコメントお待ちしてます〜!

コードが変なところや、不明点あればご気軽にご質問等いただけると嬉しいです!

このnoteは、株式会社アトラエの2021年アドベントカレンダー6日目の記事でした。明日は、アトラエが誇るフロントエンドエンジニアのカタミーさんです!

Discussion