定期的にSlack Botで記事を通知する with Qiita API

（この記事は、[「fukuoka.ex(その2) Elixir Advent Calendar 2017」](https://adventar.org/calendars/2871)の14日目、[Slack Advent Calendar 2017](https://qiita.com/advent-calendar/2017/slack)の12日目です）

昨日は@koga1020さんの[「Phoenix + Vue.js入門」](https://qiita.com/koga1020/items/c02a0fd5ae11fb5da1e0)でした！

# はじめに

|> [ElixirでSlack Botを作った with Qiita API](https://qiita.com/kobatako/items/586f82b2845f7d1583ce)
前回に引き続きSlack Botの作成を行います。前回ではSlack Botの基本的な実装を進めていきましたが、今回はスケジューリング実行をするようにします。

# スケジューリング実行でどうする？

気になってるタグのもので最新のものを定期的に取得、それぞれのタグに合わせて通知先のchannelを分けて通知するような仕組みを作りました。

# 概要

 スケジューリング実行するためにライブラリでQuantumを利用して実行しました。

[Quantum](https://hexdocs.pm/quantum/readme.html)

Quantumはcronみたいに定期実行するためのスケジューリングライブラリです。
あとはタグ情報の保存のためにETSを使いました。

# 実装

## 1. Quantumでスケジューリングの準備

まずは、スケジューリングのための準備をします。

```Elixir
defmodule ExAviso.Scheduler do
  use Quantum.Scheduler, otp_app: :ex_aviso_scheduler
end
```

実装自体はシンプルでスケジューリング用のモジュールを作い`use Quantum.Scheduler` を使うだけです。
次に`config.ex`にスケジューリング用のジョブを追加します。

```Elixir
config :ex_aviso_scheduler, ExAviso.Scheduler,
  jobs: [
    {"* * * * *", {ExAviso.Scheduler.Task, :tag_items, []}}
  ]
```

上記の設定で`ExAviso.Scheduler.Task`モジュールの`tag_items`関数が毎分実行されます。
`"* * * * *"`はcronではおなじみの設定ですね。わかりやすい。

他にも実行のスケジューリング方法として以下のようなものがあります。

```Elixir
  # １秒ごと
  {{:extended, "* * * * *"},{ExAviso.Scheduler.Task, :tag_items, []}}
  # 15分ごと
  {"*/15 * * * *", {ExAviso.Scheduler.Task, :tag_items, []}}
  # 18時、20時、22時、0時、2時、４時、６時で実行
  {"0 18-6/2 * * *", {ExAviso.Scheduler.Task, :tag_items, []}}
  # 毎日実行（"0 0 * * *"）
  {"@daily", {ExAviso.Scheduler.Task, :tag_items, []}}
  # 関数を直接実行
  {"*/15 * * * *", fn -> System.cmd("rm", ["/tmp/tmp_"]) end}
```

さらに設定をもう少し細かくしジョブに名前をつけることもできます。

```Elixir
config :ex_aviso_scheduler, ExAviso.Scheduler,
  jobs: [
    notification_tag_items: [
      schedule: {:cron, "* * * * *"},
      task: {ExAviso.Scheduler.Task, :tag_items, []}
    ]
  ]
```

## 2. タグ情報を保存する

タグ情報を保存します。まずは

* どのタグを
* どのchannelに対して

通知するのかを設定していきます。
っとその前にETSの記述がいろんなところにあるのは少々気持ちが悪いのと、ある程度管理しやすくしたいのでデータストア用のモジュールを作りました。（雑です、特に複雑なことはせずただただETSを薄くラップした程度です）

```Elixir
defmodule ExAviso.DataStorage do
  alias :ets, as: Ets

  @tags :tags

  def start_link() do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def init([]) do
    Ets.new(@tags, [:set, :named_table, :private])
    {:ok, %{@tags => 1}}
  end

  def handle_call({:fetch, table, id}, _from, t) do
    res = Ets.lookup(table, id)
    {:reply, res, t}
  end

  def handle_call({:first, table}, _from, t) do
    id = Ets.first(table)
    res = Ets.lookup(table, id)
    {:reply, res, t}
  end

  def handle_call({:next, table, id}, _from, t) do
    res_id = Ets.next(table, id)
    res = Ets.lookup(table, res_id)
    {:reply, res, t}
  end

  def handle_call({:all, table}, _from, t) do
    res =
      case Ets.first(table) do
        :"$end_of_table" ->
          []

        item_id ->
          all_list(table, item_id, Ets.lookup(table, item_id))
      end

    {:reply, res, t}
  end

  defp all_list(table, id, lists) do
    case Ets.next(table, id) do
      :"$end_of_table" ->
        lists

      item_id ->
        all_list(table, item_id, lists ++ Ets.lookup(table, item_id))
    end
  end

  def handle_call({:insert, table, obj}, _from, t) do
    id = t[@tags]
    res_id = Ets.insert(table, Tuple.insert_at(obj, 0, id))
    res = Ets.lookup(table, res_id)
    {:reply, res, Map.replace!(t, @tags, id + 1)}
  end

  def get!(table, id) do
    GenServer.call(__MODULE__, {:fetch, table, id})
  end

  def first(table) do
    GenServer.call(__MODULE__, {:fetch, table, 1})
  end

  def next(table, id) do
    GenServer.call(__MODULE__, {:next, table, id})
  end

  def all(table) do
    GenServer.call(__MODULE__, {:all, table})
  end

  def insert!(table, obj) when is_tuple(obj) do
    GenServer.call(__MODULE__, {:insert, table, obj})
  end
end
```

ではETSにタグ情報を保存します。ETSにはschemaがないらしいのでアプリケーションで制御する必要があります ~~（めんどい）~~。
今回は`{タグ名、通知先のchannel、表示件数}`で保存します。

```Elixir
alias ExAviso.DataStorage
DataStorage.insert!(:tags, {"ptyhon", "python", 1})
DataStorage.insert!(:tags, {"elixir", "elixir", 5})
DataStorage.insert!(:tags, {"ruby", "ruby", 1})
```

試しに、`python`、`elixir`、`ruby`それぞれのタグをそれぞれのchannelに通知するようにします。

## 3. Channel情報を取得する。

ここから実際の通知機能を実装していきます。

```Elixir:scheduler_task.ex
defmodule ExAviso.Scheduler.Task do
  def tag_items() do
    [token: token] = Application.get_all_env(:qiita)
    headers = [{"Authorization", "Bearer #{token}"}]
    channels = GenServer.call(ExAviso.Slack, {:channels})
    message = fetch_tag_items(Storage.all(:tags), headers, channels)
  end
```

`channels = GenServer.call(ExAviso.Slack, {:channels})`
まずはchannel名からchannel idに変換するためchannelの一覧を取得します。
（`ExAviso.Slack`は`GenServer`で起動させてます）

```Elixir:slack.ex
defmodule ExAviso.Slack do
  def handle_call({:channels}, _from, t) do
    [token: token] = Application.get_all_env(:slack)
    url = "https://slack.com/api/channels.list?token=#{token}"

    %{channels: channles} = body =
      case HTTPoison.get!(url) do
        %{status_code: 200, body: body} ->
          Poison.Parser.parse!(body, keys: :atoms)
      end
    {:reply, channles, t}
  end
```

`https://slack.com/api/channels.list`にリクエストし正常にレスポンスが帰って来ればchannel一覧が取得できます。
取得したchannel一覧を元に通知をするchannel idを取得します。

## 4. 通知を行う

```Elixir
  def fetch_tag_items([{id, tag, channel, per_page} | tail], headers, channels) do
    c = Enum.find(channels, fn c -> c.name == channel end)
    url = "https://qiita.com/api/v2/tags/#{tag}/items?page=1&per_page=#{per_page}"

    body =
      case HTTPoison.get!(url, headers) do
        %{status_code: 200, body: body} ->
          Poison.Parser.parse!(body, keys: :atoms)
      end

    e = for %{title: title, url: url} <- body, do: "#{title}[#{url}]"

    m = %Request{
      type: "message",
      channel: c.id,
      text: Enum.join([" ---- tag is [#{tag}] --- - "] ++ e, "\n"),
      ts: DateTime.utc_now() |> DateTime.to_unix()
    }

    GenServer.cast(ExAviso.Slack, {:notification, m})
    fetch_tag_items(tail, headers, channels)
  end
```

`Enum.find(channels, fn c -> c.name == channel end)`channel一覧からchannel名を元に特定のchannel情報を取得します。
`https://qiita.com/api/v2/tags/:tag_id/items`でQiitaからタグの割り当てた最新の記事を取得します。（per_pageオプションで何件取得するか決めてます）
`e = for %{title: title, url: url} <- body, do: "#{title}[#{url}]"`取得した一覧からタイトルとURLのみを抽出し、送信するためのメッセージを作成します。
`GenServer.cast(ExAviso.Slack, {:notification, m})`最後にslack通知をするためのモジュールに受けたわし通知をします。

## 5. 実行結果

<img width="693" alt="python_slack_bot.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/59ad4c82-53f4-be6d-739b-32f2fff8b239.png">


<img width="683" alt="elixir_slack_bot.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/18d3173e-c49f-3ff0-2425-935bd5992135.png">


<img width="699" alt="ruby_slack_bot.png" src="https://qiita-image-store.s3.amazonaws.com/0/215989/e44c9e00-8886-9ae1-1666-8d17687a78c0.png">


それぞれのchannelに送ることができました！！

# 最後に

* Quantumがcron likeなので楽にスケジューリング実行が実装できました。
* 秒単位での実行をcronではちょこっとした小技を使わないといけないですが、Quantumだと楽に実装できました。
* データの保存をMnesiaにしようとしたのですが、スキーマファイルを用意したりと、めんどかったのでETSにしました。ただやっぱりスキーマがないのでMnesiaにしとけばよかったと少し後悔してます（作り直そうかな、、）。
* 今はバリデーションも何もしてないので今後実装していきたいです。

明日は@piacere_exさんの[「Elixirでデータ分析入門#2：インプットしたデータを変換する」](https://qiita.com/piacere_ex/items/64c2127857599137e8a7)になります！


:stars::stars::stars::stars::stars:　満員御礼！Elixir MeetUpを6月末に開催します　:stars::stars::stars::stars::stars:
<font color="red" bold>**※応募多数により、増枠しました!!**</font>

[「fukuoka.ex#11：DB／データサイエンスにコネクトするElixir」](https://fukuokaex.connpass.com/event/87241/)を6/22（金）19時に開催します。
自分もLTで発表させていただきますので、ぜひ興味ある方はご参加ください！

![fukuokaex.png](https://qiita-image-store.s3.amazonaws.com/0/215989/16ba72c0-05ac-ac73-79bd-efc39216f436.png)