👋

カレンダーに寄り添うSlackステータスをつくる話(GAS × Slack API)

に公開

ミーティング入ったら「MTG中」にして、終わったら戻して…を毎回やるの、地味にしんどい。
「カレンダーに全部書いてあるんだから、そこから勝手にやってほしい〜」という気持ちで、Google Apps Script(GAS)+Slack APIでサクッと自動化しました。
ついでに、予定タイトルの“命名規則”からプロジェクト名を拾って絵文字を変える小ワザも入れてます。いい感じに“自分の雰囲気”が出ます🙂


どんなノリのやつ?

  • 今日の予定を見て、いま進行中のイベントがあればその内容でステータス更新

  • イベントがなければ:

    • 深夜(デフォ 1:00–6:59)→ 「寝ています」 💤
    • それ以外 → 「現在予定なし/声かけ歓迎」
  • タイトルのキーワード(MTG/作業/研究/学習/移動/昼など)で文言&絵文字をざっくり決める

  • さらに、タイトルの命名規則からプロジェクト名を抜いて、プロジェクト別アイコンに(なくてもOK)


命名規則のミニルール(なくても動くけど、あると楽しい)

  • 推奨:(プロジェクト名)_予定の名前(全角/半角かっこ&アンダースコアOK)
  • 省略形:プロジェクト名_予定の名前(かっこ無しでもOK)
  • 英字プロジェクトは小文字キーで登録しとくと楽(VRvr など)

例:

  • (研究)_論文ドラフトレビュー → 🔬
  • (開発)_API 設計ミーティング → 🛠️
  • vr_ユーザテスト → 🎮

命名統一しておくと、自動化が安定するし、プロジェクトごとに**ちょっとした“人格”**が出て楽しいです。


参考にした記事・公式

アイデアのきっかけ(命名規則のヒント)
https://qiita.com/aAyakaYamamoto/items/253e9becb54a8d3eaaa9

Slack Web API: users.profile.set
https://api.slack.com/methods/users.profile.set

Apps Script: CalendarApp / UrlFetchApp
https://developers.google.com/apps-script/reference/calendar


セットアップ(すぐ試す)

  1. GAS プロジェクトを作成
  2. スクリプト プロパティに登録
  • SLACK_TOKEN: xoxp-...users.profile:write つきユーザトークン)


  • CALENDAR_ID: 読みたいカレンダーID

  1. トリガーで main毎分

コード(素朴版:まずはこれでOK、上からCFGをいじるだけ)

/*******************************************
 * Slack ステータス自動更新(GAS × Slack, Standard)
 * - 今日の「今進行中」イベントでステータス更新
 * - 予定なし → 深夜は「寝ています」、それ以外は「声かけ歓迎」
 * - タイトル命名規則でプロジェクト絵文字(任意)
 *******************************************/

/** 設定(ここだけいじれば大体なんとかなる) */
const CFG = {
  TZ: 'Asia/Tokyo',
  SLEEP_HOURS: { start: 1, end: 7 },            // [1,7) → 1:00–6:59
  NO_EVENT_TEXT: "現在予定なし/声かけ歓迎",
  NO_EVENT_EMOJI: ":white_check_mark:",         // 例: :calendar:, :hand:, :wave:
  SLEEP_TEXT: "寝ています",
  SLEEP_EMOJI: ":sleeping:",

  // プロジェクト別アイコン(命名規則 "(proj)_..." / "proj_..." から抽出)
  PROJECT_EMOJI_MAP: {
    "研究": ":microscope:",
    "開発": ":hammer_and_wrench:",
    "vr": ":video_game:",
    "default": ":calendar:"
  },

  // タイトルのキーワードで上書き(ざっくり)
  KEYWORD_RULES: [
    { re: /(meet|MTG|会議|打ち合わせ)/i, text: (s,e)=>`MTG中 (${s}${e})`, emoji: ":spiral_calendar_pad:" },
    { re: /(作業|実装|coding|検証|デバッグ)/i, text: (s,e)=>`作業中 (${s}${e})`, emoji: ":hammer_and_wrench:" },
    { re: /(研究|論文|analysis)/i,          text: (s,e)=>`研究中 (${s}${e})`, emoji: ":microscope:" },
    { re: /(勉強|学習|reading)/i,           text: (s,e)=>`学習中 (${s}${e})`, emoji: ":books:" },
    { re: /(|lunch|ごはん)/i,             text: (s,e)=>`昼休み (${s}${e})`, emoji: ":rice_ball:" },
    { re: /(移動|通勤|通学|walk|電車|バス)/i, text: (s,e)=>`移動中 (${s}${e})`, emoji: ":blue_car:" }
  ],

  // “このへんの絵文字なら大体OK”なホワイトリスト(不正なら :spiral_calendar_pad: に落とす)
  STANDARD_EMOJIS: new Set([
    ":white_check_mark:", ":sleeping:", ":spiral_calendar_pad:", ":blue_car:", ":rice_ball:",
    ":microscope:", ":hammer_and_wrench:", ":books:", ":calendar:", ":video_game:"
  ])
};

/** Script Properties(安全に取り出す) */
function getSlackToken_() {
  const token = PropertiesService.getScriptProperties().getProperty("SLACK_TOKEN");
  if (!token) throw new Error("Set Script Property 'SLACK_TOKEN'.");
  return token;
}
function getCalendarId_() {
  const id = PropertiesService.getScriptProperties().getProperty("CALENDAR_ID");
  if (!id) throw new Error("Set Script Property 'CALENDAR_ID'.");
  return id;
}

/** 日付ユーティリティ(JST固定) */
function nowJST_() { return new Date(); }
function jstHour_(d) { return parseInt(Utilities.formatDate(d, CFG.TZ, 'H'), 10); } // 0–23
function jstYMD_(d) {
  return {
    y: parseInt(Utilities.formatDate(d, CFG.TZ, 'yyyy'), 10),
    m: parseInt(Utilities.formatDate(d, CFG.TZ, 'M'), 10) - 1,
    day: parseInt(Utilities.formatDate(d, CFG.TZ, 'd'), 10)
  };
}
function jstDayRange_(d) {
  const { y, m, day } = jstYMD_(d);
  return { start: new Date(y, m, day, 0, 0, 0), end: new Date(y, m, day + 1, 0, 0, 0) };
}

/** 絵文字フォールバック(変な値は無害化) */
function normalizeEmoji(emoji) {
  if (!emoji || typeof emoji !== "string") return ":spiral_calendar_pad:";
  if (!/^:.*:$/.test(emoji)) return ":spiral_calendar_pad:";
  if (!CFG.STANDARD_EMOJIS.has(emoji)) return ":spiral_calendar_pad:";
  return emoji;
}

/** 命名規則対応:"(proj)_..." / "proj_..." → プロジェクト絵文字 */
function pickEmojiFromTitle_(title) {
  title = String(title || "").trim();
  const MAP = CFG.PROJECT_EMOJI_MAP;

  const mParen = title.match(/^\s*[\(]([^))]+)[\)]\s*[__]/); // (proj)_
  let project = null;
  if (mParen && mParen[1]) {
    project = mParen[1].trim();
  } else {
    const split = title.split(/[__]/);                            // proj_...
    if (split.length > 1) project = split[0].replace(/^[\(]|[\)]$/g, "").trim();
  }

  if (project) {
    const key = project.toLowerCase();       // 英字は小文字キー化
    if (MAP[key]) return MAP[key];
    if (MAP[project]) return MAP[project];   // 日本語は素直に一致
  }
  return MAP["default"];
}

/** 予定 → ステータス文言(キーワード優先 → プロジェクト) */
function chooseStatus_(title, startHHmm, endHHmm) {
  title = String(title || "");

  // 1) キーワード優先(“今なにしてる?”に素直)
  for (const r of CFG.KEYWORD_RULES) {
    if (r.re.test(title)) {
      return {
        text: typeof r.text === "function" ? r.text(startHHmm, endHHmm) : r.text,
        emoji: r.emoji
      };
    }
  }
  // 2) プロジェクト規則(“誰の文脈?”の雰囲気)
  return {
    text: `${title} (${startHHmm}${endHHmm})`,
    emoji: pickEmojiFromTitle_(title)
  };
}

/** カレンダーイベント → Slack ステータス */
function statusFromEvent_(event) {
  const s = event.getStartTime(), e = event.getEndTime();
  const H = x => x.getHours() + ":" + ("00" + x.getMinutes()).slice(-2);
  let title = String(event.getTitle() || "").trim();
  if (!title) title = "(無題の予定)";

  const chosen = chooseStatus_(title, H(s), H(e));
  const emoji = normalizeEmoji(chosen.emoji);
  return { profile: { status_text: chosen.text, status_emoji: emoji, status_expiration: 0 } };
}

/** Slackへ送信(JSONでポン) */
function postSlackStatus_(status) {
  const URL = "https://slack.com/api/users.profile.set";
  const TOKEN = getSlackToken_();

  const option = {
    method: "post",
    contentType: "application/json; charset=utf-8",
    headers: { "Authorization": "Bearer " + TOKEN },
    payload: JSON.stringify(status),            // { profile: {...} }
    muteHttpExceptions: true
  };

  const res = UrlFetchApp.fetch(URL, option);
  Logger.log("Slack code: " + res.getResponseCode());
  Logger.log("Slack body: " + res.getContentText());
}

/** 進行中イベント判定(終日は除外) */
function isActiveEvent_(ev, now) {
  if (ev.isAllDayEvent()) return false;
  const st = ev.getStartTime(), et = ev.getEndTime();
  return (st <= now) && (now < et);
}

/** メイン(毎分トリガー用) */
function main() {
  const now = nowJST_();
  const hour = jstHour_(now);
  const { start, end } = jstDayRange_(now);

  const cal = CalendarApp.getCalendarById(getCalendarId_());
  const events = cal.getEvents(start, end);

  let active = null;
  for (const ev of events) {
    if (isActiveEvent_(ev, now)) { active = ev; break; }
  }

  let payload;
  if (active) {
    payload = statusFromEvent_(active);
  } else {
    const asleep = (hour >= CFG.SLEEP_HOURS.start && hour < CFG.SLEEP_HOURS.end);
    payload = {
      profile: {
        status_text: asleep ? CFG.SLEEP_TEXT : CFG.NO_EVENT_TEXT,
        status_emoji: normalizeEmoji(asleep ? CFG.SLEEP_EMOJI : CFG.NO_EVENT_EMOJI),
        status_expiration: 0
      }
    };
  }
  postSlackStatus_(payload);
}

ちょい足しアイデア

  • 予定なしの絵文字、:hand: / :wave: にすると**「声かけOK」**がより伝わる
  • 命名規則のマップを増やして、プロジェクトごとに顔つきを育てる
  • 429(レート制限)の簡易リトライや、**“いったんクリア→再設定”**の上書き強化を追加

動作チェックのコツ

  • main() を手動実行 → ログに Slack code: 200 & ... "ok": true ... が出てればOK
  • いまの時間をまたぐイベントを1個だけ置いて様子を見る
  • 予定を消して数分待つ → 既定ステータスに戻るか確認
  • 命名規則のパターン((proj)_... / proj_... / 全角半角)をいくつか試す

おしまい

カレンダーとSlackをつないでおくと、“今の自分”が勝手に伝わるのが気持ちいいです。
まずはこの素朴版で回して、テキストや絵文字、命名規則を自分らしく育てていくのがオススメ。
うまく回り始めると、手で切り替えてた頃には戻れなくなります。いい感じの“気配”をどうぞ!

Discussion