Repository: line/line-bot-sdk-python
Pull request #176 (2019-06-14 09:19:05)

LINE Things scenario execution event
#167 
1. Support [**LINE Things scenario execution event**](https://developers.line.biz/en/reference/messaging-api/#scenario-result-event)
1. Add tests
1.  Update `docs/source/linebot.models.rst`

**Comment**:
- `linebot.models.events.Things` becomes an alias of `linebot.models.things.Things`.


Patch:
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/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


Test patch:
diff --git a/tests/test_webhook.py b/tests/test_webhook.py
index a6e3b5f..957e01e 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):
@@ -68,7 +68,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[0].source, SourceUser)
         self.assertEqual(events[0].source.type, 'user')
         self.assertEqual(events[0].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[0].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertIsInstance(events[0].message, TextMessage)
         self.assertEqual(events[0].message.id, '325708')
         self.assertEqual(events[0].message.type, 'text')
@@ -83,10 +82,14 @@ class TestWebhookParser(unittest.TestCase):
         self.assertEqual(events[1].source.type, 'room')
         self.assertEqual(events[1].source.room_id, 'Ra8dbf4673c4c812cd491258042226c99')
         self.assertEqual(events[1].source.user_id, None)
-        self.assertEqual(events[1].source.sender_id, 'Ra8dbf4673c4c812cd491258042226c99')
         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)
@@ -96,10 +99,15 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[2].source, SourceUser)
         self.assertEqual(events[2].source.type, 'user')
         self.assertEqual(events[2].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[2].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         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,10 +117,13 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[3].source, SourceUser)
         self.assertEqual(events[3].source.type, 'user')
         self.assertEqual(events[3].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[3].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         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)
@@ -122,7 +133,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[4].source, SourceUser)
         self.assertEqual(events[4].source.type, 'user')
         self.assertEqual(events[4].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[4].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertIsInstance(events[4].message, LocationMessage)
         self.assertEqual(events[4].message.id, '325708')
         self.assertEqual(events[4].message.type, 'location')
@@ -139,7 +149,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[5].source, SourceUser)
         self.assertEqual(events[5].source.type, 'user')
         self.assertEqual(events[5].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[5].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertIsInstance(events[5].message, StickerMessage)
         self.assertEqual(events[5].message.id, '325708')
         self.assertEqual(events[5].message.type, 'sticker')
@@ -154,7 +163,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[6].source, SourceUser)
         self.assertEqual(events[6].source.type, 'user')
         self.assertEqual(events[6].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[6].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
 
         # UnfollowEvent, SourceUser
         self.assertIsInstance(events[7], UnfollowEvent)
@@ -163,7 +171,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[7].source, SourceUser)
         self.assertEqual(events[7].source.type, 'user')
         self.assertEqual(events[7].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[7].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
 
         # JoinEvent, SourceGroup
         self.assertIsInstance(events[8], JoinEvent)
@@ -174,7 +181,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertEqual(events[8].source.type, 'group')
         self.assertEqual(events[8].source.group_id, 'Ca56f94637cc4347f90a25382909b24b9')
         self.assertEqual(events[8].source.user_id, None)
-        self.assertEqual(events[8].source.sender_id, 'Ca56f94637cc4347f90a25382909b24b9')
 
         # LeaveEvent, SourceGroup
         self.assertIsInstance(events[9], LeaveEvent)
@@ -184,7 +190,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertEqual(events[9].source.type, 'group')
         self.assertEqual(events[9].source.group_id, 'Ca56f94637cc4347f90a25382909b24b9')
         self.assertEqual(events[9].source.user_id, None)
-        self.assertEqual(events[9].source.sender_id, 'Ca56f94637cc4347f90a25382909b24b9')
 
         # PostbackEvent, SourceUser
         self.assertIsInstance(events[10], PostbackEvent)
@@ -194,7 +199,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[10].source, SourceUser)
         self.assertEqual(events[10].source.type, 'user')
         self.assertEqual(events[10].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[10].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertEqual(events[10].postback.data, 'action=buyItem&itemId=123123&color=red')
         self.assertEqual(events[10].postback.params, None)
 
@@ -206,7 +210,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[11].source, SourceUser)
         self.assertEqual(events[11].source.type, 'user')
         self.assertEqual(events[11].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[11].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertEqual(events[11].beacon.hwid, 'd41d8cd98f')
         self.assertEqual(events[11].beacon.type, 'enter')
         self.assertEqual(events[11].beacon.dm, None)
@@ -220,7 +223,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[12].source, SourceUser)
         self.assertEqual(events[12].source.type, 'user')
         self.assertEqual(events[12].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[12].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertEqual(events[12].beacon.hwid, 'd41d8cd98f')
         self.assertEqual(events[12].beacon.type, 'enter')
         self.assertEqual(events[12].beacon.dm, '1234567890abcdef')
@@ -234,7 +236,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[13].source, SourceUser)
         self.assertEqual(events[13].source.type, 'user')
         self.assertEqual(events[13].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[13].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertEqual(events[13].link.result, 'ok')
         self.assertEqual(events[13].link.nonce, 'Vb771wDYtXuammLszK6h9A')
 
@@ -247,7 +248,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertEqual(events[14].source.type, 'group')
         self.assertEqual(events[14].source.group_id, 'Ca56f94637cc4347f90a25382909b24b9')
         self.assertEqual(events[14].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[14].source.sender_id, 'Ca56f94637cc4347f90a25382909b24b9')
         self.assertIsInstance(events[14].message, TextMessage)
         self.assertEqual(events[14].message.id, '325708')
         self.assertEqual(events[14].message.type, 'text')
@@ -262,7 +262,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertEqual(events[15].source.type, 'room')
         self.assertEqual(events[15].source.room_id, 'Ra8dbf4673c4c812cd491258042226c99')
         self.assertEqual(events[15].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[15].source.sender_id, 'Ra8dbf4673c4c812cd491258042226c99')
         self.assertIsInstance(events[15].message, TextMessage)
         self.assertEqual(events[15].message.id, '325708')
         self.assertEqual(events[15].message.type, 'text')
@@ -276,7 +275,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[16].source, SourceUser)
         self.assertEqual(events[16].source.type, 'user')
         self.assertEqual(events[16].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[16].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertEqual(events[16].postback.data, 'action=buyItem&itemId=123123&color=red')
         self.assertEqual(events[16].postback.params['date'], '2013-04-01')
 
@@ -288,7 +286,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[17].source, SourceUser)
         self.assertEqual(events[17].source.type, 'user')
         self.assertEqual(events[17].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[17].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertEqual(events[17].postback.data, 'action=buyItem&itemId=123123&color=red')
         self.assertEqual(events[17].postback.params['time'], '10:00')
 
@@ -300,7 +297,6 @@ class TestWebhookParser(unittest.TestCase):
         self.assertIsInstance(events[18].source, SourceUser)
         self.assertEqual(events[18].source.type, 'user')
         self.assertEqual(events[18].source.user_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
-        self.assertEqual(events[18].source.sender_id, 'U206d25c2ea6bd87c17655609a1c37cb8')
         self.assertEqual(events[18].postback.data, 'action=buyItem&itemId=123123&color=red')
         self.assertEqual(events[18].postback.params['datetime'], '2013-04-01T10:00')
 
@@ -312,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)
@@ -348,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)
@@ -366,6 +364,29 @@ 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):
diff --git a/tests/text/webhook.json b/tests/text/webhook.json
index bfa54ec..6ef33fd 100644
--- a/tests/text/webhook.json
+++ b/tests/text/webhook.json
@@ -24,7 +24,12 @@
       },
       "message": {
         "id": "325708",
-        "type": "image"
+        "type": "image",
+        "contentProvider": {
+          "type": "external",
+          "originalContentUrl": "https://example.com",
+          "previewImageUrl": "https://example.com"
+        }
       }
     },
     {
@@ -37,7 +42,13 @@
       },
       "message": {
         "id": "325708",
-        "type": "video"
+        "type": "video",
+        "duration": 60000,
+        "contentProvider": {
+          "type": "external",
+          "originalContentUrl": "https://example.com",
+          "previewImageUrl": "https://example.com"
+        }
       }
     },
     {
@@ -50,7 +61,12 @@
       },
       "message": {
         "id": "325708",
-        "type": "audio"
+        "type": "audio",
+        "duration": 60000,
+        "contentProvider": {
+          "type": "external",
+          "originalContentUrl": "https://example.com"
+        }
       }
     },
     {
@@ -327,6 +343,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"
+            }
+          ]
+        }
+      }
     }
   ]
 }