1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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

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

最近はAWS CDKを使ったLambdaの開発体験が良いので浮気気味だったのですが、Azure Functionsも好きです。ただ、IaCはBicepでは無く書きたいなーという気分で、CDK for Terraformも試したのですが、いやここはPulumiっしょ!と思いやってみました。そういえばプルミはハワイ語で「ほうき」を意味するみたいです。

因みにですが、Azure OpenAI Serviceは2023年7月、東日本リージョンが選択できるようになりましたね⭐

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

本題

今回、構築テンプレートとしてこちらから始めてみる事にしました。

ドキュメント通りにやると直ぐに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のコードです。

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`;
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}`;
}
.env
LINE_CHANNEL_ACCESS_TOKEN=<値をセット>
LINE_CHANNEL_SECRET=<値をセット>

AppServicePlanのOSはWindowsでは無く、Linuxにしてます。
(SecretにはAzure KeyVaultとか使ったほうが良いのだと思いますが、とりあえずで...💦)

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

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

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;
    }
};
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
Messaging API設定のWebhook URLは今回の場合、https://************.azurewebsites.net/api/chatです。

因みに、変なメッセージ(どんなメッセージを送ったのかは内緒)を送るとAzure OpenAI Serviceのコンテンツフィルターに引っかかりますね。
image.png
安心感があります。

さいごに

Pulumi良きです!

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

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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?