Next.js を使って WOFF に入門する話

## はじめに

こんにちは。2024 年ももう終わりですね。はやすぎ〜。

最近は LINE WORKS アプリで動く Web アプリ、いわゆる WOFF アプリを作る機会があったので、
[LINE WORKS アドベントカレンダー 2024](https://qiita.com/advent-calendar/2024/lineworks) のネタとして、
本稿では React / Next.js を用いた WOFF アプリの構築方法を皆様に共有したいと思います。

## WOFF とは

- WOFF(WORKS Front-end Framework)は、LINE WORKS が提供する Web アプリプラットフォームです。

- LINE WORKS アプリには、WOFF アプリを実行するための専用ブラウザ、**WOFF ブラウザ**があります。
- モバイル版 LINE WORKS アプリのトークルームから**WOFF URL**にアクセスすると、**WOFF ブラウザ**が動作します。
  - **WOFF URL**は、後述の Developer Console でアプリを登録すると発行される専用の URL です。
    - この URL を経由した場合のみ WOFF ブラウザが動作し、それ以外の場合は通常の In-app ブラウザが開かれます。

## 環境設定

### Developer Console へのアプリ登録

- [Developer Console](https://dev.worksmobile.com/) は LINE WORKS アプリの設定を行うためのページです。

  - 使用するためにはアクセス権限が必要です。
  - もし権限を持っていない場合、以下の記事を参考に権限をお持ちのメンバーに追加を依頼してみてください。
    - [0 から始める LINE WORKS API](https://qiita.com/mmclsntr/items/3da41a9d03d6dba16290)。

- 右側の API ClientApp メニューから「アプリの新規追加」を選択します。

  - 作成したアプリ内の「WOFF アプリ」の「登録」を選択し、WOFF アプリをデプロイするためのエンドポイントを登録します。
  - 登録後、WOFF URL が発行されます。

- 発行された WOFF URL にアクセスすると、WOFF ブラウザでトークルームを開くことができます。

  - この段階で、モバイルサイズ向けに実装された Web アプリを WOFF アプリとして使用することが可能になります。

- 例: [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app) にて作成した初期状態の Next.js アプリを動作させているサーバーに WOFF URL からアクセス。
  - 特に WOFF SDK を使っていませんが、WOFF ブラウザを活用することが可能です。

![create-next-app](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/335883/f3c9cdc2-3239-48f5-ebfe-ec757b95eca6.jpeg)

### WOFF SDK

- LINE WORKS ユーザー情報の取得やトークルームへのメッセージ送信を行いたい場合は、WOFF SDK を利用して機能を実装できます。
  - WOFF ブラウザの利用のみであれば、Developer Console だけで環境設定が可能です。
- 利用例

  - WOFF SDK を通して取得（`woff.getAccessToken()`）したトークンを使用して、バックエンド側で LINE WORKS API を利用した機能を実装する。
  - WOFF で処理した結果をメッセージにしてトークルームに転送する。

- Reference
  - [WOFF SDK Reference](https://developers.worksmobile.com/jp/docs/woff-api)
  - [LINE WORKS API](https://developers.worksmobile.com/jp/docs/api)

### Next.js への導入

- WOFF SDK は 2024 年 12 月初旬現在、JavaScript の静的ファイルとして配布されているため、通常`<script>`タグを使ってロードします。

```html
<script
  charset="utf-8"
  src="https://static.worksmobile.net/static/wm/woff/edge/3.6.2/sdk.js"
></script>
```

- Next.js では[`<Script> Component`](https://nextjs.org/docs/pages/api-reference/components/script)が提供されているため、こちらを利用することが可能です。

```jsx
<Script strategy="afterInteractive" src="https://static.worksmobile.net/static/wm/woff/edge/3.6.2/sdk.js">
```

スクリプトがロードされると、window オブジェクト内に`window.woff`というオブジェクトが登録されます。
`woff`オブジェクトとしても参照できますが、本稿では`window`から参照します。

#### 型定義

2024 年 12 月中旬現在、公式の型定義は提供されていません。

そのため、TypeScript の恩恵を利用したい筆者は、ドキュメントを参照しながら手動で型定義を作成しました。

```typescript
/** @see https://developers.worksmobile.com/jp/docs/bot-send-flex */
interface FlexTemplate {
  type: "flex";
  altText: string;
  contents: object;
  i18nAltTexts?: Array<{ language: string; altText: string }>;
}

/** Woff SDK interface
 * Unknown functions are defined as () => void
 * You need to check & modify if it's match how these works
 * @see https://developers.worksmobile.com/jp/docs/woff-api
 */
export interface Woff {
  checkFeature: () => void;
  closeWindow: () => void;
  getAId: () => void;
  getAccessToken: () => string;
  getContext: () => {
    viewType: "compact" | "tall" | "full";
    endpointUrl: string;
    permanentLinkPattern: string;
    clientId: string;
    clientType: "PC_WEB" | "MOBILE_APP" | "PC_APP";
  };
  getDecodedIDToken: () => void;
  getFeatures: () => void;
  getFriendship: Fucntion;
  getIDToken: () => void;
  getIsVideoAutoPlay: () => void;
  getLanguage: () => string;
  getOS: () => "ios" | "android" | "web";
  getProfile: () => Promise<{
    domainId: string;
    userId: string;
    displayName: string;
  }>;
  getProfilePlus: () => void;
  getVersion: () => string;
  getWorksVersion: () => string;
  init: (
    config: { woffId: string },
    successCallback: () => void,
    errorCallback: () => void
  ) => Promise<void>;
  isApiAvailable: () => boolean;
  isInClient: () => boolean;
  isLoggedIn: () => boolean;
  login: (loginConfig: { redirectUri?: string; domain?: string }) => void;
  logout: () => void;
  openWindow: (params: { url: string; external: boolean }) => void;
  permanentLink: {
    createUrl: () => void;
    setExtraQueryParam: () => void;
  };
  ready: Promise<void>;
  scanQR: () => Promise<string>;
  sendCalendarVoipUrl: () => void;
  sendFlexMessage: (message: { flex: FlexTemplate }) => Promise<void>;
  sendMessage: (message: { content: string }) => Promise<void>;
  sendMessages: () => void;
  shareTargetPicker: () => void;
  userPicker: () => void;
}

export interface WoffError {
  code: string;
  message: string;
}

declare global {
  interface Window {
    woff: Woff;
  }
}
```

Window オブジェクトを拡張し、`window.woff`を追加しています。
プロジェクトの`tsconfig.json`の`include`に指定することで使用可能になります。

```js
  // WOFF type definitions: `src/types/woff.d.ts`
  "include": ["next-env.d.ts", "src/types/woff.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
```

### SDK の初期化

上記では Next.js の`Script` コンポーネントを SDK スクリプト読み込みに使用しました。

これを用いることにより、例えば SDK スクリプトのロードイベントにて、WOFF SDK の初期化処理を以下のように追加できます。

```tsx
<Script
  strategy="afterInteractive"
  src={WOFF_SDK_SRC}
  onLoad={() => {
    // WOFF SDK initialization on loading sdk.js
    window.woff.init({ woffId: YOUR_WOFF_ID }, successHandler, errorHandler);
  }}
/>
```

`woff.init()` による初期化完了時のイベントに対して、任意の処理を第 2 引数, 第 3 引数から渡すことができます。

```typescript
/** This is called when initialization of woff succeeds */
const successHandler: Parameters<Woff["init"]>[1]> = async () => {
  await window.woff.ready;
  // Add your success handling
};

/** This is called when initialization of woff fails */
const errorHandler: Parameters<Woff["init"]>[2]> = () => {
  // Add your error handling
};
```

## サンプルアプリの作成

WOFF SDK の練習として、トークルームにお絵描きを投稿するというシンプルなアプリを Next.js(app router)で作成してみました。

お絵描き部分には[react-sketch-canvas (MIT License)](https://github.com/vinothpandian/react-sketch-canvas) の Example を参考にしながら利用しました。

Developer Console から発行された**WOFF URL**にトークルームからアクセスするとこのような形で見えます。

絵心がないのでとりあえず文字を書きます。。。

![canvas.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/335883/04d08fed-c52d-e833-51aa-6104cdd2ead5.jpeg)

### Next.js project の作成

- [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app) コマンドを用いて、Next.js プロジェクトを新規に作ります。
  - 使用したい環境に Doc を参考に合わせてオプションをつけます。(e.g. Typescript, eslint, tailwind css)
  - `Would you like to use App Router? (recommended)` のような質問に Yes/No で回答するとプロジェクトが作成できます。
    - 今回は app router を用いることにしました。

```
npx create-next-app@latest my-project --typescript --eslint --tailwind
```

### WOFF SDK の読み込み

- このサンプルアプリでは `app/layout.tsx` にスクリプトのロードなどを配置しました。
  - [上述](#sdk-の初期化)の`Script` をラップした`WoffScriptLoader` Component を作成しました。
  - 別途アプリ全体へのステート共有のための Context Provider も配置します。
    - SDK の初期化成功時にステートを更新し、子コンポーネントへ通知するために使っています。

```tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      {/* SDKの初期化成功済を共有するステートをアプリケーション全体に共有 */}
      <WoffStateProvider>
        <Suspense>
          {/* SDK の読み込みを行う部分 */}
          <WoffScriptLoader />
        </Suspense>
        <body
          className={`${geistSans.variable} ${geistMono.variable} antialiased`}
        >
          {children}
        </body>
      </WoffStateProvider>
    </html>
  );
}
```

### お絵描きの保存、トークルームへの送信

- お絵描きに使用する`ReactSketchCanvas` では ref Object を渡すことができ、それを経由して画像を Export できます。
- これをトークルームへ Flex Message 機能を用いて送信することを考えます。

```tsx
import { FC, useRef, useState } from "react";
import { ReactSketchCanvas, ReactSketchCanvasRef } from "react-sketch-canvas";

export const Sketch: FC = () => {
  const canvasRef = useRef<ReactSketchCanvasRef>(null);
  const [strokeColor, setStrokeColor] = useState("#000000");
  const [mode, setMode] = useState<"draw" | "erase">("draw");

  return (
    <div className="flex size-full flex-col gap-4">
      <div className="flex items-center justify-center gap-4">
        <ReactSketchCanvas ref={canvasRef} strokeColor={strokeColor} />
        {/* switches here... */}
      </div>
    </div>
  );
};
```

#### 送信ボタンハンドラの実装

- `Post` ボタンを押した際にお絵描きした画像をトークルームに送信するように実装します。
- 送信前の準備として以下のような処理をボタンハンドラで行いました。
  - キャンバス上のお絵描きを.png の Data URL に変換
  - バックエンドに渡してトークルームからもアクセス可能な URL を発行

```tsx
const pngDataUrl = await canvasRef.current.exportImage("png");

// Execute API to store image & publish image URL
const response = await fetch("/api/store", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    file: { name: `${uuidv4()}.png`, data: pngDataUrl },
  }),
});
```

- [Flex Message](https://developers.worksmobile.com/jp/docs/bot-send-content#flex) 機能では画像 URL を使ってメッセージに画像をのせることができます。
  - URL には[制約](https://developers.worksmobile.com/jp/docs/bot-send-flex-image#image-request-parameter) があり、PNG 形式, 1000 文字以内, HTTPS のみ可能です
- Flex Message 用 API [`woff.sendFlexMessage`](https://developers.worksmobile.com/jp/docs/woff-api#woff-sendflexmessage)を用いて WOFF アプリを開いているトークルームへメッセージを送信します。
  - WOFF ブラウザが使用可能な LINE WORKS モバイルアプリ以外では使用できません。
  - WOFF ブラウザを使用しているかどうかは[`woff.isInClient()`](https://developers.worksmobile.com/jp/docs/woff-api#woff-isinclient)で判定が可能です。

```tsx
// Make sure if the browser is WOFF or another.
if (window.woff.isInClient()) {
  // Send flex message with image to WORKS Talkroom
  window.woff.sendFlexMessage({ flex: crateFlexMessage(json.url) });
}
```

- 画像送信用の Flex Template は以下のようになっています
  - [ガイド](https://developers.worksmobile.com/jp/docs/bot-send-flex)を参考にカスタマイズが可能ので、ぜひ試してみてください。

```typescript
const createFlexMessage = (url: string): FlexTemplate => {
  return {
    type: "flex",
    altText: "Your sketch",
    contents: {
      type: "bubble",
      body: {
        type: "box",
        layout: "vertical",
        contents: [
          {
            type: "image",
            url,
            size: "full",
            aspectRatio: "2:1",
          },
        ],
      },
    },
  };
};
```

#### 画像 URL の発行

- 画像 URL 発行用には、バックエンドには練習を兼ねて Next.js の API および Supabase を使用しました

  - Supabase は[Github](https://github.com/supabase/supabase/tree/master/docker) の docker-compose を用いて VM 上に[セルフホスト](https://supabase.com/docs/guides/self-hosting/docker)しました
  - [supabase/storage](https://supabase.com/docs/guides/storage) にお絵描きした画像を保存し、URL を発行するようにしました

- Next.js API ルートを実装し、サーバー上から supabase の操作を行なっていきます。
- 二度手間になりますが、まずはクライアント側から渡された DataURL をバイナリデータにもどします。

```typescript
export async function POST(request: NextRequest) {
  // .... skip ....

  // Remove the data URL prefix to get the base64-encoded data
  // because the data URL is not supported by the Buffer constructor
  const base64Data = data.replace(/^data:image\/\w+;base64,/, "");
  const buffer = Buffer.from(base64Data, "base64");
```

- 変換したバイナリデータを[supabase-js](https://github.com/supabase/supabase-js)を使用して storage に保存します。
- 保存ができたら画像 URL を発行してクライアントに返します。
  - 必要に応じて、クライアントから HTTPS でアクセスできるように画像 URL をプロキシします。

```typescript
// Upload the image to the specified bucket
const { error } = await supabase.storage
  .from(BUCKET_NAME)
  .upload(name, buffer, {
    upsert: true,
    contentType: "image/png",
  });

// ... error handling ... //

// Publish URL
const { data: urlData, error: urlError } = await supabase.storage
  .from(BUCKET_NAME)
  .createSignedUrl(name, 60 * 60);

// ... error handling ... //

const proxiedUrl = replaceToProxiedUrl(urlData.signedUrl);

// Return to client
return new NextResponse(
  JSON.stringify({
    message: `Image uploaded successfully: ${name}`,
    url: proxiedUrl,
  }),
  {
    status: 200,
    headers: {
      "Content-Type": "application/json",
    },
  }
);
```

#### 送信結果

トークルームに投稿されました。やったね！！

![talk.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/335883/ce07e470-7705-9fe2-31b9-752398cdfaad.jpeg)


## ハマったポイント

### ローカル実行時の初期化エラー

- Developer Console に登録する Endpoint 以外から WOFF を実行すると、WOFF SDK 初期化時にエラーが生じます
- Local 環境でデバッグしたい時のために、WOFF アプリを別途 Developer Console へ登録して用意しておくと捗ったので、個人的におすすめです
  - Developer Console 画面から`localhost` の URL を Endpoint に指定した WOFF アプリを登録します
- 初期化時に WOFF ID が設定されていないと、エラーとなるので設定忘れにも注意しましょう
  - 環境変数などに発行された WOFF ID を保持しておき、初期化の際に使用できるようにしておきます
  - [ログイン](https://developers.worksmobile.com/jp/docs/woff-api#woff-login)をしておかないと、WOFF SDK の大半の機能は使えないため、初期化タイミングなどでログインをさせます
    - WOFF ブラウザのみでの実行する場合は不要です
    - 現在は HTTPS で登録しても、下記のように指定しておくと localhost への HTTP URL にリダイレクトされます。

```typescript
if (!window.woff.isLoggedIn()) {
  window.woff.login({
    redirectUri: "http://localhost:3000",
  });
}
```

### OAuth Scope の設定

- Developer Console のアプリ登録画面から[OAuth Scope](https://developers.worksmobile.com/jp/docs/auth-scope)の設定が可能です
  - 筆者は設定前に`woff.login`を実行し、何度もエラー画面に遷移するという挙動にハマりました
  - 最低限`user.profile.read` を設定するとログイン可能になります
    - WOFF SDK では[`woff.getProfile`](https://developers.worksmobile.com/jp/docs/woff-api#woff-getprofile) のようにユーザー情報の取得が可能になるためこちらの設定が必要になります
  - メッセージを送る場合は`bot` または`bot.message` の権限が必要です

## まとめ

- 本稿では WOFF SDK と Next.js を組み合わせた Web アプリの作成方法を紹介しました。
- WOFF SDK は LINE WORKS 特有の機能を使いたい時に役に立ちます。
  - トークルームにメッセージ送信したい時
  - WORKS API を使用するバックエンドと連携したい時
  - WOFF ブラウザの開閉を利用したい時
- サンプルアプリではお絵描きした画像をLINE WORKSのトークルームに送信することができました。

みなさんも WOFF で色々なアイデア試してみてください。良いお年を :thumbsup: