Algomatic Tech Blog

Algomaticの開発チームによる Tech Blog です

AI同僚「Amigo」の裏側 ~勤務場所選択機能編~

こんにちは、横断CoSの大田(@OTA57)です。
先日以下のnoteを書きました。

note.com

このエントリでは、その裏側の仕組みやコードを紹介していきます。
まずは事例①の弊社の勤務場所を選択する機能です。

使っているのはスプレッドシートとGAS、Slackです。
GASは毎朝定時実行するものと、SlackからのActionを受け取る二つがあります。
(本当は同じGASで書けるのですが、弊社はAmigoに他のこともやらせているのでGASを分けています)

Slackの設定について①

初めにSlackの設定についても少し触れたいと思います。
こちらのurlでワークスペースのアプリ一覧を見ることができます。
https://api.slack.com/apps
右上の「Create New App」をクリックすると新規で作成できます。
僕はフルスクラッチ派ですが、どちらでもいけると思います。
必要なsecret系はVerification TokenとOAuth Tokenです。

Verification TokenはBasic Informationのページから取得できます。

Basic Information

OAuth TokenはOAuth & PermissionsのページからBot User OAuth Tokenを使用します。

OAuth & Permissions

あと必要な設定としてはbotへのscopeの付与が必要です。
同じくOAuth & Permissionsのページの下部のScopes - Bot Token Scopesのところにscopeを付与していきます。
今回の処理に必要なscopeは以下です。

  • chat:write
  • users:read
  • users:email

ここまで設定すれば定時実行の方は動きます。

毎朝の定時実行のコード

それではまずは毎朝の定時実行の方のコードです。
こちらは至ってシンプルです。

const SLACK_OAUTH_TOKEN = PropertiesService.getScriptProperties().getProperty("SLACK_OAUTH_TOKEN");

const TARGET_CHANNEL_ID = PropertiesService.getScriptProperties().getProperty("TARGET_CHANNEL_ID");
const OPENAI_API_KEY=PropertiesService.getScriptProperties().getProperty("OPENAI_API_KEY");

function postDaily() {
  const today = new Date();
  const prompt = `会社のSlackに流すおはようのメッセージの添える気の利いた文章をください。
  季節や日付を加味してもらうといいと思います。
  今日は${Utilities.formatDate(today, "JST", "M/dd(E)")}です。
  必ず日本語でお願いします。`
  const message = callOpenAI(prompt);

  const url = 'https://slack.com/api/chat.postMessage';
  const payload = {
    "channel": TARGET_CHANNEL_ID,
    "blocks": buildBlocks(message),
  };

  postToSlack(url, payload);
}

function postToSlack(url, payload) {
  var params = {
    method: "post",
    headers: {
      "Authorization": "Bearer " + SLACK_OAUTH_TOKEN,
      "Content-type": "application/json; charset=UTF-8",
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  var res = UrlFetchApp.fetch(url, params);
  return res.getContentText();
}

function buildBlocks(message) {
  return [
        {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": `<!subteam^xxx>\n${message}`
            }
        },
    {
            "type": "divider"
        },
    {
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": "今日の出勤場所を選択してね!\n*(交通手当の計算に必要なので必ずお願いします)*"
            }
        },
        {
            "type": "actions",
            "elements": [
                {
                    "type": "button",
                    "text": {
                        "type": "plain_text",
                        "text": "オフィスに出社!",
                        "emoji": true
                    },
                    "value": "workLocation",
                    "action_id": "workAtOffice"
                },
        {
                    "type": "button",
                    "text": {
                        "type": "plain_text",
                        "text": "リモートします!",
                        "emoji": true
                    },
                    "value": "workLocation",
                    "action_id": "workRemote"
                },
                {
                    "type": "button",
                    "text": {
                        "type": "plain_text",
                        "text": "泣く泣くリモートです。。。",
                        "emoji": true
                    },
                    "value": "workLocation",
                    "action_id": "sadlyWorkRemote"
                }
            ]
        }
    ]
}

function callOpenAI(prompt) {
  var endpoint = 'https://api.openai.com/v1/chat/completions';
  var data = {
    model: "gpt-4o-mini",
    messages: [{
      role: "user",
      content: prompt
    }]
  };

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

  var response = UrlFetchApp.fetch(endpoint, options);
  var json = JSON.parse(response.getContentText());
  return json.choices[0].message.content
}

これに対するアウトプットが以下のようになります。

実際のアウトプット

特段解説することもないですが、生成したメッセージと提携文を組み合わせているってくらいです。
前はプロンプトももう少し凝っていたのですが、今のOpenAIの性能だとこのくらいの指示で十分用途に耐えます。
SlackのActionの仕様的にはvalueを共通化して、action_idで処理を分岐するというのは本来の用途ではないと思うのですが、勤務場所選択以外にも様々な種類のactionを同じbotにやらせるためにこのようにしています。
GASの方でトリガーを設定して毎朝n時に発火するようにすれば完了です。

Actionを受け取る側のコード

続いて、Actionを受け取る側のGASです。

const LOG_SHEET_ID = 'xxxx';
const logSpreadSheet = SpreadsheetApp.openById(LOG_SHEET_ID);
const logSheet = logSpreadSheet.getSheetByName('Log');
const SLACK_OAUTH_TOKEN = PropertiesService.getScriptProperties().getProperty("SLACK_OAUTH_TOKEN");
const SLACK_VERIFICATION_TOKEN = PropertiesService.getScriptProperties().getProperty("SLACK_VERIFICATION_TOKEN");

// 勤怠場所関連
const WORK_LOCATION_SHEET_ID = 'xxxx';
const workLocationSpreadSheet = SpreadsheetApp.openById(WORK_LOCATION_SHEET_ID);
const workLocationCalcSheet = workLocationSpreadSheet.getSheetByName('calc');
const KEY_OFFICE = "オフィス";
const KEY_REMOTE = "リモート";
const AM_FULLTIMES_CHANNEL_ID = 'xxxx'
const WORK_LOCATION_OPENAI_API_KEY=PropertiesService.getScriptProperties().getProperty("WORK_LOCATION_OPENAI_API_KEY");

function log(message) {
  let row = logSheet.getLastRow();
  logSheet.getRange(row+1,1).setValue(new Date());
  logSheet.getRange(row+1,2).setValue(message);
}

function getToSlack(url) {
  var options = {
    method: 'get',
    headers: {
      'Authorization': 'Bearer ' + SLACK_OAUTH_TOKEN
    },
    muteHttpExceptions: true // HTTP例外をミュートに設定
  };

  var response = UrlFetchApp.fetch(url, options);
  if (response.getResponseCode() == 200) {
    var data = JSON.parse(response.getContentText());
    return data;
  } else {
    log('Error: ' + response.getResponseCode());
    return {};
  }
}

function postToSlack(url, payload) {
  var params = {
    method: "post",
    headers: {
      "Authorization": "Bearer " + SLACK_OAUTH_TOKEN,
      "Content-type": "application/json; charset=UTF-8",
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  var res = UrlFetchApp.fetch(url, params);
  log(res.getContentText());
  return res.getContentText();
}

function getSlackUserProfile(userId) {
  var url = `https://slack.com/api/users.info?user=${userId}`;

  var res = getToSlack(url);
  return res.user.profile;
}

function callOpenAI(prompt, apiKey) {
  var endpoint = 'https://api.openai.com/v1/chat/completions';  // チャット用のエンドポイント
  var data = {
    model: "gpt-4o-mini",
    messages: [{
      role: "user",
      content: prompt
    }]
  };

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

  var response = UrlFetchApp.fetch(endpoint, options);
  var json = JSON.parse(response.getContentText());
  return json.choices[0].message.content
}

function doGet(e) {
  return ContentService.createTextOutput(JSON.stringify({ 'text': 'OK' })).setMimeType(ContentService.MimeType.JSON);
}

function doPost(e) {
  try {
    var response;

    var payload = JSON.parse(e.parameter.payload);

    // Tokenの確認
    if (payload.token !== SLACK_VERIFICATION_TOKEN) {
      response =  "Invalid token";
      return ContentService.createTextOutput(response);
    }

    if (payload.type === 'block_actions') {
      var action = payload.actions[0]; // 複数のアクションがある場合は適切に処理してください。

      switch(action.type) {
        case 'button':
          switch(action.value) {
            case 'workLocation':
              response = handleWorkLocation(payload, action);
              break;
            default:
              response = JSON.stringify({"ok": true});
              break;
          }
        default:
          response = JSON.stringify({"ok": true});
          break;
      }
    }
    
    return ContentService.createTextOutput(response);
  } catch(e) {
    log(e);
    return ContentService.createTextOutput(e);
  }
}

// 勤怠場所関連
function handleWorkLocation(payload, action) {
  switch(action.action_id) {
    case 'workAtOffice':
      logAttendance(payload, KEY_OFFICE);
      break;
    case 'workRemote':
      logAttendance(payload, KEY_REMOTE);
      break;
    case 'sadlyWorkRemote':
      logAttendance(payload, KEY_REMOTE, true);
      break;
    // その他のボタン
    default:
      // その他のボタンが押された時の処理
  }

  return JSON.stringify({ 'text': 'OK' });
}

function logAttendance(payload, location, sadly=false) {
  var inputTime =  new Date();
  var targetDate = Utilities.formatDate(inputTime, "JST", "yyyy/MM/dd");
  var userId = payload.user.id;
  var profile = getSlackUserProfile(userId);

  // 連打の場合
  workLocationCalcSheet.getRange(1, 1).setValue(`=QUERY('raw data'!A:E,"WHERE B=date '${Utilities.formatDate(inputTime, "JST", "yyyy-MM-dd")}' AND D='${profile.email}'")`);
  if (workLocationCalcSheet.getRange(2, 1).getValue() != "") {
    postWorkLocationDuplicateMessage(userId);
    return;
  }

  // スプシに記入. [入力時刻,ターゲット日付,Slack ID,メールアドレス,勤務場所]
  const workLocationTargetSheet = workLocationSpreadSheet.getSheetByName('raw data');
  let row = workLocationTargetSheet.getLastRow();
  workLocationTargetSheet.getRange(row+1,1).setValue(inputTime);
  workLocationTargetSheet.getRange(row+1,2).setValue(targetDate);
  workLocationTargetSheet.getRange(row+1,3).setValue(userId);
  workLocationTargetSheet.getRange(row+1,4).setValue(profile.email);
  workLocationTargetSheet.getRange(row+1,5).setValue(location);
  workLocationTargetSheet.getRange(row+1,6).setValue(`=iferror(VLOOKUP(D${row+1},'社員番号マスタ'!$B:$C,2,false),"")`);

  // Slackに投稿
  var opposite = location == KEY_OFFICE ? KEY_REMOTE : KEY_OFFICE;
  var location_text = sadly ? "泣く泣くリモート" : location;
  var prompt = `${profile.display_name}さんは今日、${Utilities.formatDate(inputTime, "JST", "M/dd(E)")}${opposite}ではなく、${location_text}で働くそうです。
  ${profile.display_name}さんを鼓舞する気の利いた一言をください。`
  var message = callOpenAIGPT35(prompt, WORK_LOCATION_OPENAI_API_KEY);

  var url = 'https://slack.com/api/chat.postMessage';
  var payload = {
    "channel": AM_FULLTIMES_CHANNEL_ID,
    "thread_ts": payload.message.ts,
    "blocks": [{
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": `<@${userId}>さんは今日は${location_text}です!\n${message}`
            }
        }]
  };

  postToSlack(url, payload);
}

function postWorkLocationDuplicateMessage(userId) {
  var url = 'https://slack.com/api/chat.postEphemeral';
  var payload = {
    "channel": AM_FULLTIMES_CHANNEL_ID,
    "user": userId,
    "blocks": [{
            "type": "section",
            "text": {
                "type": "mrkdwn",
                "text": `<@${userId}>さん!\n連打!ダメ!絶対!\n修正はこちらから\nhttps://docs.google.com/spreadsheets/d/xxxx/edit#gid=0`
            }
        }],
  };

  postToSlack(url, payload);
}

またスプレッドシートはこんな構成です。

実際のスプレッドシート

こちらもコードを読めばそこまで難しいことはないのですが、いくつかハマったところと工夫をしているところを共有します。

  1. GASをアプリケーションとしてdeployする際のエンドポイントとなるdoGet関数とdoPost関数は、用途的にはdoPost関数だけでいいはずなのですが、なぜかdoGet関数もないと動かなかったのでシンプルなdoGet関数を定義してます。
  2. GASのアプリケーションの場合、GCPと連携しないとログが出ないので、ログ用のスプレッドシートを用意してそこにロギングするようにしています。
  3. 連打対策として完璧ではないのですが、calcシートにquery関数文自体をsetValueすることで、その日・その人の記録の存在チェックを行なっています。
  4. どんどん新しい行が追加されていくので、vlookupで社員番号と紐づけている列にも関数文をsetValueすることを忘れないようにしています。
Slackの設定②

SlackのappへのこちらのGASの登録方法です。
GASの右上の「デプロイ」ボタンから「新しいデプロイ」を選びます。
デプロイの設定画面で「種類の選択」から「ウェブアプリ」を選びます。
「説明」にはその時のバージョンに関する説明を入れておくと後々楽です。
実行するユーザーは「自分」を選びます。そうしないとSlackがこのGASを実行できないからです。
最後にアクセスできるユーザーを全員にします。これも同じくそうしておかないとSlackがこのGASを実行できないからです。

デプロイ設定画面

デプロイするとウェブアプリのURLが表示されるのでこれをコピーします。

デプロイ後の画面

それをSlackのAppの設定のInteractivity & ShortcutsのページのRequest URLにセットして、保存します。

Interactivity & Shortcuts

これで動くはずですが、動かない場合は、上でセットしたロギングの関数等を用いてデバッグしてください。

終わりに

まだこの仕組みは基本的な部分しか押さえていませんが、
この仕組みを拡張すれば打刻システムも作れますし、APIが存在するシステムであればスプレッドシートでなくても連携できるはずです。
Slackで打刻できるようになるだけでも従業員の手間は減ると思いますが、その間に生成AIの処理を組み込むことでより仕事を楽しみながらできるようになると思います。
また、何気ない1処理ですが、ボタン押下時に1人1人を鼓舞する気の利いた一言をAmigoに喋らせています。

Amigoからの返答

こういう1処理がユーザーの体験・満足度を変えることも多々ありますよね。

また次回別の機能についても解説できればと思います。

これからもこういった日々の作業・仕事にも生成AIを積極的に組み込んでいく所存です。
そんなAlgomticに興味のある方はぜひ以下の採用ページをご覧ください。

jobs.algomatic.jp