Google Apps ScriptとSlack APIを使用して自動的にSlackステータスを更新する方法

## はじめに

仕事中のSlackステータス更新は一手間です。この記事では、勤務時間、休憩時間、休日に応じて自動的にSlackステータスを更新するスクリプトを紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55550/e2426661-4740-49a1-afcc-6acaa51ad2f5.png)

## 主な機能

- 勤務時間中は「アクティブ」状態 🟢
- 休日と勤務時間外は「離席中」状態 ⚬
- 昼休み🍱と休憩時☕には専用のステータス（毎日ランダムに変化）
- 祝日や特定の休暇期間（ゴールデンウィーク、夏休み、お正月）に対応 🎏 🏖️ 🎍

## 準備

1. Slack APIトークンの取得
   - [Slack API](https://api.slack.com/)でアプリを作成し、ユーザートークンを取得します。
   - 必要なスコープ: `users.profile:write`, `users:write`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/55550/6c2af896-3aba-96c2-5ede-8ac363104367.png)

2. Google Apps Scriptプロジェクトの作成
   - [Google Apps Script](https://script.google.com/)で新しいプロジェクトを作成します。

## コード

以下のコードをGoogle Apps Scriptプロジェクトに貼り付けます。

```javascript
// 定数の整理
const CONSTANTS = {
  VACATION_PERIODS: {
    GOLDEN_WEEK: { start: '04-29', end: '05-05', name: 'ゴールデンウィーク', emoji: '🎏' },
    SUMMER_VACATION: { start: '08-13', end: '08-15', name: '夏休み', emoji: '🏖️' },
    NEW_YEAR: { start: '12-29', end: '01-03', name: 'お正月', emoji: '🎍' },
  },
  WORK_HOURS: {
    START: 9,
    END: 18,
    LUNCH_START: 12,
    LUNCH_END: 12.75,  // 12:45
    BREAK_START: 15,
    BREAK_END: 15.25,  // 15:15
  },
  STATUSES: {
    DEFAULT: { presence: 'auto', text: '', emoji: '' },
    OUTSIDE_WORK: { presence: 'away', text: '', emoji: '' },
    HOLIDAY: { presence: 'away', text: '休日', emojiType: 'HOLIDAY' },
    LUNCH: { presence: 'away', text: '昼食', emojiType: 'LUNCH' },
    BREAK: { presence: 'auto', text: '休憩', emojiType: 'BREAK' },
  },
  CALENDAR_ID: 'ja.japanese#holiday@group.v.calendar.google.com',
  EMOJIS: {
    HOLIDAY: ['🌴', '🏔️', '🏖️', '📖', '🎮'],
    LUNCH: ['🍱', '🍛', '🍜', '🍝', '🍣', '🍙', '🍔', '🥪', '🥗', '🍕'],
    BREAK: ['☕', '🍵', '🥤', '🍡', '🍩'],
  },
};

function getRandomEmoji(type) {
  const emojis = CONSTANTS.EMOJIS[type];
  return emojis[Math.floor(Math.random() * emojis.length)];
}

function getTodayEmoji(type) {
  const today = new Date().toDateString();
  const storedEmoji = PropertiesService.getScriptProperties().getProperty(`TODAY_${type}_EMOJI`);
  const storedDate = PropertiesService.getScriptProperties().getProperty(`${type}_EMOJI_DATE`);

  if (storedEmoji && storedDate === today) {
    return storedEmoji;
  } else {
    const newEmoji = getRandomEmoji(type);
    PropertiesService.getScriptProperties().setProperty(`TODAY_${type}_EMOJI`, newEmoji);
    PropertiesService.getScriptProperties().setProperty(`${type}_EMOJI_DATE`, today);
    return newEmoji;
  }
}

function updateUserStatus() {
  const userToken = PropertiesService.getScriptProperties().getProperty('USER_TOKEN');
  const now = new Date();
  const currentStatus = determineStatus(now);
  const previousStatus = getPreviousStatus();

  if (isStatusDifferent(currentStatus, previousStatus)) {
    updateSlackStatus(userToken, currentStatus);
    savePreviousStatus(currentStatus);
    Logger.log(`ステータス更新: ${currentStatus.text} ${currentStatus.emoji}`);
  } else {
    Logger.log('更新不要: 前回と同じ');
  }
}

function determineStatus(date) {
  const vacationPeriod = checkVacationPeriod(date);
  if (vacationPeriod) {
    return { presence: 'away', text: vacationPeriod.name, emoji: vacationPeriod.emoji };
  }

  if (isHoliday(date) || isWeekend(date)) {
    const { presence, text, emojiType } = CONSTANTS.STATUSES.HOLIDAY;
    return { presence, text, emoji: getTodayEmoji(emojiType) };
  }

  return getWorkdayStatus(date);
}

function isHoliday(date) {
  const calendar = CalendarApp.getCalendarById(CONSTANTS.CALENDAR_ID);
  return calendar.getEventsForDay(date).length > 0;
}

function isWeekend(date) {
  const day = date.getDay();
  return day === 0 || day === 6;
}

function getWorkdayStatus(date) {
  const hours = date.getHours() + date.getMinutes() / 60;
  const { WORK_HOURS, STATUSES } = CONSTANTS;

  if (hours < WORK_HOURS.START || hours >= WORK_HOURS.END) {
    return STATUSES.OUTSIDE_WORK;
  }

  if (hours >= WORK_HOURS.LUNCH_START && hours < WORK_HOURS.LUNCH_END) {
    const { presence, text, emojiType } = STATUSES.LUNCH;
    return { presence, text, emoji: getTodayEmoji(emojiType) };
  }

  if (hours >= WORK_HOURS.BREAK_START && hours < WORK_HOURS.BREAK_END) {
    const { presence, text, emojiType } = STATUSES.BREAK;
    return { presence, text, emoji: getTodayEmoji(emojiType) };
  }

  return STATUSES.DEFAULT;
}

function checkVacationPeriod(date) {
  const formattedDate = Utilities.formatDate(date, 'Asia/Tokyo', 'MM-dd');
  return Object.values(CONSTANTS.VACATION_PERIODS).find(period => 
    isDateInRange(formattedDate, period.start, period.end)
  );
}

function isDateInRange(date, start, end) {
  if (start <= end) {
    return date >= start && date <= end;
  } else {
    // 年をまたぐ期間（例：年末年始）の処理
    return date >= start || date <= end;
  }
}

function updateSlackStatus(token, status) {
  const { presence, text, emoji } = status;
  const presenceUrl = 'https://slack.com/api/users.setPresence';
  const statusUrl = 'https://slack.com/api/users.profile.set';
  const options = {
    'method': 'post',
    'headers': { 
      'Authorization': 'Bearer ' + token,
      'Content-Type': 'application/json; charset=UTF-8'
    },
    'timeout': 30,  // 30秒のタイムアウトを設定
  };

  try {
    updatePresence(presenceUrl, options, presence);
    updateStatus(statusUrl, options, text, emoji);
    Logger.log(`ステータス更新成功: ${text} ${emoji}`);
  } catch (error) {
    Logger.log('エラー発生: ' + error.message);
  }
}

function updatePresence(url, options, presence) {
  options.payload = JSON.stringify({ 'presence': presence });
  try {
    const response = UrlFetchApp.fetch(url, options);
    const result = JSON.parse(response.getContentText());
    if (!result.ok) {
      throw new Error('Slack Presence APIエラー: ' + JSON.stringify(result));
    }
  } catch (error) {
    Logger.log('updatePresenceでエラー発生: ' + error.message);
    throw error;
  }
}

function updateStatus(url, options, status_text, status_emoji) {
  options.payload = JSON.stringify({
    'profile': { status_text, status_emoji }
  });
  try {
    const response = UrlFetchApp.fetch(url, options);
    const result = JSON.parse(response.getContentText());
    if (!result.ok) {
      throw new Error('Slack Status APIエラー: ' + JSON.stringify(result));
    }
  } catch (error) {
    Logger.log('updateStatusでエラー発生: ' + error.message);
    throw error;
  }
}

function getPreviousStatus() {
  const storedStatus = PropertiesService.getScriptProperties().getProperty('PREVIOUS_STATUS');
  return storedStatus ? JSON.parse(storedStatus) : null;
}

function savePreviousStatus(status) {
  PropertiesService.getScriptProperties().setProperty('PREVIOUS_STATUS', JSON.stringify(status));
}

function isStatusDifferent(status1, status2) {
  if (!status2) return true;  // 初回実行時は必ず更新
  return status1.presence !== status2.presence ||
         status1.text !== status2.text ||
         status1.emoji !== status2.emoji;
}
```

## 主要な関数の説明

### `updateUserStatus()`

メイン関数です。現在の日時に基づいてステータスを決定し、前回のステータスと比較して変更がある場合のみSlackを更新します。

### `determineStatus(date)`

与えられた日時に応じて適切なステータスを決定します。休暇期間、休日、勤務時間内かどうかをチェックします。

### `getWorkdayStatus(date)`

平日の勤務時間内のステータスを決定します。昼休みや休憩時間も考慮します。

### `getTodayEmoji(type)`
その日のランダムな絵文字を取得または生成します。同じ日であれば同じ絵文字を返します。

### `updateSlackStatus(token, status)`
Slack APIを呼び出してステータスを更新します。

### `getPreviousStatus()`と`savePreviousStatus(status)`
スクリプトのプロパティを使用して、前回のステータスを取得・保存します。

### `isStatusDifferent(status1, status2)`
2つのステータスを比較し、変更があるかどうかを判断します。

## 使用方法

1. スクリプトプロパティに`USER_TOKEN`という名前でSlack APIトークンを設定します。
1. `CONSTANTS`オブジェクト内の各設定（勤務時間、休暇期間など）を必要に応じて調整します。
1. スクリプトエディタで`updateUserStatus`関数を実行してテストします。
1. トリガーを設定して定期的に実行されるようにします。
トリガーを以下のように設定：
- 実行する関数を選択：「updateUserStatus」
- 実行するデプロイを選択：「Head」
- イベントのソースを選択：「時間主導型」
- 時間ベースのトリガーのタイプを選択：「分ベースのタイマー」
- 分ベースのタイマーの間隔を選択：「5分おき」

注意：
- トリガーの頻度は、Slack APIのレート制限とGoogle Apps Scriptの実行時間制限を考慮して設定してください。
- 初めて設定する際は、Google アカウントの認証が必要になる場合があります。

トリガーを設定することで、スクリプトが自動的に定期実行され、Slackステータスが常に最新の状態に保たれます。

## カスタマイズ

- `CONSTANTS.VACATION_PERIODS`を編集して、休暇期間を設定できます。
- `CONSTANTS.WORK_HOURS`で勤務時間、昼休み、休憩時間を調整できます。
- `CONSTANTS.STATUSES`でステータステキストや絵文字を設定できます。
- `CONSTANTS.EMOJIS`で各状態に使用する絵文字のリストをカスタマイズできます。

## 注意点

- **重要**: このスクリプトは前回の自動更新時のステータスを保存し、ステータス変更の必要性を判断しています。そのため、Slackアプリでステータスを変更した場合は、それ以降にスクリプトに設定されている時間まで更新は行われません。これは、Slackアプリでステータスを変更していても、スクリプトの設定時間になるとステータスが変更されるということでもあります。
- 絵文字は日ごとにランダムに選択されます。
- Slack APIトークンは秘密情報として扱い、決して公開しないでください。
- Slackアプリのインストールにはワークスペース管理者の承認が必要な場合があります。
- このスクリプトは、ステータスに変更がある場合のみSlack APIを呼び出すため、API制限に抵触するリスクが低くなっています。

## まとめ

このスクリプトを使用することで、Slackステータスの管理を自動化し、より効率的なコミュニケーションを実現できます。ランダムな絵文字を使用することで、より楽しく効果的なステータス更新が可能になります。必要に応じてカスタマイズし、あなたの働き方に合わせて最適化してください。

コードを改善された人は、編集リクエストを送ってください。


## 参考リンク

- [Slack API Documentation](https://api.slack.com/docs)
- [Google Apps Script Documentation](https://developers.google.com/apps-script)