lib/wechat_component.ex

defmodule WeChat.Component do
  @moduledoc """
  Use for WeChat official accounts third-party platform applications, `WeChat` module
  is usually used for WeChat's functional APIs invoke directly, `WeChat.Component` is used to
  call to refetch/refresh authorizer's access token internally.
  """

  require Logger

  alias WeChat.{Utils, Error}

  defmacro __using__(opts \\ []) do
    default_opts =
      opts
      |> Macro.prewalk(&Macro.expand(&1, __CALLER__))
      |> Keyword.take([:adapter_storage, :appid, :authorizer_appid, :scenario])

    quote do
      def default_opts, do: unquote(default_opts)

      @doc """
      See WeChat.request/2 for more information.
      """
      def request(method, options) do
        options = Utils.merge_keyword(options, unquote(default_opts))
        WeChat.component_request(method, options)
      end

      @doc """
      The expire time (in seconds) to `access_token` and `ticket` temporary storage,
      by default it is 7200 seconds
      """
      defdelegate expires_in(), to: WeChat

      defoverridable request: 2, expires_in: 0
    end
  end

  @doc """
  A function helper to fetch `component` application's access token.

  When apply it to hub, if no available component access token from hub's storage, there will use `verify_ticket` to refetch a new one,
  this function can be used for refresh function.
  """
  def fetch_component_access_token(appid, adapter_storage) when is_atom(adapter_storage) do
    fetch_component_access_token(appid, {adapter_storage, nil})
  end

  def fetch_component_access_token(appid, {adapter_storage, args}) do
    case adapter_storage.fetch_component_access_token(appid, args) do
      {:ok, %WeChat.Token{access_token: nil}} ->
        component_access_token = remote_get_component_access_token(appid, adapter_storage, args)
        Logger.info("get component_access_token from remote: #{inspect(component_access_token)}")
        component_access_token

      {:ok, %WeChat.Token{access_token: _}} = token ->
        token

      {:error, error} ->
        {:error, error}
    end
  end

  defp remote_get_component_access_token(appid, adapter_storage, args) do
    case adapter_storage.fetch_component_verify_ticket(appid, args) do
      {:ok, verify_ticket} when verify_ticket != nil ->
        Logger.info(
          ">>> verify_ticket when remote_get_component_access_token: #{inspect(verify_ticket)}"
        )

        result =
          WeChat.request(:post,
            url: "/cgi-bin/component/api_component_token",
            body: %{"component_verify_ticket" => verify_ticket},
            appid: appid,
            adapter_storage: {adapter_storage, args}
          )

        case result do
          {:ok, response} ->
            access_token = Map.get(response.body, "component_access_token")
            expires_in = Map.get(response.body, "expires_in")

            {
              :ok,
              %WeChat.Token{
                access_token: access_token,
                expires_in: expires_in,
                timestamp: Utils.now_unix()
              }
            }

          {:error, error} ->
            Logger.error(
              "remote call /cgi-bin/component/api_component_token for appid: #{inspect(appid)} occurs an error: #{
                inspect(error)
              }"
            )

            {:error, error}
        end

      {:error, error} ->
        Logger.error(
          "occur an error: #{inspect(error)} when get component access token for appid: #{
            inspect(appid)
          }"
        )

        raise %WeChat.Error{
          message:
            "verify_ticket is not existed for appid: #{inspect(appid)}, please try re-authorize",
          reason: "verify_ticket_not_found"
        }
    end
  end

  @doc """
  A function helper to fetch authorizer application's access token from `component` application.

  When apply it to hub, if no available access token from hub's storage, there will use `refresh_token` to refetch a new one,
  this function can be used for refresh function.
  """
  def fetch_access_token(appid, authorizer_appid, adapter_storage)
      when is_atom(adapter_storage) and adapter_storage != nil do
    fetch_access_token(appid, authorizer_appid, {adapter_storage, nil})
  end

  def fetch_access_token(appid, authorizer_appid, {adapter_storage, args}) do
    result = adapter_storage.fetch_access_token(appid, authorizer_appid, args)

    case result do
      {:ok, %WeChat.Token{access_token: access_token}} when access_token != nil ->
        result

      {:ok, %WeChat.Token{access_token: nil, refresh_token: refresh_token}}
      when refresh_token != nil ->
        refresh_or_refetch_token_to_refresh(
          appid,
          authorizer_appid,
          refresh_token,
          adapter_storage,
          args
        )

      _ ->
        find_and_refresh_access_token(appid, authorizer_appid, adapter_storage, args)
    end
  end

  defp refresh_or_refetch_token_to_refresh(
         appid,
         authorizer_appid,
         authorizer_refresh_token_str,
         adapter_storage,
         args
       ) do
    refresh_result =
      remote_refresh_authorizer_access_token(
        appid,
        authorizer_appid,
        authorizer_refresh_token_str,
        adapter_storage,
        args
      )

    Logger.info(
      ">>>> remote refresh authorizer_access_token result: #{inspect(refresh_result)} <<<<"
    )

    case refresh_result do
      nil ->
        find_and_refresh_access_token(appid, authorizer_appid, adapter_storage, args)

      {:ok, %WeChat.Token{access_token: _access_token}} ->
        refresh_result
    end
  end

  defp remote_refresh_authorizer_access_token(
         appid,
         authorizer_appid,
         authorizer_refresh_token_str,
         adapter_storage,
         args
       ) do
    data = %{
      "authorizer_appid" => authorizer_appid,
      "authorizer_refresh_token" => authorizer_refresh_token_str
    }

    request_result =
      WeChat.request(:post,
        url: "/cgi-bin/component/api_authorizer_token",
        body: data,
        appid: appid,
        authorizer_appid: authorizer_appid,
        adapter_storage: {adapter_storage, args}
      )

    Logger.info(
      ">>>> remote_refresh_authorizer_access_token request_result: #{inspect(request_result)} <<<<"
    )

    case request_result do
      {:ok, response} ->
        {
          :ok,
          %WeChat.Token{
            access_token: Map.get(response.body, "authorizer_access_token"),
            timestamp: Map.get(response.body, "timestamp"),
            expires_in: Map.get(response.body, "expires_in")
          }
        }

      {:error, error} ->
        # errcode: 61023, invalid refresh_token
        # try to refetch refresh_token, and then use it to refresh authorizer access_token
        Logger.info(
          "remote_refresh_authorizer_access_token occurs error: #{inspect(error)}, will try to refetch refresh_token and use it to refresh authorizer access_token"
        )

        nil
    end
  end

  defp find_and_refresh_access_token(appid, authorizer_appid, adapter_storage, args) do
    behaviours = adapter_storage.module_info[:attributes][:behaviour]

    if WeChat.Storage.ComponentClient in behaviours do
      # For client use case, send request to hub for refresh.
      adapter_storage.refresh_access_token(appid, authorizer_appid, nil, args)
    else
      # For hub use case, the cached component refresh token is expired,
      # rerun refresh token by authorizer list.
      case remote_find_authorizer_refresh_token(appid, authorizer_appid, adapter_storage, args) do
        {:ok, refresh_token_str} ->
          Logger.info(
            ">>> refresh_token: #{inspect(refresh_token_str)} from remote_find_authorizer_refresh_token"
          )

          remote_refresh_authorizer_access_token(
            appid,
            authorizer_appid,
            refresh_token_str,
            adapter_storage,
            args
          )

        error ->
          error
      end
    end
  end

  defp remote_find_authorizer_refresh_token(
         appid,
         authorizer_appid,
         adapter_storage,
         args,
         offset \\ 0,
         count \\ 500
       ) do
    request_result =
      WeChat.request(:post,
        appid: appid,
        url: "/cgi-bin/component/api_get_authorizer_list",
        body: %{"offset" => offset, "count" => count},
        adapter_storage: {adapter_storage, args}
      )

    case request_result do
      {:ok, response} ->
        total_count = Map.get(response.body, "total_count")
        Logger.info("remote get authorizer_list total_count: #{total_count}, offset: #{offset}")
        list = Map.get(response.body, "list")

        matched =
          Enum.find(list, fn item ->
            Map.get(item, "authorizer_appid") == authorizer_appid
          end)

        if matched != nil do
          {:ok, Map.get(matched, "refresh_token")}
        else
          size_in_list = length(list)

          if size_in_list == 0 or size_in_list == total_count do
            Logger.error(
              "not find matched authorizer_appid: #{authorizer_appid} in authorizer_list, please double check."
            )

            {:error, %Error{reason: "invalid_authorizer_appid"}}
          else
            if offset + 1 < total_count do
              remote_find_authorizer_refresh_token(
                appid,
                authorizer_appid,
                adapter_storage,
                args,
                offset + size_in_list,
                count
              )
            else
              Logger.error(
                "not find matched authorizer_appid: #{authorizer_appid} in authorizer_list, please double check."
              )

              {:error, %Error{reason: "invalid_authorizer_appid"}}
            end
          end
        end

      {:error, error} ->
        Logger.error("remote_find_authorizer_refresh_token error: #{inspect(error)}")
        {:error, error}
    end
  end
end