Repository: line/line-bot-sdk-python
Pull request #178 (2019-06-17 14:47:29)

New field 'destination' in Webhook request
#131
1. Add a new field **destination** in Webhook request.
1. Add tests
1. Update [README](https://github.com/line/line-bot-sdk-python/blob/issue-131-destination/README.rst#webhook)
   - Add an explanation about _WebhookHandler.add_


Patch:
diff --git a/README.rst b/README.rst
index e9f1d0d..4f24f81 100644
--- a/README.rst
+++ b/README.rst
@@ -737,18 +737,25 @@ WebhookParser
 
     parser = linebot.WebhookParser('YOUR_CHANNEL_SECRET')
 
-parse(self, body, signature)
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+parse(self, body, signature, as_payload=False)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-Parses the webhook body and builds an event object list. If the signature does NOT
-match, InvalidSignatureError is raised.
+Parses the webhook body, and returns a list of Event objects or a WebhookPayload object (depending on as_payload).
+If the signature does NOT match, ``InvalidSignatureError`` is raised.
 
 .. code:: python
 
     events = parser.parse(body, signature)
 
     for event in events:
-        # Do something
+        do_something(event)
+
+.. code:: python
+
+    payload = parser.parse(body, signature, as_payload=True)
+
+    for event in payload.events:
+        do_something(payload.event, payload.destination)
 
 WebhookHandler
 ~~~~~~~~~~~~~~
@@ -765,19 +772,18 @@ WebhookHandler
 handle(self, body, signature)
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-Handles webhooks. If the signature does NOT match,
-InvalidSignatureError is raised.
+Handles webhooks with **handlers** added
+by the decorators `add <#add-self-event-message-none>`__ and `default <#default-self>`__.
+If the signature does NOT match, ``InvalidSignatureError`` is raised.
 
 .. code:: python
 
     handler.handle(body, signature)
 
-Add handler method
-^^^^^^^^^^^^^^^^^^
+add(self, event, message=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
-You can add a handler method by using the ``add`` decorator.
-
-``add(self, event, message=None)``
+Add a **handler** method by using this decorator.
 
 .. code:: python
 
@@ -787,15 +793,30 @@ You can add a handler method by using the ``add`` decorator.
             event.reply_token,
             TextSendMessage(text=event.message.text))
 
-When the event is an instance of MessageEvent and event.message is an instance of
-TextMessage, this handler method is called.
+When the event is an instance of MessageEvent and event.message is an instance of TextMessage,
+this handler method is called.
 
-Set default handler method
-^^^^^^^^^^^^^^^^^^^^^^^^^^
+.. code:: python
 
-You can set the default handler method by using the ``default`` decorator.
+    @handler.add(MessageEvent)
+    def handle_message(event, destination):
+        # do something
 
-``default(self)``
+If the arity of the handler method is more than one,
+a destination property in a webhook request is passed to it as the second argument.
+
+.. code:: python
+
+    @handler.add(FollowEvent)
+    def handle_follow():
+        # do something
+
+If the arity of the handler method is zero, the handler method is called with no arguments.
+
+default(self)
+^^^^^^^^^^^^^
+
+Set the default **handler** method by using this decorator.
 
 .. code:: python
 
@@ -805,6 +826,15 @@ You can set the default handler method by using the ``default`` decorator.
 
 If there is no handler for an event, this default handler method is called.
 
+WebhookPayload
+~~~~~~~~~~~~~~~
+
+https://developers.line.biz/en/reference/messaging-api/#request-body
+
+- WebhookPayload
+    - destination
+    - events: list[`Event <#event>`__]
+
 Webhook event object
 ~~~~~~~~~~~~~~~~~~~~
 
@@ -977,6 +1007,7 @@ Test by using tox. We test against the following versions.
 -  3.4
 -  3.5
 -  3.6
+-  3.7
 
 To run all tests and to run ``flake8`` against all versions, use:
 
@@ -1002,8 +1033,8 @@ And more... TBD
    :target: https://travis-ci.org/line/line-bot-sdk-python
 .. |PyPI version| image:: https://badge.fury.io/py/line-bot-sdk.svg
    :target: https://badge.fury.io/py/line-bot-sdk
-.. |Documentation Status| image:: https://readthedocs.org/projects/line-bot-sdk-python/badge/?version=latest
-   :target: http://line-bot-sdk-python.readthedocs.io/en/latest/?badge=latest
+.. |Documentation Status| image:: https://readthedocs.org/projects/line-bot-sdk-python/badge/?version=stable
+   :target: http://line-bot-sdk-python.readthedocs.io/en/stable
 
 License
 --------
diff --git a/docs/source/linebot.models.rst b/docs/source/linebot.models.rst
index fb2f4dc..2b641f1 100644
--- a/docs/source/linebot.models.rst
+++ b/docs/source/linebot.models.rst
@@ -1,6 +1,14 @@
 linebot.models package
 ======================
 
+linebot.models.actions module
+--------------------------
+
+.. automodule:: linebot.models.actions
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 linebot.models.base module
 --------------------------
 
@@ -49,6 +57,14 @@ linebot.models.responses module
     :undoc-members:
     :show-inheritance:
 
+linebot.models.rich_menu module
+-------------------------------
+
+.. automodule:: linebot.models.rich_menu
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
 linebot.models.send_messages module
 -----------------------------------
 
@@ -72,3 +88,11 @@ linebot.models.template module
     :members:
     :undoc-members:
     :show-inheritance:
+
+linebot.models.things module
+------------------------------
+
+.. automodule:: linebot.models.things
+    :members:
+    :undoc-members:
+    :show-inheritance:
diff --git a/linebot/__init__.py b/linebot/__init__.py
index 2598d05..35fb9b2 100644
--- a/linebot/__init__.py
+++ b/linebot/__init__.py
@@ -31,4 +31,5 @@ from .webhook import (  # noqa
     SignatureValidator,
     WebhookParser,
     WebhookHandler,
+    WebhookPayload,
 )
diff --git a/linebot/models/__init__.py b/linebot/models/__init__.py
index cb52705..1a3346a 100644
--- a/linebot/models/__init__.py
+++ b/linebot/models/__init__.py
@@ -136,3 +136,10 @@ from .template import (  # noqa
     ImageCarouselTemplate,
     ImageCarouselColumn,
 )
+from .things import (  # noqa
+    DeviceLink,
+    DeviceUnlink,
+    ScenarioResult,
+    ActionResult,
+    Things,
+)
diff --git a/linebot/models/events.py b/linebot/models/events.py
index 4783251..cfddc35 100644
--- a/linebot/models/events.py
+++ b/linebot/models/events.py
@@ -20,8 +20,8 @@ from abc import ABCMeta
 
 from future.utils import with_metaclass
 
-from .base import Base
-from .messages import (
+from linebot.models.base import Base
+from linebot.models.messages import (
     TextMessage,
     ImageMessage,
     VideoMessage,
@@ -30,7 +30,13 @@ from .messages import (
     StickerMessage,
     FileMessage
 )
-from .sources import SourceUser, SourceGroup, SourceRoom
+from linebot.models.sources import SourceUser, SourceGroup, SourceRoom
+from linebot.models.things import (
+    DeviceUnlink,
+    DeviceLink,
+    ScenarioResult,
+)
+from linebot.models.things import Things  # noqa
 
 
 class Event(with_metaclass(ABCMeta, Base)):
@@ -360,20 +366,20 @@ class ThingsEvent(Event):
     """Webhook ThingsEvent.
 
     https://developers.line.biz/en/reference/messaging-api/#device-link-event
+    https://developers.line.biz/en/reference/messaging-api/#device-unlink-event
+    https://developers.line.biz/en/reference/messaging-api/#scenario-result-event
 
-    Indicates that a LINE Things-compatible device has been linked with LINE by
-    a user operation.
+    Event sent from LINE Things Webhook service.
     """
 
     def __init__(self, timestamp=None, source=None, reply_token=None, things=None, **kwargs):
         """__init__ method.
 
-        :param long timestamp: Time of the event in milliseconds
         :param source: Source object
         :type source: T <= :py:class:`linebot.models.sources.Source`
         :param str reply_token: Reply token
         :param things: Things object
-        :type things: :py:class:`linebot.models.events.Things`
+        :type things: T <= :py:class:`linebot.models.things.Things`
         :param kwargs:
         """
         super(ThingsEvent, self).__init__(
@@ -382,8 +388,12 @@ class ThingsEvent(Event):
 
         self.type = 'things'
         self.reply_token = reply_token
-        self.things = self.get_or_new_from_json_dict(
-            things, Things
+        self.things = self.get_or_new_from_json_dict_with_types(
+            things, {
+                'link': DeviceLink,
+                'unlink': DeviceUnlink,
+                'scenarioResult': ScenarioResult,
+            }
         )
 
 
@@ -498,21 +508,3 @@ class Link(Base):
 
         self.result = result
         self.nonce = nonce
-
-
-class Things(Base):
-    """Things.
-
-    https://developers.line.biz/en/docs/line-things/develop-bot/#link-event
-    """
-
-    def __init__(self, device_id=None, type=None, **kwargs):
-        """__init__ method.
-
-        :param str device_id: Device ID of the device that was linked with LINE.
-        :param str type: link or unlink
-        """
-        super(Things, self).__init__(**kwargs)
-
-        self.device_id = device_id
-        self.type = type
diff --git a/linebot/models/messages.py b/linebot/models/messages.py
index ace6e23..f541d29 100644
--- a/linebot/models/messages.py
+++ b/linebot/models/messages.py
@@ -41,7 +41,7 @@ class Message(with_metaclass(ABCMeta, Base)):
 class TextMessage(Message):
     """TextMessage.
 
-    https://devdocs.line.me/en/#text-message
+    https://developers.line.biz/en/reference/messaging-api/#wh-text
 
     Message object which contains the text sent from the source.
     """
@@ -62,67 +62,89 @@ class TextMessage(Message):
 class ImageMessage(Message):
     """ImageMessage.
 
-    https://devdocs.line.me/en/#image-message
+    https://developers.line.biz/en/reference/messaging-api/#wh-image
 
     Message object which contains the image content sent from the source.
     The binary image data can be retrieved with the Content API.
     """
 
-    def __init__(self, id=None, **kwargs):
+    def __init__(self, id=None, content_provider=None, **kwargs):
         """__init__ method.
 
         :param str id: Message ID
+        :param content_provider: ContentProvider object
+        :type content_provider:
+            :py:class:`linebot.models.messages.ContentProvider` | dict[str, str]
         :param kwargs:
         """
         super(ImageMessage, self).__init__(id=id, **kwargs)
 
         self.type = 'image'
+        self.content_provider = self.get_or_new_from_json_dict(
+            content_provider, ContentProvider
+        )
 
 
 class VideoMessage(Message):
     """VideoMessage.
 
-    https://devdocs.line.me/en/#video-message
+    https://developers.line.biz/en/reference/messaging-api/#wh-video
 
     Message object which contains the video content sent from the source.
     The binary video data can be retrieved with the Content API.
     """
 
-    def __init__(self, id=None, **kwargs):
+    def __init__(self, id=None, duration=None, content_provider=None, **kwargs):
         """__init__ method.
 
         :param str id: Message ID
+        :param long duration: Length of video file (milliseconds)
+        :param content_provider: ContentProvider object
+        :type content_provider:
+            :py:class:`linebot.models.messages.ContentProvider` | dict[str, str]
         :param kwargs:
         """
         super(VideoMessage, self).__init__(id=id, **kwargs)
 
         self.type = 'video'
+        self.duration = duration
+        self.content_provider = self.get_or_new_from_json_dict(
+            content_provider, ContentProvider
+        )
 
 
 class AudioMessage(Message):
     """AudioMessage.
 
-    https://devdocs.line.me/en/#audio-message
+    https://developers.line.biz/en/reference/messaging-api/#wh-audio
 
     Message object which contains the audio content sent from the source.
     The binary audio data can be retrieved with the Content API.
     """
 
-    def __init__(self, id=None, **kwargs):
+    def __init__(self, id=None, duration=None, content_provider=None, **kwargs):
         """__init__ method.
 
         :param str id: Message ID
+        :param long duration: Length of audio file (milliseconds)
+        :param content_provider: ContentProvider object
+        :type content_provider:
+            :py:class:`linebot.models.messages.ContentProvider` | dict[str, str]
         :param kwargs:
         """
         super(AudioMessage, self).__init__(id=id, **kwargs)
 
         self.type = 'audio'
+        self.duration = duration
+        self.content_provider = self.get_or_new_from_json_dict(
+            content_provider, ContentProvider
+        )
 
 
 class LocationMessage(Message):
     """LocationMessage.
 
-    https://devdocs.line.me/en/#location-message
+    https://developers.line.biz/en/reference/messaging-api/#wh-location
     """
 
     def __init__(self, id=None, title=None, address=None, latitude=None, longitude=None,
@@ -148,7 +170,7 @@ class LocationMessage(Message):
 class StickerMessage(Message):
     """StickerMessage.
 
-    https://devdocs.line.me/en/#sticker-message
+    https://developers.line.biz/en/reference/messaging-api/#wh-sticker
 
     Message object which contains the sticker data sent from the source.
     For a list of basic LINE stickers and sticker IDs, see sticker list.
@@ -172,7 +194,7 @@ class StickerMessage(Message):
 class FileMessage(Message):
     """FileMessage.
 
-    https://devdocs.line.me/en/#file-message
+    https://developers.line.biz/en/reference/messaging-api/#wh-file
 
     Message object which contains the file content sent from the source.
     The binary file data can be retrieved with the Content API.
@@ -191,3 +213,21 @@ class FileMessage(Message):
         self.type = 'file'
         self.file_size = file_size
         self.file_name = file_name
+
+
+class ContentProvider(Base):
+    """Content provider."""
+
+    def __init__(self, type=None, original_content_url=None, preview_image_url=None, **kwargs):
+        """__init__ method.
+
+        :param str type: Provider of the content. `line` or `external`.
+        :param str original_content_url: URL of the content.
+        :param str preview_image_url: URL of the preview image.
+        :param kwargs:
+        """
+        super(ContentProvider, self).__init__(**kwargs)
+
+        self.type = type
+        self.original_content_url = original_content_url
+        self.preview_image_url = preview_image_url
diff --git a/linebot/models/things.py b/linebot/models/things.py
new file mode 100644
index 0000000..04a1ac0
--- /dev/null
+++ b/linebot/models/things.py
@@ -0,0 +1,150 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+"""linebot.models.things module."""
+
+from __future__ import unicode_literals
+
+from abc import ABCMeta
+
+from future.utils import with_metaclass
+
+from .base import Base
+
+
+class Things(with_metaclass(ABCMeta, Base)):
+    """Abstract Base Class of Things."""
+
+    def __init__(self, device_id=None, **kwargs):
+        """__init__ method.
+
+        :param str device_id: Device ID.
+        :param kwargs:
+        """
+        super(Things, self).__init__(**kwargs)
+        self.device_id = device_id
+
+
+class DeviceLink(Things):
+    """DeviceLink.
+
+    https://developers.line.biz/en/reference/messaging-api/#device-link-event
+
+    Indicates that a user linked a device with LINE.
+    """
+
+    def __init__(self, device_id=None, **kwargs):
+        """__init__ method.
+
+        :param str device_id: Device ID of the device that has been linked with LINE.
+        :param kwargs:
+        """
+        super(DeviceLink, self).__init__(device_id=device_id, **kwargs)
+
+        self.type = 'link'
+
+
+class DeviceUnlink(Things):
+    """DeviceUnlink.
+
+    https://developers.line.biz/en/reference/messaging-api/#device-unlink-event
+
+    Indicates that the user unlinked a device from LINE.
+    """
+
+    def __init__(self, device_id=None, **kwargs):
+        """__init__ method.
+
+        :param str device_id: Device ID of the device that was unlinked from LINE.
+        :param kwargs:
+        """
+        super(DeviceUnlink, self).__init__(device_id=device_id, **kwargs)
+
+        self.type = 'unlink'
+
+
+class ScenarioResult(Things):
+    """ScenarioResult.
+
+    https://developers.line.biz/en/reference/messaging-api/#scenario-result-event
+
+    Indicates that an automatic communication scenario has been executed.
+    """
+
+    def __init__(self, device_id=None, result=None, **kwargs):
+        """__init__ method.
+
+        :param str device_id: Device ID of the device that executed the scenario.
+        :param str result: ScenarioResultPayload object.
+        :param kwargs:
+        """
+        super(ScenarioResult, self).__init__(device_id=device_id, **kwargs)
+
+        self.type = 'scenarioResult'
+        self.result = self.get_or_new_from_json_dict(
+            result, ScenarioResultPayload
+        )
+
+
+class ScenarioResultPayload(Base):
+    """ScenarioResultPayload."""
+
+    def __init__(self, scenario_id=None, revision=None, start_time=None,
+                 result_code=None, end_time=None, action_results=None,
+                 ble_notification_payload=None, error_reason=None, **kwargs):
+        """__init__ method.
+
+        :param str scenario_id: Scenario ID executed.
+        :param long revision: Revision number.
+        :param long start_time: Timestamp for when execution of scenario
+            action started (milliseconds).
+        :param long end_time: Timestamp for when execution of scenario
+            was completed (milliseconds).
+        :param str result_code: Scenario execution completion status.
+        :param action_results: Array of actions specified in a scenario.
+        :type action_results: list[T <= :py:class:`linebot.models.things.ActionResult`]
+        :param str ble_notification_payload: Data contained in notification.
+        :param str error_reason: Error response.
+        :param kwargs:
+        """
+        super(ScenarioResultPayload, self).__init__(**kwargs)
+
+        self.scenario_id = scenario_id
+        self.revision = revision
+        self.start_time = start_time
+        self.end_time = end_time
+        self.result_code = result_code
+        self.action_results = [self.get_or_new_from_json_dict(it, ActionResult)
+                               for it in action_results]
+        self.ble_notification_payload = ble_notification_payload
+        self.error_reason = error_reason
+
+
+class ActionResult(Base):
+    """ActionResult.
+
+    Execution result of individual operations specified in action
+    """
+
+    def __init__(self, type=None, data=None, **kwargs):
+        """__init__ method.
+
+        :param str type: Type of the executed action.
+        :param str data: Base64-encoded binary data.
+        :param kwargs:
+        """
+        super(ActionResult, self).__init__(**kwargs)
+
+        self.type = type
+        self.data = data
diff --git a/linebot/webhook.py b/linebot/webhook.py
index 1fed867..29eb085 100644
--- a/linebot/webhook.py
+++ b/linebot/webhook.py
@@ -38,7 +38,6 @@ from .models.events import (
 )
 from .utils import LOGGER, PY3, safe_compare_digest
 
-
 if hasattr(hmac, "compare_digest"):
     def compare_digest(val1, val2):
         """compare_digest function.
@@ -101,10 +100,27 @@ class SignatureValidator(object):
         ).digest()
 
         return compare_digest(
-                signature.encode('utf-8'), base64.b64encode(gen_signature)
+            signature.encode('utf-8'), base64.b64encode(gen_signature)
         )
 
 
+class WebhookPayload(object):
+    """Webhook Payload.
+
+    https://developers.line.biz/en/reference/messaging-api/#request-body
+    """
+
+    def __init__(self, events=None, destination=None):
+        """__init__ method.
+
+        :param events: Information about the events.
+        :type events: list[T <= :py:class:`linebot.models.events.Event`]
+        :param str destination: User ID of a bot that should receive webhook events.
+        """
+        self.events = events
+        self.destination = destination
+
+
 class WebhookParser(object):
     """Webhook Parser."""
 
@@ -115,13 +131,15 @@ class WebhookParser(object):
         """
         self.signature_validator = SignatureValidator(channel_secret)
 
-    def parse(self, body, signature):
+    def parse(self, body, signature, as_payload=False):
         """Parse webhook request body as text.
 
         :param str body: Webhook request body (as text)
         :param str signature: X-Line-Signature value (as text)
+        :param bool as_payload: (optional) True to return WebhookPayload object.
         :rtype: list[T <= :py:class:`linebot.models.events.Event`]
-        :return:
+            | :py:class:`linebot.webhook.WebhookPayload`
+        :return: Events list, or WebhookPayload instance
         """
         if not self.signature_validator.validate(body, signature):
             raise InvalidSignatureError(
@@ -156,11 +174,17 @@ class WebhookParser(object):
             else:
                 LOGGER.warn('Unknown event type. type=' + event_type)
 
-        return events
+        if as_payload:
+            return WebhookPayload(events=events, destination=body_json['destination'])
+        else:
+            return events
 
 
 class WebhookHandler(object):
-    """Webhook Handler."""
+    """Webhook Handler.
+
+    Please read https://github.com/line/line-bot-sdk-python#webhookhandler
+    """
 
     def __init__(self, channel_secret):
         """__init__ method.
@@ -197,7 +221,7 @@ class WebhookHandler(object):
         """[Decorator] Set default handler method.
 
         :rtype: func
-        :return:
+        :return: decorator
         """
         def decorator(func):
             self._default = func
@@ -211,9 +235,9 @@ class WebhookHandler(object):
         :param str body: Webhook request body (as text)
         :param str signature: X-Line-Signature value (as text)
         """
-        events = self.parser.parse(body, signature)
+        payload = self.parser.parse(body, signature, as_payload=True)
 
-        for event in events:
+        for event in payload.events:
             func = None
             key = None
 
@@ -235,8 +259,10 @@ class WebhookHandler(object):
                 args_count = self.__get_args_count(func)
                 if args_count == 0:
                     func()
-                else:
+                elif args_count == 1:
                     func(event)
+                else:
+                    func(event, payload.destination)
 
     def __add_handler(self, func, event, message=None):
         key = self.__get_handler_key(event, message=message)


Test patch:
diff --git a/tests/test_webhook.py b/tests/test_webhook.py
index 203881d..57cf54c 100644
--- a/tests/test_webhook.py
+++ b/tests/test_webhook.py
@@ -27,8 +27,8 @@ from linebot.models import (
     MemberJoinedEvent, MemberLeftEvent, ThingsEvent,
     TextMessage, ImageMessage, VideoMessage, AudioMessage,
     LocationMessage, StickerMessage, FileMessage,
-    SourceUser, SourceRoom, SourceGroup
-)
+    SourceUser, SourceRoom, SourceGroup,
+    DeviceLink, DeviceUnlink, ScenarioResult, ActionResult)
 
 
 class TestSignatureValidator(unittest.TestCase):
@@ -85,6 +85,11 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[1].message, ImageMessage)
         self.assertEqual(events[1].message.id, '325708')
         self.assertEqual(events[1].message.type, 'image')
+        self.assertEqual(events[1].message.content_provider.type, 'external')
+        self.assertEqual(events[1].message.content_provider.original_content_url,
+                         "https://example.com")
+        self.assertEqual(events[1].message.content_provider.preview_image_url,
+                         "https://example.com")
 
         # MessageEvent, SourceUser, VideoMessage
         self.assertIsInstance(events[2], MessageEvent)
@@ -97,6 +102,12 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[2].message, VideoMessage)
         self.assertEqual(events[2].message.id, '325708')
         self.assertEqual(events[2].message.type, 'video')
+        self.assertEqual(events[2].message.duration, 60000)
+        self.assertEqual(events[2].message.content_provider.type, 'external')
+        self.assertEqual(events[2].message.content_provider.original_content_url,
+                         "https://example.com")
+        self.assertEqual(events[2].message.content_provider.preview_image_url,
+                         "https://example.com")
 
         # MessageEvent, SourceUser, AudioMessage
         self.assertIsInstance(events[3], MessageEvent)
@@ -109,6 +120,10 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[3].message, AudioMessage)
         self.assertEqual(events[3].message.id, '325708')
         self.assertEqual(events[3].message.type, 'audio')
+        self.assertEqual(events[3].message.duration, 60000)
+        self.assertEqual(events[3].message.content_provider.type, 'external')
+        self.assertEqual(events[3].message.content_provider.original_content_url,
+                         "https://example.com")
 
         # MessageEvent, SourceUser, LocationMessage
         self.assertIsInstance(events[4], MessageEvent)
@@ -293,8 +308,9 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[19].source, SourceUser)
         self.assertEqual(events[19].source.type, 'user')
         self.assertEqual(events[19].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[19].things.device_id, 't2c449c9d1')
+        self.assertIsInstance(events[19].things, DeviceLink)
         self.assertEqual(events[19].things.type, 'link')
+        self.assertEqual(events[19].things.device_id, 't2c449c9d1')
 
         # MemberJoinedEvent
         self.assertIsInstance(events[20], MemberJoinedEvent)
@@ -329,8 +345,9 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[22].source, SourceUser)
         self.assertEqual(events[22].source.type, 'user')
         self.assertEqual(events[22].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[22].things.device_id, 't2c449c9d1')
+        self.assertIsInstance(events[22].things, DeviceUnlink)
         self.assertEqual(events[22].things.type, 'unlink')
+        self.assertEqual(events[22].things.device_id, 't2c449c9d1')
 
         # MessageEvent, SourceUser, FileMessage
         self.assertIsInstance(events[23], MessageEvent)
@@ -347,52 +364,89 @@ class TestWebhookParser(unittest.TestCase):
         self.assertEqual(events[23].message.file_name, "file.txt")
         self.assertEqual(events[23].message.file_size, 2138)
 
+        # ThingsEvent, SourceUser, scenarioResult
+        self.assertIsInstance(events[24], ThingsEvent)
+        self.assertEqual(events[24].reply_token, 'nHuyWiB7yP5Zw52FIkcQobQuGDXCTA')
+        self.assertEqual(events[24].type, 'things')
+        self.assertEqual(events[24].timestamp, 1547817848122)
+        self.assertIsInstance(events[24].source, SourceUser)
+        self.assertEqual(events[24].source.type, 'user')
+        self.assertEqual(events[24].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
+        self.assertIsInstance(events[24].things, ScenarioResult)
+        self.assertEqual(events[24].things.type, 'scenarioResult')
+        self.assertEqual(events[24].things.device_id, 't2c449c9d1')
+        self.assertEqual(events[24].things.result.scenario_id, 'XXX')
+        self.assertEqual(events[24].things.result.revision, 2)
+        self.assertEqual(events[24].things.result.start_time, 1547817845950)
+        self.assertEqual(events[24].things.result.end_time, 1547817845952)
+        self.assertEqual(events[24].things.result.result_code, 'success')
+        self.assertEqual(events[24].things.result.ble_notification_payload, 'AQ==')
+        self.assertIsInstance(events[24].things.result.action_results[0], ActionResult)
+        self.assertEqual(events[24].things.result.action_results[0].type, 'binary')
+        self.assertEqual(events[24].things.result.action_results[0].data, '/w==')
+        self.assertIsInstance(events[24].things.result.action_results[1], ActionResult)
+        self.assertEqual(events[24].things.result.action_results[1].type, 'void')
+
 
 class TestWebhookHandler(unittest.TestCase):
     def setUp(self):
         self.handler = WebhookHandler('channel_secret')
-        self.calls = []
 
         @self.handler.add(MessageEvent, message=TextMessage)
-        def message_text(event):
-            self.calls.append('1 ' + event.type + '_' + event.message.type)
+        def message_text(event, destination):
+            self.assertEqual('message', event.type)
+            self.assertEqual('text', event.message.type)
+            self.assertEqual('U123', destination)
 
-        @self.handler.add(
-            MessageEvent, message=(ImageMessage, VideoMessage, AudioMessage))
+        @self.handler.add(MessageEvent,
+                          message=(ImageMessage, VideoMessage, AudioMessage))
         def message_content(event):
-            self.calls.append('2 ' + event.type + '_' + event.message.type)
+            self.assertEqual('message', event.type)
+            self.assertIn(
+                event.message.type,
+                ['image', 'video', 'audio']
+            )
 
         @self.handler.add(MessageEvent, message=StickerMessage)
         def message_sticker(event):
-            self.calls.append('3 ' + event.type + '_' + event.message.type)
+            self.assertEqual('message', event.type)
+            self.assertEqual('sticker', event.message.type)
 
         @self.handler.add(MessageEvent)
         def message(event):
-            self.calls.append(event.type + '_' + event.message.type)
+            self.assertEqual('message', event.type)
+            self.assertNotIn(
+                event.message.type,
+                ['text', 'image', 'video', 'audio', 'sticker']
+            )
 
         @self.handler.add(FollowEvent)
-        def follow(event):
-            self.calls.append('4 ' + event.type)
+        def follow(event, destination):
+            self.assertEqual('follow', event.type)
+            self.assertEqual('U123', destination)
 
         @self.handler.add(JoinEvent)
         def join(event):
-            self.calls.append('5 ' + event.type)
+            self.assertEqual('join', event.type)
 
         @self.handler.add(PostbackEvent)
         def postback(event):
-            self.calls.append('6 ' + event.type)
+            self.assertEqual('postback', event.type)
 
         @self.handler.add(BeaconEvent)
         def beacon(event):
-            self.calls.append('7 ' + event.type)
+            self.assertEqual('beacon', event.type)
 
         @self.handler.add(AccountLinkEvent)
         def account_link(event):
-            self.calls.append('8 ' + event.type)
+            self.assertEqual('accountLink', event.type)
 
         @self.handler.default()
         def default(event):
-            self.calls.append('default ' + event.type)
+            self.assertNotIn(
+                event.type,
+                ['message', 'follow', 'join', 'postback', 'beacon', 'accountLink']
+            )
 
     def test_handler(self):
         file_dir = os.path.dirname(__file__)
@@ -405,26 +459,6 @@ class TestWebhookHandler(unittest.TestCase):
 
         self.handler.handle(body, 'signature')
 
-        self.assertEqual(self.calls[0], '1 message_text')
-        self.assertEqual(self.calls[1], '2 message_image')
-        self.assertEqual(self.calls[2], '2 message_video')
-        self.assertEqual(self.calls[3], '2 message_audio')
-        self.assertEqual(self.calls[4], 'message_location')
-        self.assertEqual(self.calls[5], '3 message_sticker')
-        self.assertEqual(self.calls[6], '4 follow')
-        self.assertEqual(self.calls[7], 'default unfollow')
-        self.assertEqual(self.calls[8], '5 join')
-        self.assertEqual(self.calls[9], 'default leave')
-        self.assertEqual(self.calls[10], '6 postback')
-        self.assertEqual(self.calls[11], '7 beacon')
-        self.assertEqual(self.calls[12], '7 beacon')
-        self.assertEqual(self.calls[13], '8 accountLink')
-        self.assertEqual(self.calls[14], '1 message_text')
-        self.assertEqual(self.calls[15], '1 message_text')
-        self.assertEqual(self.calls[16], '6 postback')
-        self.assertEqual(self.calls[17], '6 postback')
-        self.assertEqual(self.calls[18], '6 postback')
-
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/text/webhook.json b/tests/text/webhook.json
index bfa54ec..5899f8a 100644
--- a/tests/text/webhook.json
+++ b/tests/text/webhook.json
@@ -1,4 +1,5 @@
 {
+  "destination": "U123",
   "events": [
     {
       "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
@@ -24,7 +25,12 @@
       },
       "message": {
         "id": "325708",
-        "type": "image"
+        "type": "image",
+        "contentProvider": {
+          "type": "external",
+          "originalContentUrl": "https://example.com",
+          "previewImageUrl": "https://example.com"
+        }
       }
     },
     {
@@ -37,7 +43,13 @@
       },
       "message": {
         "id": "325708",
-        "type": "video"
+        "type": "video",
+        "duration": 60000,
+        "contentProvider": {
+          "type": "external",
+          "originalContentUrl": "https://example.com",
+          "previewImageUrl": "https://example.com"
+        }
       }
     },
     {
@@ -50,7 +62,12 @@
       },
       "message": {
         "id": "325708",
-        "type": "audio"
+        "type": "audio",
+        "duration": 60000,
+        "contentProvider": {
+          "type": "external",
+          "originalContentUrl": "https://example.com"
+        }
       }
     },
     {
@@ -327,6 +344,36 @@
         "fileName": "file.txt",
         "fileSize": 2138
       }
+    },
+    {
+      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
+      "type": "things",
+      "source": {
+        "type": "user",
+        "userId": "U206d25c2ea6bd87c17655609a1c37cb8"
+      },
+      "timestamp": 1547817848122,
+      "things": {
+        "type": "scenarioResult",
+        "deviceId": "t2c449c9d1",
+        "result": {
+          "scenarioId": "XXX",
+          "revision": 2,
+          "startTime": 1547817845950,
+          "endTime": 1547817845952,
+          "resultCode": "success",
+          "bleNotificationPayload": "AQ==",
+          "actionResults": [
+            {
+              "type": "binary",
+              "data": "/w=="
+            },
+            {
+              "type": "void"
+            }
+          ]
+        }
+      }
     }
   ]
 }