lib/wechat/token_checker.ex

defmodule WeChat.TokenChecker do
  @moduledoc """
  Token 检查器

  定期(默认为每 5 分钟检查一次)检查 access_token,如果发现失效,则自动刷新

  按以下方式配置,将自动给对应的 client 增加检查

  **注意:此模块不支持企业微信**

      config :wechat, :check_token_for_clients, [ClientA, ClientB, ClientC]

  """

  use GenServer
  require Logger

  @type check_fun :: (-> WeChat.response())
  @type refresh_fun :: (-> any)
  @typep id :: WeChat.Storage.Adapter.store_id()
  @typep refresh_options :: WeChat.Refresher.DefaultSettings.refresh_options()

  @default_settings %{
    # 5 minutes
    check_interval: 5 * 60,
    checks: %{}
  }

  @spec maybe_add_client(WeChat.client(), refresh_options) :: :ok
  def maybe_add_client(client, refresh_options) do
    if client in Application.get_env(:wechat, :check_token_for_clients, []) do
      add_client(client, refresh_options)
    end
  end

  @spec add_to_check_clients(WeChat.client()) :: :ok
  def add_to_check_clients(client) do
    clients = Application.get_env(:wechat, :check_token_for_clients, [])
    clients = WeChat.Utils.uniq_and_sort([client | clients])
    Application.put_env(:wechat, :check_token_for_clients, clients)
  end

  @spec remove_from_check_clients(WeChat.client()) :: :ok
  def remove_from_check_clients(client) do
    clients = Application.get_env(:wechat, :check_token_for_clients, []) |> List.delete(client)
    Application.put_env(:wechat, :check_token_for_clients, clients)
  end

  @spec add_client(WeChat.client()) :: :ok | nil
  def add_client(client) do
    refresher = WeChat.refresher()

    with %{refresh_options: refresh_options} <- refresher.client_options(client) do
      add_client(client, refresh_options)
    end
  end

  @spec add_client(WeChat.client(), refresh_options) :: :ok
  def add_client(client, refresh_options) do
    refresher = WeChat.refresher()

    Enum.each(refresh_options, fn {{id, key}, _fun, _ref} ->
      case key do
        :access_token ->
          check_fun = fn ->
            WeChat.Account.get_quota(client, "/cgi-bin/openapi/quota/get")
          end

          refresh_fun = fn -> refresher.refresh(client, id, key) end
          add(id, check_fun, refresh_fun)

        :component_access_token ->
          check_fun = fn ->
            WeChat.Component.get_quota(client, "/cgi-bin/openapi/quota/get")
          end

          refresh_fun = fn -> refresher.refresh_component(id, key) end
          add(id, check_fun, refresh_fun)

        _ ->
          :ignore
      end
    end)
  end

  @spec add(id, check_fun, refresh_fun) :: :ok
  def add(id, check_fun, refresh_fun) do
    GenServer.call(__MODULE__, {:add, id, check_fun, refresh_fun})
  end

  @spec remove_client(WeChat.client()) :: :ok | nil
  def remove_client(client) do
    refresher = WeChat.refresher()

    with %{refresh_options: refresh_options} <- refresher.client_options(client) do
      for {{id, key}, _fun, _ref} <- refresh_options,
          key == :component_access_token or key == :access_token do
        remove(id)
      end
    end
  end

  @spec remove(id) :: :ok
  def remove(id) do
    GenServer.call(__MODULE__, {:remove, id})
  end

  @spec ids() :: [id]
  def ids do
    GenServer.call(__MODULE__, :ids)
  end

  @spec start_link(settings :: map) :: GenServer.on_start()
  def start_link(settings \\ %{}) do
    GenServer.start_link(__MODULE__, settings, name: __MODULE__)
  end

  @impl true
  def init(settings) do
    state = Map.merge(@default_settings, settings || %{})
    :timer.send_interval(state.check_interval * 1000, :check)
    {:ok, state}
  end

  @impl true
  def handle_info(:check, state) do
    for {id, opts} <- state.checks do
      check_token(id, opts)
    end

    {:noreply, state}
  end

  @impl true
  def handle_call({:add, id, check_fun, refresh_fun}, _from, state) do
    checks = Map.put(state.checks, id, %{check_fun: check_fun, refresh_fun: refresh_fun})
    {:reply, :ok, %{state | checks: checks}}
  end

  def handle_call({:remove, id}, _from, state) do
    checks = Map.delete(state.checks, id)
    {:reply, :ok, %{state | checks: checks}}
  end

  def handle_call(:ids, _from, state) do
    {:reply, Map.keys(state.checks), state}
  end

  defp check_token(id, %{check_fun: check_fun, refresh_fun: refresh_fun}) do
    with {:ok, %{status: 200, body: %{"errcode" => code}}} <- check_fun.(),
         true <- code in [40001, 42001] do
      Logger.info("TokenChecker found the token of #{inspect(id)} already expired, go refresh.")
      refresh_fun.()
    end
  rescue
    reason ->
      error_msg = Exception.format(:error, reason, __STACKTRACE__)
      Logger.error(error_msg)
      {:error, error_msg}
  catch
    error, reason ->
      error_msg = Exception.format(error, reason, __STACKTRACE__)
      Logger.error(error_msg)
      {:error, error_msg}
  end
end