Webhook サービスを作成する

前のステップで作成したビルド済みエージェントには、Webhook が必要です。このチュートリアルでは、そのシンプルさから、Webhook をホストするために Cloud Run 関数を使用しますが、Webhook サービスはその他のさまざまな方法でホストできます。この例では Go プログラミング言語も使用していますが、Cloud Run 関数でサポートされている言語を使用できます。

関数の作成

Cloud Run 関数は Google Cloud コンソールで作成できます(ドキュメントに移動して、コンソールを開きます)。このチュートリアルの関数を作成するには:

  1. Dialogflow エージェントと関数の両方が同じプロジェクト内にあることが重要です。これが、Dialogflow が関数に安全にアクセスするための最も簡単な方法です。関数を作成する前に、Google Cloud コンソールからプロジェクトを選択します。

    プロジェクト セレクタに移動

  2. Cloud Run 関数の概要ページを開きます。

    Cloud Run 関数の概要に移動

  3. [関数を作成] をクリックして、次のフィールドを設定します。

    • 環境: 第 1 世代
    • 関数名: tutorial-telecommunications-webhook
    • リージョン: エージェントにリージョンを指定した場合は、同じリージョンを使用します。
    • HTTP トリガータイプ: HTTP
    • URL: ここのコピーボタンをクリックして、値を保存します。Webhook を構成するときに、この URL が必要になります。
    • 認証: 認証が必要です
    • HTTPS が必須: チェックボックスをオンにする
  4. [保存] をクリックします。

  5. [次へ] をクリックします(特別なランタイム、ビルド、接続、セキュリティ設定は必要ありません)。

  6. 次のフィールドを設定します。

    • ランタイム: 最新の Go ランタイムを選択します。
    • ソースコード: インライン エディタ
    • エントリ ポイント: HandleWebhookRequest
  7. コードを次のように置き換えます。

    package cxtwh
    
    import (
    	"context"
    	"encoding/json"
    	"fmt"
    	"log"
    	"net/http"
    	"os"
    	"strings"
    
    	"cloud.google.com/go/spanner"
      "google.golang.org/grpc/codes"
    )
    
    // client is a Spanner client, created only once to avoid creation
    // for every request.
    // See: https://cloud.google.com/functions/docs/concepts/go-runtime#one-time_initialization
    var client *spanner.Client
    
    func init() {
    	// If using a database, these environment variables will be set.
    	pid := os.Getenv("PROJECT_ID")
    	iid := os.Getenv("SPANNER_INSTANCE_ID")
    	did := os.Getenv("SPANNER_DATABASE_ID")
    	if pid != "" && iid != "" && did != "" {
    		db := fmt.Sprintf("projects/%s/instances/%s/databases/%s",
    			pid, iid, did)
    		log.Printf("Creating Spanner client for %s", db)
    		var err error
    		// Use the background context when creating the client,
    		// but use the request context for calls to the client.
    		// See: https://cloud.google.com/functions/docs/concepts/go-runtime#contextcontext
    		client, err = spanner.NewClient(context.Background(), db)
    		if err != nil {
    			log.Fatalf("spanner.NewClient: %v", err)
    		}
    	}
    }
    
    type fulfillmentInfo struct {
    	Tag string `json:"tag"`
    }
    
    type sessionInfo struct {
    	Session    string                 `json:"session"`
    	Parameters map[string]interface{} `json:"parameters"`
    }
    
    type text struct {
    	Text []string `json:"text"`
    }
    
    type responseMessage struct {
    	Text text `json:"text"`
    }
    
    type fulfillmentResponse struct {
    	Messages []responseMessage `json:"messages"`
    }
    
    // webhookRequest is used to unmarshal a WebhookRequest JSON object. Note that
    // not all members need to be defined--just those that you need to process.
    // As an alternative, you could use the types provided by the Dialogflow protocol buffers:
    // https://pkg.go.dev/google.golang.org/genproto/googleapis/cloud/dialogflow/cx/v3#WebhookRequest
    type webhookRequest struct {
    	FulfillmentInfo fulfillmentInfo `json:"fulfillmentInfo"`
    	SessionInfo     sessionInfo     `json:"sessionInfo"`
    }
    
    // webhookResponse is used to marshal a WebhookResponse JSON object. Note that
    // not all members need to be defined--just those that you need to process.
    // As an alternative, you could use the types provided by the Dialogflow protocol buffers:
    // https://pkg.go.dev/google.golang.org/genproto/googleapis/cloud/dialogflow/cx/v3#WebhookResponse
    type webhookResponse struct {
    	FulfillmentResponse fulfillmentResponse `json:"fulfillmentResponse"`
    	SessionInfo         sessionInfo         `json:"sessionInfo"`
    }
    
    // detectCustomerAnomaly handles same-named tag.
    func detectCustomerAnomaly(ctx context.Context, request webhookRequest) (
    	webhookResponse, error) {
    	// Create session parameters that are populated in the response.
    	// This example hard codes values, but a real system
    	// might look up this value in a database.
    	p := map[string]interface{}{
    		"anomaly_detect":        "false",
    		"purchase":              "device protection",
    		"purchase_amount":       "12.25",
    		"bill_without_purchase": "54.34",
    		"total_bill":            "66.59",
    		"first_month":           "January 1",
    	}
    	// Build and return the response.
    	response := webhookResponse{
    		SessionInfo: sessionInfo{
    			Parameters: p,
    		},
    	}
    	return response, nil
    }
    
    // validatePhoneLine handles same-named tag.
    func validatePhoneLine(ctx context.Context, request webhookRequest) (
    	webhookResponse, error) {
    	// Create session parameters that are populated in the response.
    	// This example hard codes values, but a real system
    	// might look up this value in a database.
    	p := map[string]interface{}{
    		"domestic_coverage":   "true",
    		"phone_line_verified": "true",
    	}
    	// Build and return the response.
    	response := webhookResponse{
    		SessionInfo: sessionInfo{
    			Parameters: p,
    		},
    	}
    	return response, nil
    }
    
    // cruisePlanCoverage handles same-named tag.
    func cruisePlanCoverage(ctx context.Context, request webhookRequest) (
    	webhookResponse, error) {
    	// Get the existing parameter values
    	port := request.SessionInfo.Parameters["destination"].(string)
    	port = strings.ToLower(port)
    	// Check if the port is covered
    	covered := "false"
    	if client != nil {
    		// A Spanner client exists, so access the database.
    		// See: https://pkg.go.dev/cloud.google.com/go/spanner#ReadOnlyTransaction.ReadRow
    		row, err := client.Single().ReadRow(ctx,
    			"Destinations",
    			spanner.Key{port},
    			[]string{"Covered"})
    		if err != nil {
    			if spanner.ErrCode(err) == codes.NotFound {
    				log.Printf("Port %s not found", port)
    			} else {
    				return webhookResponse{}, err
    			}
    		} else {
    			// A row was returned, so check the value
    			var c bool
    			err := row.Column(0, &c)
    			if err != nil {
    				return webhookResponse{}, err
    			}
    			if c {
    				covered = "true"
    			}
    		}
    	} else {
    		// No Spanner client exists, so use hardcoded list of ports.
    		coveredPorts := map[string]bool{
    			"anguilla": true,
    			"canada":   true,
    			"mexico":   true,
    		}
    		_, ok := coveredPorts[port]
    		if ok {
    			covered = "true"
    		}
    	}
    	// Create session parameters that are populated in the response.
    	// This example hard codes values, but a real system
    	// might look up this value in a database.
    	p := map[string]interface{}{
    		"port_is_covered": covered,
    	}
    	// Build and return the response.
    	response := webhookResponse{
    		SessionInfo: sessionInfo{
    			Parameters: p,
    		},
    	}
    	return response, nil
    }
    
    // internationalCoverage handles same-named tag.
    func internationalCoverage(ctx context.Context, request webhookRequest) (
    	webhookResponse, error) {
    	// Get the existing parameter values
    	destination := request.SessionInfo.Parameters["destination"].(string)
    	destination = strings.ToLower(destination)
    	// Hardcoded list of covered international monthly destinations
    	coveredMonthly := map[string]bool{
    		"anguilla":  true,
    		"australia": true,
    		"brazil":    true,
    		"canada":    true,
    		"chile":     true,
    		"england":   true,
    		"france":    true,
    		"india":     true,
    		"japan":     true,
    		"mexico":    true,
    		"singapore": true,
    	}
    	// Hardcoded list of covered international daily destinations
    	coveredDaily := map[string]bool{
    		"brazil":    true,
    		"canada":    true,
    		"chile":     true,
    		"england":   true,
    		"france":    true,
    		"india":     true,
    		"japan":     true,
    		"mexico":    true,
    		"singapore": true,
    	}
    	// Check coverage
    	coverage := "neither"
    	_, monthly := coveredMonthly[destination]
    	_, daily := coveredDaily[destination]
    	if monthly && daily {
    		coverage = "both"
    	} else if monthly {
    		coverage = "monthly_only"
    	} else if daily {
    		coverage = "daily_only"
    	}
    	// Create session parameters that are populated in the response.
    	// This example hard codes values, but a real system
    	// might look up this value in a database.
    	p := map[string]interface{}{
    		"coverage": coverage,
    	}
    	// Build and return the response.
    	response := webhookResponse{
    		SessionInfo: sessionInfo{
    			Parameters: p,
    		},
    	}
    	return response, nil
    }
    
    // cheapestPlan handles same-named tag.
    func cheapestPlan(ctx context.Context, request webhookRequest) (
    	webhookResponse, error) {
    	// Create session parameters that are populated in the response.
    	// This example hard codes values, but a real system
    	// might look up this value in a database.
    	p := map[string]interface{}{
    		"monthly_cost":   70,
    		"daily_cost":     100,
    		"suggested_plan": "monthly",
    	}
    	// Build and return the response.
    	response := webhookResponse{
    		SessionInfo: sessionInfo{
    			Parameters: p,
    		},
    	}
    	return response, nil
    }
    
    // Define a type for handler functions.
    type handlerFn func(ctx context.Context, request webhookRequest) (
    	webhookResponse, error)
    
    // Create a map from tag to handler function.
    var handlers map[string]handlerFn = map[string]handlerFn{
    	"detectCustomerAnomaly": detectCustomerAnomaly,
    	"validatePhoneLine":     validatePhoneLine,
    	"cruisePlanCoverage":    cruisePlanCoverage,
    	"internationalCoverage": internationalCoverage,
    	"cheapestPlan":          cheapestPlan,
    }
    
    // handleError handles internal errors.
    func handleError(w http.ResponseWriter, err error) {
    	log.Printf("ERROR: %v", err)
    	http.Error(w,
    		fmt.Sprintf("ERROR: %v", err),
    		http.StatusInternalServerError)
    }
    
    // HandleWebhookRequest handles WebhookRequest and sends the WebhookResponse.
    func HandleWebhookRequest(w http.ResponseWriter, r *http.Request) {
    	var request webhookRequest
    	var response webhookResponse
    	var err error
    
    	// Read input JSON
    	if err = json.NewDecoder(r.Body).Decode(&request); err != nil {
    		handleError(w, err)
    		return
    	}
    	log.Printf("Request: %+v", request)
    
    	// Get the tag from the request, and call the corresponding
    	// function that handles that tag.
    	tag := request.FulfillmentInfo.Tag
    	if fn, ok := handlers[tag]; ok {
    		response, err = fn(r.Context(), request)
    	} else {
    		err = fmt.Errorf("Unknown tag: %s", tag)
    	}
    	if err != nil {
    		handleError(w, err)
    		return
    	}
    	log.Printf("Response: %+v", response)
    
    	// Send response
    	if err = json.NewEncoder(w).Encode(&response); err != nil {
    		handleError(w, err)
    		return
    	}
    }

  8. [デプロイ] をクリックします。

  9. ステータス インジケーターが関数が正常にデプロイされたことを示すまで待ちます。待機中に、デプロイしたコードを確認します。

エージェントの Webhook を構成する

Webhook がサービスとして存在するようになったため、この Webhook をエージェントに関連付けます。エージェントの Webhook を作成するには:

  1. Dialogflow CX Console を開きます。
  2. ご自身の Google Cloud プロジェクトを選択します。
  3. エージェントを選択します。
  4. [Manage] タブを選択します。
  5. [Webhooks] をクリックします。
  6. 既存の Webhook(ビルド済みエージェントによって作成されたもの)をクリックします。
  7. [Webhook URL] を関数の作成時に保存した Webhook URL に更新します。
  8. [サブタイプ] を「Standard」に設定します。
  9. [保存] をクリックします。

エージェントを試す

エージェントを試す準備が整いました。[Test Agent] ボタンをクリックして、シミュレータを開きます。 エージェントとの次の会話を試行します。

会話ターン 自分 エージェント
1 こんにちは こんにちは。私は仮想通信エージェントです。請求と計画に関する疑問の解決、トラブルシューティング、旅行やクルーズの計画の追加をお手伝いします。ご用件をお知らせください。
2 クルーズ計画を追加したい 検索します。登録している電話番号をお知らせください。
3 123-123-1234 クルーズの日数をお知らせください。
4 7 オプション 1 には、会話とテキストが含まれています。1 日あたりの費用は $[XX] です。詳しくは、[URL] をご覧ください。オプション 2 には、トーク、テキスト、データが含まれます。1 日あたりの費用は $[XX] です。詳しくは、[URL] をご覧ください。どのクルーズプランを追加しますか?
5 オプション 1 承知しました。お客様のアカウントにそのプランを追加しました。クルーズ中は、地上での滞在も必要な場合があります。どの港に停まりますか?
6 メキシコ では、そちらで何日滞在しますか。
7 3 おめでとうございます。メキシコでは地上でも無料でカバーされます。設定が完了しました。他に何かご不明な点はございませんか?

この特定の会話は、Cruise Plan のフローで管理されていました。

会話ターン 6 で、目的地の港として「メキシコ」を指定しました。目的地の港と日数は、Collect Port ページの destination および trip_duration フォーム パラメータとしてキャプチャされます。これらのパラメータの定義は、エージェントで確認できます。

Dialogflow コンソールの [ポートの収集] ページのスクリーンショット

Collect Port ページには、フォーム完了の条件ルート $page.params.status = "FINAL" があります。2 つのフォーム パラメータが指定されると、このルートが呼び出されます。このルートは Webhook を呼び出し、Webhook に cruisePlanCoverage タグを渡します。前述の Webhook コードを調べると、このタグは同じ名前の関数を呼び出すことがわかります。

この関数は、指定された目的地がプランの対象かどうかを判断します。 この関数は、データベースに接続するための情報が特定の環境変数に設定されているかどうかを確認します。これらの環境変数が設定されていない場合、関数はハードコードされた目的地のリストを使用します。以降の手順では、宛先のプランの対象範囲を検証するために、データベースからデータを取得するように関数の環境を変更します。

トラブルシューティング

Webhook コードにロギング ステートメントが含まれています。問題が発生した場合は、関数のログを表示してみてください。

詳細

上述の手順の詳細については、以下をご覧ください。