chromium/chromeos/ash/services/libassistant/conversation_controller.cc

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

#include "chromeos/ash/services/libassistant/conversation_controller.h"

#include <memory>

#include "base/memory/raw_ref.h"
#include "base/sequence_checker.h"
#include "base/strings/string_number_conversions.h"
#include "base/task/sequenced_task_runner.h"
#include "base/thread_annotations.h"
#include "chromeos/ash/services/assistant/public/cpp/features.h"
#include "chromeos/ash/services/libassistant/grpc/assistant_client.h"
#include "chromeos/ash/services/libassistant/public/mojom/conversation_controller.mojom.h"
#include "chromeos/ash/services/libassistant/util.h"
#include "chromeos/assistant/internal/internal_util.h"
#include "chromeos/assistant/internal/libassistant/shared_headers.h"
#include "chromeos/assistant/internal/proto/shared/proto/conversation.pb.h"
#include "chromeos/assistant/internal/proto/shared/proto/v2/delegate/event_handler_interface.pb.h"
#include "chromeos/assistant/internal/proto/shared/proto/v2/internal_options.pb.h"
#include "chromeos/strings/grit/chromeos_strings.h"
#include "mojo/public/cpp/bindings/pending_receiver.h"
#include "ui/base/l10n/l10n_util.h"

namespace ash::libassistant {

using assistant::AssistantInteractionMetadata;
using assistant::AssistantInteractionType;
using assistant::AssistantQuerySource;

namespace {

constexpr base::TimeDelta kStopInteractionDelayTime = base::Milliseconds(500);

// A macro which ensures we are running on the main thread.
#define ENSURE_MOJOM_THREAD(method, ...)                                    \
  DVLOG(3) << __func__;                                                     \
  if (!mojom_task_runner_->RunsTasksInCurrentSequence()) {                  \
    mojom_task_runner_->PostTask(                                           \
        FROM_HERE,                                                          \
        base::BindOnce(method, weak_factory_.GetWeakPtr(), ##__VA_ARGS__)); \
    return;                                                                 \
  }

// Helper function to convert |action::Suggestion| to |AssistantSuggestion|.
std::vector<assistant::AssistantSuggestion> ToAssistantSuggestion(
    const std::vector<chromeos::assistant::action::Suggestion>& suggestions) {
  std::vector<assistant::AssistantSuggestion> result;
  for (const auto& suggestion : suggestions) {
    assistant::AssistantSuggestion assistant_suggestion;
    assistant_suggestion.id = base::UnguessableToken::Create();
    assistant_suggestion.text = suggestion.text;
    assistant_suggestion.icon_url = GURL(suggestion.icon_url);
    assistant_suggestion.action_url = GURL(suggestion.action_url);
    result.push_back(std::move(assistant_suggestion));
  }

  return result;
}

// Helper function to convert |action::Notification| to |AssistantNotification|.
assistant::AssistantNotification ToAssistantNotification(
    const chromeos::assistant::action::Notification& notification) {
  assistant::AssistantNotification assistant_notification;
  assistant_notification.title = notification.title;
  assistant_notification.message = notification.text;
  assistant_notification.action_url = GURL(notification.action_url);
  assistant_notification.client_id = notification.notification_id;
  assistant_notification.server_id = notification.notification_id;
  assistant_notification.consistency_token = notification.consistency_token;
  assistant_notification.opaque_token = notification.opaque_token;
  assistant_notification.grouping_key = notification.grouping_key;
  assistant_notification.obfuscated_gaia_id = notification.obfuscated_gaia_id;
  assistant_notification.from_server = true;

  if (notification.expiry_timestamp_ms) {
    assistant_notification.expiry_time =
        base::Time::FromMillisecondsSinceUnixEpoch(
            notification.expiry_timestamp_ms);
  }

  // The server sometimes sends an empty |notification_id|, but our client
  // requires a non-empty |client_id| for notifications. Known instances in
  // which the server sends an empty |notification_id| are for Reminders.
  if (assistant_notification.client_id.empty()) {
    assistant_notification.client_id =
        base::UnguessableToken::Create().ToString();
  }

  for (const auto& button : notification.buttons) {
    assistant_notification.buttons.push_back(
        {button.label, GURL(button.action_url),
         /*remove_notification_on_click=*/true});
  }
  return assistant_notification;
}
}  // namespace

////////////////////////////////////////////////////////////////////////////////
// GrpcEventsObserver
////////////////////////////////////////////////////////////////////////////////

class ConversationController::GrpcEventsObserver
    : public GrpcServicesObserver<
          ::assistant::api::OnConversationStateEventRequest>,
      public GrpcServicesObserver<::assistant::api::OnDeviceStateEventRequest> {
 public:
  explicit GrpcEventsObserver(ConversationController* parent)
      : parent_(*parent) {}
  GrpcEventsObserver(const GrpcEventsObserver&) = delete;
  GrpcEventsObserver& operator=(const GrpcEventsObserver&) = delete;
  ~GrpcEventsObserver() override = default;

  std::string AddPendingTextInteraction(const std::string& query,
                                        AssistantQuerySource source) {
    return NewPendingInteraction(AssistantInteractionType::kText, source,
                                 query);
  }

  // GrpcServicesObserver:
  // Invoked when a conversation state event has been received.
  void OnGrpcMessage(const ::assistant::api::OnConversationStateEventRequest&
                         request) override {
    if (!request.event().has_on_turn_started())
      return;

    const auto& turn_started = request.event().on_turn_started();
    // Retrieve the cached interaction metadata associated with this
    // conversation turn or construct a new instance if there's no match in the
    // cache.
    AssistantInteractionMetadata interaction_metadata;
    auto it = pending_interactions_.find(turn_started.turn_id());
    if (it != pending_interactions_.end()) {
      interaction_metadata = it->second;
      pending_interactions_.erase(it);
    } else {
      interaction_metadata.type = turn_started.is_mic_open()
                                      ? AssistantInteractionType::kVoice
                                      : AssistantInteractionType::kText;
      interaction_metadata.source =
          AssistantQuerySource::kLibAssistantInitiated;
    }

    for (auto& observer : parent_->observers_) {
      observer->OnInteractionStarted(interaction_metadata);
    }
  }

  // Invoked when a device state event has been received.
  void OnGrpcMessage(
      const ::assistant::api::OnDeviceStateEventRequest& request) override {
    const auto& event = request.event();
    if (event.has_on_notification_removed()) {
      const auto& grouping_id =
          request.event().on_notification_removed().grouping_id();
      if (grouping_id.empty())
        RemoveAllNotifications();
      else
        RemoveNotification(grouping_id);
      return;
    }

    if (event.has_on_communication_error()) {
      if (event.on_communication_error().error_code() ==
          ::assistant::api::events::DeviceStateEvent::OnCommunicationError::
              AUTH_TOKEN_FAIL) {
        for (auto& observer : parent_->authentication_state_observers_) {
          observer->OnAuthenticationError();
        }
      }
    }
  }

 private:
  void RemoveAllNotifications() {
    parent_->notification_delegate_->RemoveAllNotifications(
        /*from_server=*/true);
  }

  void RemoveNotification(const std::string& id) {
    parent_->notification_delegate_->RemoveNotificationByGroupingKey(
        id, /*from_server=*/true);
  }

  std::string NewPendingInteraction(AssistantInteractionType interaction_type,
                                    AssistantQuerySource source,
                                    const std::string& query) {
    auto id = base::NumberToString(next_interaction_id_++);
    pending_interactions_.emplace(
        id, AssistantInteractionMetadata(interaction_type, source, query));
    return id;
  }

  int next_interaction_id_ = 1;
  std::map<std::string, AssistantInteractionMetadata> pending_interactions_;
  const raw_ref<ConversationController> parent_;
};

////////////////////////////////////////////////////////////////////////////////
// ConversationController
////////////////////////////////////////////////////////////////////////////////

ConversationController::ConversationController()
    : receiver_(this),
      events_observer_(std::make_unique<GrpcEventsObserver>(this)),
      action_module_(
          std::make_unique<chromeos::assistant::action::CrosActionModule>(
              assistant::features::IsAppSupportEnabled(),
              assistant::features::IsWaitSchedulingEnabled())),
      mojom_task_runner_(base::SequencedTaskRunner::GetCurrentDefault()) {
  action_module_->AddObserver(this);
}

ConversationController::~ConversationController() = default;

void ConversationController::Bind(
    mojo::PendingReceiver<mojom::ConversationController> receiver,
    mojo::PendingRemote<mojom::NotificationDelegate> notification_delegate) {
  // Cannot bind the receiver twice.
  DCHECK(!receiver_.is_bound());
  receiver_.Bind(std::move(receiver));

  // Binds remote notification delegate.
  notification_delegate_.Bind(std::move(notification_delegate));
}

void ConversationController::AddActionObserver(
    chromeos::assistant::action::AssistantActionObserver* observer) {
  action_module_->AddObserver(observer);
}

void ConversationController::AddAuthenticationStateObserver(
    mojo::PendingRemote<mojom::AuthenticationStateObserver> observer) {
  authentication_state_observers_.Add(std::move(observer));
}

void ConversationController::OnAssistantClientRunning(
    AssistantClient* assistant_client) {
  // Only when Libassistant is running we can start sending queries.
  assistant_client_ = assistant_client;
  requests_are_allowed_ = true;

  // Register the action module when all libassistant services are ready.
  // `action_module_` outlives gRPC services.
  assistant_client->RegisterActionModule(action_module_.get());

  assistant_client_->AddConversationStateEventObserver(events_observer_.get());
  assistant_client_->AddDeviceStateEventObserver(events_observer_.get());
}

void ConversationController::OnDestroyingAssistantClient(
    AssistantClient* assistant_client) {
  assistant_client_ = nullptr;
}

void ConversationController::SendTextQuery(const std::string& query,
                                           AssistantQuerySource source,
                                           bool allow_tts) {
  DVLOG(1) << __func__;

  DCHECK(requests_are_allowed_)
      << "Should not receive requests before Libassistant is running";
  if (!assistant_client_)
    return;

  MaybeStopPreviousInteraction();

  // Configs |VoicelessOptions|.
  ::assistant::api::VoicelessOptions options;
  options.set_is_user_initiated(true);
  if (!allow_tts) {
    options.set_modality(::assistant::api::VoicelessOptions::TYPING_MODALITY);
  }
  // Remember the interaction metadata, and pass the generated conversation id
  // to LibAssistant.
  options.set_conversation_turn_id(
      events_observer_->AddPendingTextInteraction(query, source));

  // Builds text interaction.
  auto interaction = CreateTextQueryInteraction(query);

  assistant_client_->SendVoicelessInteraction(
      interaction, /*description=*/"text_query", options, base::DoNothing());
}

void ConversationController::StartVoiceInteraction() {
  DVLOG(1) << __func__;

  DCHECK(requests_are_allowed_)
      << "Should not receive requests before Libassistant is running";
  if (!assistant_client_) {
    VLOG(1) << "Starting voice interaction without assistant manager.";
    return;
  }

  MaybeStopPreviousInteraction();

  assistant_client_->StartVoiceInteraction();
}

void ConversationController::StartEditReminderInteraction(
    const std::string& client_id) {
  DCHECK(requests_are_allowed_)
      << "Should not receive requests before Libassistant is running";
  if (!assistant_client_)
    return;

  // Cancels any ongoing StopInteraction posted by StopActiveInteraction()
  // before we move forward to start an EditReminderInteraction. Failing to
  // do this could expose a race condition and potentially result in the
  // following EditReminderInteraction getting barged in and cancelled.
  // See b/182948180.
  MaybeStopPreviousInteraction();

  ::assistant::api::VoicelessOptions options;
  options.set_is_user_initiated(true);

  assistant_client_->SendVoicelessInteraction(
      CreateEditReminderInteraction(client_id),
      /*description=*/std::string(), options, base::DoNothing());
}

void ConversationController::StopActiveInteraction(bool cancel_conversation) {
  if (!assistant_client_) {
    VLOG(1) << "Stopping interaction without assistant manager.";
    return;
  }

  // We do not stop the interaction immediately, but instead we give
  // Libassistant a bit of time to stop on its own accord. This improves
  // stability as Libassistant might misbehave when it's forcefully stopped.
  auto stop_callback = [](base::WeakPtr<ConversationController> weak_this,
                          bool cancel_conversation) {
    if (!weak_this || !weak_this->assistant_client_) {
      return;
    }
    VLOG(1) << "Stopping Assistant interaction.";
    weak_this->assistant_client_->StopAssistantInteraction(cancel_conversation);
  };

  stop_interaction_closure_ =
      std::make_unique<base::CancelableOnceClosure>(base::BindOnce(
          stop_callback, weak_factory_.GetWeakPtr(), cancel_conversation));

  mojom_task_runner_->PostDelayedTask(FROM_HERE,
                                      stop_interaction_closure_->callback(),
                                      kStopInteractionDelayTime);
}

void ConversationController::RetrieveNotification(
    AssistantNotification notification,
    int32_t action_index) {
  DCHECK(requests_are_allowed_)
      << "Should not receive requests before Libassistant is running";
  if (!assistant_client_)
    return;

  auto request_interaction = CreateNotificationRequestInteraction(
      notification.server_id, notification.consistency_token,
      notification.opaque_token, action_index);

  ::assistant::api::VoicelessOptions options;
  options.set_is_user_initiated(true);

  assistant_client_->SendVoicelessInteraction(
      request_interaction,
      /*description=*/"RequestNotification", options, base::DoNothing());
}

void ConversationController::DismissNotification(
    AssistantNotification notification) {
  DCHECK(requests_are_allowed_)
      << "Should not receive requests before Libassistant is running";
  if (!assistant_client_)
    return;

  auto dismissed_interaction = CreateNotificationDismissedInteraction(
      notification.server_id, notification.consistency_token,
      notification.opaque_token, {notification.grouping_key});

  ::assistant::api::VoicelessOptions options;
  options.set_obfuscated_gaia_id(notification.obfuscated_gaia_id);

  assistant_client_->SendVoicelessInteraction(
      dismissed_interaction, /*description=*/"DismissNotification", options,
      base::DoNothing());
}

void ConversationController::SendAssistantFeedback(
    const AssistantFeedback& feedback) {
  DCHECK(requests_are_allowed_)
      << "Should not receive requests before Libassistant is running";
  if (!assistant_client_)
    return;

  std::string raw_image_data(feedback.screenshot_png.begin(),
                             feedback.screenshot_png.end());
  auto interaction =
      CreateSendFeedbackInteraction(feedback.assistant_debug_info_allowed,
                                    feedback.description, raw_image_data);

  ::assistant::api::VoicelessOptions options;
  options.set_is_user_initiated(false);

  assistant_client_->SendVoicelessInteraction(
      interaction, /*description=*/"send feedback with details", options,
      base::DoNothing());
}

void ConversationController::AddRemoteObserver(
    mojo::PendingRemote<mojom::ConversationObserver> observer) {
  observers_.Add(std::move(observer));
}

// Called from Libassistant thread.
void ConversationController::OnShowHtml(const std::string& html_content,
                                        const std::string& fallback) {
  ENSURE_MOJOM_THREAD(&ConversationController::OnShowHtml, html_content,
                      fallback);

  for (auto& observer : observers_)
    observer->OnHtmlResponse(html_content, fallback);
}

// Called from Libassistant thread.
void ConversationController::OnShowText(const std::string& text) {
  ENSURE_MOJOM_THREAD(&ConversationController::OnShowText, text);

  for (auto& observer : observers_)
    observer->OnTextResponse(text);
}

// Called from Libassistant thread.
void ConversationController::OnShowSuggestions(
    const std::vector<chromeos::assistant::action::Suggestion>& suggestions) {
  ENSURE_MOJOM_THREAD(&ConversationController::OnShowSuggestions, suggestions);

  for (auto& observer : observers_)
    observer->OnSuggestionsResponse(ToAssistantSuggestion(suggestions));
}

// Called from Libassistant thread.
void ConversationController::OnOpenUrl(const std::string& url,
                                       bool in_background) {
  ENSURE_MOJOM_THREAD(&ConversationController::OnOpenUrl, url, in_background);

  for (auto& observer : observers_)
    observer->OnOpenUrlResponse(GURL(url), in_background);
}

// Called from Libassistant thread.
// Note that OnVerifyAndroidApp() will be handled by |DisplayController|
// directly since it stores an updated list of all installed Android Apps on the
// device.
void ConversationController::OnOpenAndroidApp(
    const assistant::AndroidAppInfo& app_info,
    const chromeos::assistant::InteractionInfo& interaction) {
  ENSURE_MOJOM_THREAD(&ConversationController::OnOpenAndroidApp, app_info,
                      interaction);

  for (auto& observer : observers_)
    observer->OnOpenAppResponse(app_info);

  // Note that we will always set |provider_found| to true since the preceding
  // OnVerifyAndroidApp() should already confirm that the requested provider is
  // available on the device.
  auto interaction_proto = CreateOpenProviderResponseInteraction(
      interaction.interaction_id, /*provider_found=*/true);
  ::assistant::api::VoicelessOptions options;
  options.set_obfuscated_gaia_id(interaction.user_id);

  assistant_client_->SendVoicelessInteraction(
      interaction_proto, /*description=*/"open_provider_response", options,
      base::DoNothing());
}

// Called from Libassistant thread.
void ConversationController::OnScheduleWait(int id, int time_ms) {
  ENSURE_MOJOM_THREAD(&ConversationController::OnScheduleWait, id, time_ms);

  DCHECK(assistant::features::IsWaitSchedulingEnabled());

  // Schedule a wait for |time_ms|, notifying the CrosActionModule when the wait
  // has finished so that it can inform LibAssistant to resume execution.
  mojom_task_runner_->PostDelayedTask(
      FROM_HERE,
      base::BindOnce(
          [](const base::WeakPtr<ConversationController>& weak_ptr, int id) {
            if (weak_ptr) {
              weak_ptr->action_module_->OnScheduledWaitDone(
                  id, /*cancelled=*/false);
            }
          },
          weak_factory_.GetWeakPtr(), id),
      base::Milliseconds(time_ms));

  // Notify subscribers that a wait has been started.
  for (auto& observer : observers_)
    observer->OnWaitStarted();
}

// Called from Libassistant thread.
void ConversationController::OnShowNotification(
    const chromeos::assistant::action::Notification& notification) {
  ENSURE_MOJOM_THREAD(&ConversationController::OnShowNotification,
                      notification);

  notification_delegate_->AddOrUpdateNotification(
      ToAssistantNotification(notification));
}

void ConversationController::OnInteractionStarted(
    const AssistantInteractionMetadata& metadata) {
  stop_interaction_closure_.reset();
}

void ConversationController::OnInteractionFinished(
    assistant::AssistantInteractionResolution resolution) {
  stop_interaction_closure_.reset();
}

void ConversationController::OnGrpcMessageForTesting(
    const ::assistant::api::OnDeviceStateEventRequest& request) {
  events_observer_->OnGrpcMessage(request);
}

void ConversationController::MaybeStopPreviousInteraction() {
  DVLOG(1) << __func__;

  if (!stop_interaction_closure_ || stop_interaction_closure_->IsCancelled()) {
    return;
  }

  stop_interaction_closure_->callback().Run();
}

}  // namespace ash::libassistant