Pipedreamのカスタムアクションを自作するまで（OpenAIによるChatGPT風アクション編）

## 前書き

まずはこちらの画像と映像を御覧ください。
|Slack(画像)|Discord(映像)|
|:-:|:-:|
| ![pipedream-openai-slack.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6805/d04d23f4-bdf6-d99c-afca-81f8cfb065ce.png) | [![](https://i.ytimg.com/vi/eMxSY-KAy84/hq2.jpg)](https://www.youtube.com/shorts/eMxSY-KAy84)|

※Discord(映像)はYouTubeショートに飛びます



これらは、いずれもPipedreamのワークフローで作った、「質問に対してOpenAIからの回答をレスポンスする」様子です。
今回のアドカレでは、「3部構成（仮）」と称してPipedreamをネタにずっと記事を書いていましたが、今回は**自作アクションによる処理の再利用**に焦点を当てていこうと思います。

## 作ったものについて

### コード

この[GitHubリポジトリ](https://github.com/attakei-lab/pipedream-actions/)にある`openai/actions/chatgpt.mjs`です。
現時点だと、このコードを記事の手順に従って自分のアカウントで`pd publish`すると、私と同じようにOpenAIと戯れる事ができます。[^1]

https://github.com/attakei-lab/pipedream-actions/

### OpenAI APIを使って、ChatGPT「風」リアクションを返すためのワークフロー

![workflows.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6805/a3002e51-ef56-f16e-3dad-a698438357b5.png)

このような2個のワークフローを作っています。
それぞれ、次のような処理になっています。

1. プラットフォーム上の新規メッセージを検知する。
1. 処理対象化を判定する。
    * 対象外ならここで処理を終了する。
1. OpenAI APIへポストして、結果を受け取る。
    * Slack版のほうがステップが多いが、これはSlack側ではprefix制[^2]にしており、除去する必要があるため。
1. 結果をプラットフォームに返信する。

[^1]: 記事を書いている間に細かい調整がはいっており、実際は記事のコードと違っています。
[^2]: 最初にある画像の通り、`chatgpt: (質問文)`という投稿に対してのみ反応する実装です。

## Pipedreamにおけるアクションの自作処理おさらい

最初は「Nodeアクション」として内部の処理を直接実装する形式にしていました。

### 自作処理の中身

```js:action.mjs
import fetch from "node-fetch";

// To use previous step data, pass the `steps` object to the run() function
export default defineComponent({
  async run({ steps, $ }) {
    // Return data to use it in future steps
    const apiUrl = "https://api.openai.com/v1/completions";
    const payload = {
      model: "text-davinci-003",
      prompt: steps.trigger.event.text.substring(9),
      max_tokens: 3000,
      temperature: 0,
    };
    const options = {
      method: "post",
      body: JSON.stringify(payload),
      headers: {
        "content-type": "application/json",
        "authorization": `Bearer ${process.env.OPENAI_APIKEY}` 
      }
    };
    const result = await fetch(apiUrl, options).then(res => res.json());
    console.log(result.choices[0].text.trim());
    return {
      raw: result,
      text: result.choices[0].text.trim(),
    }
  },
})
```

基本的には[OpenAI](https://openai.com/)が提供している[Completions API](https://beta.openai.com/docs/api-reference/completions)に所定の形式でPOSTリクエストを投げるだけで、処理の大半をここに費やしています。[^3] [^5] API Keyについてはアカウントの作成後に簡単に作成することができるので割愛します。

`console.log`と`return`内の`text`で、レスポンスのオブジェクトからピンポイントで値を取ってきているのは、ここが「APIによって生成されたテキスト」=「回答」となっているためです。
また、今回のアクションでは明確に回答に相当する部分のみを必要としているため、APIの結果全体を`raw`キーにセットするのとは別に`text`キーに回答部分のみセットし、後続処理に引き渡しています。

なお、PipedreamのNodeランタイムの関係で、`node-fetch`が必要となっています。`import`で宣言しておくだけで、自動的にダウンロードとインポートを実施してくれます。[^6]

### Slackワークフローだけでなく、Discordワークフローでも使いたい

最初のSlack用ワークフローは上記の通り、自作処理で実装しました。
この後にDiscord用ワークフローを作るのですが、最初に考える方法が「Nodeアクションのコードをそのままコピペする」です。
しかし、色々と面倒なのもあって「コピペ以外で使い回せないか？」という発想が出てきます。

その場合、「一つのアクションとして登録する」という手段を取ることが出来ます。

[^3]: ChatGPTのCLI用Golang実装である、[kkdai/chatgpt](https://github.com/kkdai/chatgpt)の中身を参照しました。[^4]
[^4]: Node系だと[chatgpt-api](https://github.com/transitive-bullshit/chatgpt-api)というより本来の動きに近い実装があります。ただしPipedream上だと動きません。
[^5]: ので、厳密にはタグとして載せているChatGPTと全く同じ動きで回答を作っているかまでは分かりません。
[^6]: 忘れてたのですが、`@pipedream/axios`で十分でした。

## 自作処理から自作アクションへ

ガイドがきちんと用意されています。より詳細を追いたい場合は、ドキュメントを参照しましょう。

https://pipedream.com/docs/components/quickstart/nodejs/actions/

### まずはCLIの入手と認証を

アクションを後ほど登録するために、PipedreamのCLIコマンドを入手します。
各種OS用のバイナリが用意されているので、下記URLのガイドに従ってインストールしましょう。

Linux向けもシングルバイナリで提供されているため、セットアップは非常に簡単です。

https://pipedream.com/docs/cli/install/

入手後は`pd login`を実行することで、ブラウザの起動とブラウザ上での認証が実行されます。
すでにアカウントがあれば連携画面が出るので、そのまま進みましょう。問題なく進めば、以降は`pd`コマンドでの各種操作が可能になります。

https://pipedream.com/docs/cli/login/

### アクションとしての定義をする

Pipedreamのカスタムアクションは「必要な属性を持つESModule」となっています。アクションとしての要素のみを抜き出すと以下のようになります。

```js:action.js
export default {
  key: "openai-shortcut-chatgpt",
  name: "Completions text by OpenAI",
  description: "For creation ChatGPT like answer by OpenAI API",
  version: "0.1.0",
  type: "action",
  props: {
    // TODO: 定義しましょう
  },
  async run() {
    // TODO: 実装しましょう
    return {};
  },};
```

簡単な説明

| 名前        | 型                 | 役割                 |
|:------------|:------------------|:----------------------|
| key         | string            | 識別子                |
| name        | string            | アクション選択時の名前 |
| description | string            | アクション選択時の説明 |
| version     | versioning-string | アクションのバージョン |
| type        | "action"          | コンポーネントの種別   |
| props       | object            | アクションの引数       |
| run         | async function    | アクションの実処理     |

極端な話、これらが定義・実装されており、全てが`export`されていればアクションになります。

とはいえ、これだと独立して何かを実行するだけになってしまうので、実際の処理を移植していきましょう。
重要なのは`props`と`run`です。

#### `props`

`props`はアクションの引数です。ここで定義した値のみを用いて実際の処理が動くと考えればよいでしょう。
例えば、今回の処理では「API Key」を使って「質問となる文」をPOSTするため、2項目を必要とします。

```js
export default {
  props: {
    apiKey: {
      type: "string",
      label: "API Key",
      secret: true,
    },
    text: {
      type: "string",
      label: "Target text",
    },
  },
  // 略
}
```

Pipedreamの画面上では`label`のが表示され、`type`に応じて入力の受付を制御してくれます。（後述）
また、`run`内の内部処理上では`this.text`のような`props`のキーがそのままプロパティになります。

#### `run`

アクション内の自作と基本的に同じです。ただし、汎用的な処理にする必要があるため、`steps`は使えずに前述の通り`props`のみを使用していきます。
細かい制御をさせたい場合は、`props`の中身を充実させましょう。

```diff_javascript:actions.mjs
export default {
  async run({ $ }) {
    const apiUrl = "https://api.openai.com/v1/completions";
    const payload = {
      model: "text-davinci-003",
-     prompt: this.text,
+     prompt: steps.trigger.event.text.substring(9),

      max_tokens: 4000,
      temperature: 0,
    };
    const options = {
      method: "post",
      body: JSON.stringify(payload),
      headers: {
        "content-type": "application/json",
-       "authorization": `Bearer ${process.env.OPENAI_APIKEY}` 
+       "authorization": `Bearer ${this.apiKey}`,
      },
    };

    const result = await fetch(apiUrl, options).then((res) => res.json());
+   $.export("$summary", "Successfully api");
    return {
      raw: result,
      text: result.choices[0].text.trim(),
    };
  },
};
```

これが実装上の差分です。基本的にOpenAIのAPIに引き渡す情報の出どころが変わっただけとなっています。[^7]

### テストする？

このままアップロードしてワークフローでトライ＆エラーでもいいのですが、本当はテストがあると良いかもしれません。
ただし、この手の処理は単機能故にシンプルなので、ローカルで動作させるドライバーアプリだけで事足りることも多いです。

今回は、リポジトリ内で`openai/demo.mjs`という簡易デモを用意して、実装を直すたびに正しく動くかを簡易的に確認する手法を取りました。

### アップロードする

認証済みCLIで`publish`コマンドを実行するだけです。

```console
pd publish action.mjs
```

注意点として、アクションのプロパティにある`key`と`version`の組み合わせがユニークになっている必要があります。
そのため、再度アップロードする際は、かならず`version`も更新しましょう。

### 実際に組み込んでみる

アップロード完了後にワークフロービルダーでアクションを追加しようとすると、「My Actions」の中に、アップロード済みアクションが`name`,`description`を表示する形式で一覧化されます。

![list-myactions.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6805/e8537058-cb99-f1b2-824a-6af88bc006bc.png)

利用予定のアクションをクリックすると、先程`props`で定義したパラメーターを渡せるようになっています。

![openai-action.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/6805/ccda0e0d-bfbe-da25-42c3-b34b558c8a76.png)

これで、自作のアクションを使い回せるようになりました。

[^7]: `$.export`については別の自作アクションからの流用で、無くてもおそらく困りません。

## 後書き

ここまでで、自分のアカウント内でちょっと複雑な自作アクションを使い回せるようになりました。
このフェーズの先には、Pipedreamの公開リポジトリに提供することでの「公式アクション化」というステージが存在します。

いつか「この（他人の）ワークフローはワシが育てた」的なことを言ってみたいものですね。

### 最後に

OpenAIの回答は、その性質から必ずしも正しい内容となっている保証は出来ません。
用法用量を守って、適切に試しましょう。