Raycast で作業スピードを爆速化 ! Google カレンダー連携 拡張機能の作り方

はじめに

この記事はGoodpatch Advent Calendar 2022 の21日目の記事です。

Goodpatch の運営する ReDesigner で、主にフロントエンドの開発を行っております、かずです。

この記事では、OAuth2 を利用して Google カレンダー と連携する Raycast 拡張機能の作り方を解説したいと思います。

目次

Raycastについて

Raycastとは?

Raycast は 無料で利用できるMac用のランチャーアプリです。

www.raycast.com

Raycastについては以前から Twitter で記事が流れており、名前は知っていたのですが、私はあまりランチャーを使って作業していなかったのでスルーしていました。

しかし、以下の記事を読んで開眼し、Raycastにドハマりしてしまいました。

zenn.dev

「欲しいもんなんでもあるやん、こいつ!」

Raycast の何がいいのか

Raycastの良いところを挙げると、

  • インストール直後から使える基本機能が充実している
    • ウィンドウ操作
    • プレビュー・検索機能付きのクリップボード履歴
    • emoji 一覧呼び出し
  • カスタマイズが容易
    • クイックリンクの追加がすぐできる
    • store で、さまざまな拡張機能が提供されている
  • 拡張機能の開発がしやすい
    • TypeScript + React で開発できる
    • 雛形作成もランチャーメニューから一発で実行
  • チーム共有ができる
    • カスタマイズしたリンクやコマンドをチーム内で共有できる(無料枠あり)

こういった機能の説明については、既にさまざまな記事でまとめられています。 基本機能の詳細についてはそちらを見ていたただくとして、本記事では日本語の解説記事がまだ少ない Raycastの拡張機能の作り方 について説明していきます。

テーマと成果物

作るもの(テーマ)

まず、作るものについてです。

突然の自分語りで恐縮ですが、筆者は生来面倒臭がり屋でして、日々のちょっとしたことでも面倒だな、辛いな、と感じてしまいます。

最近私が面倒だなと感じているのは、 Zoom ミーティングへの参加 です。 Zoomミーティングへの参加は、一般的に次のような流れになります。

  1. 通知などによってミーティング時間になったことを知る
  2. カレンダーアプリや Googleカレンダーなどを開く (マウス操作)
  3. 現在の予定を見つけて開く (マウス操作)
  4. ZoomのURLを開く (マウス操作)
  5. ZoomのWebページが開き、Zoomミーティングがアプリで開くのを待つ

開発ではほぼキーボードのみで操作しているのに、マウス操作をなんどもさせられ、別のウィンドウに移動されらえ、タブが無駄に開かれ、ローディングで待たされ...と、とてもストレスフルなのです!

(そんなに?? と思われる方もいらっしゃると思いますが、あくまで今回のテーマ設定ということで、何卒ご容赦ください)

そこで Raycastです!

Raycast で現在開催されているミーティングを取得できれば、キーボードのみの簡単操作で直接Zoomミーティングに参加できるのでは? と思い至ってしまったのです。

というわけで(?)、Google カレンダー から現在付近で開催しているZoomミーティングを取得して、Enterで即参加する Raycast コマンドを作っていきたいと思います。

完成した拡張機能の挙動

完成品がこちらになります (3分クッキング方式) github.com

実際に動かしてみましょう。

Zoomミーティングを設定した予定を現在時刻に追加します。

拡張機能のコマンドを実行すると...

Zoom ミーティングに参加することができました!

拡張機能 開発手順

ここから、具体的な開発の手順に入っていきます。

コードについては、部分的に説明をしていくので、リポジトリのコード合わせて確認してください。 github.com

必要環境

Raycast 公式の Getting Started を参照して準備します。

developers.raycast.com

筆者が開発に使用した環境は以下です。

  • Raycast v1.45.1
  • Node.js v18.12.1
  • npm v8.19.2
  • VSCode v1.74.0

開発支援のため、以下のVSCode拡張が入っていると良いと思います。

  • TypeScript
  • eslint
  • Prettier

拡張機能コードの新規作成

まず始めに、拡張機能のテンプレートから新しいコードベースを生成します。

Raycast を hotkey などから起動し、 create extension と入力して、 Create Extension を選択します。

補足: ここで表示されない場合は、Raycast を開き、 Cmd + , から Extension タブを開いて、 Create Extension コマンドを enable にします。

Create Extension で Enter を押すとフォームが開くので、以下のように選択・入力します。

注意点として、

  • TemplateList and Detail を選択するようにしてください。これはこの後の開発でやることを少なくするためです。
  • Extension Name は自身が開発する拡張機能の中で一意である必要があります。

フォーム入力後に Cmd + Enter すると、コードが生成された旨が表示されます。

さらに Enter を押すと、作成した拡張機能一覧が表示されます。

今作った拡張機能を選択して Enter を押すと、生成されたコードのフォルダがVSCodeで開きます。

拡張機能のビルド & インストール

コードを書きかえる前に、ひとまず拡張機能を実行できるようにします。

VSCode でターミナルを開き、 以下のコマンドを実行します。

$ npm i
$ npm run dev

これだけで拡張機能のコマンドが Raycast で実行できるようになっています!

npm run dev を実行すると Ctrl + c 等で終了するまで動き続け、コードを更新するたびにビルド・インストールが実行され、常に新の状態のコマンドが実行できます。 また、console.log などの出力結果を表示することができます。

この挙動は Nuxt などの開発体験に近く、デバッグが非常にやりやすかったです。

OAuth2 同意画面・認証情報の作成

メールやカレンダーなど、Google が管理する個人の情報にアクセスするためには、認証・認可が必要です。その準備として、GCP 上で、OAuth 同意画面認証情報 を作成する必要があります。

プロジェクトの作成

以下から GCP コンソールを開きます。 console.cloud.google.com

プロジェクトを適当な名前で新規作成します。

OAuth 同意画面の作成

ハンバーガーメニューから、APIとサービス を開き、OAuth 同意画面 をクリックします。

User Type は 外部 を選択し、作成 ボタンをクリックします。 (組織内でのみ利用する場合はここを 内部 にします)

アプリ情報の中の必須項目を埋めます。 アプリ名、ユーザーサポートメール、デベロッパーの連絡先情報などが必須になります。

スコープはとくに設定がないので 保存して次へ をクリックしてスキップします。

テストユーザーに、今回カレンダーを参照したいユーザーのメールアドレスを登録します。 (この記事内では OAuth 同意画面を外部公開せずテスト状態で使用するので、テストユーザーが必要になります)

認証情報の作成

次に認証情報を作成します。

再度メニューから、認証情報 ページに移動し、認証情報を作成 から OAuth クライアントID を選択します。

アプリケーションの種類、名前、バンドルIDを指定します。 このとき、画像の通り、アプリケーションの種類は iOS、バンドルIDは com.raycast に設定します。

作成 ボタンをクリックして作成します。 このあと利用するので、クライアントIDをコピーしておきます。

OAuth2 認証機能の実装

下準備が長かったですが、ようやくここから拡張機能のコードを書いていきます。

基本となるコード

OAuth2 認証機能については、公式の Example にほぼそのまま使えるコードがあるので、こちらをベースとして改修して使います。

github.com

node-fetch のインストール

このコードでは、node-fetch を利用しているので、そのまま利用できるようにインストールしておきます

$ npm i node-fetch

github.com

Google OAuth2 認証コード

今回の用途に合わせて Example から書き換えたものがこちらです。

// src/utils/googleOauth.ts

import { OAuth } from "@raycast/api";
import fetch from "node-fetch";

// Create an OAuth client ID via https://console.developers.google.com/apis/credentials
// As application type choose "iOS" (required for PKCE)
// As Bundle ID enter: com.raycast
const clientId = "<Your OAuth Client ID>";

export const client = new OAuth.PKCEClient({
  redirectMethod: OAuth.RedirectMethod.AppURI,
  providerName: "Google",
  providerIcon: "google-logo.png",
  providerId: "google",
  description: "Connect your Google account",
});

// Authorization

export async function authorize(scopes: string[]): Promise<void> {
  const tokenSet = await client.getTokens();
  if (tokenSet?.accessToken) {
    if (tokenSet.refreshToken && tokenSet.isExpired()) {
      await client.setTokens(await refreshTokens(tokenSet.refreshToken));
    }
    return;
  }

  const authRequest = await client.authorizationRequest({
    endpoint: "https://accounts.google.com/o/oauth2/v2/auth",
    clientId: clientId,
    scope: scopes.join(" "),
  });
  const { authorizationCode } = await client.authorize(authRequest);
  await client.setTokens(await fetchTokens(authRequest, authorizationCode));
}

async function fetchTokens(authRequest: OAuth.AuthorizationRequest, authCode: string): Promise<OAuth.TokenResponse> {
  const params = new URLSearchParams();
  params.append("client_id", clientId);
  params.append("code", authCode);
  params.append("verifier", authRequest.codeVerifier);
  params.append("grant_type", "authorization_code");
  params.append("redirect_uri", authRequest.redirectURI);

  const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", body: params });
  if (!response.ok) {
    console.error("fetch tokens error:", await response.text());
    throw new Error(response.statusText);
  }
  return (await response.json()) as OAuth.TokenResponse;
}

async function refreshTokens(refreshToken: string): Promise<OAuth.TokenResponse> {
  const params = new URLSearchParams();
  params.append("client_id", clientId);
  params.append("refresh_token", refreshToken);
  params.append("grant_type", "refresh_token");

  const response = await fetch("https://oauth2.googleapis.com/token", { method: "POST", body: params });
  if (!response.ok) {
    console.error("refresh tokens error:", await response.text());
    throw new Error(response.statusText);
  }
  const tokenResponse = (await response.json()) as OAuth.TokenResponse;
  tokenResponse.refresh_token = tokenResponse.refresh_token ?? refreshToken;
  return tokenResponse;
}

join-zoom-from-calender/googleOauth.ts at a80568469981adbb854082b50e4bfba17e1f1576 · kazudeath1/join-zoom-from-calender · GitHub

変更点は以下になります。

  • OAuth client を export して、外部ファイルから import できるようにした
  • scopes を authorize の引数として渡せるようにした
  • 不要な関数の削除

実際に使用するときには、<Your OAuth Client ID> となっているところを、前の章で作成した クライアントIDに変更してください。

このファイルを src/utils/googleOauth.ts として保存しておきます。

Google API ライブラリのインストール・インポート

Google API を利用するので、Node.js 向けのライブラリをインストール・インポートして使います。

インストール
$ npm i googleapis

github.com

インポート

ここで注意点です

以下のように普通にインポートするとエラーが発生します。

import { google } from 'googleapis';

const calenderClient =  google.calendar('v3')

これは、拡張機能のヒープ領域が制限されているためで、googleapis ような大規模なライブラリを読み込むと、それだけで領域が埋まってしまうのです。

Raycast の Slack Community で質問してみると、制限を増やすことはできず、分割インポートするしかないとのことでした。

今回利用するのはカレンダーAPIのみなので、以下のように部分的にインポートして利用します。

import { calendar_v3 } from "googleapis/build/src/apis/calendar/v3";
const calenderClient = new calendar_v3.Calendar({});

github.com

コマンドの仮実装

ここからテスト実行しながら機能を追加していくので、Reactコンポーネントとしての概形を作っておき、実行できるようにします。

// src/index.tsx

import { ActionPanel, Detail, List, Action } from "@raycast/api";
import { useEffect, useState } from "react";
import { calendar_v3 } from "googleapis/build/src/apis/calendar/v3";

type MeetingState = {
  meetings: { event: calendar_v3.Schema$Event; url: string }[];
  isLoading: boolean;
  error?: string;
};

export default function Command() {
  const state = useMeetingInCalendar();

  if (state.error) {
    return <Detail markdown={state.error} />;
  }

  return (
    <List>
      <List.Item
        icon="list-icon.png"
        title="Greeting"
        actions={
          <ActionPanel>
            <Action.Push title="Show Details" target={<Detail markdown="# Hey! 👋" />} />
          </ActionPanel>
        }
      />
    </List>
  );
}

function useMeetingInCalendar() {
  const [state, setState] = useState<MeetingState>({ meetings: [], isLoading: true });

  useEffect(() => {
    (async () => {
       
       // 非同期でデータ取得・フィルタ・整形をする
  
     })()
  }
}

非同期でデータを取得する部分を useMeetingInCalendar という名前の Hooks として切り出しています。 Hooks の中で setStateが呼ばれる度に Viewが更新されます。

非同期データ取得

アクセストークン取得

ここから非同期データ取得部分、つまり useEffect にかこまれた部分を記述していきます。

アクセストークン取得は事前に作成しておいたGoogle OAuth2 認証コードを用いて次のようにします。

今回はカレンダーAPIからカレンダー一覧と、イベント情報を取得したいので、スコープとして https://www.googleapis.com/auth/calendar.readonly を指定します。

import { authorize, client } from "./utils/googleOauth";

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

    // 認証・トークン取得
    await authorize(["https://www.googleapis.com/auth/calendar.readonly"]);
    const token = await client.getTokens();

authorize を実行すると、RaycastのOAuth API が呼ばれ、コマンド実行時に以下のような画面が表示されます。 Sign in with Google から認証・認可を実行できます。

OAuth 同意画面がテスト状態になっているので、以下のような画面が表示されます。 続行をクリックして続けます。

アクセス権を確認して続行をクリックします。

正しく設定されていれば、この後フォーカスが Raycast に移り、認証が完了した旨が表示されるはずです。

カレンダー一覧取得

取得したアクセストークンを利用してカレンダーの一覧を取得するコードです。

       // カレンダー一覧取得
      const calendars = await calenderClient.calendarList
        .list({
          oauth_token: token.accessToken,
          minAccessRole: "owner",
        })
現在時刻の前後15分の予定を抽出

時間に関する操作を簡単に行いたいので、dayjs をインストールします。

$ npm i dayjs

github.com

カレンダーAPIでは、予定の時間的なフィルタ範囲をtimeMin, timeMax で指定できるようになっています。 どちらもISO8601の形式の文字列で指定するため、コードは以下のようになります。

import dayjs from "dayjs";

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

     // 現在時刻前後15分以内の予定を取得
      const now = dayjs();
      const timeMin = now.add(-15, "minutes").toISOString();
      const timeMax = now.add(15, "minutes").toISOString();

      const events = await calenderClient.events
        .list({
          oauth_token: token?.accessToken,
          calendarId: calendars.data.items[0].id,
          timeMin,
          timeMax,
        })
        .catch((e: Error) => {
          setState({ isLoading: false, meetings: [], error: e.message });
        });
      if (!events?.data?.items) {
        return;
      }

なお、今回のコードでは複数取得される可能性のあるカレンダー一覧から最初の1個を決め打ちで使用しています。

          calendarId: calendars.data.items[0].id,

大抵の人はメインで使っているカレンダーが1番目に取得できるので、ほとんど支障ないと思いますが、もし拡張機能として公開する場合は、複数の中からデフォルトのカレンダーを選択させる機能も必要だと思っています。

Zoom ミーティングが設定されている予定の抽出

予定の中でZoomミーティングが設定されているもののみ候補として表示したいので、フィルタをかけて抽出します。

注意点として、Zoom ミーティングは、アドオンやブラウザ拡張機能をつかってGoogle カレンダーの予定に追加できるのですが、ミーティングURLの保存場所が異なることがあります。

例えば、Zoom for Google Workspaceの場合は event.conferenceData.entryPoints[n].uri に保存されますし、Zoom Schedulerの場合は event.location に保存されます。

この違いを吸収するため、今回は以下のような形でフィルターをかけています。

      const zoomUrlRegex = /^https:\/\/\S+?\.zoom\./;


      const meetings = events.data.items.reduce((meetings, event) => {
        let url;
        // Zoom for Google Workspace 用
        if (event.conferenceData?.entryPoints?.[0]?.uri?.match(zoomUrlRegex)) {
          url = event.conferenceData?.entryPoints?.[0]?.uri;
        } 
        // Zoom Scheduler 用
        else if (event.location?.match(zoomUrlRegex)) {
          url = event.location;
        }
        if (url) {
          meetings.push({ event, url });
        }
        return meetings;
      }, [] as MeetingState["meetings"]);

Zoom ミーティング一覧の表示と起動

ここまでくれば後は表示させるだけです。

RaycastのUIコンポーネントを使って Viewを作成します。

import { ActionPanel, Detail, List, Action } from "@raycast/api";

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

export default function Command() {
  const state = useMeetingInCalendar();

  if (state.error) {
    return <Detail markdown={state.error} />;
  }

  return (
    <List isLoading={state.isLoading}>
      {state.meetings.map((meeting) => (
        <List.Item
          key={meeting.event.id}
          icon="list-icon.png"
          title={meeting.event.summary || "名称未設定"}
          actions={
            <ActionPanel>
              <Action.Open target={meeting.url} application="us.zoom.xos" title="Open by Zoom" />
              <Action.OpenInBrowser url={meeting.url} title="Open in browser" />
              <Action.Push target={<Detail markdown={JSON.stringify(meeting.event, null, 4)} />} title="Show Data" />
            </ActionPanel>
          }
        />
      ))}
    </List>
  );
}

<List> でリストビューを表示します。 isLoading を指定することによって、データ取得中にローディング表示させることができます。

<ActionPanel>, <Action.Xxxx> によって項目を選択したときの操作を定義しておきます。 <ActionPanel>内の一番上の Action は Enter を押したときのデフォルト動作になります。

<Action.Open> に、application="us.zoom.xos" を指定することで、ブラウザを介さず、直接Zoomアプリでミーティングを開くことができます。

おわりに

今回はOAuth2 を利用した Raycast 拡張機能の作り方を説明しました。 開発体験としては間違いなく良いのですが、罠はそこそこあり、解決に少し時間がかかってしまったので、今後開発する人が同じ部分で嵌らないように願っています。

反省として、予想では手頃な大きさの記事になる予定だったのですが、実装したことを書くだけでとても長くなってしまいました。 RaycastのUIコンポーネントやOAuth2の処理の流れなども説明したかったのですが、それぞれで一記事作れてしまいそうなので、割愛いたします。

Raycastは設定レベルのカスタマイズでも、生産性を爆上げできるポテンシャルがあります。 個人・チーム・組織レベルでの活用を検討してみてはいかがでしょうか?

それでは!