From 7915b6d4edeeb9f3aca0ad93d3b8daf7b2e9c530 Mon Sep 17 00:00:00 2001 From: Adam Millerchip Date: Mon, 2 Sep 2019 20:46:29 +0900 Subject: [PATCH] Initial checkin. I originally developed this as an employee under the assumption that this would be released as an official SDK maintained by the company. But this is not the case, so I'm resetting the history to develop this in a personal capacity. This commit represents the progress until now. --- .formatter.exs | 32 ++ .gitignore | 30 ++ README.md | 97 ++++++ config/config.exs | 18 + lib/line_bot.ex | 390 ++++++++++++++++++++++ lib/line_bot/api_client.ex | 124 +++++++ lib/line_bot/application.ex | 24 ++ lib/line_bot/body_reader_plug.ex | 28 ++ lib/line_bot/dispatcher.ex | 73 ++++ lib/line_bot/event_info.ex | 28 ++ lib/line_bot/message.ex | 125 +++++++ lib/line_bot/message/action.ex | 118 +++++++ lib/line_bot/message/flex.ex | 255 ++++++++++++++ lib/line_bot/message/image_map.ex | 68 ++++ lib/line_bot/message/quick_reply.ex | 27 ++ lib/line_bot/message/template.ex | 124 +++++++ lib/line_bot/request_logger_plug.ex | 13 + lib/line_bot/token_server.ex | 133 ++++++++ lib/line_bot/validator_plug.ex | 57 ++++ lib/line_bot/webhook.ex | 102 ++++++ mix.exs | 106 ++++++ mix.lock | 22 ++ sample/.formatter.exs | 29 ++ sample/README.md | 13 + sample/config/config.exs | 10 + sample/lib/line_bot_sample.ex | 295 ++++++++++++++++ sample/lib/line_bot_sample/application.ex | 19 ++ sample/lib/line_bot_sample/flex.ex | 163 +++++++++ sample/lib/line_bot_sample/image_map.ex | 59 ++++ sample/lib/line_bot_sample/router.ex | 34 ++ sample/mix.exs | 29 ++ sample/mix.lock | 19 ++ sample/test/line_bot_sample_test.exs | 4 + sample/test/test_helper.exs | 1 + test/line_bot/api_client_test.exs | 64 ++++ test/line_bot/validator_plug_test.exs | 59 ++++ test/line_bot_test.exs | 82 +++++ test/test_helper.exs | 241 +++++++++++++ 38 files changed, 3115 insertions(+) create mode 100644 .formatter.exs create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/config.exs create mode 100644 lib/line_bot.ex create mode 100644 lib/line_bot/api_client.ex create mode 100644 lib/line_bot/application.ex create mode 100644 lib/line_bot/body_reader_plug.ex create mode 100644 lib/line_bot/dispatcher.ex create mode 100644 lib/line_bot/event_info.ex create mode 100644 lib/line_bot/message.ex create mode 100644 lib/line_bot/message/action.ex create mode 100644 lib/line_bot/message/flex.ex create mode 100644 lib/line_bot/message/image_map.ex create mode 100644 lib/line_bot/message/quick_reply.ex create mode 100644 lib/line_bot/message/template.ex create mode 100644 lib/line_bot/request_logger_plug.ex create mode 100644 lib/line_bot/token_server.ex create mode 100644 lib/line_bot/validator_plug.ex create mode 100644 lib/line_bot/webhook.ex create mode 100644 mix.exs create mode 100644 mix.lock create mode 100644 sample/.formatter.exs create mode 100644 sample/README.md create mode 100644 sample/config/config.exs create mode 100644 sample/lib/line_bot_sample.ex create mode 100644 sample/lib/line_bot_sample/application.ex create mode 100644 sample/lib/line_bot_sample/flex.ex create mode 100644 sample/lib/line_bot_sample/image_map.ex create mode 100644 sample/lib/line_bot_sample/router.ex create mode 100644 sample/mix.exs create mode 100644 sample/mix.lock create mode 100644 sample/test/line_bot_sample_test.exs create mode 100644 sample/test/test_helper.exs create mode 100644 test/line_bot/api_client_test.exs create mode 100644 test/line_bot/validator_plug_test.exs create mode 100644 test/line_bot_test.exs create mode 100644 test/test_helper.exs diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..35657c6 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,32 @@ +# Used by "mix format" and to export configuration. +export_locals_without_parens = [ + plug: 1, + plug: 2, + forward: 2, + forward: 3, + forward: 4, + inspect: 1, + match: 2, + match: 3, + get: 2, + get: 3, + post: 2, + post: 3, + put: 2, + put: 3, + patch: 2, + patch: 3, + delete: 2, + delete: 3, + options: 2, + options: 3, + test_get_for: 4, + test_post_msg: 4, + test_post_uri: 3 +] + +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + locals_without_parens: export_locals_without_parens, + export: [locals_without_parens: export_locals_without_parens] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ac9fe1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# The directory Mix will write compiled artifacts to. +/_build/ +/sample/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ +/sample/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ +/sample/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ +/sample/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +line_bot-*.tar + +*.swp +.tool-versions diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb42b75 --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# Line Bot + +This package provides the basic framework required to implement and run a Line Bot. + +There are two main modules in this package: + +1. `LineBot` provides helpers to call the various APIs, for example `LineBot.send_reply/3` to reply to an event. This module also defines callbacks for you to implement, which are called when your bot receives events from the Line server. +2. `LineBot.Webhook` provides a `Plug` for handling HTTP requests from the line server, and forwarding them to your callback module, which should implement the `LineBot` behaviour. + +## Installation + +Add `:line_bot` to your mix deps: + +```elixir +defp deps do + [ + {:line_bot, path: "../"} + ] +end +``` + +## Features + +1. Defines callbacks (see `LineBot`) to handle all of the possible [events](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) a bot can receive. +1. Provides a plug (`LineBot.Webhook`) to automatically verify, decode, and dispatch webhook requests a bot received. +1. Provides API helpers (see `LineBot`) for all of the documented Messaging API endpoints. + * Automatically retrieves, maintains, renews, and injects the access token into API requests a bot makes. + * When necessary, automatically handles encoding and decoding of JSON and adding the required HTTP headers. +1. Defines structs for all of the available message types, to allow for compile-time checking. See `LineBot.Message`. + + +## Getting Started + +### 1. Configure OAuth credentials for your bot. + +Credentials are available from the [Line Developers](https://developers.line.biz/) site. The credentials are called `Channel ID` and `Channel secret` on the developers site. + +In `config/config.exs`: + +```elixir +import Config +config :line_bot, + client_id: YOUR_CHANNEL_ID + client_secret: YOUR_CHANNEL_SECRET +``` + +### 2. Create a module that implements the `LineBot` behaviour to handle callbacks. + +The recommended way to do this is to `use LineBot`, which will create default callbacks handlers, and then override the events you want to handle. The default implementations return without doing anything. + +An example is available in the [sample application](../sample/lib/line_bot_sample.ex). + +### 3. Forward webhook requests to `LineBot.Webhook`, and tell it your callback module. + +Using `Plug.Router`, this can be done as follows: + +```elixir +forward "/bot", to: LineBot.Webhook, callback: YourCallbackModule +``` + +The forwarded URL should be whatever you specified as the callback URI on the Line Developers site. + +For detailed instructions, see `LineBot.Webhook`. You can also check the [sample application](../sample/lib/line_bot_sample/router.ex). + +## Not supported + +The [Rich Menu API](https://developers.line.biz/en/reference/messaging-api/#rich-menu) is not currently implemented, although `LineBot.APIClient` can be used to call the API manually. + +For example, you can post to the rich menu API like this: + +```elixir +menu = %{ + "areas" => [ + %{ + "action" => %LineBot.Message.Action.URI{uri: "http://example.com"}, + "bounds" => %{"height" => 1686, "width" => 2500, "x" => 0, "y" => 0} + } + ], + "chatBarText" => "test", + "name" => "test", + "selected" => false, + "size" => %{"height" => 1686, "width" => 2500} +} +LineBot.APIClient.post("richmenu", menu) +``` + +And get like this: + +```elixir +LineBot.APIClient.get("richmenu/list") +``` + +## TODO + +* [ ] Tests +* [ ] Static resources +* [ ] Update post-publish sample links. diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..2a9d398 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,18 @@ +import Config + +case Mix.env() do + :dev -> + config :line_bot, + client_id: 123, + client_secret: "secret" + + :test -> + config :line_bot, + client_id: 123, + client_secret: "secret", + api_client: MockAPIClient, + token_server: MockTokenServer + + _ -> + [] +end diff --git a/lib/line_bot.ex b/lib/line_bot.ex new file mode 100644 index 0000000..02e5b96 --- /dev/null +++ b/lib/line_bot.ex @@ -0,0 +1,390 @@ +defmodule LineBot do + alias LineBot.EventInfo + + @api_client Application.get_env(:line_bot, :api_client, LineBot.APIClient) + + @moduledoc """ + A module for sending and receiving messages with the Line Messaging API. + + This module: + * Provides functions for calling the various Line Messaging API endpoints. + * Specifies a behaviour that defines event handlers which are called to handle + [webhook event objects](https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects) + from the Messaging API. + + ## Callbacks + If an event contains event-specific data, it is passed as the first argument to the event handler. + Each callback is provided with a `t:LineBot.EventInfo.t/0` struct, which contains common metadata for + each request. If available, a `reply_token` for use with the `send_reply/3` + is included as the final argument. + + Unknown events are passed to `c:handle_other/4`. + + All of the callbacks are optional. If a callback is not implemented, the event will be ignored. + """ + + @doc """ + Called when a [Message event](https://developers.line.biz/en/reference/messaging-api/#message-event) + is received. + """ + @callback handle_message(message :: map, info :: EventInfo.t(), reply_token :: binary) :: any + + @doc """ + Called when a [Follow event](https://developers.line.biz/en/reference/messaging-api/#follow-event) + is received. + """ + @callback handle_follow(info :: EventInfo.t(), reply_token :: binary) :: any + + @doc """ + Called when an [Unfollow event](https://developers.line.biz/en/reference/messaging-api/#unfollow-event) + is received. + """ + @callback handle_unfollow(info :: EventInfo.t()) :: any + + @doc """ + Called when a [Join event](https://developers.line.biz/en/reference/messaging-api/#join-event) + is received. + """ + @callback handle_join(info :: EventInfo.t(), reply_token :: binary) :: any + + @doc """ + Called when a [Leave event](https://developers.line.biz/en/reference/messaging-api/#leave-event) + is received. + """ + @callback handle_leave(info :: EventInfo.t()) :: any + + @doc """ + Called when a [Member Joined event](https://developers.line.biz/en/reference/messaging-api/#member-joined-event) + is received. + """ + @callback handle_member_joined( + members :: list, + info :: EventInfo.t(), + reply_token :: binary + ) :: any + + @doc """ + Called when a [Member Left event](https://developers.line.biz/en/reference/messaging-api/#member-left-event) + is received. + """ + @callback handle_member_left(members :: list, info :: EventInfo.t()) :: any + + @doc """ + Called when a [Postback event](https://developers.line.biz/en/reference/messaging-api/#postback-event) + is received. + """ + @callback handle_postback( + data :: any, + info :: EventInfo.t(), + reply_token :: binary + ) :: any + + @doc """ + Called when a [Beacon event](https://developers.line.biz/en/reference/messaging-api/#beacon-event) + is received. + """ + @callback handle_beacon( + data :: map, + info :: EventInfo.t(), + reply_token :: binary + ) :: any + + @doc """ + Called when an [Account Link event](https://developers.line.biz/en/reference/messaging-api/#account-link-event) + is received. + """ + @callback handle_account_link( + data :: map, + info :: EventInfo.t(), + reply_token :: binary + ) :: any + + @doc """ + Called when a [LINE Things Scenario Execution event](https://developers.line.biz/en/reference/messaging-api/#scenario result) + is received. + """ + @callback handle_things( + data :: map, + info :: EventInfo.t(), + reply_token :: binary + ) :: any + + @doc """ + Called when an unknown even it received. The `type` contains the unknown event type, and the full event is passed as the `event` argument. If a reply token was not present in the event, `reply_token` will be `nil`. + """ + @callback handle_other( + type :: binary, + event :: map, + info :: EventInfo.t(), + reply_token :: binary | nil + ) :: any + + @optional_callbacks handle_message: 3, + handle_follow: 2, + handle_unfollow: 1, + handle_join: 2, + handle_leave: 1, + handle_member_joined: 3, + handle_member_left: 2, + handle_postback: 3, + handle_beacon: 3, + handle_account_link: 3, + handle_things: 3, + handle_other: 4 + + defmacro __using__(_opts) do + quote do + @behaviour LineBot + + def handle_message(message, info, reply_token), do: :ok + def handle_follow(info, reply_token), do: :ok + def handle_unfollow(info), do: :ok + def handle_join(info, reply_token), do: :ok + def handle_leave(info), do: :ok + def handle_member_joined(members, info, reply_token), do: :ok + def handle_member_left(members, info), do: :ok + def handle_postback(data, info, reply_token), do: :ok + def handle_beacon(data, info, reply_token), do: :ok + def handle_account_link(data, info, reply_token), do: :ok + def handle_things(data, info, reply_token), do: :ok + def handle_other(type, event, info, reply_token), do: :ok + + defoverridable LineBot + end + end + + @type api_response :: + {:ok, map()} + | {:unauthorized, map()} + | {:forbidden, map()} + | {:not_found, map()} + | {:too_many_requests, map()} + | {:server_error, map()} + | {:error, HTTPoison.Error.t()} + + defp try_get(uri, params \\ []) do + case @api_client.get(uri, [], params: params) do + {:ok, %{status_code: 200, body: body}} -> {:ok, body} + {:ok, %{status_code: 401, body: body}} -> {:unauthorized, body} + {:ok, %{status_code: 403, body: body}} -> {:forbidden, body} + {:ok, %{status_code: 404, body: body}} -> {:not_found, body} + {:ok, %{status_code: 429, body: body}} -> {:too_many_requests, body} + {:ok, %{status_code: 500, body: body}} -> {:server_error, body} + {:error, %HTTPoison.Error{} = error} -> {:error, error} + end + end + + defp try_post(uri, data \\ %{}) do + case @api_client.post(uri, data) do + {:ok, %{status_code: 200, body: body}} -> {:ok, body} + {:ok, %{status_code: 401, body: body}} -> {:unauthorized, body} + {:ok, %{status_code: 403, body: body}} -> {:forbidden, body} + {:ok, %{status_code: 404, body: body}} -> {:not_found, body} + {:ok, %{status_code: 429, body: body}} -> {:too_many_requests, body} + {:ok, %{status_code: 500, body: body}} -> {:server_error, body} + {:error, %HTTPoison.Error{} = error} -> {:error, error} + end + end + + @doc """ + Sends one or more [reply messages](https://developers.line.biz/en/reference/messaging-api/#send-reply-message). + """ + @spec send_reply( + reply_token :: String.t(), + messages :: [LineBot.Message.t()] | LineBot.Message.t(), + notification_disabled :: boolean() + ) :: api_response() + def send_reply(reply_token, messages, notification_disabled \\ false) + + def send_reply(reply_token, messages, notification_disabled) when is_list(messages) do + try_post("message/reply", %{ + "replyToken" => reply_token, + "messages" => messages, + "notificationDisabled" => notification_disabled + }) + end + + def send_reply(to, message, notification_disabled), + do: send_reply(to, [message], notification_disabled) + + @doc """ + Sends one or more [push messages](https://developers.line.biz/en/reference/messaging-api/#send-push-message). + """ + @spec send_push( + to :: String.t(), + messages :: [LineBot.Message.t()] | LineBot.Message.t(), + notification_disabled :: boolean() + ) :: api_response() + def send_push(to, messages, notification_disabled \\ false) + + def send_push(to, messages, notification_disabled) when is_list(messages) do + try_post("message/push", %{ + "to" => to, + "messages" => messages, + "notificationDisabled" => notification_disabled + }) + end + + def send_push(to, message, notification_disabled), + do: send_push(to, [message], notification_disabled) + + @doc """ + Sends one or more [multicast messages](https://developers.line.biz/en/reference/messaging-api/#send-multicast-message). + + """ + @spec send_multicast( + to :: [String.t()], + messages :: [LineBot.Message.t()] | LineBot.Message.t(), + notification_disabled :: boolean() + ) :: api_response() + def send_multicast(to, messages, notification_disabled \\ false) + + def send_multicast(to, messages, notification_disabled) + when is_list(to) and is_list(messages) do + try_post("message/multicast", %{ + "to" => to, + "messages" => messages, + "notificationDisabled" => notification_disabled + }) + end + + def send_multicast(to, message, notification_disabled) when is_list(to) do + send_multicast(to, [message], notification_disabled) + end + + @doc """ + Sends one or more [broadcast messages](https://developers.line.biz/en/reference/messaging-api/#send-broadcast-message). + """ + @spec send_broadcast( + messages :: [LineBot.Message.t()] | LineBot.Message.t(), + notification_disabled :: boolean() + ) :: api_response() + def send_broadcast(messages, notification_disabled \\ false) + + def send_broadcast(messages, notification_disabled) when is_list(messages) do + try_post("message/broadcast", %{ + "messages" => messages, + "notificationDisabled" => notification_disabled + }) + end + + def send_broadcast(message, notification_disabled) do + send_broadcast([message], notification_disabled) + end + + @doc """ + Calls the [Get content](https://developers.line.biz/en/reference/messaging-api/#get-content) API. + """ + @spec get_content(message_id :: binary()) :: HTTPoison.Response.t() + def get_content(message_id), do: @api_client.get!("message/#{message_id}/content") + + @doc """ + Calls the [Get number of message deliveries](https://developers.line.biz/en/reference/messaging-api/#get-number-of-delivery-messages) API. + """ + @spec get_quota(date :: String.t()) :: api_response() + def get_quota(date), do: try_get("message/quota", date: date) + + @doc """ + Calls the [Get number of messages sent this month](https://developers.line.biz/en/reference/messaging-api/#get-consumption) API. + """ + @spec get_quota_consumption() :: api_response() + def get_quota_consumption(), do: try_get("message/quota/consumption") + + @doc """ + Calls the [Get number of sent reply messages](https://developers.line.biz/en/reference/messaging-api/#get-number-of-reply-messages) API. + """ + @spec get_sent_reply_count(date :: String.t()) :: api_response() + def get_sent_reply_count(date), do: try_get("message/delivery/reply", date: date) + + @doc """ + Calls the [Get number of sent push messages](https://developers.line.biz/en/reference/messaging-api/#get-number-of-push-messages) API. + """ + @spec get_sent_push_count(date :: String.t()) :: api_response() + def get_sent_push_count(date), do: try_get("message/delivery/push", date: date) + + @doc """ + Calls the [Get number of sent multicast messages](https://developers.line.biz/en/reference/messaging-api/#get-number-of-multicast-messages) API. + """ + @spec get_sent_multicast_count(date :: String.t()) :: api_response() + def get_sent_multicast_count(date), do: try_get("message/delivery/multicast", date: date) + + @doc """ + Calls the [Get number of sent broadcast messages](https://developers.line.biz/en/reference/messaging-api/#get-number-of-broadcast-messages) API. + """ + @spec get_sent_broadcast_count(date :: String.t()) :: api_response() + def get_sent_broadcast_count(date), do: try_get("message/delivery/broadcast", date: date) + + @doc """ + Calls the [Get number of message deliveries](https://developers.line.biz/en/reference/messaging-api/#get-number-of-delivery-messages) API. + """ + @spec get_sent_message_count(date :: String.t()) :: api_response() + def get_sent_message_count(date), do: try_get("insight/message/delivery", date: date) + + @doc """ + Calls the [Get number of followers](https://developers.line.biz/en/reference/messaging-api/#get-number-of-followers) API. + """ + @spec get_follower_count(date :: String.t()) :: api_response() + def get_follower_count(date), do: try_get("insight/followers", date: date) + + @doc """ + Calls the [Get friend demographics](https://developers.line.biz/en/reference/messaging-api/#get-demographic) API. + """ + @spec get_follower_demographics() :: api_response() + def get_follower_demographics(), do: try_get("insight/demographic") + + @doc """ + Calls the [Get profile](https://developers.line.biz/en/reference/messaging-api/#get-profile) API. + """ + @spec get_profile(user_id :: String.t()) :: api_response() + def get_profile(user_id), do: try_get("profile/#{user_id}") + + @doc """ + Calls the [Get group member user IDs](https://developers.line.biz/en/reference/messaging-api/#get-group-member-user-ids) API. + """ + @spec get_group_member_ids(group_id :: String.t(), start :: binary() | nil) :: api_response() + def get_group_member_ids(group_id, start \\ nil) do + start = if start, do: [start: start], else: [] + try_get("group/#{group_id}/members/ids", start) + end + + @doc """ + Calls the [Get group member profile](https://developers.line.biz/en/reference/messaging-api/#get-group-member-profile) API. + """ + @spec get_group_member_profile(group_id :: String.t(), user_id :: String.t()) :: api_response() + def get_group_member_profile(group_id, user_id) do + try_get("group/#{group_id}/member/#{user_id}") + end + + @doc """ + Calls the [Leave group](https://developers.line.biz/en/reference/messaging-api/#leave-group) API. + """ + @spec leave_group(group_id :: String.t()) :: api_response() + def leave_group(group_id), do: try_post("group/#{group_id}/leave") + + @doc """ + Calls the [Get room member user IDs](https://developers.line.biz/en/reference/messaging-api/#get-room-member-user-ids) API. + """ + @spec get_room_member_ids(room_id :: String.t(), start :: binary() | nil) :: api_response() + def get_room_member_ids(room_id, start \\ nil) do + start = if start, do: [start: start], else: [] + try_get("room/#{room_id}/members/ids", start) + end + + @doc """ + Calls the [Get room member profile](https://developers.line.biz/en/reference/messaging-api/#get-room-member-profile) API. + """ + @spec get_room_member_profile(room_id :: String.t(), user_id :: String.t()) :: api_response() + def get_room_member_profile(room_id, user_id), do: try_get("room/#{room_id}/member/#{user_id}") + + @doc """ + Calls the [Leave room](https://developers.line.biz/en/reference/messaging-api/#leave-room) API. + """ + @spec leave_room(room_id :: String.t()) :: api_response() + def leave_room(room_id), do: try_post("room/#{room_id}/leave") + + @doc """ + Calls the [Issue link token](https://developers.line.biz/en/reference/messaging-api/#issue-link-token) API. + """ + @spec issue_link_token(user_id :: String.t()) :: api_response() + def issue_link_token(user_id), do: try_post("user/#{user_id}/linkToken") +end diff --git a/lib/line_bot/api_client.ex b/lib/line_bot/api_client.ex new file mode 100644 index 0000000..a559147 --- /dev/null +++ b/lib/line_bot/api_client.ex @@ -0,0 +1,124 @@ +defmodule LineBot.APIClient do + use GenServer + use HTTPoison.Base + require Logger + + @token_server Application.get_env(:line_bot, :token_server, LineBot.TokenServer) + + @moduledoc """ + An implementation of `HTTPoison.Base` for making API calls to the Messaging API. + + ## API Client + + This module is for creating and sending HTTP requests manually. To use the documented messaging + API endpoints, use the `LineBot` module, which calls the functions in this module. + + ## Implementation + + This module extends `HTTPoison.Base` with the following behaviour: + + * The `Authorization` header containing the bearer token is automatically added. The access + token is retreived from `LineBot.TokenServer`. + * URLs are automatically prefixed with `https://api.line.me/v2/bot/`. When making a request, + only the path after this prefix is required. + * JSON responses are automatically decoded. + * POST requests are automatically encoded into JSON, and the appropriate `Content-Type` header + is added. + * 401 Unauthorized errors are caught. When an unauthorized request is received, an attempt + to retrieve a new access token is made by calling `LineBot.TokenServer.purge/0`. However, + after three successive unauthorized errors, further unauthorized responses are returned + directly. + """ + + # GenServer + ############ + + @impl GenServer + def init(_), do: {:ok, 0} + + @impl GenServer + def handle_call(:get_and_inc_auth_fail, _from, count), do: {:reply, count, count + 1} + + @impl GenServer + def handle_call(:reset, _from, _count), do: {:reply, :ok, 0} + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + # HTTPoison + ############ + + @impl HTTPoison.Base + @doc "Prepends the request url with `https://api.line.me/v2/bot/`." + def process_request_url(url), do: super("https://api.line.me/v2/bot/" <> url) + + @impl HTTPoison.Base + @doc """ + Adds the OAuth Bearer token to the `Authorization` header. + The token is retrieved by calling `LineBot.TokenServer.get_token/0`. + """ + def process_request_headers(headers) do + [{"Authorization", "Bearer #{@token_server.get_token()}"} | super(headers)] + end + + @impl HTTPoison.Base + @doc """ + If the reponse headers indidate that the response is JSON, the response body is + automatically decoded. + """ + def process_response(%{headers: headers, body: body} = response) do + # TODO make this configurable + Logger.debug("API Response: " <> inspect(response)) + + case Enum.find(headers, &(String.downcase(elem(&1, 0)) == "content-type")) do + {_, "application/json" <> _} -> put_in(response.body, Jason.decode!(body)) + _ -> super(response) + end + end + + @impl HTTPoison.Base + @doc """ + Issues a POST request to the given url. + The body is automatically encoded into JSON, and the `Content-Type` header is added. + """ + def post(url, body, headers \\ []) do + super(url, Jason.encode!(body), [{"Content-Type", "application/json"} | headers]) + end + + @impl HTTPoison.Base + @doc """ + Issues a POST request to the given url. + The body is automatically encoded into JSON, and the `Content-Type` header is added. + """ + def post!(url, body, headers \\ []) do + super(url, Jason.encode!(body), [{"Content-Type", "application/json"} | headers]) + end + + @impl HTTPoison.Base + @doc """ + In addition to the `HTTPoison.request/5` behaviour, will automatically refresh the access + token after an unauthorized request. Gives up after three subsequent unauthorized errors. + """ + def request(method, url, body, headers, opts \\ []) do + case super(method, url, body, headers, opts) do + {:ok, %{status_code: 200} = response} -> + {:via, Registry, {LineBot.Registry, "client"}} + GenServer.call(__MODULE__, :reset) + {:ok, response} + + {:ok, %{status_code: 401} = response} -> + if GenServer.call(__MODULE__, :get_and_inc_auth_fail) < 3 do + Logger.warn("Unauthorized. Purge token and try again.") + @token_server.purge() + request(method, url, body, headers, opts) + else + Logger.warn("Unauthorized after multiple attempts with different tokens. Giving up.") + {:ok, response} + end + + other -> + other + end + end +end diff --git a/lib/line_bot/application.ex b/lib/line_bot/application.ex new file mode 100644 index 0000000..782da3f --- /dev/null +++ b/lib/line_bot/application.ex @@ -0,0 +1,24 @@ +defmodule LineBot.Application do + @moduledoc false + + use Application + + def start(_type, _args) do + with {:ok, client_id} when not is_nil(client_id) and client_id != "" <- + Application.fetch_env(:line_bot, :client_id), + {:ok, client_secret} when not is_nil(client_secret) and client_secret != "" <- + Application.fetch_env(:line_bot, :client_secret) do + children = [ + LineBot.TokenServer, + LineBot.APIClient, + {Task.Supervisor, name: LineBot.TaskSupervisor} + ] + + opts = [strategy: :one_for_one, name: LineBot.Supervisor] + Supervisor.start_link(children, opts) + else + error when error in [:error, {:ok, nil}, {:ok, ""}] -> + {:error, "Not configured. Set config :line_bot, client_id: ..., client_secret: ..."} + end + end +end diff --git a/lib/line_bot/body_reader_plug.ex b/lib/line_bot/body_reader_plug.ex new file mode 100644 index 0000000..40d16ce --- /dev/null +++ b/lib/line_bot/body_reader_plug.ex @@ -0,0 +1,28 @@ +defmodule LineBot.BodyReaderPlug do + import Plug.Conn + + @behaviour Plug + @moduledoc false + + @impl true + def init(_opts), do: nil + + @impl true + def call(conn, _opts) do + case Plug.Conn.read_body(conn) do + {:ok, body, conn} -> + put_private(conn, :line_bot_raw_body, body) + + {:more, _body, _conn} -> + raise Plug.BadRequestError + + {:error, :timeout} -> + raise Plug.TimeoutError + + {:error, _} -> + raise Plug.BadRequestError + end + end + + def read_cached_body(conn, _opts), do: {:ok, conn.private.line_bot_raw_body, conn} +end diff --git a/lib/line_bot/dispatcher.ex b/lib/line_bot/dispatcher.ex new file mode 100644 index 0000000..7140d4d --- /dev/null +++ b/lib/line_bot/dispatcher.ex @@ -0,0 +1,73 @@ +defmodule LineBot.Dispatcher do + @moduledoc false + + def dispatch_events([], _destination, _callback), do: :ok + + def dispatch_events([event | events], destination, callback) do + dispatch_event(event, destination, callback) + dispatch_events(events, destination, callback) + end + + defp dispatch_event(event, destination, callback) do + info = %LineBot.EventInfo{ + user_id: event["source"]["userId"], + source: event["source"], + destination: destination, + timestamp: DateTime.from_unix!(event["timestamp"], :millisecond) + } + + async(callback, handler(event["type"], event, info)) + end + + defp async(callback, {function, args}) do + Task.Supervisor.start_child(LineBot.TaskSupervisor, fn -> apply(callback, function, args) end) + end + + defp handler("message", event, info) do + {:handle_message, [event["message"], info, event["replyToken"]]} + end + + defp handler("follow", event, info) do + {:handle_follow, [info, event["replyToken"]]} + end + + defp handler("unfollow", _event, info) do + {:handle_unfollow, [info]} + end + + defp handler("join", event, info) do + {:handle_join, [info, event["replyToken"]]} + end + + defp handler("leave", _event, info) do + {:handle_leave, [info]} + end + + defp handler("memberJoined", event, info) do + {:handle_member_joined, [event["joined"]["members"], info, event["replyToken"]]} + end + + defp handler("memberLeft", event, info) do + {:handle_member_left, [event["left"]["members"], info]} + end + + defp handler("postback", event, info) do + {:handle_postback, [event["postback"], info, event["replyToken"]]} + end + + defp handler("beacon", event, info) do + {:handle_beacon, [event["beacon"], info, event["replyToken"]]} + end + + defp handler("accountLink", event, info) do + {:handle_account_link, [event["link"], info, event["replyToken"]]} + end + + defp handler("things", event, info) do + {:handle_things, [event["things"], info, event["replyToken"]]} + end + + defp handler(type, event, info) do + {:handle_other, [type, event, info, event["replyToken"]]} + end +end diff --git a/lib/line_bot/event_info.ex b/lib/line_bot/event_info.ex new file mode 100644 index 0000000..f3a8d0b --- /dev/null +++ b/lib/line_bot/event_info.ex @@ -0,0 +1,28 @@ +defmodule LineBot.EventInfo do + defstruct ~w(user_id source destination timestamp)a + + @moduledoc """ + This struct contains common metadata for each event that is passed to a `LineBot` event handler. + """ + + @typedoc """ + This struct contains common metadata for each event that is passed to a `LineBot` event handler. + * `user_id` - The ID of the user that caused this event to be sent. This information is + extracted from the `source` object for convenience. + * `source` - information about the message source. This corresponds to the + [source object](https://developers.line.biz/en/reference/messaging-api/#common-properties) of + an event. + * `destination` - the user ID of the bot that should receive the event. This corresponds to the + [destination property](https://developers.line.biz/en/reference/messaging-api/#request-body) + of the webhook request. + * `timestamp` - a `DateTime` struct containing the time of the event. This corresponds to the + [timestamp property](https://developers.line.biz/en/reference/messaging-api/#request-body) + of an event. + """ + @type t :: %__MODULE__{ + user_id: String.t(), + source: %{required(String.t()) => String.t()}, + destination: String.t(), + timestamp: DateTime.t() + } +end diff --git a/lib/line_bot/message.ex b/lib/line_bot/message.ex new file mode 100644 index 0000000..b84b7cf --- /dev/null +++ b/lib/line_bot/message.ex @@ -0,0 +1,125 @@ +defmodule LineBot.Message do + @moduledoc """ + Represents any of the possible [Message objects](https://developers.line.biz/en/reference/messaging-api/#message-objects). + """ + @type t :: + LineBot.Message.Text.t() + | LineBot.Message.Sticker.t() + | LineBot.Message.Image.t() + | LineBot.Message.Video.t() + | LineBot.Message.Audio.t() + | LineBot.Message.Location.t() + | LineBot.Message.Flex.t() + | LineBot.Message.Imagemap.t() + | LineBot.Message.Template.t() + + defmacro __using__(_opts) do + quote do + defimpl Jason.Encoder do + def encode(struct, opts) do + struct + |> Map.from_struct() + |> Enum.reject(&(elem(&1, 1) == nil)) + |> Map.new() + |> Jason.Encode.map(opts) + end + end + end + end +end + +defmodule LineBot.Message.Text do + use LineBot.Message + + @moduledoc """ + Represents a [Text message](https://developers.line.biz/en/reference/messaging-api/#text-message). + """ + @type t :: %__MODULE__{ + text: String.t(), + type: :text, + quickReply: LineBot.Message.QuickReply.t() | nil + } + @enforce_keys [:text] + defstruct [:text, :quickReply, type: :text] +end + +defmodule LineBot.Message.Sticker do + use LineBot.Message + + @moduledoc """ + Represents a [Sticker message](https://developers.line.biz/en/reference/messaging-api/#sticker-message). + """ + @type t :: %__MODULE__{ + packageId: String.t(), + stickerId: String.t(), + type: :sticker, + quickReply: LineBot.Message.QuickReply.t() | nil + } + @enforce_keys [:packageId, :stickerId] + defstruct [:packageId, :stickerId, :quickReply, type: :sticker] +end + +defmodule LineBot.Message.Image do + use LineBot.Message + + @moduledoc """ + Represents an [Image message](https://developers.line.biz/en/reference/messaging-api/#image-message). + """ + @type t :: %__MODULE__{ + originalContentUrl: String.t(), + previewImageUrl: String.t(), + type: :image, + quickReply: LineBot.Message.QuickReply.t() | nil + } + @enforce_keys [:originalContentUrl, :previewImageUrl] + defstruct [:originalContentUrl, :previewImageUrl, :quickReply, type: :image] +end + +defmodule LineBot.Message.Video do + use LineBot.Message + + @moduledoc """ + Represents a [Video message](https://developers.line.biz/en/reference/messaging-api/#video-message). + """ + @type t :: %__MODULE__{ + originalContentUrl: String.t(), + previewImageUrl: String.t(), + type: :video, + quickReply: LineBot.Message.QuickReply.t() | nil + } + @enforce_keys [:originalContentUrl, :previewImageUrl] + defstruct [:originalContentUrl, :previewImageUrl, :quickReply, type: :video] +end + +defmodule LineBot.Message.Audio do + use LineBot.Message + + @moduledoc """ + Represents an [Audio message](https://developers.line.biz/en/reference/messaging-api/#audio-message). + """ + @type t :: %__MODULE__{ + originalContentUrl: String.t(), + duration: number(), + type: :audio, + quickReply: LineBot.Message.QuickReply.t() | nil + } + @enforce_keys [:originalContentUrl, :duration] + defstruct [:originalContentUrl, :duration, :quickReply, type: :audio] +end + +defmodule LineBot.Message.Location do + use LineBot.Message + + @moduledoc """ + Represents a [Location message](https://developers.line.biz/en/reference/messaging-api/#location-message). + """ + @type t :: %__MODULE__{ + title: String.t(), + address: String.t(), + latitude: float(), + longitude: float(), + quickReply: LineBot.Message.QuickReply.t() | nil + } + @enforce_keys [:title, :address, :latitude, :longitude] + defstruct [:title, :address, :latitude, :longitude, :quickReply, type: :location] +end diff --git a/lib/line_bot/message/action.ex b/lib/line_bot/message/action.ex new file mode 100644 index 0000000..6c7c821 --- /dev/null +++ b/lib/line_bot/message/action.ex @@ -0,0 +1,118 @@ +defmodule LineBot.Message.Action do + @moduledoc """ + Represents any one of the possible [Action objects](https://developers.line.biz/en/reference/messaging-api/#action-objects). + """ + + @type t() :: + LineBot.Message.Action.Message.t() + | LineBot.Message.Action.URI.t() + | LineBot.Message.Action.DateTimePicker.t() + | LineBot.Message.Action.Camera.t() + | LineBot.Message.Action.CameraRoll.t() + | LineBot.Message.Action.Location.t() +end + +defmodule LineBot.Message.Action.Postback do + @moduledoc """ + Represents a [Postback action](https://developers.line.biz/en/reference/messaging-api/#postback-action). + """ + use LineBot.Message + + @type t :: %__MODULE__{ + label: String.t(), + data: String.t(), + displayText: String.t(), + type: :postback + } + @enforce_keys [:data] + defstruct [:label, :data, :displayText, type: :postback] +end + +defmodule LineBot.Message.Action.Message do + @moduledoc """ + Represents a [Message action](https://developers.line.biz/en/reference/messaging-api/#message-action). + """ + use LineBot.Message + + @type t :: %__MODULE__{ + label: String.t(), + text: String.t(), + type: :message + } + @enforce_keys [:text] + defstruct [:label, :text, type: :message] +end + +defmodule LineBot.Message.Action.URI do + @moduledoc """ + Represents a [URI action](https://developers.line.biz/en/reference/messaging-api/#postback-action). + """ + use LineBot.Message + + @type t :: %__MODULE__{ + label: String.t(), + uri: String.t(), + altUri: %{desktop: :http | :https | :line | :tel}, + type: :uri + } + @enforce_keys [:uri] + defstruct [:label, :uri, :altUri, type: :uri] +end + +defmodule LineBot.Message.Action.DateTimePicker do + @moduledoc """ + Represents a [Datetime picker action](https://developers.line.biz/en/reference/messaging-api/#postback-action). + """ + use LineBot.Message + + @type t :: %__MODULE__{ + label: String.t(), + data: String.t(), + mode: :date | :time | :datetime, + initial: String.t(), + max: String.t(), + min: String.t(), + type: :datetimepicker + } + @enforce_keys [:data, :mode] + defstruct [:label, :data, :mode, :initial, :max, :min, type: :datetimepicker] +end + +defmodule LineBot.Message.Action.Camera do + @moduledoc """ + Represents a [Camera action](https://developers.line.biz/en/reference/messaging-api/#postback-action). + """ + @derive Jason.Encoder + @type t :: %__MODULE__{ + label: String.t(), + type: :camera + } + @enforce_keys [:label] + defstruct [:label, type: :camera] +end + +defmodule LineBot.Message.Action.CameraRoll do + @moduledoc """ + Represents a [Camera roll action](https://developers.line.biz/en/reference/messaging-api/#postback-action). + """ + @derive Jason.Encoder + @type t :: %__MODULE__{ + label: String.t(), + type: :cameraRoll + } + @enforce_keys [:label] + defstruct [:label, type: :cameraRoll] +end + +defmodule LineBot.Message.Action.Location do + @moduledoc """ + Represents a [Locaiton action](https://developers.line.biz/en/reference/messaging-api/#location-action). + """ + @derive Jason.Encoder + @type t :: %__MODULE__{ + label: String.t(), + type: :location + } + @enforce_keys [:label] + defstruct [:label, type: :location] +end diff --git a/lib/line_bot/message/flex.ex b/lib/line_bot/message/flex.ex new file mode 100644 index 0000000..238ce07 --- /dev/null +++ b/lib/line_bot/message/flex.ex @@ -0,0 +1,255 @@ +defmodule LineBot.Message.Flex do + use LineBot.Message + + @moduledoc """ + Represents a [Flex message](https://developers.line.biz/en/reference/messaging-api/#flex-message). + """ + @type t :: %__MODULE__{ + altText: String.t(), + contents: LineBot.Message.Flex.Carousel.t() | LineBot.Message.Flex.Bubble.t(), + type: :flex, + quickReply: LineBot.Message.QuickReply.t() | nil + } + + @type component :: + LineBot.Message.Flex.Box.t() + | LineBot.Message.Flex.Button.t() + | LineBot.Message.Flex.Filler.t() + | LineBot.Message.Flex.Image.t() + | LineBot.Message.Flex.Separator.t() + | LineBot.Message.Flex.Spacer.t() + | LineBot.Message.Flex.Text.t() + | LineBot.Message.Flex.Icon.t() + + @enforce_keys [:altText, :contents] + defstruct [:altText, :contents, :quickReply, type: :flex] +end + +# Containers + +defmodule LineBot.Message.Flex.Carousel do + use LineBot.Message + + @moduledoc """ + Represents a [Carousel container](https://developers.line.biz/en/reference/messaging-api/#f-carousel). + """ + @type t :: %__MODULE__{ + contents: [LineBot.Message.Flex.Bubble.t()], + type: :carousel + } + defstruct [:contents, type: :carousel] +end + +defmodule LineBot.Message.Flex.Bubble do + use LineBot.Message + + @moduledoc """ + Represents a [Bubble container](https://developers.line.biz/en/reference/messaging-api/#bubble). + """ + @type t :: %__MODULE__{ + direction: :ltr | :rtl | nil, + header: LineBot.Message.Flex.Box.t() | nil, + hero: LineBot.Message.Flex.Image.t() | nil, + body: LineBot.Message.Flex.Box.t() | nil, + footer: LineBot.Message.Flex.Box.t() | nil, + styles: LineBot.Message.Flex.BubbleStyle.t() | nil, + type: :bubble + } + defstruct [:direction, :header, :hero, :body, :footer, :styles, type: :bubble] +end + +defmodule LineBot.Message.Flex.BubbleStyle do + use LineBot.Message + + @moduledoc """ + Represents a [Bubble style](https://developers.line.biz/en/reference/messaging-api/#bubble-style). + """ + @type t :: %__MODULE__{ + header: LineBot.Message.Flex.BubbleStyleBlock.t() | nil, + hero: LineBot.Message.Flex.BubbleStyleBlock.t() | nil, + body: LineBot.Message.Flex.BubbleStyleBlock.t() | nil, + footer: LineBot.Message.Flex.BubbleStyleBlock.t() | nil + } + defstruct [:header, :hero, :body, :footer] +end + +defmodule LineBot.Message.Flex.BubbleStyleBlock do + use LineBot.Message + + @moduledoc """ + Represents a [Block bubble style](https://developers.line.biz/en/reference/messaging-api/#block-style). + """ + @type t :: %__MODULE__{ + backgroundColor: String.t() | nil, + separator: boolean() | nil, + separatorColor: String.t() | nil + } + defstruct [:backgroundColor, :separator, :separatorColor] +end + +# Components + +defmodule LineBot.Message.Flex.Box do + use LineBot.Message + + @moduledoc """ + Represents a [Box component](https://developers.line.biz/en/reference/messaging-api/#box). + """ + @type t :: %__MODULE__{ + layout: :horizontal | :vertical | :baseline, + contents: [LineBot.Message.Flex.component()], + flex: integer() | nil, + spacing: :none | :xs | :sm | :md | :lg | :xl | :xxl | nil, + margin: :none | :xs | :sm | :md | :lg | :xl | :xxl | nil, + action: [LineBot.Message.Action.t()] | nil, + type: :box + } + @enforce_keys [:layout, :contents] + defstruct [:layout, :contents, :flex, :spacing, :margin, :action, type: :box] +end + +defmodule LineBot.Message.Flex.Button do + use LineBot.Message + + @moduledoc """ + Represents a [Button component](https://developers.line.biz/en/reference/messaging-api/#button). + """ + @type t :: %__MODULE__{ + action: LineBot.Message.Action.t(), + flex: integer() | nil, + margin: :none | :xs | :sm | :md | :lg | :xl | :xxl | nil, + height: :sm | :md | nil, + style: :link | :primary | :secondary | nil, + color: String.t() | nil, + gravity: :top | :bottom | :center | nil, + type: :button + } + @enforce_keys [:action] + defstruct [:action, :flex, :margin, :height, :style, :color, :gravity, type: :button] +end + +defmodule LineBot.Message.Flex.Filler do + @derive Jason.Encoder + @moduledoc """ + Represents a [Filler component](https://developers.line.biz/en/reference/messaging-api/#filler). + """ + @type t :: %__MODULE__{type: :filler} + defstruct type: :filler +end + +defmodule LineBot.Message.Flex.Icon do + use LineBot.Message + + @moduledoc """ + Represents an [Icon component](https://developers.line.biz/en/reference/messaging-api/#icon). + """ + @type t :: %__MODULE__{ + url: String.t(), + margin: :none | :xs | :sm | :md | :lg | :xl | :xxl | nil, + size: :none | :xs | :sm | :md | :lg | :xl | :xxl | :"3xl" | :"4xl" | :"5xl" | nil, + aspectRatio: String.t() | nil, + type: :icon + } + @enforce_keys [:url] + defstruct [:url, :margin, :size, :aspectRatio, type: :icon] +end + +defmodule LineBot.Message.Flex.Image do + use LineBot.Message + + @moduledoc """ + Represents an [Image component](https://developers.line.biz/en/reference/messaging-api/#f-image). + """ + @type t :: %__MODULE__{ + url: String.t(), + flex: integer() | nil, + margin: :none | :xs | :sm | :md | :lg | :xl | :xxl | nil, + align: :start | :end | :center | nil, + gravity: :top | :bottom | :center | nil, + size: + :none | :xs | :sm | :md | :lg | :xl | :xxl | :"3xl" | :"4xl" | :"5xl" | :full | nil, + aspectRatio: String.t() | nil, + aspectMode: :cover | :fit | nil, + backgroundColor: String.t() | nil, + action: LineBot.Message.Action.t() | nil, + type: :image + } + @enforce_keys [:url] + defstruct [ + :url, + :flex, + :margin, + :align, + :gravity, + :size, + :aspectRatio, + :aspectMode, + :backgroundColor, + :action, + type: :image + ] +end + +defmodule LineBot.Message.Flex.Separator do + use LineBot.Message + + @moduledoc """ + Represents a [Separator component](https://developers.line.biz/en/reference/messaging-api/#separator). + """ + @type t :: %__MODULE__{ + margin: :none | :xs | :sm | :md | :lg | :xl | :xxl | nil, + color: String.t() | nil, + type: :separator + } + defstruct [:margin, :color, type: :separator] +end + +defmodule LineBot.Message.Flex.Spacer do + use LineBot.Message + + @moduledoc """ + Represents a [Spacer component](https://developers.line.biz/en/reference/messaging-api/#spacer). + """ + @type t :: %__MODULE__{ + size: :none | :xs | :sm | :md | :lg | :xl | :xxl | nil, + type: :separator + } + defstruct [:size, type: :spacer] +end + +defmodule LineBot.Message.Flex.Text do + use LineBot.Message + + @moduledoc """ + Represents an [Text component](https://developers.line.biz/en/reference/messaging-api/#f-text). + """ + @type t :: %__MODULE__{ + text: String.t(), + flex: integer() | nil, + margin: :none | :xs | :sm | :md | :lg | :xl | :xxl | nil, + size: :none | :xs | :sm | :md | :lg | :xl | :xxl | :"3xl" | :"4xl" | :"5xl" | nil, + align: :start | :end | :center | nil, + gravity: :top | :bottom | :center | nil, + wrap: boolean() | nil, + maxLines: integer() | nil, + weight: :regular | :bold | nil, + color: String.t() | nil, + action: LineBot.Message.Action.t() | nil, + type: :text + } + @enforce_keys [:text] + defstruct [ + :text, + :flex, + :margin, + :size, + :align, + :gravity, + :wrap, + :maxLines, + :weight, + :color, + :action, + type: :text + ] +end diff --git a/lib/line_bot/message/image_map.ex b/lib/line_bot/message/image_map.ex new file mode 100644 index 0000000..ae78b0d --- /dev/null +++ b/lib/line_bot/message/image_map.ex @@ -0,0 +1,68 @@ +defmodule LineBot.Message.Imagemap do + use LineBot.Message + + @moduledoc """ + Represents an [Imagemap message](https://developers.line.biz/en/reference/messaging-api/#imagemap-message). + """ + @type t :: %__MODULE__{ + baseUrl: String.t(), + altText: String.t(), + baseSize: %{width: integer(), height: integer()}, + video: LineBot.Message.Imagemap.Video.t() | nil, + actions: [ + LineBot.Message.Imagemap.Action.Message.t() | LineBot.Message.Imagemap.Action.URI.t() + ], + type: :imagemap, + quickReply: LineBot.Message.QuickReply.t() | nil + } + @enforce_keys [:baseUrl, :altText, :baseSize, :actions] + defstruct [:baseUrl, :altText, :baseSize, :video, :actions, :quickReply, type: :imagemap] +end + +defmodule LineBot.Message.Imagemap.Video do + use LineBot.Message + + @moduledoc """ + Represents the video component of a `t:LineBot.Message.Imagemap.t/0`. + """ + @type t :: %__MODULE__{ + originalContentUrl: String.t(), + previewImageUrl: String.t(), + area: %{x: integer(), y: integer(), width: integer(), height: integer()}, + externalLink: %{linkUri: String.t(), label: String.t()} | nil + } + @enforce_keys [:originalContentUrl, :previewImageUrl, :area] + defstruct [:originalContentUrl, :previewImageUrl, :area, :externalLink] +end + +defmodule LineBot.Message.Imagemap.Action.Message do + use LineBot.Message + + @moduledoc """ + Represents an [Imagemap Message action object](https://developers.line.biz/en/reference/messaging-api/#imagemap-message-action-object). + """ + @type t :: %__MODULE__{ + label: String.t() | nil, + text: String.t(), + area: %{x: integer(), y: integer(), width: integer(), height: integer()}, + type: :message + } + @enforce_keys [:text, :area] + defstruct [:label, :text, :area, type: :message] +end + +defmodule LineBot.Message.Imagemap.Action.URI do + use LineBot.Message + + @moduledoc """ + Represents an [Imagemap URI action object](https://developers.line.biz/en/reference/messaging-api/#imagemap-uri-action-object). + """ + @type t :: %__MODULE__{ + label: String.t() | nil, + linkUri: String.t(), + area: %{x: integer(), y: integer(), width: integer(), height: integer()}, + type: :uri + } + @enforce_keys [:linkUri, :area] + defstruct [:label, :linkUri, :area, type: :uri] +end diff --git a/lib/line_bot/message/quick_reply.ex b/lib/line_bot/message/quick_reply.ex new file mode 100644 index 0000000..af0c009 --- /dev/null +++ b/lib/line_bot/message/quick_reply.ex @@ -0,0 +1,27 @@ +defmodule LineBot.Message.QuickReply do + @derive Jason.Encoder + + @moduledoc """ + Represents a [Quick reply](https://developers.line.biz/en/reference/messaging-api/#quick-reply) object. + """ + @type t :: %__MODULE__{ + items: [LineBot.Message.QuickReplyItem.t()] + } + @enforce_keys [:items] + defstruct [:items] +end + +defmodule LineBot.Message.QuickReplyItem do + use LineBot.Message + + @moduledoc """ + Represents a [Quick reply button](https://developers.line.biz/en/reference/messaging-api/#quick-reply-button-object) object. + """ + @type t :: %__MODULE__{ + imageUrl: String.t(), + action: LineBot.Message.Action.t(), + type: :action + } + @enforce_keys [:action] + defstruct [:imageUrl, :action, type: :action] +end diff --git a/lib/line_bot/message/template.ex b/lib/line_bot/message/template.ex new file mode 100644 index 0000000..f7adbeb --- /dev/null +++ b/lib/line_bot/message/template.ex @@ -0,0 +1,124 @@ +defmodule LineBot.Message.Template do + use LineBot.Message + + @moduledoc """ + Represents a [Template message](https://developers.line.biz/en/reference/messaging-api/#template-messages). + """ + @type t :: %__MODULE__{ + altText: String.t(), + template: + LineBot.Message.Template.Buttons.t() + | LineBot.Message.Template.Confirm.t() + | LineBot.Message.Template.Carousel.t() + | LineBot.Message.Template.ImageCarousel.t(), + type: :template, + quickReply: LineBot.Message.QuickReply.t() | nil + } + @enforce_keys [:altText, :template] + defstruct [:altText, :template, :quickReply, type: :template] +end + +defmodule LineBot.Message.Template.Buttons do + use LineBot.Message + + @moduledoc """ + Represents a [Buttons template](https://developers.line.biz/en/reference/messaging-api/#buttons). + """ + @type t :: %__MODULE__{ + thumbnailImageUrl: String.t() | nil, + imageAspectRatio: :rectangle | :square | nil, + imageSize: :cover | :contain | nil, + imageBackgroundColor: String.t() | nil, + title: String.t() | nil, + text: String.t(), + defaultAction: LineBot.Message.Action.t() | nil, + actions: [LineBot.Message.Action.t()], + type: :buttons + } + @enforce_keys [:text, :actions] + defstruct [ + :thumbnailImageUrl, + :imageAspectRatio, + :imageSize, + :imageBackgroundColor, + :title, + :text, + :defaultAction, + :actions, + type: :buttons + ] +end + +defmodule LineBot.Message.Template.Confirm do + @derive Jason.Encoder + @moduledoc """ + Represents a [Confirm template](https://developers.line.biz/en/reference/messaging-api/#confirm). + """ + @type t :: %__MODULE__{ + text: String.t(), + actions: [LineBot.Message.Action.t()], + type: :confirm + } + @enforce_keys [:text, :actions] + defstruct [:text, :actions, type: :confirm] +end + +defmodule LineBot.Message.Template.Carousel do + use LineBot.Message + + @moduledoc """ + Represents a [Carousel template](https://developers.line.biz/en/reference/messaging-api/#carousel). + """ + @type t :: %__MODULE__{ + columns: [LineBot.Message.Template.Carousel.Column.t()], + imageAspectRatio: :rectangle | :square | nil, + imageSize: :cover | :contain | nil, + type: :carousel + } + @enforce_keys [:columns] + defstruct [:columns, :imageAspectRatio, :imageSize, type: :carousel] +end + +defmodule LineBot.Message.Template.Carousel.Column do + use LineBot.Message + + @moduledoc """ + Represents a [Column object for carousel](https://developers.line.biz/en/reference/messaging-api/#column-object-for-carousel). + """ + @type t :: %__MODULE__{ + thumbnailImageUrl: String.t() | nil, + imageBackgroundColor: String.t() | nil, + title: String.t() | nil, + text: String.t(), + defaultAction: LineBot.Message.Action.t() | nil, + actions: [LineBot.Message.Action.t()] + } + @enforce_keys [:text] + defstruct [:thumbnailImageUrl, :imageBackgroundColor, :title, :text, :defaultAction, :actions] +end + +defmodule LineBot.Message.Template.ImageCarousel do + @derive Jason.Encoder + @moduledoc """ + Represents an [Image carousel template](https://developers.line.biz/en/reference/messaging-api/#image-carousel). + """ + @type t :: %__MODULE__{ + columns: [LineBot.Message.Template.ImageCarousel.Column.t()], + type: :image_carousel + } + @enforce_keys [:columns] + defstruct [:columns, type: :image_carousel] +end + +defmodule LineBot.Message.Template.ImageCarousel.Column do + @derive Jason.Encoder + @moduledoc """ + Represents a [Column object for image carousel](https://developers.line.biz/en/reference/messaging-api/#column-object-for-image-carousel). + """ + @type t :: %__MODULE__{ + imageUrl: String.t(), + action: LineBot.Message.Action.t() + } + @enforce_keys [:imageUrl, :action] + defstruct [:imageUrl, :action] +end diff --git a/lib/line_bot/request_logger_plug.ex b/lib/line_bot/request_logger_plug.ex new file mode 100644 index 0000000..ed18a52 --- /dev/null +++ b/lib/line_bot/request_logger_plug.ex @@ -0,0 +1,13 @@ +defmodule LineBot.RequestLoggerPlug do + @behaviour Plug + @moduledoc false + + @impl true + def init(opts), do: Keyword.get(opts, :level, :debug) + + @impl true + def call(%Plug.Conn{private: %{line_bot_raw_body: request}} = conn, level) do + Logger.bare_log(level, "Webhook Request: #{String.replace(request, ~r(\n\s*), "")}") + conn + end +end diff --git a/lib/line_bot/token_server.ex b/lib/line_bot/token_server.ex new file mode 100644 index 0000000..0f1d7f5 --- /dev/null +++ b/lib/line_bot/token_server.ex @@ -0,0 +1,133 @@ +defmodule LineBot.TokenServer do + defmodule Behaviour do + @moduledoc false + @callback get_token() :: binary + @callback revoke_token() :: term + @callback purge() :: term + end + + use GenServer + @behaviour Behaviour + require Logger + + @moduledoc """ + A GenServer that manages the [OAuth token](https://developers.line.biz/en/reference/messaging-api/#oauth) + for Line API calls. + + This server is started automatically by the `:line_bot` application, and requires the + `:client_id`, and `:client_secret` to have been configured. + + The current token is retrieved with `get_token/0`, which also fetches a new token from the auth + server if the current one has expired (or has never been fetched). + + """ + + ## Server + + @impl GenServer + def init(_) do + state = %{ + token: nil, + expires_on: DateTime.utc_now(), + client_id: Application.fetch_env!(:line_bot, :client_id), + client_secret: Application.fetch_env!(:line_bot, :client_secret) + } + + {:ok, state} + end + + @impl GenServer + def handle_call(:token, _from, state) do + state = + case DateTime.compare(state.expires_on, DateTime.utc_now()) do + :gt -> state + _ -> refresh_token(state) + end + + {:reply, state.token, state} + end + + @impl GenServer + def handle_call(:revoke, from, state) do + post_revoke_token(state.token) + handle_call(:purge, from, state) + end + + @impl GenServer + def handle_call(:purge, _from, state) do + {:reply, :ok, %{state | token: nil, expires_on: DateTime.utc_now()}} + end + + defp refresh_token(state) do + {token, expires_in} = post_request_token(state.client_id, state.client_secret) + # Set the expiry 5 minutes earlier, to avoid edge cases + expires_on = DateTime.add(DateTime.utc_now(), expires_in - 300, :second) + Logger.info("Retrieved new access token that expires on #{expires_on}.") + %{state | token: token, expires_on: expires_on} + end + + @headers [{"Content-Type", "application/x-www-form-urlencoded"}] + + defp post_request_token(client_id, client_secret) do + data = [ + grant_type: "client_credentials", + client_id: client_id, + client_secret: client_secret + ] + + %{"access_token" => access_token, "expires_in" => expires_in} = + "https://api.line.me/v2/oauth/accessToken" + |> HTTPoison.post!({:form, data}, @headers) + |> Map.get(:body) + |> Jason.decode!() + + {access_token, expires_in} + end + + defp post_revoke_token(access_token) do + HTTPoison.post!( + "https://api.line.me/v2/oauth/revoke", + {:form, [access_token: access_token]}, + @headers + ) + end + + ## Client + + @doc false + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Gets the currently stored token. If the token has expired, a new token is retrieved. + """ + @impl Behaviour + def get_token() do + GenServer.call(__MODULE__, :token) + end + + @doc """ + Revokes the current access token. This revokes the token with the API server, so the token will + become invalid and cannot be used elsewhere. + + A new token will be retrieved on the next call + to `get_token/0`. + """ + @impl Behaviour + def revoke_token() do + GenServer.call(__MODULE__, :revoke) + end + + @doc """ + Purges the current access token. This only removes the token from this process' state, it does + not revoke the token. + + A new token will be retrieved on the next call to `get_token/0`. + To revoke the token, use `revoke_token/0`. + """ + @impl Behaviour + def purge() do + GenServer.call(__MODULE__, :purge) + end +end diff --git a/lib/line_bot/validator_plug.ex b/lib/line_bot/validator_plug.ex new file mode 100644 index 0000000..5566c3f --- /dev/null +++ b/lib/line_bot/validator_plug.ex @@ -0,0 +1,57 @@ +defmodule LineBot.ValidatorPlug do + import Plug.Conn + require Logger + + @moduledoc false + @behaviour Plug + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _opts) do + client_secret = Application.fetch_env!(:line_bot, :client_secret) + skip_validation = Application.get_env(:line_bot, :skip_validation, false) + + validate(conn, client_secret, skip_validation: skip_validation) + end + + defp validate(conn, _client_secret, skip_validation: true) do + Logger.warn("Skipping signature validation because :skip_validation is enabled.") + conn + end + + defp validate(conn, client_secret, skip_validation: false) do + with [sig] <- get_req_header(conn, "x-line-signature"), + body when not is_nil(body) <- conn.private[:line_bot_raw_body] do + expected_sig = + :crypto.hmac(:sha256, client_secret, body) + |> Base.encode64() + + if sig === expected_sig do + conn + else + Logger.warn("Signature was invalid. Rejecting. Sig: #{sig}") + forbidden(conn) + end + else + _ -> + Logger.warn("Failed to validate signature. Signature header or body missing?") + forbidden(conn) + end + end + + defp forbidden(conn) do + conn + |> send_resp(:forbidden, "Signature Invalid") + |> halt + end + + @doc """ + A helper for local debugging. Produces a valid signature for the supplied `body`, + which can be used in the `X-Line-Signature` header. + """ + def sign(body) do + :crypto.hmac(:sha256, Application.get_env(:line_bot, :client_secret), body) |> Base.encode64() + end +end diff --git a/lib/line_bot/webhook.ex b/lib/line_bot/webhook.ex new file mode 100644 index 0000000..9abde8d --- /dev/null +++ b/lib/line_bot/webhook.ex @@ -0,0 +1,102 @@ +defmodule LineBot.Webhook do + use Plug.Router + require Logger + + @moduledoc """ + This module is a `Plug` that handles incoming events from the Line server, and forwards + them to your `LineBot` callback. + + It is recommended that you set up your own `Plug.Router`, and forward requests to the + webhook URL to this plug, like this: + + forward "/bot", to: LineBot.Webhook, callback: YourCallbackModule + + This plug will read, validate, and parse the request body, so it must not appear + in the same pipeline as other plugs such as `Plug.Parsers` that also read the request body. + + Note: The _verify_ button in the Developer Center when saving the webhook URL sends + dummy data. This module replies to that request directly, rather than forwarding the + request to your callback. + + ## Skipping Validation + By default, requests are validated against the [X-Line-Signature](https://developers.line.biz/en/reference/messaging-api/#signature-validation) header. If the signature cannot be validated, a `403 Forbidden` response is returned. + + During development, it may be convenient to temporarily disable this behaviour. This can be achieved via configuration by setting `:skip_validation` to `true`. + + config :line_bot, skip_validation: true + """ + + plug :check_not_already_parsed + plug LineBot.BodyReaderPlug + plug LineBot.ValidatorPlug + + plug Plug.Parsers, + parsers: [:json], + json_decoder: Jason, + body_reader: {LineBot.BodyReaderPlug, :read_cached_body, []} + + plug LineBot.RequestLoggerPlug, level: :debug + + plug :match + plug :put_callback, builder_opts() + plug :dispatch + + post "/", do: dispatch_events(conn) + match "/", do: send_resp(conn, :method_not_allowed, "") + match _, do: send_resp(conn, :not_found, "") + + @impl true + @doc """ + Called when this plug is initialized. Expects an implementation of `LineBot` to be + passed as the `:callback` option. + """ + def init(opts) do + case Keyword.fetch(opts, :callback) do + {:ok, callback} -> callback + :error -> raise "Must provide callback module: LineBot.Webhook, callback: YourModule" + end + end + + defp dispatch_events( + %Plug.Conn{ + body_params: %{ + "events" => [%{"source" => %{"userId" => "Udeadbeefdeadbeefdeadbeefdeadbeef"}} | _] + } + } = conn + ) do + Logger.debug("handled webhoook verify request") + send_resp(conn, :ok, "") + end + + defp dispatch_events( + %Plug.Conn{ + private: %{line_bot_callback: callback}, + body_params: %{"destination" => destination, "events" => events} + } = conn + ) do + Task.Supervisor.start_child(LineBot.TaskSupervisor, fn -> + LineBot.Dispatcher.dispatch_events(events, destination, callback) + end) + + send_resp(conn, :ok, "") + end + + defp dispatch_events(%Plug.Conn{private: %{line_bot_raw_body: request}} = conn) do + Logger.warn("Unrecognised request: #{request}") + send_resp(conn, :bad_request, "Unrecognised request") + end + + defp check_not_already_parsed(%Plug.Conn{body_params: %Plug.Conn.Unfetched{}} = conn, _opts) do + conn + end + + defp check_not_already_parsed(conn, _opts) do + Logger.error("Request must not be parsed by Plug.Parsers before reaching LineBot.Webhook") + + conn + |> send_resp(:internal_server_error, "Body parsed before reaching Line Bot Webhook") + |> halt() + end + + defp put_callback(conn, callback), do: put_private(conn, :line_bot_callback, callback) +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..bb4a7ce --- /dev/null +++ b/mix.exs @@ -0,0 +1,106 @@ +defmodule LineBot.MixProject do + use Mix.Project + + def project do + [ + app: :line_bot, + version: "0.1.0", + elixir: "~> 1.9", + start_permanent: Mix.env() == :prod, + deps: deps(), + docs: docs() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + mod: {LineBot.Application, []}, + extra_applications: [:logger] + ] + end + + defp docs do + [ + main: "readme", + extras: ["README.md"], + # source_ref: "v#{@version}", + groups_for_modules: [ + LineBot: [ + LineBot, + LineBot.APIClient, + LineBot.EventInfo, + LineBot.TokenServer, + LineBot.Webhook + ], + Messages: [ + LineBot.Message, + LineBot.Message.Text, + LineBot.Message.Sticker, + LineBot.Message.Image, + LineBot.Message.Video, + LineBot.Message.Audio, + LineBot.Message.Location, + LineBot.Message.Imagemap, + LineBot.Message.Template, + LineBot.Message.Flex, + LineBot.Message.QuickReply, + LineBot.Message.QuickReplyItem + ], + Actions: [ + LineBot.Message.Action, + LineBot.Message.Action.Postback, + LineBot.Message.Action.Message, + LineBot.Message.Action.URI, + LineBot.Message.Action.DateTimePicker, + LineBot.Message.Action.Camera, + LineBot.Message.Action.CameraRoll, + LineBot.Message.Action.Location + ], + "Flex Message": [ + LineBot.Message.Flex.Carousel, + LineBot.Message.Flex.Bubble, + LineBot.Message.Flex.BubbleStyle, + LineBot.Message.Flex.BubbleStyleBlock, + LineBot.Message.Flex.Box, + LineBot.Message.Flex.Button, + LineBot.Message.Flex.Filler, + LineBot.Message.Flex.Icon, + LineBot.Message.Flex.Image, + LineBot.Message.Flex.Separator, + LineBot.Message.Flex.Spacer, + LineBot.Message.Flex.Text + ], + Imagemap: [ + LineBot.Message.Imagemap.BaseSize, + LineBot.Message.Imagemap.Video, + LineBot.Message.Imagemap.Video.ExternalLink, + LineBot.Message.Imagemap.Area, + LineBot.Message.Imagemap.Action.Message, + LineBot.Message.Imagemap.Action.URI + ], + Template: [ + LineBot.Message.Template.Buttons, + LineBot.Message.Template.Confirm, + LineBot.Message.Template.Carousel, + LineBot.Message.Template.Carousel.Column, + LineBot.Message.Template.Column, + LineBot.Message.Template.ImageCarousel, + LineBot.Message.Template.ImageCarousel.Column + ] + ] + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:plug, "~> 1.8"}, + {:httpoison, "~> 1.5"}, + {:jason, "~> 1.1"}, + {:ex_doc, "~> 0.21", only: :dev, runtime: false}, + {:dialyxir, "~> 0.5", only: :dev, runtime: false}, + {:mox, "~> 0.5", only: :test} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..c3c5a95 --- /dev/null +++ b/mix.lock @@ -0,0 +1,22 @@ +%{ + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"}, + "earmark": {:hex, :earmark, "1.3.5", "0db71c8290b5bc81cb0101a2a507a76dca659513984d683119ee722828b424f6", [:mix], [], "hexpm"}, + "ex_doc": {:hex, :ex_doc, "0.21.1", "5ac36660846967cd869255f4426467a11672fec3d8db602c429425ce5b613b90", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, + "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "httpoison": {:hex, :httpoison, "1.5.1", "0f55b5b673b03c5c327dac7015a67cb571b99b631acc0bc1b0b98dcd6b9f2104", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, + "mox": {:hex, :mox, "0.5.1", "f86bb36026aac1e6f924a4b6d024b05e9adbed5c63e8daa069bd66fb3292165b", [:mix], [], "hexpm"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.5.1", "c90796ecee0289dbb5ad16d3ad06f957b0cd1199769641c961cfe0b97db190e0", [:mix], [], "hexpm"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, + "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, +} diff --git a/sample/.formatter.exs b/sample/.formatter.exs new file mode 100644 index 0000000..355b36e --- /dev/null +++ b/sample/.formatter.exs @@ -0,0 +1,29 @@ +# Used by "mix format" and to export configuration. +export_locals_without_parens = [ + plug: 1, + plug: 2, + forward: 2, + forward: 3, + forward: 4, + inspect: 1, + match: 2, + match: 3, + get: 2, + get: 3, + post: 2, + post: 3, + put: 2, + put: 3, + patch: 2, + patch: 3, + delete: 2, + delete: 3, + options: 2, + options: 3 +] + +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + locals_without_parens: export_locals_without_parens, + export: [locals_without_parens: export_locals_without_parens] +] diff --git a/sample/README.md b/sample/README.md new file mode 100644 index 0000000..447cf55 --- /dev/null +++ b/sample/README.md @@ -0,0 +1,13 @@ +# LineBotSample + +Sample implementation of a Line Bot using the `line_bot` package. + +## Running + +1. Get dependencies: `mix deps.get`. +1. Set environment variables + ```sh + export LINE_CLIENT_ID=... + export LINE_CLIENT_SECRET="..." + ``` +1. Start the application: `iex -S mix` or `mix run --no-halt`. diff --git a/sample/config/config.exs b/sample/config/config.exs new file mode 100644 index 0000000..1a4a885 --- /dev/null +++ b/sample/config/config.exs @@ -0,0 +1,10 @@ +import Config + +config :line_bot, + client_id: System.get_env("LINE_CLIENT_ID"), + client_secret: System.get_env("LINE_CLIENT_SECRET") + +# Use to skip request signature validation local testing. +# config :line_bot, skip_validation: true + +config :line_bot_sample, port: System.get_env("PORT") diff --git a/sample/lib/line_bot_sample.ex b/sample/lib/line_bot_sample.ex new file mode 100644 index 0000000..e945984 --- /dev/null +++ b/sample/lib/line_bot_sample.ex @@ -0,0 +1,295 @@ +defmodule LineBotSample do + use LineBot + require Logger + + alias LineBot.Message + # TODO update static resource locations + + # ---------------------------------------------------------------------------- + # Helpers + # ---------------------------------------------------------------------------- + defp log_and_reply(request_message, reply_message, reply_token) do + Logger.info("Handling message event: #{inspect request_message}") + LineBot.send_reply(reply_token, [reply_message]) + end + + @help %Message.Text{ + text: """ + Available demo commands: + + help + text + sticker + image + video + audio + location + flex + imagemap + imagemap video + template + quick + flag + """ + } + + # ---------------------------------------------------------------------------- + # Message Event + # ---------------------------------------------------------------------------- + @impl true + def handle_message(%{"type" => "text", "text" => "help"} = request_message, _info, reply_token) do + log_and_reply(request_message, @help, reply_token) + end + + @impl true + def handle_message(%{"type" => "text", "text" => "text"} = request_message, _info, reply_token) do + reply_message = %Message.Text{text: "Hello \u{10008D}"} + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message( + %{"type" => "text", "text" => "sticker"} = request_message, + _info, + reply_token + ) do + reply_message = %Message.Sticker{packageId: 11537, stickerId: 52_002_769} + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message(%{"type" => "text", "text" => "image"} = request_message, _info, reply_token) do + reply_message = %Message.Image{ + originalContentUrl: "https://adamu.github.io/images/full.jpg", + previewImageUrl: "https://adamu.github.io/images/preview.jpg" + } + + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message(%{"type" => "text", "text" => "video"} = request_message, _info, reply_token) do + reply_message = %Message.Video{ + originalContentUrl: "https://adamu.github.io/images/video.mp4", + previewImageUrl: "https://adamu.github.io/images/video_preview.jpg" + } + + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message(%{"type" => "text", "text" => "audio"} = request_message, _info, reply_token) do + reply_message = %Message.Audio{ + originalContentUrl: "https://adamu.github.io/images/audio.m4a", + duration: 60_000 + } + + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message( + %{"type" => "text", "text" => "location"} = request_message, + _info, + reply_token + ) do + reply_message = %Message.Location{ + title: "Kyoto Station", + address: "京都府京都市下京区東塩小路高倉町8-3", + latitude: 34.985407, + longitude: 135.75845 + } + + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message(%{"type" => "text", "text" => "flex"} = request_message, _info, reply_token) do + reply_message = LineBotSample.Flex.make_flex_message() + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message( + %{"type" => "text", "text" => "imagemap"} = request_message, + _info, + reply_token + ) do + reply_message = LineBotSample.Imagemap.make_imagemap_message() + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message( + %{"type" => "text", "text" => "imagemap video"} = request_message, + _, + reply_token + ) do + reply_message = LineBotSample.Imagemap.make_imagemap_video_message() + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message( + %{"type" => "text", "text" => "template"} = request_message, + _info, + reply_token + ) do + reply_message = %Message.Template{ + altText: "Please select an option", + template: %Message.Template.Confirm{ + text: "Please select an option", + actions: [ + %Message.Action.Message{label: "Yes", text: "Yes"}, + %Message.Action.Message{label: "No", text: "No"} + ] + } + } + + log_and_reply(request_message, reply_message, reply_token) + end + + @impl true + def handle_message(%{"type" => "text", "text" => "quick"} = request_message, _info, reply_token) do + quick_reply = %Message.QuickReply{ + items: [ + %Message.QuickReplyItem{ + action: %Message.Action.Postback{ + label: "line bot postback", + data: "line bot postback data", + displayText: "Line Bot Postback!" + } + } + ] + } + + reply_message = %Message.Text{ + text: "Quick reply please!", + quickReply: quick_reply + } + + log_and_reply(request_message, reply_message, reply_token) + end + + # Fun example that converts country codes to unicode flags by shifting + # the input up to the "Regional Indicator Symbol" unicode block. + # e.g. flag gb => 🇬🇧 + @impl true + def handle_message( + %{"type" => "text", "text" => "flag " <> code} = request_message, + _info, + reply_token + ) do + code = + code + |> String.trim() + |> String.upcase() + |> String.to_charlist() + |> Enum.map(&(&1 + 0x1F1A5)) + |> List.to_string() + + log_and_reply(request_message, %Message.Text{text: code}, reply_token) + end + + @impl true + def handle_message(message, info, _reply_token) do + Logger.info("Handling message event: #{inspect message}\n#{inspect info}") + end + + # ---------------------------------------------------------------------------- + # Follow Event + # ---------------------------------------------------------------------------- + @impl true + def handle_follow(info, reply_token) do + Logger.info("Handling follow event: " <> inspect(info)) + LineBot.send_reply(reply_token, [@help]) + end + + # ---------------------------------------------------------------------------- + # Unfollow Event + # ---------------------------------------------------------------------------- + @impl true + def handle_unfollow(info) do + Logger.info("Handling unfollow event: " <> inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Join Event + # ---------------------------------------------------------------------------- + @impl true + def handle_join(info, _reply_token) do + Logger.info("Handling join event: " <> inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Leave Event + # ---------------------------------------------------------------------------- + @impl true + def handle_leave(info) do + Logger.info("Handling leave event: " <> inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Member Joined Event + # ---------------------------------------------------------------------------- + @impl true + def handle_member_joined(members, info, _reply_token) do + Logger.info("Handling member_joined event: #{inspect members}") + Logger.info(inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Member Left Event + # ---------------------------------------------------------------------------- + @impl true + def handle_member_left(members, info) do + Logger.info("Handling member_left event: #{inspect members}") + Logger.info(inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Postback Event + # ---------------------------------------------------------------------------- + @impl true + def handle_postback(postback, info, _reply_token) do + Logger.info("Handling postback event: #{inspect postback}") + Logger.info(inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Beacon Event + # ---------------------------------------------------------------------------- + @impl true + def handle_beacon(beacon, info, _reply_token) do + Logger.info("Handling beacon event: #{inspect beacon}") + Logger.info(inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Account Link Event + # ---------------------------------------------------------------------------- + @impl true + def handle_account_link(link, info, _reply_token) do + Logger.info("Handling account_link event: #{inspect link}") + Logger.info(inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Things Event + # ---------------------------------------------------------------------------- + @impl true + def handle_things(things, info, _reply_token) do + Logger.info("Handling things event: #{inspect things}") + Logger.info(inspect(info)) + end + + # ---------------------------------------------------------------------------- + # Unknown Event Type + # ---------------------------------------------------------------------------- + @impl true + def handle_other(type, event, info, reply_token) do + Logger.info("Handling unknown #{type} event: #{inspect event}") + Logger.info(inspect(info)) + Logger.info(inspect reply_token) + end +end diff --git a/sample/lib/line_bot_sample/application.ex b/sample/lib/line_bot_sample/application.ex new file mode 100644 index 0000000..e329d5a --- /dev/null +++ b/sample/lib/line_bot_sample/application.ex @@ -0,0 +1,19 @@ +defmodule LineBotSample.Application do + use Application + require Logger + + @moduledoc false + + def start(_type, _args) do + port = Application.get_env(:line_bot_sample, :port) || 4000 + + children = [ + Plug.Cowboy.child_spec(scheme: :http, plug: LineBotSample.Router, options: [port: port]) + ] + + opts = [strategy: :one_for_one, name: LineBotSample.Supervisor] + + Logger.info("Starting bot server on port #{port}...") + Supervisor.start_link(children, opts) + end +end diff --git a/sample/lib/line_bot_sample/flex.ex b/sample/lib/line_bot_sample/flex.ex new file mode 100644 index 0000000..e4079d8 --- /dev/null +++ b/sample/lib/line_bot_sample/flex.ex @@ -0,0 +1,163 @@ +defmodule LineBotSample.Flex do + def make_flex_message do + %LineBot.Message.Flex{ + altText: "Your Brown Store Receipt", + contents: %LineBot.Message.Flex.Bubble{ + body: %LineBot.Message.Flex.Box{ + layout: "vertical", + contents: [ + %LineBot.Message.Flex.Text{ + text: "RECEIPT", + weight: "bold", + color: "#1DB446", + size: "sm" + }, + %LineBot.Message.Flex.Text{ + text: "Brown Store", + weight: "bold", + size: "xxl", + margin: "md" + }, + %LineBot.Message.Flex.Text{ + text: "Miraina Tower, 4-1-6 Shinjuku, Tokyo", + size: "xs", + color: "#aaaaaa", + wrap: true + }, + %LineBot.Message.Flex.Separator{margin: "xxl"}, + %LineBot.Message.Flex.Box{ + layout: "vertical", + margin: "xxl", + spacing: "sm", + contents: [ + %LineBot.Message.Flex.Box{ + layout: "horizontal", + contents: [ + %LineBot.Message.Flex.Text{ + text: "Energy Drink", + size: "sm", + color: "#555555", + flex: 0 + }, + %LineBot.Message.Flex.Text{ + text: "$2.99", + size: "sm", + color: "#111111", + align: "end" + } + ] + }, + %LineBot.Message.Flex.Box{ + layout: "horizontal", + contents: [ + %LineBot.Message.Flex.Text{ + text: "Chewing Gum", + size: "sm", + color: "#555555", + flex: 0 + }, + %LineBot.Message.Flex.Text{ + text: "$0.99", + size: "sm", + color: "#111111", + align: "end" + } + ] + }, + %LineBot.Message.Flex.Box{ + layout: "horizontal", + contents: [ + %LineBot.Message.Flex.Text{ + text: "Bottled Water", + size: "sm", + color: "#555555", + flex: 0 + }, + %LineBot.Message.Flex.Text{ + text: "$3.33", + size: "sm", + color: "#111111", + align: "end" + } + ] + } + ] + }, + %LineBot.Message.Flex.Separator{margin: "xxl"}, + %LineBot.Message.Flex.Box{ + layout: "horizontal", + margin: "xxl", + contents: [ + %LineBot.Message.Flex.Text{text: "ITEMS", size: "sm", color: "#555555"}, + %LineBot.Message.Flex.Text{text: "3", size: "sm", color: "#111111", align: "end"} + ] + }, + %LineBot.Message.Flex.Box{ + layout: "horizontal", + margin: "xxl", + contents: [ + %LineBot.Message.Flex.Text{text: "TOTAL", size: "sm", color: "#555555"}, + %LineBot.Message.Flex.Text{ + text: "$7.31", + size: "sm", + color: "#111111", + align: "end" + } + ] + }, + %LineBot.Message.Flex.Box{ + layout: "horizontal", + margin: "xxl", + contents: [ + %LineBot.Message.Flex.Text{text: "CASH", size: "sm", color: "#555555"}, + %LineBot.Message.Flex.Text{ + text: "$8.0", + size: "sm", + color: "#111111", + align: "end" + } + ] + }, + %LineBot.Message.Flex.Box{ + layout: "horizontal", + margin: "xxl", + contents: [ + %LineBot.Message.Flex.Text{text: "CHANGE", size: "sm", color: "#555555"}, + %LineBot.Message.Flex.Text{ + text: "$0.69", + size: "sm", + color: "#111111", + align: "end" + } + ] + }, + %LineBot.Message.Flex.Separator{margin: "xxl"}, + %LineBot.Message.Flex.Box{ + layout: "horizontal", + margin: "md", + contents: [ + %LineBot.Message.Flex.Text{ + text: "PAYMENT ID", + size: "xs", + color: "#aaaaaa", + flex: 0 + }, + %LineBot.Message.Flex.Text{ + text: "#743289384279", + size: "xs", + color: "#aaaaaa", + align: "end" + } + ] + } + ] + }, + styles: %LineBot.Message.Flex.BubbleStyle{ + footer: %LineBot.Message.Flex.BubbleStyleBlock{ + separator: true + } + } + } + } + end +end diff --git a/sample/lib/line_bot_sample/image_map.ex b/sample/lib/line_bot_sample/image_map.ex new file mode 100644 index 0000000..a4dfc83 --- /dev/null +++ b/sample/lib/line_bot_sample/image_map.ex @@ -0,0 +1,59 @@ +defmodule LineBotSample.Imagemap do + def make_imagemap_message do + %LineBot.Message.Imagemap{ + baseUrl: "https://adamu.github.io/images/rich/", + altText: "Please select an option", + baseSize: %{width: 1040, height: 1040}, + actions: [ + %LineBot.Message.Imagemap.Action.URI{ + linkUri: "https://store.line.me/family/manga/en", + area: %{x: 0, y: 0, width: 512, height: 512} + }, + %LineBot.Message.Imagemap.Action.URI{ + linkUri: "https://store.line.me/family/music/en", + area: %{x: 512, y: 0, width: 512, height: 512} + }, + %LineBot.Message.Imagemap.Action.URI{ + linkUri: "https://store.line.me/family/play/en", + area: %{x: 0, y: 512, width: 512, height: 512} + }, + %LineBot.Message.Imagemap.Action.Message{ + text: "Fortune!", + area: %{x: 512, y: 512, width: 512, height: 512} + } + ] + } + end + + def make_imagemap_video_message do + %LineBot.Message.Imagemap{ + baseUrl: "https://adamu.github.io/images/rich/", + altText: "Please select an option", + baseSize: %{width: 1040, height: 1040}, + video: %LineBot.Message.Imagemap.Video{ + originalContentUrl: "https://adamu.github.io/images/imagemap/video.mp4", + previewImageUrl: "https://adamu.github.io/images/imagemap/preview.jpg", + area: %{x: 0, y: 0, width: 512, height: 512}, + externalLink: %{linkUri: "https://line.me", label: "LINE"} + }, + actions: [ + %LineBot.Message.Imagemap.Action.URI{ + linkUri: "https://store.line.me/family/manga/en", + area: %{x: 0, y: 0, width: 512, height: 512} + }, + %LineBot.Message.Imagemap.Action.URI{ + linkUri: "https://store.line.me/family/music/en", + area: %{x: 512, y: 0, width: 512, height: 512} + }, + %LineBot.Message.Imagemap.Action.URI{ + linkUri: "https://store.line.me/family/play/en", + area: %{x: 0, y: 512, width: 512, height: 512} + }, + %LineBot.Message.Imagemap.Action.Message{ + text: "Fortune!", + area: %{x: 512, y: 512, width: 512, height: 512} + } + ] + } + end +end diff --git a/sample/lib/line_bot_sample/router.ex b/sample/lib/line_bot_sample/router.ex new file mode 100644 index 0000000..87995e3 --- /dev/null +++ b/sample/lib/line_bot_sample/router.ex @@ -0,0 +1,34 @@ +defmodule LineBotSample.Router do + use Plug.Router + use Plug.ErrorHandler + require Logger + + plug Plug.Logger + + plug :match + plug :dispatch + + forward "/bot", to: LineBot.Webhook, callback: LineBotSample + + get "/" do + send_resp(conn, :ok, "Server is alive.") + end + + match "/" do + send_resp(conn, :method_not_allowed, "") + end + + match _ do + send_resp(conn, :not_found, "") + end + + def handle_errors(conn, %{kind: :error, reason: reason, stack: _stack}) do + message = Exception.message(reason) + Logger.error(message) + send_resp(conn, conn.status, message) + end + + def handle_errors(conn, _) do + send_resp(conn, conn.status, "Server Error") + end +end diff --git a/sample/mix.exs b/sample/mix.exs new file mode 100644 index 0000000..6b0523e --- /dev/null +++ b/sample/mix.exs @@ -0,0 +1,29 @@ +defmodule LineBotSample.MixProject do + use Mix.Project + + def project do + [ + app: :line_bot_sample, + version: "0.1.0", + elixir: "~> 1.9", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + mod: {LineBotSample.Application, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:plug_cowboy, "~> 2.0"}, + {:jason, "~> 1.1"}, + {:line_bot, path: "../"} + ] + end +end diff --git a/sample/mix.lock b/sample/mix.lock new file mode 100644 index 0000000..949e812 --- /dev/null +++ b/sample/mix.lock @@ -0,0 +1,19 @@ +%{ + "certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"}, + "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"}, + "hackney": {:hex, :hackney, "1.15.1", "9f8f471c844b8ce395f7b6d8398139e26ddca9ebc171a8b91342ee15a19963f4", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"}, + "httpoison": {:hex, :httpoison, "1.5.1", "0f55b5b673b03c5c327dac7015a67cb571b99b631acc0bc1b0b98dcd6b9f2104", [:mix], [{:hackney, "~> 1.8", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}, + "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, + "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, + "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, + "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"}, + "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, + "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.0.1", "d798f8ee5acc86b7d42dbe4450b8b0dadf665ce588236eb0a751a132417a980e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, + "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, + "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], [], "hexpm"}, + "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"}, +} diff --git a/sample/test/line_bot_sample_test.exs b/sample/test/line_bot_sample_test.exs new file mode 100644 index 0000000..4244f9d --- /dev/null +++ b/sample/test/line_bot_sample_test.exs @@ -0,0 +1,4 @@ +defmodule LineBotSampleTest do + use ExUnit.Case + doctest LineBotSample +end diff --git a/sample/test/test_helper.exs b/sample/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/sample/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start() diff --git a/test/line_bot/api_client_test.exs b/test/line_bot/api_client_test.exs new file mode 100644 index 0000000..b7ea960 --- /dev/null +++ b/test/line_bot/api_client_test.exs @@ -0,0 +1,64 @@ +defmodule LineBot.APIClientTest do + use ExUnit.Case + import Mox + alias LineBot.APIClient + + describe "Failure counter server" do + test "initialises to 0" do + assert APIClient.init(nil) == {:ok, 0} + end + + test "get_and_inc_auth_fail fetches and increments count" do + assert APIClient.handle_call(:get_and_inc_auth_fail, nil, 1) == {:reply, 1, 2} + end + + test "reset resets the auth count" do + assert APIClient.handle_call(:reset, nil, 123_123) == {:reply, :ok, 0} + end + end + + describe "HTTPoison.Base" do + test "process_request_url adds prefix" do + assert "https://api.line.me/v2/bot/foo" == APIClient.process_request_url("foo") + end + + test "process_response passes through non-json responses" do + response = %HTTPoison.Response{headers: [], body: "opaque"} + assert response == APIClient.process_response(response) + end + + test "process_repsonse decodes json responses" do + content_type = {"content-type", "application/json"} + response = %HTTPoison.Response{headers: [content_type], body: ~S'{"foo":"bar"}'} + assert %{"foo" => "bar"} == APIClient.process_response(response).body + end + + test "process_request_headers adds auth header from token server" do + expect(MockTokenServer, :get_token, fn -> "dummy_token" end) + expect(MockTokenServer, :get_token, fn -> "changed_token" end) + + assert [{"Authorization", "Bearer dummy_token"}] == APIClient.process_request_headers([]) + + assert [{"Authorization", "Bearer changed_token"}, {"other", "header"}] == + APIClient.process_request_headers([{"other", "header"}]) + end + + # TODO these call super. Not sure how to test them without calling HTTPoison. + # test "post adds json header and encodes" + # test "post! adds json header and encodes" + + # TODO Test failure counter with global state? + # What we really want to do here is start up an independent one for this test + # can check the state against that. However, current code hardwires the process name. + # Can we just test against the global object? + # setup do + # start_supervised(APIClient) + # :ok + # end + # request + # 200 => {ok, response} + # 401 when < 3, purges token server and tries again + # 401 when >= 3, {:ok, repsonse} + # other => other + end +end diff --git a/test/line_bot/validator_plug_test.exs b/test/line_bot/validator_plug_test.exs new file mode 100644 index 0000000..a3d1bda --- /dev/null +++ b/test/line_bot/validator_plug_test.exs @@ -0,0 +1,59 @@ +defmodule LineBot.ValidatorPlugTest do + use ExUnit.Case + use Plug.Test + alias LineBot.ValidatorPlug + + describe "init" do + test "init just returns its arguments" do + assert :foo == ValidatorPlug.init(:foo) + assert ["bar"] == ValidatorPlug.init(["bar"]) + assert %{"key" => "value"} == ValidatorPlug.init(%{"key" => "value"}) + end + end + + describe "call" do + setup do + Application.put_env(:line_bot, :client_secret, "dummy key") + end + + test "fails if client_secret is not in env" do + Application.delete_env(:line_bot, :client_secret) + assert %ArgumentError{} = catch_error(ValidatorPlug.call(conn(:get, "/"), nil)) + end + + test "returns conn unaltered if skip_validation is set" do + Application.put_env(:line_bot, :skip_validation, true) + conn = conn(:get, "/") + assert conn == ValidatorPlug.call(conn, nil) + Application.delete_env(:line_bot, :skip_validation) + end + + test "rejects requests with no X-Line-Signature header" do + conn = conn(:post, "/") |> put_private(:line_bot_raw_body, "body") + assert %Plug.Conn{status: 403, halted: true} = ValidatorPlug.call(conn, nil) + end + + test "rejects requests with no body" do + conn = conn(:post, "/") |> put_req_header("x-line-signature", "foo") + assert %Plug.Conn{status: 403, halted: true} = ValidatorPlug.call(conn, nil) + end + + test "rejects requests when the signature is invalid" do + conn = + conn(:post, "/") + |> put_req_header("x-line-signature", "foo") + |> put_private(:line_bot_raw_body, "body") + + assert %Plug.Conn{status: 403, halted: true} = ValidatorPlug.call(conn, nil) + end + + test "passes requests when the signature is valid" do + conn = + conn(:post, "/") + |> put_req_header("x-line-signature", "tzyYOZaQ8aI4GIwXQyDcTmuVRPB8lcKvLJ5cM++Nhjc=") + |> put_private(:line_bot_raw_body, "body") + + assert conn == ValidatorPlug.call(conn, nil) + end + end +end diff --git a/test/line_bot_test.exs b/test/line_bot_test.exs new file mode 100644 index 0000000..9750f6f --- /dev/null +++ b/test/line_bot_test.exs @@ -0,0 +1,82 @@ +defmodule LineBotTest do + use ExUnit.Case, async: true + import LineBot.TestHelpers + import Mox + + test "defines callbacks for every event" do + expected_callbacks = [ + handle_unfollow: 1, + handle_things: 3, + handle_postback: 3, + handle_other: 4, + handle_message: 3, + handle_member_left: 2, + handle_member_joined: 3, + handle_leave: 1, + handle_join: 2, + handle_follow: 2, + handle_beacon: 3, + handle_account_link: 3 + ] + + assert LineBot.behaviour_info(:callbacks) == expected_callbacks + assert LineBot.behaviour_info(:optional_callbacks) == expected_callbacks + end + + test_post_msg :send_reply, "message/reply", "token", %{ + "replyToken" => "token", + "messages" => ["message"], + "notificationDisabled" => "disabled" + } + + test_post_msg :send_push, "message/push", "to", %{ + "to" => "to", + "messages" => ["message"], + "notificationDisabled" => "disabled" + } + + test_post_msg :send_multicast, "message/multicast", ["to"], %{ + "to" => ["to"], + "messages" => ["message"], + "notificationDisabled" => "disabled" + } + + test_post_msg :send_broadcast, "message/broadcast", nil, %{ + "messages" => ["message"], + "notificationDisabled" => "disabled" + } + + test_post_uri :leave_group, "group/group_id/leave", "group_id" + test_post_uri :leave_room, "room/room_id/leave", "room_id" + test_post_uri :issue_link_token, "user/user_id/linkToken", "user_id" + + test_get_for :get_quota, "message/quota", ["date"], date: "date" + test_get_for :get_sent_reply_count, "message/delivery/reply", ["date"], date: "date" + test_get_for :get_sent_push_count, "message/delivery/push", ["date"], date: "date" + test_get_for :get_sent_multicast_count, "message/delivery/multicast", ["date"], date: "date" + test_get_for :get_sent_broadcast_count, "message/delivery/broadcast", ["date"], date: "date" + test_get_for :get_sent_message_count, "insight/message/delivery", ["date"], date: "date" + test_get_for :get_follower_count, "insight/followers", ["date"], date: "date" + test_get_for :get_quota_consumption, "message/quota/consumption", [], [] + test_get_for :get_follower_demographics, "insight/demographic", [], [] + test_get_for :get_profile, "profile/profile_id", ["profile_id"], [] + test_get_for :get_group_member_ids, "group/group_id/members/ids", ["group_id"], [] + test_get_for :get_room_member_ids, "room/room_id/members/ids", ["room_id"], [] + + test_get_for :get_group_member_profile, + "group/group_id/member/user_id", + ["group_id", "user_id"], + [] + + test_get_for :get_room_member_profile, + "room/room_id/member/user_id", + ["room_id", "user_id"], + [] + + test "get_content/1" do + response = %HTTPoison.Response{body: "dummy"} + expect(MockAPIClient, :get!, fn "message/1/content" -> response end) + + assert response == LineBot.get_content("1") + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..9af4dd4 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,241 @@ +Mox.defmock(MockAPIClient, for: HTTPoison.Base) +Mox.defmock(MockTokenServer, for: LineBot.TokenServer.Behaviour) + +defmodule LineBot.TestHelpers do + defmacro test_get_with_status(function, uri, args, params, status, status_atom) do + quote do + test "when get response is #{unquote(status)}" do + expect(MockAPIClient, :get, fn unquote(uri), [], params: unquote(params) -> + {:ok, %{status_code: unquote(status), body: "body"}} + end) + + assert {unquote(status_atom), "body"} == apply(LineBot, unquote(function), unquote(args)) + end + end + end + + defmacro test_get_for(function, uri, args, params) do + quote do + describe Atom.to_string(unquote(function)) do + test_get_with_status( + unquote(function), + unquote(uri), + unquote(args), + unquote(params), + 200, + :ok + ) + + test_get_with_status( + unquote(function), + unquote(uri), + unquote(args), + unquote(params), + 401, + :unauthorized + ) + + test_get_with_status( + unquote(function), + unquote(uri), + unquote(args), + unquote(params), + 403, + :forbidden + ) + + test_get_with_status( + unquote(function), + unquote(uri), + unquote(args), + unquote(params), + 404, + :not_found + ) + + test_get_with_status( + unquote(function), + unquote(uri), + unquote(args), + unquote(params), + 429, + :too_many_requests + ) + + test_get_with_status( + unquote(function), + unquote(uri), + unquote(args), + unquote(params), + 500, + :server_error + ) + + test "when error occurs during post" do + error = %HTTPoison.Error{reason: "test error"} + + expect(MockAPIClient, :get, fn unquote(uri), [], params: unquote(params) -> + {:error, error} + end) + + assert {:error, error} == apply(LineBot, unquote(function), unquote(args)) + end + end + end + end + + defmacro expect_post_status(uri, status) do + quote do + expect(MockAPIClient, :post, fn unquote(uri), data -> + {:ok, %{status_code: unquote(status), body: data}} + end) + end + end + + defmacro test_post_msg_with_status(function, uri, to, status, status_atom) do + quote do + test "when post response is #{unquote(status)}", %{data: data} do + expect_post_status(unquote(uri), unquote(status)) + args = [["message"], "disabled"] + args = if unquote(to), do: [unquote(to) | args], else: args + assert {unquote(status_atom), data} == apply(LineBot, unquote(function), args) + end + end + end + + defmacro test_post_msg(function, uri, to, data) do + quote do + describe Atom.to_string(unquote(function)) do + setup do + %{data: unquote(data)} + end + + test_post_msg_with_status(unquote(function), unquote(uri), unquote(to), 200, :ok) + + test_post_msg_with_status( + unquote(function), + unquote(uri), + unquote(to), + 401, + :unauthorized + ) + + test_post_msg_with_status(unquote(function), unquote(uri), unquote(to), 403, :forbidden) + test_post_msg_with_status(unquote(function), unquote(uri), unquote(to), 404, :not_found) + + test_post_msg_with_status( + unquote(function), + unquote(uri), + unquote(to), + 429, + :too_many_requests + ) + + test_post_msg_with_status( + unquote(function), + unquote(uri), + unquote(to), + 500, + :server_error + ) + + test "when error occurs during post" do + error = %HTTPoison.Error{reason: "test error"} + expect(MockAPIClient, :post, fn unquote(uri), _data -> {:error, error} end) + + args = [["message"], "disabled"] + args = if unquote(to), do: [unquote(to) | args], else: args + assert {:error, error} == apply(LineBot, unquote(function), args) + end + + test "notification_disabled defaults to false" do + expect(MockAPIClient, :post, fn unquote(uri), data -> + {:ok, %{status_code: 200, body: data}} + end) + + args = [["message"]] + args = if unquote(to), do: [unquote(to) | args], else: args + + assert {:ok, %{"notificationDisabled" => false}} = + apply(LineBot, unquote(function), args) + + expect(MockAPIClient, :post, fn unquote(uri), data -> + {:ok, %{status_code: 200, body: data}} + end) + + args = [["message"], true] + args = if unquote(to), do: [unquote(to) | args], else: args + + assert {:ok, %{"notificationDisabled" => true}} = + apply(LineBot, unquote(function), args) + end + + test "single message is converted to single-element list" do + expect(MockAPIClient, :post, fn unquote(uri), data -> + {:ok, %{status_code: 200, body: data}} + end) + + args = ["message"] + args = if unquote(to), do: [unquote(to) | args], else: args + + assert {:ok, %{"messages" => ["message"]}} = apply(LineBot, unquote(function), args) + end + end + end + end + + defmacro test_post_uri_with_status(function, uri, arg, status, status_atom) do + quote do + test "when post response is #{unquote(status)}" do + data = %{} + expect_post_status(unquote(uri), unquote(status)) + assert {unquote(status_atom), data} == apply(LineBot, unquote(function), [unquote(arg)]) + end + end + end + + # TODO remove this duplication by refactoring the macro to test try_post behaviour, + # and extracting the surrouding logic + defmacro test_post_uri(function, uri, arg) do + quote do + describe Atom.to_string(unquote(function)) do + test_post_uri_with_status(unquote(function), unquote(uri), unquote(arg), 200, :ok) + + test_post_uri_with_status( + unquote(function), + unquote(uri), + unquote(arg), + 401, + :unauthorized + ) + + test_post_uri_with_status(unquote(function), unquote(uri), unquote(arg), 403, :forbidden) + test_post_uri_with_status(unquote(function), unquote(uri), unquote(arg), 404, :not_found) + + test_post_uri_with_status( + unquote(function), + unquote(uri), + unquote(arg), + 429, + :too_many_requests + ) + + test_post_uri_with_status( + unquote(function), + unquote(uri), + unquote(arg), + 500, + :server_error + ) + + test "when error occurs during post" do + error = %HTTPoison.Error{reason: "test error"} + expect(MockAPIClient, :post, fn unquote(uri), _data -> {:error, error} end) + assert {:error, error} == apply(LineBot, unquote(function), [unquote(arg)]) + end + end + end + end +end + +ExUnit.start()