👋
カレンダーに寄り添うSlackステータスをつくる話(GAS × Slack API)
ミーティング入ったら「MTG中」にして、終わったら戻して…を毎回やるの、地味にしんどい。
「カレンダーに全部書いてあるんだから、そこから勝手にやってほしい〜」という気持ちで、Google Apps Script(GAS)+Slack APIでサクッと自動化しました。
ついでに、予定タイトルの“命名規則”からプロジェクト名を拾って絵文字を変える小ワザも入れてます。いい感じに“自分の雰囲気”が出ます🙂
どんなノリのやつ?
-
今日の予定を見て、いま進行中のイベントがあればその内容でステータス更新
-
イベントがなければ:
- 深夜(デフォ 1:00–6:59)→ 「寝ています」 💤
- それ以外 → 「現在予定なし/声かけ歓迎」 ✅
-
タイトルのキーワード(MTG/作業/研究/学習/移動/昼など)で文言&絵文字をざっくり決める
-
さらに、タイトルの命名規則からプロジェクト名を抜いて、プロジェクト別アイコンに(なくてもOK)
命名規則のミニルール(なくても動くけど、あると楽しい)
- 推奨:
(プロジェクト名)_予定の名前
(全角/半角かっこ&アンダースコアOK) - 省略形:
プロジェクト名_予定の名前
(かっこ無しでもOK) - 英字プロジェクトは小文字キーで登録しとくと楽(
VR
→vr
など)
例:
-
(研究)_論文ドラフトレビュー
→ 🔬 -
(開発)_API 設計ミーティング
→ 🛠️ -
vr_ユーザテスト
→ 🎮
命名統一しておくと、自動化が安定するし、プロジェクトごとに**ちょっとした“人格”**が出て楽しいです。
参考にした記事・公式
アイデアのきっかけ(命名規則のヒント)
Slack Web API: users.profile.set
Apps Script: CalendarApp / UrlFetchApp
セットアップ(すぐ試す)
- GAS プロジェクトを作成
- スクリプト プロパティに登録
-
SLACK_TOKEN
:xoxp-...
(users.profile:write つきユーザトークン)
-
CALENDAR_ID
: 読みたいカレンダーID
- トリガーで
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