웹훅 서비스 만들기

마지막 단계에서 만든 사전 빌드된 에이전트에는 웹훅이 필요합니다. Cloud Run Functions는 단순성으로 인해 이 튜토리얼에서 웹훅을 호스팅하는 데 사용되지만 웹훅 서비스를 호스팅할 수 있는 방법에는 여러 가지가 있습니다. 또한 이 예시에서는 Go 프로그래밍 언어를 사용하지만 Cloud Run Functions에서 지원하는 모든 언어를 사용할 수 있습니다.

함수 만들기

Cloud Run Functions는 Google Cloud 콘솔로 생성될 수 있습니다(문서 보기, 콘솔 열기). 이 튜토리얼에 사용할 함수를 만들려면 다음 안내를 따르세요.

  1. Dialogflow 에이전트 및 함수가 모두 동일한 프로젝트에 있어야 합니다. 이것은 Dialogflow의 함수 보안 액세스를 위한 가장 쉬운 방법입니다. 함수를 만들기 전에 Google Cloud 콘솔에서 프로젝트를 선택합니다.

    프로젝트 선택기로 이동

  2. Cloud Run Functions 개요 페이지를 엽니다.

    Cloud Run Functions 개요로 이동

  3. 함수 만들기를 클릭하고 다음 필드를 설정합니다.

    • 환경: 1세대
    • 함수 이름: tutorial-telecommunications-webhook
    • 리전: 에이전트에 리전을 지정한 경우 동일한 리전 사용
    • HTTP 트리거 유형: HTTP
    • URL: 여기에서 복사 버튼을 클릭하고 값을 저장합니다. 웹훅을 구성할 때 이 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. 상태 표시기에 함수가 성공적으로 배포된 것으로 표시될 때까지 기다립니다. 기다리는 동안 바로 전에 배포한 코드를 검사합니다.

에이전트를 위한 웹훅 구성

이제 웹훅이 서비스로 존재하므로 이 웹훅을 에이전트에 연결합니다. 에이전트의 웹훅을 만들려면 다음 안내를 따르세요.

  1. Dialogflow CX 콘솔을 엽니다.
  2. Google Cloud 프로젝트를 선택합니다.
  3. 에이전트를 선택합니다.
  4. 관리 탭을 선택합니다.
  5. 웹훅을 클릭합니다.
  6. 기존 웹훅(사전 빌드된 에이전트로 생성됨)을 클릭합니다.
  7. 웹훅 URL을 함수를 만들 때 저장한 웹훅 URL로 업데이트합니다.
  8. 하위 유형을 표준으로 설정합니다.
  9. 저장을 클릭합니다.

에이전트 사용해 보기

이제 에이전트를 사용해 볼 수 있습니다. 테스트 에이전트 버튼을 클릭하여 시뮬레이터를 엽니다. 에이전트와 다음 대화를 시도해 봅니다.

대화 차례 상담사
1 안녕하세요 안녕하세요. 저는 가상 통신 에이전트입니다. 청구 및 요금제 관련 질문을 해결하고 문제를 해결하고 여행 및 크루즈 일정을 추가하는 데 도움을 드릴 수 있습니다. 무엇을 도와드릴까요?
2 크루즈 일정을 추가하고 싶습니다. 한번 확인해 보겠습니다. 전화번호가 어떻게 되나요?
3 123-123-1234 크루즈 일정이 어떻게 되나요?
4 7 옵션 1에는 통화 및 문자가 포함됩니다. 요금은 하루 $[XX]입니다. 자세한 내용은 [URL]을 참조하세요. 옵션 2에는 통화, 문자, 데이터가 포함됩니다. 요금은 하루 $[XX]입니다. 자세한 내용은 [URL]을 참조하세요. 어떤 크루즈 일정을 추가하시겠어요?
5 옵션 1 알겠습니다. 해당 일정을 계정에 추가했습니다. 크루즈 이용 중에 육지 일정도 필요할 수 있습니다. 어떤 항구에 머물까요?
6 멕시코 거기에서 며칠을 보낼 생각인가요?
7 3 좋은 소식입니다. 멕시코 육지 일정은 무료로 이용할 수 있습니다. 이제 준비가 끝났습니다. 도움이 더 필요한 사항은 없나요?

이 특정 대화는 Cruise Plan 흐름에서 관리되었습니다.

대화 차례 6번에서 '멕시코'를 목적지 항구로 입력했습니다. 목적지 항구와 기간이 Collect Port 페이지의 destinationtrip_duration 양식 매개변수로 캡처됩니다. 에이전트를 탐색하여 이러한 매개변수 정의를 찾습니다.

Dialogflow 콘솔의 항구 수집 페이지 스크린샷

Collect Port 페이지에는 양식 작성을 위한 조건 경로 $page.params.status = "FINAL"이 있습니다. 두 가지 양식 파라미터가 제공되면 이 경로가 호출됩니다. 이 경로는 웹훅을 호출하고 웹훅에 cruisePlanCoverage 태그를 제공합니다. 위의 웹훅 코드를 살펴보면 이 태그가 동일한 이름이 지정된 함수를 호출하는 것을 확인할 수 있습니다.

이 함수는 제공된 목적지가 일정에 포함되는지 여부를 결정합니다. 이 함수는 데이터베이스에 연결하는 데 필요한 정보로 특정 환경 변수가 설정되었는지 확인합니다. 이러한 환경 변수가 설정되지 않으면 함수에서 하드코딩된 목적지 목록을 사용합니다. 다음 단계에서는 목적지 위치에 포함된 일정을 검증하기 위해 데이터베이스에서 데이터를 가져오도록 함수의 환경을 변경합니다.

문제 해결

웹훅 코드에는 로깅 구문이 포함되어 있습니다. 문제가 있으면 함수의 로그를 확인해 보세요.

추가 정보

위 단계에 대한 자세한 내용은 다음을 참조하세요.