Azure Durable Functions で遊んでみた Part2 - チャット風クイズアプリを作る(バックエンド編)

# はじめに

以前、Durable Functionsで遊んでみる記事を書いたのですが、その第２弾ということで、前回使っていなかったタイマー処理や外部イベント、永続的オーケストレーションを使って、チャット風クイズアプリを作ってみたいと思います。
前回に引き続き node で作成します。

**前回の投稿：**

https://qiita.com/fsdg-nagumo/items/8f50005b5cbe32b9d680

:::note
この記事では**バックエンド部分**のみを扱っています。
クライアント部分についてはクライアント編にて記載しています。
:::

**クライアント編：**

https://qiita.com/fsdg-nagumo/private/f06c014fe09f82cc9553

# ざっくりの仕様

- クイズのお題を入力してクイズ開始すると、サーバーでクイズを作成しクライアントに送信する
    - クイズは選択式問題にする
- クライアントから回答が送信されたら、答え合わせしてクライアントに解答を送信する
- 回答の制限時間を設け、制限時間が過ぎたら回答を打ち切ってクライアントに解答を送信する
- 答え合わせの後、新たなクイズを作成しクライアントに送信する
- 成績の集計や表示はしない

わざわざDurable Functions にしてサーバー側で制御せずとも、クライアント側で制御した方が簡単そうですが、Durable Functionsを使うのが目的なので、サーバー側で色々やるようにします。

# 全体の構成

今回作ろうとしているアプリの全体構成はこんな感じです。

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2884047/28db5638-4949-e9dc-b61a-26ea9a3e1817.png)

ここで Azure SignalR というサービスがしれっと登場していますが、Azure SignalRは、Web アプリケーションにリアルタイムの通信機能を簡単に追加できるサービスです。Azure SignalR を使うと、サーバーからクライアントにリアルタイムでデータをプッシュすることができます。SignalRという.NETのライブラリをベースにしていますが、クラウドでスケーラブルに動作します。
今回はサーバー起点でクライアントにメッセージを送るために使用します。

https://learn.microsoft.com/ja-jp/azure/azure-signalr/signalr-overview

あと、クイズの出題と答え合わせはChatGPT先生にお願いします！

# 各構成要素の役割

| No. | 要素                  | タイプ        | 役割                                                                                                                       |
| --: | ------------------- | :------------: | ------------------------------------------------------------------------------------------------------------------------ |
| 1   | negotiate           | 関数アプリ<br/>(HttpTrigger)  | クライアントが Azure SignalR へ接続するためのURLおよびアクセストークンを取得する                                                                        |
| 2   | startQuizChat       | 関数アプリ<br/>(HttpTrigger)  | クイズチャットのオーケストレーションを開始する<br/>オーケストレーション関数(orchestrateQuizChat)にアクセスするためのURL情報を返す                                          |
| 3   | orchestrateQuizChat | 関数アプリ<br/>(Orchestrator) | タイマーとクライアントからの外部イベントに応じて、以下3つのアクティビティ関数の実行を制御します<ul><li>isUserConnected</li><li>notifyStatus</li><li>talk</li></ul> |
| 4   | isUserConnected     | 関数アプリ<br/>(Activity)     | クライアントが Azure SignalR に接続しているかどうかはチェックする                                                                                 |
| 5   | notifyStatus        | 関数アプリ<br/>(Activity)     | サーバーの状態をクライアントに通知する                                                                                                      |
| 6   | talk                | 関数アプリ<br/>(Activity)     | ChatGPT API を呼び出してクイズの出題依頼や、クライアントの回答に対する答え合わせを行う                                                                        |
| 7   | Azure SignalR       | Azure SignalR            | サーバーからクライアントに対してメッセージを送信するための接続を保持する                                                                                     |
| 8   | ChatGPT API            | ChatGPT API            | talk アクティビティからのメッセージに対して返答してもらう                                                                                                                         |

# 必要な環境

- Node.js をインストールしておく (執筆時のバージョンは `v18.14.2`)
- [Azure CLI](https://learn.microsoft.com/ja-jp/cli/azure/install-azure-cli) をインストールしておく
- [Azure Functions Core Tools](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-run-local?tabs=v4%2Cwindows%2Ccsharp%2Cportal%2Cbash#install-the-azure-functions-core-tools) をインストールしておく
- VSCode に[Azure Functions の拡張機能](https://marketplace.visualstudio.com/items?itemName=ms-azuretools.vscode-azurefunctions)をインストールしておく (任意)

# 準備

## Azure SignalR のリソースを作成する

`<resource group>`、`<resource name>` は実際の名前に置き換えてください。

```bash:bash
resourceGroup=<resource group>
name=<resource name>
allowedOrigins=http://localhost:3000  # ローカルからの接続を許可する
sku=Free_F1
serviceMode=Serverless

# リソース作成
az signalr create \
    --name $name \
    --resource-group $resourceGroup \
    --sku $sku \
    --allowed-origins $allowedOrigins \
    --service-mode $serviceMode

# 接続文字列確認
connectionString=$(
    az signalr key list \
        --name $name \
        --resource-group $resourceGroup \
        --query primaryConnectionString \
        --output tsv
)
echo Azure SignalR connection string: $connectionString
```

実行すると最後に接続文字列が表示されるのでメモっておきます。(`Endpoint=`以降)

```bash
Azure SignalR connection string: Endpoint=https://<resource name>.service.signalr.net;AccessKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx;Version=1.0;
```

## Open AI API のアクセスキーを取得する

ChatGPT API を利用するので、アクセスキーを取得しておきます。

https://platform.openai.com/account/api-keys

# 実装！

## プロジェクトの初期化

```bash:bash
func init --worker-runtime node --language typescript
```

## 必要なパッケージのインストール

```bash:bash
npm i -E openai jsonwebtoken http-status-codes date-fns
npm i -D azurite @types/jsonwebtoken
```

## ローカル設定ファイルの編集

local.setting.json に Open AI APIのアクセスキーと Azure SignalR の接続文字列を設定します。
また、Azure Storage のエミュレーター (azurite) を使用するため、AzureWebJobStorage も設定します。
更に、クライアントから接続させるためCORS設定を追加します。

```diff_json:local.settings.json
{
  "IsEncrypted": false,
  "Values": {
    "FUNCTIONS_WORKER_RUNTIME": "node",
-   "AzureWebJobsStorage": "",
+   "AzureWebJobsStorage": "UseDevelopmentStorage=true",
+   "OPEN_AI_API_KEY": "<取得したOpen AI API のアクセスキー>",
+   "AzureSignalRConnectionString": "<Azure SignalR リソース作成時に取得した接続文字列>"
  },
+ "Host": {
+   "LocalHttpPort": 7071,
+   "CORS": "http://localhost:3000",
+   "CORSCredentials": true
+ }
}
```

## 関数アプリの作成

関数を VSCode の拡張機能か、`func new` コマンドで作成していきます。
作成する関数アプリの概要は以下の通りです。

| No. | 関数名                 | テンプレート                         | 入力バインド  | 出力バインド  |
| --: | ------------------- | ------------------------------ | ------- | ------- |
| 1   | negotiate           | SignalR negotiate HTTP trigger | SignalR | -       |
| 2   | startQuizChat       | Durable Functions HTTP starter | -       | -       |
| 3   | orchestrateQuizChat | Durable Functions orchestrator | -       | -       |
| 4   | isUserConnected     | Durable Functions activity     | -       | -       |
| 5   | notifyStatus        | Durable Functions activity     | -       | SignalR |
| 6   | talk                | Durable Functions activity     | -       | SignalR |

:::note warn
SignalR negotiate HTTP trigger は VSCode の拡張機能では選択できなかったので、`func new` コマンドで作成する必要があります (2023/04現在)
:::

### 1. negotiate

<details> 
<summary><b>index.ts</b></summary> 

```typescript:negotiate/index.ts
import { AzureFunction, Context, HttpRequest, HttpResponse } from '@azure/functions'

/** SignalR に接続するためのエンドポイント、アクセストークンを返す */
const httpTrigger: AzureFunction = async function (
  context: Context,
  req: HttpRequest,
  connectionInfo: unknown
): Promise<void> {
  context.res = { body: connectionInfo } satisfies HttpResponse
}

export default httpTrigger
```

</details> 

クライアントが Azure SignalR に接続するための情報(URL、アクセストークン)を発行するための関数です。
入力バインドで受け取った Azure SignalR の接続情報をそのまま返します。
テンプレートのままでOKです。

<details> 
<summary><b>function.json</b></summary> 

```json:negotiate/function.json 
{
  "disabled": false,
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "methods": ["post"],
      "name": "req"
    },
    {
      "type": "http",
      "direction": "out",
      "name": "res"
    },
    {
      "type": "signalRConnectionInfo",
      "name": "connectionInfo",
      "hubName": "serverless",
      "userId": "{query.userId}",
      "connectionStringSetting": "AzureSignalRConnectionString",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/negotiate/index.js"
}
``` 

</details> 

Azure SignalR のリソース作成時に `--service-mode` を `Serverless` に設定しているので、SignalR 入力バインドの `hubName` を `serverless` に変更します。
また、接続しているクライアントを識別できるようにするため、`userId` を設定します。(userId はクエリパラメータとして取得)

#### 【参考】Azure SignalR への接続シーケンス

以下の手順でクライアントから Azure SignalR に接続できるようになります。

```mermaid
sequenceDiagram
participant C as クライアント
participant A as Azure Functions (negotiate)
participant S as Azure SignalR
C->>A: ネゴシエーション要求
A->>S: 接続先URLとアクセストークンを要求
S-->>A: 接続先URLとアクセストークン
A-->>C: 接続先URLとアクセストークン
C->>S: ハブへの接続要求
S-->>C: ハブへの接続確立
```

negotiate のエンドポイントにアクセスすると以下の様なレスポンスが返ってきますが、この辺りはクライアントライブラリがハブへの接続確立までやってくれるので、意識する必要はありません。
(クライアントライブラリの使い方は [クライアント編](https://qiita.com/fsdg-nagumo/private/f06c014fe09f82cc9553#1-azure-signalr-%E3%81%A8%E3%81%AE%E6%8E%A5%E7%B6%9A%E7%A2%BA%E7%AB%8B) にて！)

```bash:bash
curl -X POST http://localhost:7071/api/negotiate?userId=xxxxxxxxxxxxxxxxxxxxx

{
  "url": "https://<resource name>.service.signalr.net/client/?hub=serverless",
  "accessToken": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
}
```

### 2. startQuizChat

<details> 
<summary><b>index.ts</b></summary> 

```typescript:startQuizChat/index.ts
import * as df from 'durable-functions'
import { AzureFunction, Context, HttpRequest, HttpResponse } from '@azure/functions'
import { StatusCodes } from 'http-status-codes'
import { FUNCTION_NAME } from '../lib/constants'
import { OrchestrateQuizChatInput, StartChatRequestBody } from '../lib/types'

/** Quiz Chat のオーケストレーションを開始する */
const httpStart: AzureFunction = async function (
  context: Context,
  req: HttpRequest
): Promise<unknown> {
  const { userId, quizTopic, currentInstanceId } = (req.body ?? {}) as StartChatRequestBody

  if (!userId || !quizTopic) {
    return {
      status: StatusCodes.BAD_REQUEST,
    }
  }

  const client = df.getClient(context)

  // 起動済みのインスタンスがある場合は停止
  if (currentInstanceId) {
    const status = await client.getStatus(currentInstanceId)
    if (status?.runtimeStatus === df.OrchestrationRuntimeStatus.Running) {
      await client.terminate(currentInstanceId, 'Restart New')
    }
  }

  // オーケストレーターを開始
  const input: OrchestrateQuizChatInput = { userId, quizTopic }
  const instanceId = await client.startNew(
    FUNCTION_NAME.orchestrator.orchestrateQuizChat,
    undefined,
    input
  )

  // インスタンスにアクセスするためのURL情報を含んだレスポンスを返す
  return client.createCheckStatusResponse(context.bindingData.req, instanceId)
}

export default httpStart
```

</details>

<details> 
<summary><b>function.json</b></summary> 

```json:startQuizChat/function.json
{
  "bindings": [
    {
      "authLevel": "function",
      "name": "req",
      "type": "httpTrigger",
      "direction": "in",
      "methods": ["post", "options"]
    },
    {
      "name": "$return",
      "type": "http",
      "direction": "out"
    },
    {
      "name": "starter",
      "type": "orchestrationClient",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/startQuizChat/index.js"
}
``` 

</details> 

クライアントからの開始操作を受けて、オーケストレーションを開始します。
2重起動を防ぐため、既に起動済みの場合は停止してから新しいオーケストレーションを開始するようにしています。

startQuizChat のエンドポイントにアクセスすると以下の様なレスポンスが返ってきます。
(レスポンスの詳細は [クライアント編](https://qiita.com/fsdg-nagumo/private/f06c014fe09f82cc9553#2-%E5%87%BA%E9%A1%8C%E9%96%8B%E5%A7%8B:~:text=077KepMIkW_kR9RvDgurkezG%2Dr9HErOb9c8ptsw9ALaBAzFubKYscw%3D%3D%22%0A%7D-,%E3%81%9D%E3%82%8C%E3%81%9E%E3%82%8C%E4%BB%A5%E4%B8%8B%E3%81%AE%E6%A7%98%E3%81%AA%E6%A9%9F%E8%83%BD%E3%81%8C%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%80%82,-No.) にて！)

```bash:bash
curl -X POST http://localhost:7071/api/startQuizChat -d "{\"userId\":\"xxxxxxxxxxxxxxxx\",\"quizTopic\":\"Some Topic\"}"

{
  "id": "1bc5891ea1ee4a928b0cb3a631555b06",
  "statusQueryGetUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "sendEventPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/raiseEvent/{eventName}?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "terminatePostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/terminate?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "rewindPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/rewind?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "purgeHistoryDeleteUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "restartPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/restart?taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "suspendPostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/suspend?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw==",
  "resumePostUri": "http://localhost:7071/runtime/webhooks/durabletask/instances/1bc5891ea1ee4a928b0cb3a631555b06/resume?reason={text}&taskHub=TestHubName&connection=Storage&code=077KepMIkW_kR9RvDgurkezG-r9HErOb9c8ptsw9ALaBAzFubKYscw=="
}
```

### 3. orchestrateQuizChat

<details> 
<summary><b>index.ts</b></summary> 

```typescript:orchestrateQuizChat/index.ts
import * as df from 'durable-functions'
import { add } from 'date-fns'
import { FUNCTION_NAME } from '../lib/constants'
import { Task } from 'durable-functions/lib/src/task'
import { IOrchestrationFunctionContext } from 'durable-functions/lib/src/iorchestrationfunctioncontext'
import { ChatMessage, OrchestrateQuizChatInput, Status, StatusMessage } from '../lib/types'

/** 外部イベントとChatGPTへのメッセージ送信を制御する */
const orchestrator = df.orchestrator(function* (context) {
  const input = context.df.getInput<OrchestrateQuizChatInput>()
  const { userId, quizTopic } = input

  // ユーザーがSignalRに接続しているかどうか確認
  const isConnected = yield context.df.callActivity(FUNCTION_NAME.activity.isUserConnected, userId)
  // 接続していなかったら終了
  if (!isConnected) return

  try {
    // ChatGPTの入力中を通知
    yield createNotifyStatusChangeTask(context, userId, 'Typing')
    // クイズの出題依頼を送信
    const requestMessage: ChatMessage = {
      userId,
      content: [
        `${quizTopic}に関するクイズを1問出題してください。`,
        '選択式問題にしてください。',
        '解答は伏せてください。',
      ].join('\n'),
    }
    const question = yield createTalkTask(context, requestMessage)

    // ユーザー入力を待機するタスク("NewMessage" という名前のイベントを監視する)
    const messageMonitorTask = context.df.waitForExternalEvent('NewMessage')
    // 回答期限(30秒)まで待機するタスク
    const deadline = add(context.df.currentUtcDateTime, {
      seconds: 30,
    })
    const deadlineTimer = context.df.createTimer(deadline)

    // いずれかのタスクが完了するまで待機
    const winner = yield context.df.Task.any([messageMonitorTask, deadlineTimer])

    const tasks: Task[] = [
      // 入力中を通知するタスク
      createNotifyStatusChangeTask(context, userId, 'Typing'),
    ]

    // ユーザーの回答を受信したら回答を送信
    if (winner === messageMonitorTask) {
      deadlineTimer.cancel()
      const receivedMessage = messageMonitorTask.result as ChatMessage

      // 回答送信タスク
      const message: ChatMessage = {
        ...receivedMessage,
        currentReply: question,
      }
      tasks.push(createTalkTask(context, message))
    }
    // 時間切れになったら降参のメッセージを送信
    else {
      // 時間切れを通知
      yield createNotifyStatusChangeTask(context, userId, 'TimeUp')

      const message: ChatMessage = {
        userId,
        content: '降参です。答えを教えてください。',
        currentReply: question,
      }
      // 降参メッセージ送信タスク
      tasks.push(createTalkTask(context, message))
    }

    // ChatGPTの入力中を通知するタスクと会話タスクを実行
    yield context.df.Task.all(tasks)
  } catch (error) {
    context.log.error(error)
    // エラーを通知
    yield createNotifyStatusChangeTask(context, userId, 'Error')
    return
  }

  // オーケストレーションを再実行
  context.df.continueAsNew(input)
})

/** 会話タスクを作成 */
const createTalkTask = (context: IOrchestrationFunctionContext, message: ChatMessage): Task => {
  return context.df.callActivity(FUNCTION_NAME.activity.talk, message)
}

/** ステータス変更を通知するタスクを作成 */
const createNotifyStatusChangeTask = (
  context: IOrchestrationFunctionContext,
  userId: string,
  status: Status
): Task => {
  return context.df.callActivity(FUNCTION_NAME.activity.notifyStatus, {
    userId,
    status,
  } satisfies StatusMessage)
}

export default orchestrator
```

</details>

<details> 
<summary><b>function.json</b></summary> 

```json:orchestrateQuizChat/function.ts 
{
  "bindings": [
    {
      "name": "context",
      "type": "orchestrationTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/orchestrateQuizChat/index.js"
}
``` 

</details> 

クイズの出題と制限時間の制御、クライアントからの回答イベントの監視を行います。
以下の様なフローになっています。

```mermaid
flowchart
A(開始) --> B[クライアントが Azure SignalR に<br/>接続しているかチェック]
B --> C{true?}
C -->|Yes| D[クライアントに<br/>ChatGPTの返答待ちを通知]
C -->|No| Z(終了)
D --> E[ChatGPT にクイズの出題依頼を送信し<br/>クライアントにクイズを通知]
E --> F[A.クライアントからの回答を<br/>待機するタスク]
E --> G[B.回答期限 30秒 まで<br/>待機するタスク]
F --> H[いずれかの終了を待機]
G --> H
H -->|Aが先に終了| I[クライアントに<br/>ChatGPTの返答待ちを通知]
I --> J[ChatGPT に回答を送信し<br/>クライアントに解答を通知]
J --> Y[オーケストレーションを再実行]
H -->|Bが先に終了| K[クライアントに時間切れと<br/>ChatGPTの返答待ちを通知]
K --> L[ChatGPT に降参メッセージ送信し<br/>クライアントに解答を通知]
L --> Y
Y --> A
```

最初にクライアントが Azure SignalR に接続しているかどうかチェックしていますが、クイズ終了操作をせずにページを閉じた場合を考慮して、接続が切れていたら終了するようにしています。

「ChatGPTの返答待ちを通知」となってる部分は、クライアント側で「入力中...」のような表示をさせるためのトリガーとなる通知です。

「A.クライアントからの回答を待機するタスク」が**外部イベント**、「B.回答期限 30秒 まで待機するタスク」が**タイマー**になります。

そして「オーケストレーションを再実行」としてループさせている部分が**永続的オーケストレーション**ですね。
オーケストレーター関数は普通に while でループさせることも可能ですが、関数の履歴が増え続けてパフォーマンスの悪化につながるため、推奨されていないようです。
代わりに、`continueAsNew` 関数で再実行させることでループ処理を実現できます。

https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-timers?tabs=javascript

https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-external-events?tabs=javascript

https://learn.microsoft.com/ja-jp/azure/azure-functions/durable/durable-functions-eternal-orchestrations?tabs=javascript

### 4. isUserConnected

<details> 
<summary><b>index.ts</b></summary> 

```typescript:isUserConnected/index.ts 
import { AzureFunction, Context } from '@azure/functions'
import { StatusCodes } from 'http-status-codes'
import { sign } from 'jsonwebtoken'

const API_ENDPOINT_BASE = '/api/v1/hubs/serverless/users/'

/** ユーザーが SignalR に接続しているかどうか確認する */
const activityFunction: AzureFunction = async function (
  context: Context,
  userId: string
): Promise<boolean> {
  const apiEndpoint = `${API_ENDPOINT_BASE}${userId}`
  const { url, accessToken } = createSignalRAccessToken(apiEndpoint)

  try {
    // Azure SignalR Service REST API にリクエスト発行
    const response = await fetch(url, {
      method: 'HEAD',
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    })

    // 未接続の場合はステータスコード = 404 となる
    return response.status === StatusCodes.OK
  } catch (error) {
    context.log.error(error)
    return false
  }
}

/** SignalR の接続文字列からエンドポイントとシークレットを取得する */
const parseSignalRConnectionString = () => {
  const connectionString = process.env['AzureSignalRConnectionString']
  if (!connectionString) throw Error("Missing env: 'AzureSignalRConnectionString'")

  const [endpoint, key] = connectionString.split(';')

  return {
    endpoint: endpoint.replace('Endpoint=', ''),
    key: key.replace('AccessKey=', ''),
  }
}

/** Azure SignalR Service REST API のアクセストークンを生成する */
const createSignalRAccessToken = (endpoint: string) => {
  const { endpoint: origin, key } = parseSignalRConnectionString()
  const audience = `${origin}${endpoint}`
  const accessToken = sign({ aud: audience }, key, { expiresIn: '1h' })
  return { url: audience, accessToken }
}

export default activityFunction
``` 

</details> 

<details> 
<summary><b>function.json</b></summary> 

```json:isUserConnected/function.ts 
{
  "bindings": [
    {
      "name": "userId",
      "type": "activityTrigger",
      "direction": "in"
    }
  ],
  "scriptFile": "../dist/isUserConnected/index.js"
}
``` 

</details> 

クライアントが Azure SignalR に接続しているかどうかチェックするための関数です。
Azure SignalR Service REST API に対してコネクションを取得するリクエストを投げています。
Typescript 向けのクライアントライブラリがなかったので、仕方なく自前で実装しました。
(アクセストークン生成の勉強になったのでヨシ！)

https://learn.microsoft.com/ja-jp/azure/azure-signalr/signalr-reference-data-plane-rest-api

### 5. nofityStatus

<details> 
<summary><b>index.ts</b></summary> 

```typescript:nofityStatus/index.ts 
import { AzureFunction, Context } from '@azure/functions'
import { StatusMessage } from '../lib/types'
import { SIGNALR_MESSAGE } from '../lib/constants'

/** ステータスの変更をクライアントに通知する */
const activityFunction: AzureFunction = async function (
  context: Context,
  message: StatusMessage
): Promise<void> {
  const { userId, status } = message

  // "StatusChanged" という名前でメッセージ通知する
  context.bindings.signalRMessages = [
    { userId, target: SIGNALR_MESSAGE.statusChanged, arguments: [status] },
  ]
}

export default activityFunction
``` 

</details> 

サーバー側のステータス変更をクライアントに通知するための関数です。
SignalR 出力バインドを使用してメッセージ通知しています。

SignalR に送信するメッセージに `userId` を設定しているので、該当のユーザーのみに通知されます。
`userId` を設定しないと、接続中の全ユーザーにブロードキャストされます。
```typescript
  // "StatusChanged" という名前でメッセージ通知する
  context.bindings.signalRMessages = [
    { userId, target: SIGNALR_MESSAGE.statusChanged, arguments: [status] },
  ]
```

<details> 
<summary><b>function.json</b></summary> 

```json:nofityStatus/function.ts 
{
  "bindings": [
    {
      "name": "status",
      "type": "activityTrigger",
      "direction": "in"
    },
    {
      "type": "signalR",
      "name": "signalRMessages",
      "hubName": "serverless",
      "connectionStringSetting": "AzureSignalRConnectionString",
      "direction": "out"
    }
  ],
  "scriptFile": "../dist/notifyStatus/index.js"
}
``` 

</details> 

negotiate と同様、`hubName` には `serverless` を設定します。

### 6. talk

<details> 
<summary><b>index.ts</b></summary> 

```typescript:talk/index.ts 
import { AzureFunction, Context } from '@azure/functions'
import { ChatCompletionRequestMessage, Configuration, OpenAIApi } from 'openai'
import { ChatMessage } from '../lib/types'
import { SIGNALR_MESSAGE } from '../lib/constants'

/** ChatGPT にメッセージを送信して返答を返す */
const activityFunction: AzureFunction = async function (
  context: Context,
  message: ChatMessage
): Promise<string> {
  const apiKey = process.env['OPEN_AI_API_KEY']
  if (!apiKey) throw Error('Missing OpenAI Key')

  const configuration = new Configuration({ apiKey })
  const openai = new OpenAIApi(configuration)

  const { userId, content, currentReply } = message
  const messages: ChatCompletionRequestMessage[] = [
    // AIの設定
    {
      role: 'system',
      content: 'あなたはクイズの出題者です。日本語で返答してください。',
    },
  ]
  // 直近でChatGPTが発言した内容を設定
  if (currentReply) messages.push({ role: 'assistant', content: currentReply })

  // ユーザーのメッセージ
  messages.push({ role: 'user', content })

  // ChatGPTにメッセージ送信
  const reply = await sendMessageToChatGPT(context, openai, messages)
  if (!reply) throw Error('Could not retrieve reply.')

  // "Reply" という名前でメッセージ通知する
  context.bindings.signalRMessages = [{ userId, target: SIGNALR_MESSAGE.reply, arguments: [reply] }]

  return reply
}

/** ChatGPT にメッセージ送信 */
const sendMessageToChatGPT = async (
  context: Context,
  openai: OpenAIApi,
  messages: ChatCompletionRequestMessage[]
): Promise<string | undefined> => {
  const completion = await openai.createChatCompletion(
    { model: 'gpt-3.5-turbo', max_tokens: 400, messages },
    { timeout: 120000 }
  )

  const reply = completion.data.choices[0].message?.content
  return reply
}

export default activityFunction
``` 

</details> 

ChatGPT API とメッセージのやり取りして、返答をクライアントに通知するための関数です。
notifyStatus と同様 SignalR 出力バインドを使用しています。

記事の趣旨から外れるので API の使い方については割愛しますが、クライアントからの回答内容のみを送信すると、ChatGPT 自身が直前に発言した内容を忘れて「お前何言ってんの？」状態になってしまうので、直前の発言内容を `assistant`ロールとして設定しています。

https://platform.openai.com/docs/api-reference/chat

<details> 
<summary><b>function.json</b></summary> 

```json:talk/function.ts 
{
  "bindings": [
    {
      "name": "message",
      "type": "activityTrigger",
      "direction": "in"
    },
    {
      "type": "signalR",
      "name": "signalRMessages",
      "hubName": "serverless",
      "connectionStringSetting": "AzureSignalRConnectionString",
      "direction": "out"
    }
  ],
  "scriptFile": "../dist/talk/index.js"
}
``` 

</details> 

notifyStatus と同様、`hubName` には `serverless` を設定します。

### その他

<details> 
<summary><b>タイプ定義や定数定義</b></summary> 

#### タイプ定義

```typescript:lib/types.ts 
/** startChat API のリクエストボディ */
type StartChatRequestBody = {
  userId?: string
  quizTopic?: string
  currentInstanceId?: string
}

/** orchestrateQuizChat API の入力 */
type OrchestrateQuizChatInput = {
  userId: string
  quizTopic: string
}

/** talk API の入力 */
type ChatMessage = {
  userId: string
  content: string
  currentReply?: string
}

/** notifyStatus API の入力 */
type StatusMessage = {
  userId: string
  status: Status
}

/** サーバーステータス */
type Status = 'Typing' | 'TimeUp' | 'Error'

export type { ChatMessage, OrchestrateQuizChatInput, StartChatRequestBody, StatusMessage, Status }
``` 

#### 定数定義

```typescript:libs/constants.ts
/** 関数名 */
const FUNCTION_NAME = {
  orchestrator: {
    orchestrateQuizChat: 'orchestrateQuizChat',
  },
  activity: {
    isUserConnected: 'isUserConnected',
    talk: 'talk',
    notifyStatus: 'notifyStatus',
  },
} as const

/** Azure SignalR のメッセージ名 */
const SIGNALR_MESSAGE = {
  reply: 'Reply',
  statusChanged: 'StatusChanged',
}

export { FUNCTION_NAME, SIGNALR_MESSAGE }
```

</details> 

<details> 
<summary><b>最終的なディレクトリ構成</b></summary> 

```text 
api
├── host.json
├── isUserConnected
│   ├── function.json
│   └── index.ts
├── lib
│   ├── constants.ts
│   └── types.ts
├── local.settings.json
├── negotiate
│   ├── function.json
│   └── index.ts
├── notifyStatus
│   ├── function.json
│   └── index.ts
├── orchestrateQuizChat
│   ├── function.json
│   └── index.ts
├── package-lock.json
├── package.json
├── startQuizChat
│   ├── function.json
│   └── index.ts
├── talk
│   ├── function.json
│   └── index.ts
└── tsconfig.json
``` 

</details> 

## 起動

実行するには、Storage エミュレーター (azurite) と関数アプリを起動する必要があります。
([前回の記事](https://qiita.com/fsdg-nagumo/items/8f50005b5cbe32b9d680#%E5%8B%95%E3%81%8B%E3%81%97%E3%81%A6%E3%81%BF%E3%82%8B)と同様)

ただし、動作確認はクライアント含めて行うので、バックエンド編としてはここで終了です！

### azurite の起動

```bash:bash
npx azurite -l azurite
```

### 関数アプリの起動

```bash:bash
npm start
```

# まとめ

Durable Functions を使用することで、タイマー処理や外部イベント処理、永続的オーケストレーションを実装することができました。
永続的オーケストレーションは停止させるまで永遠に実行され続けるので、用途によっては終了条件を決めたり、外部から終了させる仕組みを用意しておいた方が良さそうです。
(今回は、ユーザーが未接続の場合は終了させるのと、クライアントに終了ボタンを設置する予定)

また、Azure SignalR を使用してサーバーからクライアントに対してのリアルタイム通知も実装することができました。
(しかしこのサービス、あんまり使われてないんかなぁ...)

今回はバックエンドのみで、いまいち何やってるかわかりにくかったかもしれませんが、クライアント編で画面とバックエンドを繋げて動かしてみるので、良ければ併せてご覧いただけると嬉しいです！

**クライアント編：**

https://qiita.com/fsdg-nagumo/private/f06c014fe09f82cc9553