🎧 Spotify NowPlaying を Slack ステータスに自動表示する仕組みを GAS で構築してみた

## 想定読者
* GASやSlack APIをちょっと触ったことがある人
* できれば無料で済ませたい人（Vercelや有料DBは使いたくない）
    * 今回は **自分のみ** のステータス更新をしています 
* 放置で自動化したい人


## やりたいこと

![スクリーンショット 2025-06-18 19.02.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/990205/67e218d5-7bff-4b29-888d-1fe911e21c00.png)

Spotifyで今再生している曲名・アーティスト名をSlackのステータスメッセージに自動表示したい。

Slackを開いているだけで「この人、今これ聴いてるんだ」がわかる感じにしたかった。

* 更新頻度は5分でおk(そんな叩いてもな...感)
* GASを使って自動更新
   * 貧乏なので無料でなんとかしたかった

## 技術構成

| 項目           | 使用技術                                                   |
|----------------|------------------------------------------------------------|
| 曲情報取得     | Spotify Web API                                            |
| ステータス更新 | Slack Web API                      |
| スクリプト実行 | Google Apps Script（GAS）                                  |
| トークン取得   | Flask + spotipy + ngrok（※refresh_token を1回だけ取得するため） |

## やること

:::note warn
この記事で紹介する `.env` や `スプレッドシート` に保存する情報は**個人のアクセストークン**を含みます。  
GitHubなどの公開リポジトリには絶対にアップロードしないよう注意してください。
:::

### ステップ 1：Spotify Developer の準備

1. [Spotify Developer Dashboard](https://developer.spotify.com/) にログイン
1. 新しいアプリを作成する
1. `Client ID` と `Client Secret` を控える

![スクリーンショット 2025-06-18 18.40.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/990205/10cc0e33-486a-40fc-85f1-2e5038f1f971.png)

### ステップ 2：Slack API アプリの準備
1. [Slack API Console](https://api.slack.com/apps) にアクセスし、新しいアプリを作成
1. 「OAuth & Permissions」→ users.profile:write を User Token Scopes に追加
1. 「Install to Workspace」でワークスペースに追加
1. 発行された xoxp-... のトークンを控える

:::note warn
Botトークン(xoxb-...)では変更できない。Userトークンが必須
:::

### ステップ 3：Spotifyの refresh_token を取得（初回1回だけ）

:::note info
Spotifyのアクセストークンは1時間で失効するため、定期更新に使う `refresh_token` を事前に取得しておく必要がある。これが自動化のキモ
:::

1. `ngrok http 8888` を実行
    1. `Forwarding https://xxxx-xxx-xxx-xxx-xxx.ngrok-free.app`の形で出てくるので、これをメモる
    1. Spotifyで作成したアプリに `https://xxxx-xxx-xxx-xxx-xxx.ngrok-free.app/callback`のredirect uriを設定する
1. spotipy + Flask のコードをローカルに作成(コードは以下)
1. .env を設定（client_id など）
    1.  `SPOTIPY_REDIRECT_URI` は`https://xxxx-xxx-xxx-xxx-xxx.ngrok-free.app/callback`を設定する
1. コードを実行
1. ブラウザでngrokの `https://xxxx-xxx-xxx-xxx-xxx.ngrok-free.app`へアクセス
1. 認可後、ターミナルに表示された `refresh_token` を控える

:::note warn
Spotify側の仕様として、HTTPSリダイレクトが必須なため、今回は `ngrok` を使って一時的にHTTPSで公開している
:::

``` python
import os
from flask import Flask, redirect, request
from dotenv import load_dotenv
from spotipy.oauth2 import SpotifyOAuth

load_dotenv()
app = Flask(__name__)

sp_oauth = SpotifyOAuth(
    client_id=os.getenv("SPOTIPY_CLIENT_ID"),
    client_secret=os.getenv("SPOTIPY_CLIENT_SECRET"),
    redirect_uri=os.getenv("SPOTIPY_REDIRECT_URI"),
    scope="user-read-currently-playing user-read-playback-state",
    cache_path=None
)

@app.route("/")
def login():
    return redirect(sp_oauth.get_authorize_url())

@app.route("/callback")
def callback():
    code = request.args.get("code")
    token_info = sp_oauth.get_access_token(code)
    return f"Refresh Token: {token_info.get('refresh_token')}"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8888)
```

### ステップ 4：スプレッドシートの作成

:::note warn
Secretな情報を直接スプシに書き込むので、他人への共有設定はしないように
:::

1. Google スプレッドシートを新規作成
1. 以下のキーを左列（A列）、値を右列（B列）に入力

```
spotify_client_id
spotify_client_secret
spotify_refresh_token
spotify_access_token（後で自動で入ってくるので空でOK）
slack_token
```

### ステップ 5：GASスクリプトの設定

1. スプレッドシート上で「拡張機能」→「Apps Script」を開く
1. 記事の `updateSlackStatusWithSpotify()` 以下のスクリプトを貼り付け

```js
function updateSlackStatusWithSpotify() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const rows = sheet.getDataRange().getValues();
  const config = Object.fromEntries(rows);

  // 必要な情報を取得
  const clientId = config["spotify_client_id"];
  const clientSecret = config["spotify_client_secret"];
  const refreshToken = config["spotify_refresh_token"];
  let accessToken = config["spotify_access_token"];
  const slackToken = config["slack_token"];

  // アクセストークンをリフレッシュ（毎回）
  accessToken = refreshSpotifyToken(clientId, clientSecret, refreshToken);
  updateCell(sheet, "spotify_access_token", accessToken); // 更新

  const nowPlaying = getNowPlayingTrack(accessToken);
  if (nowPlaying) {
    setSlackStatus(slackToken, nowPlaying);
  } else {
    const currentStatus = getCurrentSlackStatus(slackToken);
    if (currentStatus?.status_emoji === ":musical_note:") {
      clearSlackStatus(slackToken);
    }
  }
}

function refreshSpotifyToken(clientId, clientSecret, refreshToken) {
  const token = Utilities.base64Encode(`${clientId}:${clientSecret}`);
  const payload = {
    grant_type: "refresh_token",
    refresh_token: refreshToken
  };

  const res = UrlFetchApp.fetch("https://accounts.spotify.com/api/token", {
    method: "post",
    headers: {
      Authorization: `Basic ${token}`,
      "Content-Type": "application/x-www-form-urlencoded"
    },
    payload: Object.entries(payload).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&"),
    muteHttpExceptions: true
  });

  const json = JSON.parse(res.getContentText());
  return json.access_token;
}

function getNowPlayingTrack(token) {
  const res = UrlFetchApp.fetch("https://api.spotify.com/v1/me/player/currently-playing", {
    method: "get",
    headers: {
      Authorization: `Bearer ${token}`
    },
    muteHttpExceptions: true
  });

  if (res.getResponseCode() !== 200) return null;

  const data = JSON.parse(res.getContentText());
  if (!data?.item) return null;

  const track = data.item;
  const name = track.name;
  const artists = track.artists.map(a => a.name).join(", ");
  return `${name} - ${artists}`;
}

function setSlackStatus(token, text) {
  const payload = {
    profile: {
      status_text: text,
      status_emoji: ":musical_note:",
      status_expiration: 0
    }
  };

  UrlFetchApp.fetch("https://slack.com/api/users.profile.set", {
    method: "post",
    contentType: "application/json",
    headers: {
      Authorization: `Bearer ${token}`
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });
}

function updateCell(sheet, key, value) {
  const range = sheet.getDataRange();
  const values = range.getValues();
  for (let i = 0; i < values.length; i++) {
    if (values[i][0] === key) {
      sheet.getRange(i + 1, 2).setValue(value);
      return;
    }
  }
}

function getCurrentSlackStatus(token) {
  const res = UrlFetchApp.fetch("https://slack.com/api/users.profile.get", {
    method: "get",
    headers: {
      Authorization: `Bearer ${token}`
    },
    muteHttpExceptions: true
  });

  if (res.getResponseCode() !== 200) return null;

  const data = JSON.parse(res.getContentText());
  return {
    status_text: data.profile?.status_text || "",
    status_emoji: data.profile?.status_emoji || ""
  };
}

function clearSlackStatus(token) {
  const payload = {
    profile: {
      status_text: "",
      status_emoji: "",
      status_expiration: 0
    }
  };

  UrlFetchApp.fetch("https://slack.com/api/users.profile.set", {
    method: "post",
    contentType: "application/json",
    headers: {
      Authorization: `Bearer ${token}`
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  });
}
```

### ステップ 6：トリガー設定（自動更新）
1. GASエディタの「トリガー」アイコンをクリック
1. 関数：updateSlackStatusWithSpotify を選択
1. イベントの種類：「時間主導型」→「5分おき」を設定

![スクリーンショット 2025-06-18 19.02.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/990205/67e218d5-7bff-4b29-888d-1fe911e21c00.png)

いい感じ！

## これを他人にもスケールさせるなら？

今回の構成はあくまで「自分用の自動化」

アクセスキーとかもベタ書きですし、例えば非エンジニア含めてステータス表示を行いたい！と思ったときにスケールが効かない形になっている

なので、気軽に広めるのであれば、Web UI＋バックエンド化して、OAuthトークンを一元管理とかするといいのかもなあと思っていたりしておる

## 最後に

らぷりえーるはいいぞ