PulumiでAzure OpenAIとAzure Functions使ったLineBotを一撃で作成してみる。

タイトルそのままの内容です🙂

PulumiでAzure OpenAI Serviceを使ったLineBotを一撃で作りたいなと思ったのでやってみました。

https://www.pulumi.com/

最近はAWS CDKを使ったLambdaの開発体験が良いので浮気気味だったのですが、[Azure Functions](https://learn.microsoft.com/ja-jp/azure/azure-functions/functions-overview?pivots=programming-language-csharp)も好きです。ただ、IaCは[Bicep](https://learn.microsoft.com/ja-jp/azure/azure-resource-manager/bicep/overview?tabs=bicep)では無く書きたいなーという気分で、[CDK for Terraform](https://developer.hashicorp.com/terraform/cdktf)も試したのですが、いやここはPulumiっしょ！と思いやってみました。そういえば[プルミはハワイ語で「ほうき」を意味する](https://joeduffyblog.com/2018/06/18/hello-pulumi/)みたいです。

因みにですが、Azure OpenAI Serviceは2023年7月、[東日本リージョンが選択](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/whats-new)できるようになりましたね⭐

https://learn.microsoft.com/ja-jp/azure/ai-services/openai/whats-new

Azure OpenAIが使える事が前提なので、Azure Functions、Azure OpenAIの詳しい説明などは割愛します。
あと、Pulumiの初期設定も飛ばします。過去に記事も書いてますので良ければご参考ください。

https://www.pulumi.com/docs/clouds/azure/get-started/

https://qiita.com/orc_jj/items/efbb8ad75f51bb760807

## 本題
今回、構築テンプレートとして[こちら](https://www.pulumi.com/templates/serverless-application/azure/)から始めてみる事にしました。

https://www.pulumi.com/templates/serverless-application/azure/

ドキュメント通りにやると直ぐにAzure Functionsがデプロイできたので、この内容を変更します。

以下は最終的な階層です。
```
├── app
|    └── chat
|         └── function.json
|         └── index.js
│    └── host.json
│    └── package.json
├── index.ts
├── helpers.ts
├── package-lock.json
├── package.json
├── Pulumi.dev.yaml
├── Pulumi.yaml
├── tsconfig.json
```
以下は最終的なPulumiのコードです。
```typescript:index.ts
import * as pulumi from "@pulumi/pulumi";
import * as azure from "@pulumi/azure-native";
import { getConnectionString, signedBlobReadUrl } from "./helpers";
import * as dotenv from "dotenv";

dotenv.config();

// Import the program's configuration settings.
const config = new pulumi.Config();
const appPath = config.get("appPath") || "./app";

// Create a resource group
const resourceGroup = new azure.resources.ResourceGroup("resource-group", {});

// Create a Cognitive Services
const cognitiveservices = new azure.cognitiveservices.Account("cognitiveservices", {
    resourceGroupName: resourceGroup.name,
    kind: "OpenAI",
    sku: {
        name: "S0",
    },
    location: resourceGroup.location,
    properties:{
        publicNetworkAccess: "Enabled",
    }
});
// Create a OpenAI Model
const deployment = new azure.cognitiveservices.Deployment("deployment", {
    accountName: cognitiveservices.name,
    deploymentName: "gpt-35-turbo",
    properties: {
        model: {
            format: "OpenAI",
            name: "gpt-35-turbo",
            version: "0613",
        },
    },
    resourceGroupName: resourceGroup.name,
});

// Get the keys for the Cognitive Services account.
const openaiKeys =  azure.cognitiveservices.listAccountKeysOutput({
    accountName: cognitiveservices.name,
    resourceGroupName: resourceGroup.name,
});
const openaiKey = openaiKeys.apply(openaiKeys => openaiKeys.key1 || "");

// Create LogAnalyticsWorkspace and Application Insights.
const logAnalyticsWorkspace = new azure.operationalinsights.Workspace("logAnalyticsWorkspace", {
    resourceGroupName: resourceGroup.name,
});

const appInsights = new azure.insights.Component("appInsights", {
    applicationType: "web",
    kind: "web",
    resourceGroupName: resourceGroup.name,
    workspaceResourceId: logAnalyticsWorkspace.id,
});

// Create a blob storage account.
const storageAccount = new azure.storage.StorageAccount("account", {
    resourceGroupName: resourceGroup.name,
    kind: azure.storage.Kind.StorageV2,
    sku: {
        name: azure.storage.SkuName.Standard_LRS,
    },
});

// Create a storage container for the serverless app.
const appContainer = new azure.storage.BlobContainer("app-container", {
    accountName: storageAccount.name,
    resourceGroupName: resourceGroup.name,
    publicAccess: azure.storage.PublicAccess.None,
});

// Upload the Function app to the storage container.
const appBlob = new azure.storage.Blob("app-blob", {
    accountName: storageAccount.name,
    resourceGroupName: resourceGroup.name,
    containerName: appContainer.name,
    source: new pulumi.asset.FileArchive(appPath),
});

const storageConnectionString = getConnectionString(resourceGroup.name, storageAccount.name);
const codeBlobUrl = signedBlobReadUrl(appBlob, appContainer, storageAccount, resourceGroup);

// Create an App Service plan for the Function App.
const plan = new azure.web.AppServicePlan("plan", {
    resourceGroupName: resourceGroup.name,
    sku: {
        name: "Y1",
        tier: "Dynamic",
    },
    kind: "Linux",
    reserved: true,
});

// Create the Function App.
const functionApp = new azure.web.WebApp("function-app", {
    resourceGroupName: resourceGroup.name,
    serverFarmId: plan.id,
    kind: "FunctionApp",
    siteConfig: {
        linuxFxVersion: "Node|18",
        appSettings: [
            {
                name: "AzureWebJobsStorage",
                value: storageConnectionString,

            },
            {
                name: "FUNCTIONS_WORKER_RUNTIME",
                value: "node",
            },
            {
                name: "WEBSITE_NODE_DEFAULT_VERSION",
                value: "~18",
            },
            {
                name: "FUNCTIONS_EXTENSION_VERSION",
                value: "~4",
            },
            {
                name: "WEBSITE_CONTENTAZUREFILECONNECTIONSTRING",
                value: storageConnectionString,
            },
            {
                name: "WEBSITE_RUN_FROM_PACKAGE",
                value: codeBlobUrl,
            },
            {
                name: "APPINSIGHTS_INSTRUMENTATIONKEY",
                value: appInsights.instrumentationKey, 

            },
            {
                name: "OPENAI_API_KEY",
                value: openaiKey
            },
            {
                name: "OPENAI_ENDPOINT",
                value: cognitiveservices.properties.endpoint,
            },
            {
                name: "OPENAI_DEPLOYMENT_NAME",
                value: deployment.name,
            },
            {
                name: "LINE_CHANNEL_ACCESS_TOKEN",
                value: process.env["LINE_CHANNEL_ACCESS_TOKEN"] || "",
            },
            {
                name: "LINE_CHANNEL_SECRET",
                value: process.env["LINE_CHANNEL_SECRET"] || "",
            },
        ],
        cors: {
            allowedOrigins: [
                "*"
            ],
        },
    },
});

// Export the serverless endpoint.
export const apiURL = pulumi.interpolate`https://${functionApp.defaultHostName}/api`;
```
```typescript:helpers.ts
import * as resources from "@pulumi/azure-native/resources";
import * as storage from "@pulumi/azure-native/storage";
import * as pulumi from "@pulumi/pulumi";

export function getConnectionString(resourceGroupName: pulumi.Input<string>, accountName: pulumi.Input<string>): pulumi.Output<string> {
    // Retrieve the primary storage account key.
    const storageAccountKeys = storage.listStorageAccountKeysOutput({ resourceGroupName, accountName });
    const primaryStorageKey = storageAccountKeys.keys[0].value;

    // Build the connection string to the storage account.
    return pulumi.interpolate`DefaultEndpointsProtocol=https;AccountName=${accountName};AccountKey=${primaryStorageKey}`;
}

export function signedBlobReadUrl(blob: storage.Blob,
    container: storage.BlobContainer,
    account: storage.StorageAccount,
    resourceGroup: resources.ResourceGroup): pulumi.Output<string> {

    const blobSAS = storage.listStorageAccountServiceSASOutput({
        accountName: account.name,
        protocols: storage.HttpProtocol.Https,
        sharedAccessExpiryTime: "2030-01-01",
        sharedAccessStartTime: "2021-01-01",
        resourceGroupName: resourceGroup.name,
        resource: storage.SignedResource.C,
        permissions: storage.Permissions.R,
        canonicalizedResource: pulumi.interpolate`/blob/${account.name}/${container.name}`,
        contentType: "application/json",
        cacheControl: "max-age=5",
        contentDisposition: "inline",
        contentEncoding: "deflate",
    });
    return pulumi.interpolate`https://${account.name}.blob.core.windows.net/${container.name}/${blob.name}?${blobSAS.serviceSasToken}`;
}
```
```text:.env
LINE_CHANNEL_ACCESS_TOKEN=<値をセット>
LINE_CHANNEL_SECRET=<値をセット>
```
AppServicePlanのOSはWindowsでは無く、Linuxにしてます。
(SecretにはAzure KeyVaultとか使ったほうが良いのだと思いますが、とりあえずで...💦)

ここまでは以下を参考にしつつ、諸々変更しました。

https://github.com/pulumi/examples/tree/master/azure-ts-functions

https://www.pulumi.com/registry/packages/azure-native/api-docs/

あとは、`httpTrigger`で動く`Functions`のコードを書きます。
以下はLineボットのサンプルです。

```javascript:app/chat/index.js
const { OpenAIClient, AzureKeyCredential } = require("@azure/openai");
const crypto = require("crypto");
const line = require('@line/bot-sdk');

const LINE_CHANNEL_ACCESS_TOKEN = process.env.LINE_CHANNEL_ACCESS_TOKEN;
const LINE_CHANNEL_SECRET = process.env.LINE_CHANNEL_SECRET;
const config = {
    channelAccessToken: LINE_CHANNEL_ACCESS_TOKEN,
    channelSecret: LINE_CHANNEL_SECRET
};

// LINE Client
const client = new line.Client(config);

//Azure OpenAI Client
const AZ_OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const AZ_OPENAI_ENDPOINT = process.env.OPENAI_ENDPOINT;
const modelName = process.env.OPENAI_DEPLOYMENT_NAME || "gpt-35-turbo";

if(!AZ_OPENAI_API_KEY || !AZ_OPENAI_ENDPOINT){
    throw new Error("Missing required configuration values.");
}

const openaiClient = new OpenAIClient(AZ_OPENAI_ENDPOINT, new AzureKeyCredential(AZ_OPENAI_API_KEY));

module.exports = async function (context, req) {
    const headers = {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
    };
    
    //Test
    if (req.method === "GET") {
        context.res = {
            headers,
            body: "Hello, world!",
            status: 200,
        };
        return;
    }

    if(req.body["events"].length === 0){
        context.res = { status: 200, body: "Please pass a message on the query string or in the request body" };
        return;
    }

    
    if(req.body["events"][0].type !== "message" || req.body["events"][0].message.type !== "text"){
        message = {
            type: "text",
            text: "申し訳ありませんが、テキストメッセージのみ対応しております"
        };
        client.replyMessage(req.body["events"][0].replyToken, message);
        return;
    }
    
    if(!validate_signature(req.body, req.headers["x-line-signature"])){
        context.res = {
            status: 202,
            body: "fail to validate signature"
        };
        return;
    }
    
    try{
        const ans = await chatgpt(req.body["events"][0].message.text);
        message = { type: "text", text: ans };
        client.replyMessage(req.body["events"][0].replyToken, message);
        return;
    }catch(error){
        console.error(error);
        message = { type: "text", text: "申し訳ありませんが、エラーが発生しました" };
        client.replyMessage(req.body["events"][0].replyToken, message);
        return;
    }

};

//ChatGPT
const chatgpt = async (message) => {
    try{
        const messages = [
            { role: "system", content: "親切に丁寧に思いやりをもって返答してください" },
            { role: "user", content: message },
          ];

        const result = await openaiClient.getChatCompletions(modelName, messages);
        for (const choice of result.choices) {
            const response = choice.message.content || "...";
            return response;
          }
    }
    catch (error) {
        throw new Error(`OpenAI API Error: ${error.message}`);
    }
};

const validate_signature = (body, signature) => {
    try{
        const hash = crypto.createHmac("sha256", LINE_CHANNEL_SECRET).update(Buffer.from(JSON.stringify(body))).digest("base64");
        return hash === signature;
    }catch(e){
        console.log(e)
        return false;
    }
};
```
```json:app/package.json
{
  "name": "azureOpenAi-func-app",
  "version": "0.1.0",
  "dependencies": {
    "@azure/openai": "^1.0.0-beta.6",
    "@line/bot-sdk": "^7.7.0",
    "node-fetch": "^3.3.2"
  }
}
```

最初のテンプレート時に構築したリソースは一回`pulumi destroy`して再度`pulumi up`します。

`Outputs`にAPIのURLが出力されます。
```
Outputs:
    apiURL: "https://************.azurewebsites.net/api"

Resources:
    + 11 created
```

ポータルで見ると以下のリソースがちゃんとデプロイされてます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/2ca3803b-19f8-9acb-3fe2-d5dc6d233aba.png)
Messaging API設定のWebhook URLは今回の場合、`https://************.azurewebsites.net/api/chat`です。

因みに、変なメッセージ(どんなメッセージを送ったのかは内緒）を送るとAzure OpenAI Serviceのコンテンツフィルターに引っかかりますね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2821082/e39f51f9-954d-a478-ce30-a9263e7115e1.png)
安心感があります。

## さいごに
Pulumi良きです！

Pulumi AIも軽く使ってみましたが、今のところはドキュメントとARMテンプレートを見ながらやったほうが確実で、そちらのほうが早かったので今回Pulumi AIはあまり使いませんでした。

今後もPulumiは追っていこうと思います！