toggle Engineer Blog

トグルホールディングス株式会社のエンジニアブログでは、私たちの技術的な挑戦やプロジェクトの裏側、チームの取り組みをシェアします。

ここ最近のSlack活動について振り返り

はじめに

こんにちは、トグルホールディングス株式会社でコーポレートITを担当しているM.Yukiです。

トグルホールディングスエンジニアアドベントカレンダーの13日目の記事となります。

私がトグルにジョインしたのが7月。 ありきたりなMDMの導入やらSaaSについて触れてもなあという事で
ここ最近注力してきたSlack活動について振り返りをします。

Slack勉強会

まず初めに11月末に2回に分けてSlack勉強会を実施しました。

この活動の趣旨は、ずばり皆でSlackを徹底活用して業務の生産性を爆上げしようぜ!!という事と
目立たない所で全社に貢献しているエンジニアの方を皆の前でぜひ披露したい・知ってもらいたい
という2点を狙いとして始めました。

そもそもで

ユーザーから色々お話を聞いていると
「Slackワークフローなどどうやって作ればいいのか分からない」「そもそも作成の仕方が分からない」
「Slackキャンバスやリストはどうやって活用したらいいのか分からない」

と言ったSlackの活用方法自体がイマイチ分かっていないという声がちらほらありました。

ワークフローなど活用したら生産性や自動化によって楽になっていくのになーと勿体ない、 だったら自分が勉強会を開いて全社的にSlackを使い倒してやろうと1回目はワークフローの事例紹介と実際に作成する体験をしてもらうべく
ハンズオンを実施しました。

オンライン・オフラインのハイブリッド開催で2回の合計で11名の方に参加していただき
フィードバックもいただけたので1回目としては良かったのではないでしょうか👏

こういった活動を通してツールの活用促進や生産性を上げていくのもコーポレートの領域だと思っていますので
Slackに限らず今後も導入したツールを徹底的に利活用できるような組織文化作りを進めたいと思います。

そして、事例紹介では私がジョインするまで事業部の業務フローをSlackワークフロー化して全社に貢献してくれたエンジニアの方
本業でもないのにNoと言わず役割を引き受ける姿勢はトグルの全社主義に当てはまっててかっこよです。

全メンバーがワークフローを作成して運用していく。という理想形を目指してコーポレートは後押しをガンガン進めたいと思います。

Slack絵文字大掃除

絵文字が増えすぎてて検索性が悪い!選びずらい同じような絵文字が乱立している!!という声があり
たしかにトグルではSlackの絵文字はユーザーが自由に登録できる運用のため、いつの間にか絵文字が増殖していき
12月の時点で750個程の絵文字が登録されていました。

何とかしなければ・・・でもどうやって?

絵文字の運用ルールを作ってみたところで
メンテナンスとかどうやってやればいいのだろう

Slackの最上位プランであるEnterPrise GridならadminAPIにより使われてない絵文字などのリストアップが可能なようですが
EnterPrise Gridか…と躊躇していたところ なんとオープンチャンネルに投稿されたメッセージのデータが過去1年分エクスポートが出来るというのを
知りました(本当に今更ですみません)。

とりあえず過去3か月分のデータをエクスポートして解析(Pythonで)しました。

その中の施策の一つとして絵文字ランキングTop10を発表してこちらも良いリアクションをいただけました。

使われた絵文字TOP10を発表

そして、大本命の絵文字大掃除です。

まず絵文字のリストをSlackAPI経由で取得します。
抽出した投稿データと絵文字リストを突合して全絵文字の使用回数を出して
使用されていない絵文字リストを公開すると共に、絵文字を登録したであろうユーザーに
自主的に削除を促すという事をやりました(絶賛今やってます)。

Slack管理者で勘違いされる方もいるかもしれませんが、デフォルト権限でもSlackの絵文字は登録したユーザーなら
自身で登録した絵文字を削除可能です(その他ユーザーが登録したものは削除不可)。

管理者が勝手に削除するとハレーションが起きかねないというのと
年始ということで各自で大掃除をやってもらおうという狙いです。

もちろん重複している絵文字などは後で管理者で消すつもりではあります。

まさに大掃除ですね。

Slack絵文字で悩んでいるSlack管理者の方はこちらを参考にしていただければと思います。

解析用のコードについてはAIなどに生成させるとよいです。

SlackとGASの仕組み化(Slack勉強会用)

Slack勉強会に向けて作成しました。 やりたかった事としては以下の2つです。

  • Slackに勉強会のお知らせを投稿したらGoogleカレンダーに予定を作成したい
  • 投稿したお知らせに絵文字でリアクションしたユーザーをGoogleカレンダーにゲスト招待したい

前提として - Slack Appでアプリを作成する - EventAPIを構成する - App manifestでエラーになっているとテナントへのアプリインストールができないので気を付けてください。

Botの権限は下記の通りです。

GAS作成

Slackからのテストは色々時間的なコストがかかるので テスト用の関数を用意しました。

これで不要なデプロイやSlack側のRequest URLをいちいち書き換えなくても良くなります。

イベントを受け取りメッセージとリアクションを判定します。

const SLACK_TOKEN = '';
const SLACK_CHANNEL_ID = '';

function testDoPost() {
  // テスト用のPOSTデータを生成
  const messagePayload = {
    type: "event_callback",
    event: {
      type: "message",
      ts: "",
      text: "勉強会のお知らせが投稿されました。\n\n件名:Slack\n\n日程:2024/12/07 10:00-11:00\n\n内容:test"
    }
  }
  const testPayload = {
    type: "event_callback",
    event: {
      type: "reaction_added",
      reaction: "",
      user: "",
      ts: ""
    }
  };

  // リクエストオブジェクトを模擬
  const mockRequest = {
    postData: {
      contents: JSON.stringify(messagePayload)
    }
  };

  // doPost 関数をテスト実行
  const response = doPost(mockRequest);

  // 結果をログに出力
  Logger.log(`Response: ${response.getContent()}`);
}

function doPost(e) {
  const slackEvent = JSON.parse(e.postData.contents);
  Logger.log(slackEvent);

  if (slackEvent.type === "url_verification") {
    // Slack Event APIの初回認証
    return ContentService.createTextOutput(slackEvent.challenge);
  }

  // イベントごとに処理を分岐
  const event = slackEvent.event;
  if (event.type === "message" && !event.subtype) {
    // メッセージ投稿イベントの処理
    handleMessageEvent(event);
  } else if (event.type === "reaction_added") {
    // リアクション追加イベントの処理
    handleReactionEvent(event);
  }

  return ContentService.createTextOutput("OK");
}

カレンダーを作成する前の関数、こちらに特定のユーザーIDである場合などのロジックも組み込めます。 今回は誰でも作成できるように制限はかけていません。なのでuserIdは不要になる。

function handleMessageEvent(event) {
  const messageText = event.text; // Slackメッセージの内容
  const userId = event.user; // メッセージ投稿者のユーザーID
  const channelId = event.channel; // メッセージが投稿されたチャンネルID
  const ts = event.ts;

  // 条件: メッセージに「勉強会のお知らせ」が含まれている 
  if (messageText.includes("勉強会のお知らせ")) {

    sendSlackNotification(JSON.stringify("debug1" + event.ts));
    // メッセージから件名と説明を抽出
    const { title, description, startTime, endTime } = parseMessage(messageText);

    Logger.log("debug1 " + title + " " + description + " " + startTime + " " + endTime);

    // Googleカレンダーイベントを作成
    const eventUrl = createCalendarEvent(title, description, startTime, endTime, ts);

    // Slackに返信
    //sendSlackNotification(JSON.stringify(event));
    sendSlackNotification(`カレンダーイベントを作成しました!\nイベントリンク: ${eventUrl}`);
  }
}

メッセージをパースする関数、メッセージのフォーマットに従って処理をします。

フォーマットはテスト関数に記載の通りです。日付などは2024/12/06 10:00-11:00という指定があります。

// メッセージから件名と説明を抽出
function parseMessage(message) {
  const lines = message.split("\n");
  let title = "";
  let description = "";
  let startTime = null;
  let endTime = null;

  lines.forEach(line => {
    if (line.startsWith("件名:")) {
      title = line.replace("件名:", "").trim();
    } else if (line.startsWith("日程:")) {

      Logger.log("debug2");
      const dateString = line.replace("日程:", "").trim();
      const [datePart, timePart] = dateString.split(" ").map(s => s.trim());
      Logger.log(`Parsed Date: ${datePart}, Time: ${timePart}`);

      const formattedDate = datePart.replace(/\//g, "-");

      if (!datePart || !timePart) {
        Logger.log("Invalid input format. Expected format: [YYYY/MM/DD, HH:mm-HH:mm]");
      }
      const [start, end] = timePart.split("-").map(s => s.trim());
      if (!start || !end) {
        Logger.log("Invalid time range format. Expected format: HH:mm-HH:mm");
      }
      Logger.log(`start: ${start}, end: ${end}`);

      startTime = new Date(`${formattedDate}T${start}`);
      endTime = new Date(`${formattedDate}T${end}`);

      if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) {
        Logger.log(`Invalid date or time values. Start: ${startTime}, End: ${endTime}`);
      }

      Logger.log(`Start Time: ${startTime}, End Time: ${endTime}`);

    }  else if (line.startsWith("内容:")) {
      description = line.replace("内容:", "").trim();
    }
  });

  return {
    title,
    description: description,
    startTime: startTime, // 日程が指定されていない場合は現在時刻
    endTime: endTime
  };
}

カレンダーを作成する関数、イベントリンクは404になります、AIの精度はイマイチになところです。 スクリプトプロパティにtsとeventIdを格納していますが、スプシでもいいと思います。 AIが突飛なコードを生成してます。

こちらのtsをキーにリアクションが付いたときに対象のeventIdを紐づけました。

function createCalendarEvent(title, description, startTime, endTime, ts) {
  const calendar = CalendarApp.getDefaultCalendar();
  const event = calendar.createEvent(title, startTime, endTime, { description });

  // イベントの詳細をログに出力
  Logger.log(`Event Created: Title=${event.getTitle()}, Start=${event.getStartTime()}, End=${event.getEndTime()}, ID=${event.getId()}`);

  // イベントIDを取得(そのまま使用)
  const eventId = event.getId();

  // イベントリンクを構築
  const eventUrl = `https://calendar.google.com/calendar/event?eid=${encodeURIComponent(eventId)}`;

  // イベントのIDを保存
  const scriptProperties = PropertiesService.getScriptProperties();
  scriptProperties.setProperty(ts, eventId);

  // 作成したイベントリンクを返す
  return eventUrl;
}

リアクションが付いた時の関数です。

ユーザーIDを取得しているのは、SlackAPIで対象者のメールアドレスを取得するロジックを入れているからです。 メールアドレスが取得出来たらeventIdから対象のカレンダーにゲスト招待します。

function handleReactionEvent(event) {
  const reaction = event.reaction; // リアクション名
  const userId = event.user; // リアクションを押したユーザーID
  const ts = event.item.ts;

  // 条件: リアクションが"hoge"の場合のみ処理
  if (reaction === "参加") {
    sendSlackNotification(JSON.stringify("debug2" + ts));
    const email = getEmailFromSlackUser(userId);
    if (email) {
      inviteUserToSavedEvent(email, ts);
    }
  }
}

// 保存されたイベントにユーザーを招待
function inviteUserToSavedEvent(email, ts) {
  const scriptProperties = PropertiesService.getScriptProperties();
  const eventId = scriptProperties.getProperty(ts);

  if (!eventId) {
    console.error("イベントIDが保存されていません。");
    return;
  }

  Logger.log(eventId);

  const calendar = CalendarApp.getDefaultCalendar();
  const event = calendar.getEventById(eventId);

  if (!event) {
    console.error("保存されたイベントが見つかりません。");
    return;
  }

  // ユーザーを招待
  event.addGuest(email);
}

function sendSlackNotification(message) {

  const payload = {
    channel: SLACK_CHANNEL_ID,
    text: message,
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: {
      'Authorization': 'Bearer ' + SLACK_TOKEN,
    },
    payload: JSON.stringify(payload),
  };
  var response = UrlFetchApp.fetch('https://slack.com/api/chat.postMessage', options);

  Logger.log(response);
}

function getEmailFromSlackUser(userId) {
  const url = `https://slack.com/api/users.profile.get`;
  const params = {
    method: "get",
    headers: {
      Authorization: `Bearer ${SLACK_TOKEN}`,
    },
    payload: {
      user: userId,
    },
  };

  // APIリクエストを送信
  const response = UrlFetchApp.fetch(url, params);
  const data = JSON.parse(response.getContentText());

  Logger.log(data);

  if (!data.ok) {
    Logger.log(`Slack API Error: ${data.error}`);
    return null; // エラー時は null を返す
  }

  // メールアドレスを取得
  const email = data.profile.email;

  Logger.log(`User ID: ${userId}, Email: ${email}`);
  return email || null;
}

動作テスト

作成したアプリをEventを取得するチャンネルに必ず招待してください。 EventAPIを検知できず動作しません。

おわりに

節々にAIという表現を使ってますが、はいこちらほぼ9割AIが生成したコードになります。 なのでtsとeventIdを紐づけるなどの判定を入れている以外手を動かさずに作れました。

ただし、テストコードを入れても本番環境でうまく動作しなかったりで 結局、GASのデプロイを何度も繰り返したり、EventURLを上書きしたりと この辺りは改善する必要があるのと、やはりDenoの環境に持って行った方がいいのかなと 思いました。

また、カレンダーを作成する主催者がGASの実行者となるためこの辺りは どうやって主催者を変更しようかという課題もあります。

また、来年もSlackの魔術師としてトグルのSlack活動を続けたいと思います。

以上でここ最近のSlack活動の振り返りとなります。