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.
This commit is contained in:
Adam Millerchip 2019-09-02 20:46:29 +09:00
commit 7915b6d4ed
38 changed files with 3115 additions and 0 deletions

32
.formatter.exs Normal file
View File

@ -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]
]

30
.gitignore vendored Normal file
View File

@ -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

97
README.md Normal file
View File

@ -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.

18
config/config.exs Normal file
View File

@ -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

390
lib/line_bot.ex Normal file
View File

@ -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

124
lib/line_bot/api_client.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

125
lib/line_bot/message.ex Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

102
lib/line_bot/webhook.ex Normal file
View File

@ -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

106
mix.exs Normal file
View File

@ -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

22
mix.lock Normal file
View File

@ -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"},
}

29
sample/.formatter.exs Normal file
View File

@ -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]
]

13
sample/README.md Normal file
View File

@ -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`.

10
sample/config/config.exs Normal file
View File

@ -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")

View File

@ -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 <code>
"""
}
# ----------------------------------------------------------------------------
# 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

29
sample/mix.exs Normal file
View File

@ -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

19
sample/mix.lock Normal file
View File

@ -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"},
}

View File

@ -0,0 +1,4 @@
defmodule LineBotSampleTest do
use ExUnit.Case
doctest LineBotSample
end

View File

@ -0,0 +1 @@
ExUnit.start()

View File

@ -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

View File

@ -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

82
test/line_bot_test.exs Normal file
View File

@ -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

241
test/test_helper.exs Normal file
View File

@ -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()