トラストバンクテックブログ

株式会社トラストバンクのプロダクト系メンバーによるブログです

社内定例ミーティングのアジェンダ作成とSlack通知をGASで自動化

パブリテック事業部プロダクトグループ所属のたけだです。
当社には社内全体の情シス以外に、事業部内にも以下のようなことを行う「ITSMSチーム」を立ち上げました。

  • 事業部のみで利用しているITツールや開発用ツールのライセンス/アカウント管理や、適切な権限などで利用しているかの運営
  • パブリテック事業部が提供する「LoGoシリーズ」サービスの開発にあたって必要となるガイドラインやルール策定やそのマネジメント
  • 社内のメンバーのオペレーションにおけるルール策定、改善活動、自動化

本来のITSMSとは少し異なる役割や業務も含まれますが、組織が大きくなるにつれて全体を横断しての課題やタスクが増えてきたので、必要と判断したものから優先度をつけて広く活動できるようなチームにしました。
このチームで定例会議運営の自動化を行ったので紹介します。

定例会議の要件

パブリテック事業部内の全社員が参加するオンライン定例会議の要件は以下です。

事業部は、サービスごとのチームで開発・提供しているので、週に1度全員がオンラインで顔合わせして情報共有する場になっていて、(もちろんレポート内容は重要ですが、)定例開催中であっても常にZoomチャットで担当者同士が賑やかにコメントしたり交流する場にもなっています。

つくったもの

開催日の朝9:30にGoogleドキュメントでレポートURLと、ファシリテーション担当がメンションで通知されます。

Slackで定例会議通知
※なぜダチョウ?は説明が長くなるので省きます

レポートURLにアクセスすると、前週のレポートの内容をコピーし、当日日付とファシリテーション担当者名などと一緒に最新のレポートテンプレート内に挿入された当日分のレポートにアクセスできるので、先週時点の内容から更新し始められるという仕組みになってます。見出し以外はチームごとで自由に更新できます。

定例会のレポートイメージ

デザイン担当に相談したら素敵なレポートテンプレート作ってくれた!しかも季節ごとでデザインが変わるのキャワ!

ファシリテーション担当者と開催日案内の管理はスプレッドシートで管理していて

スプシで定例会開催管理

手動でアジェンダ&レポート生成する場合にはGASの実行のためタブメニューからアジェンダ作成をクリックし

GASでhtmlのポップアップを起動させ、ドロップダウンから作成したい開催日を選択しアジェンダ作成ボタンをクリックすると

アジェンダ作成完了の表示になり、スプシ上にGoogleドキュメントIDが挿入される仕組み。

GASでHTMLファイルページ表示もできるの便利ですね。

まとめ・感想

  • GASすげぇ。ちょっとした自動化に便利。
  • 社内の皆喜んでくれたし勉強になった。

[参考]GASのコード

参考までにコード貼り付けておきます。

アジェンダ作成.gs

// 公式リファレンス https://developers.google.com/apps-script/reference/drive/drive-app?hl=ja
/**
 * Googleフォームから受け取った回答を基にアジェンダを作成する関数。
 * @param {Object} formAnswer - フォームからの回答。
 */
function createAgenda(formAnswer) {

  // 処理に必要なデータをスプシから取得
  const ss = SpreadsheetApp.getActiveSpreadsheet()
  const meetingSheet = ss.getSheetByName("会議一覧");
  const meetingList = meetingSheet.getRange("A3:E" + meetingSheet.getLastRow()).getValues();

  const meetingDataList = meetingList.map((meeting) => {
    return Utilities.formatDate(meeting[1], "JST", "yyyy-MM-dd");
  });

  const targetMeetingIndex = meetingDataList.indexOf(formAnswer.targetMeeting);
  const targetMeetingHost = meetingList[targetMeetingIndex][2];

  const targetMeetingDateText = formAnswer.targetMeeting.split("-").join(".");
  const meetingDocTitle = `${targetMeetingDateText} - 事業部定例アジェンダ`;
  const originalId = "<テンプレートのGoogleドキュメントID>";
  let meetingDocId;

  if (targetMeetingIndex <= 0 || meetingList[targetMeetingIndex - 1][4] === "" || meetingList[targetMeetingIndex - 1][4] === "-") {
    meetingDocId = copyAndRenameDocument(originalId, meetingDocTitle)
  } else {
    try {
      let preTargetMeetingId = meetingList[targetMeetingIndex - 1][4];
      meetingDocId = copyAndRenameDocument(preTargetMeetingId, meetingDocTitle)
    } catch {
      meetingDocId = copyAndRenameDocument(originalId, meetingDocTitle)
    }
  }

  meetingSheet.getRange(targetMeetingIndex + 3, 5).setValue(meetingDocId);
  moveFile(meetingDocId);
  changeNextParagraph(meetingDocId,"日付",targetMeetingDateText + " 13:00-13:50")
  changeNextParagraph(meetingDocId,"進行",targetMeetingHost)

  return meetingDocId
}


/**
 * ファイルを移動する。
 * @param {string} fileId - 移動先のフォルダID。
 */
function moveFile(fileId) {
  const folderId = '<GoogleフォルダID>';
  let file = DriveApp.getFileById(fileId);
  let folder = DriveApp.getFolderById(folderId);
  file.moveTo(folder)
}

/**
 * ファイルをコピーする。
 * @param {string} originalDocId - オリジナルのファイルID。
 * @param {string} newTitle - コピー後のファイル名。
 */
function copyAndRenameDocument(originalDocId, newTitle) {
  // オリジナルドキュメントを取得
  let originalDoc = DriveApp.getFileById(originalDocId);

  // オリジナルドキュメントをコピー
  let newDoc = originalDoc.makeCopy();

  // コピーしたドキュメントの名前を変更
  newDoc.setName(newTitle);

  // コピーしたドキュメントのIDを取得
  let newDocId = newDoc.getId();

  // コピーしたドキュメントのURLをログに出力
  return newDocId
}

/**
 * 指定したテキストと合致するパラグラフの次の要素を、指定したテキストに書き換える。
 * @param {string} targetDocId - 変更対象のドキュメントのID。
 * @param {string} targetText - 検索するパラグラフのテキスト。
 * @param {string} changeText - 変更後のテキスト。
 */
function changeNextParagraph(meetingDocId,taragetText, changeText) {
  const targetDoc = DocumentApp.openById(meetingDocId);
  const paragraphs = targetDoc.getBody().getParagraphs();

  const targetParagraph = paragraphs.filter((paragraph) => { return paragraph.getText() === taragetText });

  const style = {};
  style[DocumentApp.Attribute.FONT_FAMILY] = 'Arial';
  style[DocumentApp.Attribute.BOLD] = false;

  targetParagraph[0].getNextSibling().setText(changeText);
  targetParagraph[0].getNextSibling().setAttributes(style)
}

メニュー作成.gs

// スプレッドシートを開いた際にメニューバーのメニューを作成
function onOpen() {
  var ui = SpreadsheetApp.getUi()
    .createMenu('アジェンダ作成はこちら')
    .addItem('アジェンダ作成', 'onClickItem1')
    .addToUi();
}

// メニューバーのメニューを実行した際にHTMLを読み込みポップアップ表示する
function onClickItem1() {
  const html = HtmlService.createHtmlOutputFromFile("Popup")
    .setWidth(800)
    .setHeight(200);
  SpreadsheetApp.getUi()
    .showModalDialog(html, "アジェンダを作成したい会議日付を選択してください。");
}

// ポップアップの表示が完了したらドロップダウンに入れるリストを整形しフロントサイドにreturnする
function getMeetingList() {
  Logger.log("getMeetingListが実行されます。");
  const meetingSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("会議一覧");
  const meetingList = meetingSheet.getRange("A3:E" + meetingSheet.getLastRow()).getValues();
  const todayDate = new Date();

  const meetingDataList = meetingList
    .filter(meeting => meeting[1] >= todayDate && meeting[4] === "")  // 条件に合致する要素だけをフィルタリング
    .map(meeting => Utilities.formatDate(meeting[1], "JST", "yyyy-MM-dd"));  // フィルタリングされた要素に対して処理を適用

  Logger.log(meetingDataList);  // Logger.logを使用
  return meetingDataList;
}

Popup.html

// スプレッドシートを開いた際にメニューバーのメニューを作成
function onOpen() {
  var ui = SpreadsheetApp.getUi()
    .createMenu('アジェンダ作成はこちら')
    .addItem('アジェンダ作成', 'onClickItem1')
    .addToUi();
}

// メニューバーのメニューを実行した際にHTMLを読み込みポップアップ表示する
function onClickItem1() {
  const html = HtmlService.createHtmlOutputFromFile("Popup")
    .setWidth(800)
    .setHeight(200);
  SpreadsheetApp.getUi()
    .showModalDialog(html, "アジェンダを作成したい会議日付を選択してください。");
}

// ポップアップの表示が完了したらドロップダウンに入れるリストを整形しフロントサイドにreturnする
function getMeetingList() {
  Logger.log("getMeetingListが実行されます。");
  const meetingSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("会議一覧");
  const meetingList = meetingSheet.getRange("A3:E" + meetingSheet.getLastRow()).getValues();
  const todayDate = new Date();

  const meetingDataList = meetingList
    .filter(meeting => meeting[1] >= todayDate && meeting[4] === "")  // 条件に合致する要素だけをフィルタリング
    .map(meeting => Utilities.formatDate(meeting[1], "JST", "yyyy-MM-dd"));  // フィルタリングされた要素に対して処理を適用

  Logger.log(meetingDataList);  // Logger.logを使用
  return meetingDataList;
}

Slack通知

function routineAction() {
  // 処理に必要なデータをスプシから取得
  const webhockUrl = "https://hooks.slack.com/services/xxxxxxxxxxxxx/xxxxxxxxxxxxx";
  const ss = SpreadsheetApp.getActiveSpreadsheet()
  const meetingSheet = ss.getSheetByName("会議一覧");
  const meetingList = meetingSheet.getRange("A3:E" + meetingSheet.getLastRow()).getValues();

  const meetingDataList = meetingList.map((meeting) => {
    return Utilities.formatDate(meeting[1], "JST", "yyyy-MM-dd");
  });

  const todayDate = Utilities.formatDate(new Date(), "JST", "yyyy-MM-dd");

  if (!meetingDataList.includes(todayDate)) {
    return
  }

  const targetMeetingIndex = meetingDataList.indexOf(todayDate);
  const targetMeeting = meetingList[targetMeetingIndex];
  const targetMeetingHostName = targetMeeting[2];
  const targetMeetingHostSlackId = targetMeeting[3];

  let targetMeetingDocId;
  if (targetMeeting[4] !== "") {
    targetMeetingDocId = targetMeeting[4];
  } else {
    targetMeetingDocId = createAgenda({ targetMeeting: todayDate });
  }

  let targetMeetingURL = "https://docs.google.com/document/d/" + targetMeetingDocId + "/edit";

  let message = `<!subteam^xxxxxx> \n本日は事業部定例です。各チーム担当の方は、アジェンダの更新をお願いします!\n\n:page_facing_up:本日のアジェンダ\n${targetMeetingURL}\n:file_folder:アジェンダ格納フォルダ\nhttps://drive.google.com/drive/folders/xxxxxxxxxxxx\n:crown:本日の進行\n${targetMeetingHostName}さん(<@${targetMeetingHostSlackId}>)宜しくお願いします!`;
  postToSlack(webhockUrl, "<Slackアカウント名>", ":reaction:", message);
}

function postToSlack(url, username, icon_emoji, message) {
  const payload = {
    "text": message,
    "username": username,
    "icon_emoji": icon_emoji,
  };

  const options = {
    "method": "post",
    "contentType": "application/json",
    "payload": JSON.stringify(payload)
  };

  UrlFetchApp.fetch(url, options);
}