Repository: line/line-bot-sdk-python

Issue:
WebhookHandler.add cannot cope with handler function with *args in its parameters
## Problem
<!-- Describe a clear and concise description of what the problem is. -->
When we add a function with `*args` to the WebhookHandler,
WebhookHandler calls the function with 0 args.
It causes some problems when we use ordinary wrapped functions with a decorator.
An example of this is as follows.

```python
import functools

from linebot import WebhookHandler

def with_log(f):
    @functools.wraps(f)
    def _decorator(*args):
        log(f"args: {args}")
        return f(*args)
    return _decorator

handler = WebhookHandler(...)

@handler.add(...)
@with_log
def example_handler(event):
    pass
```

This program raises the following exception when this `example_handler` is called.

```
TypeError: example_handler() missing 1 required positional argument: 'event'
```

This is because of `WebhookHandler.__get_args_count` does not care about `*args` in the parameters.

## Solution
<!-- Describe a clear and concise description of what you want to happen. -->
Fix `WebhookHandler.__get_args_count` as follows.

If the added handler function has `*args` in its parameters, `__get_args_count` returns 2.

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index e30565b..7e26930 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -8,6 +8,7 @@ python:
   - "3.5"
   - "3.6"
   - "3.7"
+  - "3.8"
 
 install: pip install tox-travis
 
diff --git a/README.rst b/README.rst
index ccf2c2c..368f99a 100644
--- a/README.rst
+++ b/README.rst
@@ -196,6 +196,20 @@ https://developers.line.biz/en/reference/messaging-api/#get-profile
     print(profile.picture_url)
     print(profile.status_message)
 
+get\_group\_summary(self, group\_id, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Gets the group ID, group name, and group icon URL of a group
+where the LINE Official Account is a member.
+
+https://developers.line.biz/en/reference/messaging-api/#get-group-summary
+
+.. code:: python
+
+    summary = line_bot_api.get_group_summary(group_id)
+    print(summary.group_id)
+    print(summary.group_name)
+    print(summary.picture_url)
+
 get\_group\_member\_profile(self, group\_id, user\_id, timeout=None)
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
@@ -259,6 +273,30 @@ https://developers.line.biz/en/reference/messaging-api/#get-room-member-user-ids
     print(member_ids_res.member_ids)
     print(member_ids_res.next)
 
+get\_group\_members\_count(self, group\_id, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Gets the count of members in a group.
+
+https://developers.line.biz/en/reference/messaging-api/#get-members-group-count
+
+.. code:: python
+
+    group_count = line_bot_api.get_group_members_count(group_id)
+    print(group_count)
+
+get\_room\_members\_count(self, room\_id, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Gets the count of members in a room.
+
+https://developers.line.biz/en/reference/messaging-api/#get-members-room-count
+
+.. code:: python
+
+    room_count = line_bot_api.get_room_members_count(room_id)
+    print(room_count)
+
 get\_message\_content(self, message\_id, timeout=None)
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/linebot/api.py b/linebot/api.py
index 77f2034..bb6075b 100644
--- a/linebot/api.py
+++ b/linebot/api.py
@@ -30,6 +30,7 @@ from .models import (
     InsightMessageEventResponse, BroadcastResponse, NarrowcastResponse,
     MessageProgressNarrowcastResponse,
 )
+from .models.responses import Group
 
 
 class LineBotApi(object):
@@ -108,7 +109,9 @@ class LineBotApi(object):
             '/v2/bot/message/reply', data=json.dumps(data), timeout=timeout
         )
 
-    def push_message(self, to, messages, notification_disabled=False, timeout=None):
+    def push_message(
+            self, to, messages,
+            retry_key=None, notification_disabled=False, timeout=None):
         """Call push message API.
 
         https://developers.line.biz/en/reference/messaging-api/#send-push-message
@@ -120,6 +123,7 @@ class LineBotApi(object):
             Max: 5
         :type messages: T <= :py:class:`linebot.models.send_messages.SendMessage` |
             list[T <= :py:class:`linebot.models.send_messages.SendMessage`]
+        :param retry_key: (optional) Arbitrarily generated UUID in hexadecimal notation.
         :param bool notification_disabled: (optional) True to disable push notification
             when the message is sent. The default value is False.
         :param timeout: (optional) How long to wait for the server
@@ -131,6 +135,9 @@ class LineBotApi(object):
         if not isinstance(messages, (list, tuple)):
             messages = [messages]
 
+        if retry_key:
+            self.headers['X-Line-Retry-Key'] = retry_key
+
         data = {
             'to': to,
             'messages': [message.as_json_dict() for message in messages],
@@ -141,7 +148,7 @@ class LineBotApi(object):
             '/v2/bot/message/push', data=json.dumps(data), timeout=timeout
         )
 
-    def multicast(self, to, messages, notification_disabled=False, timeout=None):
+    def multicast(self, to, messages, retry_key=None, notification_disabled=False, timeout=None):
         """Call multicast API.
 
         https://developers.line.biz/en/reference/messaging-api/#send-multicast-message
@@ -156,6 +163,7 @@ class LineBotApi(object):
             Max: 5
         :type messages: T <= :py:class:`linebot.models.send_messages.SendMessage` |
             list[T <= :py:class:`linebot.models.send_messages.SendMessage`]
+        :param retry_key: (optional) Arbitrarily generated UUID in hexadecimal notation.
         :param bool notification_disabled: (optional) True to disable push notification
             when the message is sent. The default value is False.
         :param timeout: (optional) How long to wait for the server
@@ -167,6 +175,9 @@ class LineBotApi(object):
         if not isinstance(messages, (list, tuple)):
             messages = [messages]
 
+        if retry_key:
+            self.headers['X-Line-Retry-Key'] = retry_key
+
         data = {
             'to': to,
             'messages': [message.as_json_dict() for message in messages],
@@ -177,7 +188,7 @@ class LineBotApi(object):
             '/v2/bot/message/multicast', data=json.dumps(data), timeout=timeout
         )
 
-    def broadcast(self, messages, notification_disabled=False, timeout=None):
+    def broadcast(self, messages, retry_key=None, notification_disabled=False, timeout=None):
         """Call broadcast API.
 
         https://developers.line.biz/en/reference/messaging-api/#send-broadcast-message
@@ -188,6 +199,7 @@ class LineBotApi(object):
             Max: 5
         :type messages: T <= :py:class:`linebot.models.send_messages.SendMessage` |
             list[T <= :py:class:`linebot.models.send_messages.SendMessage`]
+        :param retry_key: (optional) Arbitrarily generated UUID in hexadecimal notation.
         :param bool notification_disabled: (optional) True to disable push notification
             when the message is sent. The default value is False.
         :param timeout: (optional) How long to wait for the server
@@ -200,6 +212,9 @@ class LineBotApi(object):
         if not isinstance(messages, (list, tuple)):
             messages = [messages]
 
+        if retry_key:
+            self.headers['X-Line-Retry-Key'] = retry_key
+
         data = {
             'messages': [message.as_json_dict() for message in messages],
             'notificationDisabled': notification_disabled,
@@ -211,7 +226,9 @@ class LineBotApi(object):
 
         return BroadcastResponse(request_id=response.headers.get('X-Line-Request-Id'))
 
-    def narrowcast(self, messages, recipient=None, filter=None, limit=None, timeout=None):
+    def narrowcast(
+            self, messages,
+            retry_key=None, recipient=None, filter=None, limit=None, timeout=None):
         """Call narrowcast API.
 
         https://developers.line.biz/en/reference/messaging-api/#send-narrowcast-message
@@ -223,6 +240,7 @@ class LineBotApi(object):
             Max: 5
         :type messages: T <= :py:class:`linebot.models.send_messages.SendMessage` |
             list[T <= :py:class:`linebot.models.send_messages.SendMessage`]
+        :param retry_key: (optional) Arbitrarily generated UUID in hexadecimal notation.
         :param recipient: audience object of recipient
         :type recipient: T <= :py:class:`linebot.models.recipient.AudienceRecipient`
         :param filter: demographic filter of recipient
@@ -239,6 +257,9 @@ class LineBotApi(object):
         if not isinstance(messages, (list, tuple)):
             messages = [messages]
 
+        if retry_key:
+            self.headers['X-Line-Retry-Key'] = retry_key
+
         data = {
             'messages': [message.as_json_dict() for message in messages],
             'recipient': recipient.as_json_dict(),
@@ -386,6 +407,76 @@ class LineBotApi(object):
 
         return Profile.new_from_json_dict(response.json)
 
+    def get_group_summary(self, group_id, timeout=None):
+        """Call get group summary API.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-group-summary
+
+        Gets the group ID, group name, and group icon URL of a group
+        where the LINE Official Account is a member.
+
+        :param str group_id: Group ID
+        :param timeout: (optional) How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is self.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`linebot.models.responses.Group`
+        :return: Profile instance
+        """
+        response = self._get(
+            '/v2/bot/group/{group_id}/summary'.format(group_id=group_id),
+            timeout=timeout
+        )
+
+        return Group.new_from_json_dict(response.json)
+
+    def get_group_members_count(self, group_id, timeout=None):
+        """Call get members in group count API.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-members-group-count
+
+        Gets the count of members in a group.
+
+        :param str group_id: Group ID
+        :param timeout: (optional) How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is self.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`linebot.models.responses.Group`
+        :return: Profile instance
+        """
+        response = self._get(
+            '/v2/bot/group/{group_id}/members/count'.format(group_id=group_id),
+            timeout=timeout
+        )
+
+        return response.json.get('count')
+
+    def get_room_members_count(self, room_id, timeout=None):
+        """Call get members in room count API.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-members-room-count
+
+        Gets the count of members in a room.
+
+        :param str room_id: Room ID
+        :param timeout: (optional) How long to wait for the server
+            to send data before giving up, as a float,
+            or a (connect timeout, read timeout) float tuple.
+            Default is self.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`linebot.models.responses.Group`
+        :return: Profile instance
+        """
+        response = self._get(
+            '/v2/bot/room/{room_id}/members/count'.format(room_id=room_id),
+            timeout=timeout
+        )
+
+        return response.json.get('count')
+
     def get_group_member_profile(self, group_id, user_id, timeout=None):
         """Call get group member profile API.
 
@@ -1085,5 +1176,6 @@ class LineBotApi(object):
                 status_code=response.status_code,
                 headers=dict(response.headers.items()),
                 request_id=response.headers.get('X-Line-Request-Id'),
+                accepted_request_id=response.headers.get('X-Line-Accepted-Request-Id'),
                 error=Error.new_from_json_dict(response.json)
             )
diff --git a/linebot/exceptions.py b/linebot/exceptions.py
index c66615a..470c197 100644
--- a/linebot/exceptions.py
+++ b/linebot/exceptions.py
@@ -58,13 +58,21 @@ class InvalidSignatureError(BaseError):
 class LineBotApiError(BaseError):
     """When LINE Messaging API response error, this error will be raised."""
 
-    def __init__(self, status_code, headers, request_id=None, error=None):
+    def __init__(
+            self,
+            status_code,
+            headers,
+            request_id=None,
+            accepted_request_id=None,
+            error=None
+    ):
         """__init__ method.
 
         :param int status_code: HTTP status code
         :param headers: Response headers
         :type headers: dict[str, str]
         :param str request_id: (optional) Request ID. A unique ID is generated for each request
+        :param str accepted_request_id: (optional) The same request has already been accepted
         :param error: (optional) Error class object.
         :type error: :py:class:`linebot.models.error.Error`
         """
@@ -73,6 +81,7 @@ class LineBotApiError(BaseError):
         self.status_code = status_code
         self.headers = headers
         self.request_id = request_id
+        self.accepted_request_id = accepted_request_id
         self.error = error
 
     def __str__(self):
@@ -80,5 +89,15 @@ class LineBotApiError(BaseError):
 
         :rtype: str
         """
+        if self.accepted_request_id:
+            return "{0}: status_code={1}, request_id={2}, " \
+                   "accepted_request_id={3}, error_response={4}, headers={5}" \
+                .format(self.__class__.__name__,
+                        self.status_code,
+                        self.request_id,
+                        self.accepted_request_id,
+                        self.error,
+                        self.headers)
         return '{0}: status_code={1}, request_id={2}, error_response={3}, headers={4}'.format(
-            self.__class__.__name__, self.status_code, self.request_id, self.error, self.headers)
+            self.__class__.__name__, self.status_code, self.request_id, self.error,
+            self.headers)
diff --git a/linebot/models/emojis.py b/linebot/models/emojis.py
index 76a6be1..2db8b62 100644
--- a/linebot/models/emojis.py
+++ b/linebot/models/emojis.py
@@ -30,7 +30,7 @@ class Emojis(with_metaclass(ABCMeta, Base)):
 
     """
 
-    def __init__(self, index=None, product_id=None, emoji_id=None, **kwargs):
+    def __init__(self, index=None, length=None, product_id=None, emoji_id=None, **kwargs):
         """__init__ method.
 
         :param kwargs:
@@ -38,5 +38,6 @@ class Emojis(with_metaclass(ABCMeta, Base)):
         super(Emojis, self).__init__(**kwargs)
 
         self.index = index
+        self.length = length
         self.product_id = product_id
         self.emoji_id = emoji_id
diff --git a/linebot/models/flex_message.py b/linebot/models/flex_message.py
index afccc3e..bd95169 100644
--- a/linebot/models/flex_message.py
+++ b/linebot/models/flex_message.py
@@ -109,7 +109,12 @@ class BubbleContainer(FlexContainer):
         self.size = size
         self.direction = direction
         self.header = self.get_or_new_from_json_dict(header, BoxComponent)
-        self.hero = self.get_or_new_from_json_dict(hero, ImageComponent)
+        self.hero = self.get_or_new_from_json_dict_with_types(
+            hero, {
+                'image': ImageComponent,
+                'box': BoxComponent
+            }
+        )
         self.body = self.get_or_new_from_json_dict(body, BoxComponent)
         self.footer = self.get_or_new_from_json_dict(footer, BoxComponent)
         self.styles = self.get_or_new_from_json_dict(styles, BubbleStyle)
diff --git a/linebot/models/responses.py b/linebot/models/responses.py
index 850f94b..936c60a 100644
--- a/linebot/models/responses.py
+++ b/linebot/models/responses.py
@@ -65,6 +65,27 @@ class Profile(Base):
         self.language = language
 
 
+class Group(Base):
+    """Group.
+
+    https://developers.line.biz/en/reference/messaging-api/#get-group-id-response
+    """
+
+    def __init__(self, group_id=None, group_name=None, picture_url=None, **kwargs):
+        """__init__ method.
+
+        :param str group_id
+        :param str group_name
+        :param str picture_url
+        :param kwargs:
+        """
+        super(Group, self).__init__(**kwargs)
+
+        self.group_id = group_id
+        self.group_name = group_name
+        self.picture_url = picture_url
+
+
 class MemberIds(Base):
     """MemberIds.
 
diff --git a/linebot/webhook.py b/linebot/webhook.py
index 035b3ef..eae6d23 100644
--- a/linebot/webhook.py
+++ b/linebot/webhook.py
@@ -253,26 +253,30 @@ class WebhookHandler(object):
             if func is None:
                 LOGGER.info('No handler of ' + key + ' and no default handler')
             else:
-                args_count = self.__get_args_count(func)
-                if args_count == 0:
-                    func()
-                elif args_count == 1:
-                    func(event)
-                else:
-                    func(event, payload.destination)
+                self.__invoke_func(func, event, payload)
 
     def __add_handler(self, func, event, message=None):
         key = self.__get_handler_key(event, message=message)
         self._handlers[key] = func
 
+    @classmethod
+    def __invoke_func(cls, func, event, payload):
+        (has_varargs, args_count) = cls.__get_args_count(func)
+        if has_varargs or args_count == 2:
+            func(event, payload.destination)
+        elif args_count == 1:
+            func(event)
+        else:
+            func()
+
     @staticmethod
     def __get_args_count(func):
         if PY3:
             arg_spec = inspect.getfullargspec(func)
-            return len(arg_spec.args)
+            return (arg_spec.varargs is not None, len(arg_spec.args))
         else:
             arg_spec = inspect.getargspec(func)
-            return len(arg_spec.args)
+            return (arg_spec.varargs is not None, len(arg_spec.args))
 
     @staticmethod
     def __get_handler_key(event, message=None):
diff --git a/setup.py b/setup.py
index 81ba43a..001a075 100644
--- a/setup.py
+++ b/setup.py
@@ -84,6 +84,7 @@ setup(
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
         "Topic :: Software Development"
     ]
 )
diff --git a/tox.ini b/tox.ini
index 4bb6c9f..35a3cc0 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,5 +1,5 @@
 [tox]
-envlist = py27, py34, py35, py36, py37, py37-flake8-src, py37-flake8-other
+envlist = py27, py34, py35, py36, py37, py38, py37-flake8-src, py37-flake8-other
 
 [testenv]
 deps =


Test patch:
diff --git a/tests/api/test_get_group.py b/tests/api/test_get_group.py
new file mode 100644
index 0000000..967d8be
--- /dev/null
+++ b/tests/api/test_get_group.py
@@ -0,0 +1,74 @@
+# -*- 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.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+
+    @responses.activate
+    def test_get_group_summary(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/group/group_id/summary',
+            json={
+                "groupId": "Ca56f94637c...",
+                "groupName": "Group name",
+                "pictureUrl": "https://example.com/abcdefghijklmn"
+            },
+            status=200
+        )
+
+        group = self.tested.get_group_summary('group_id')
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/group/group_id/summary')
+        self.assertEqual(group.group_id, 'Ca56f94637c...')
+        self.assertEqual(group.group_name, 'Group name')
+        self.assertEqual(group.picture_url, 'https://example.com/abcdefghijklmn')
+
+    @responses.activate
+    def test_get_group_members_count(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/group/group_id/members/count',
+            json={"count": 3},
+            status=200
+        )
+
+        count = self.tested.get_group_members_count('group_id')
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/group/group_id/members/count')
+        self.assertEqual(count, 3)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/test_get_room.py b/tests/api/test_get_room.py
new file mode 100644
index 0000000..5174f00
--- /dev/null
+++ b/tests/api/test_get_room.py
@@ -0,0 +1,50 @@
+# -*- 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.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+
+    @responses.activate
+    def test_get_room_members_count(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/room/room_id/members/count',
+            json={"count": 3},
+            status=200
+        )
+
+        count = self.tested.get_room_members_count('room_id')
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/room/room_id/members/count')
+        self.assertEqual(count, 3)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/test_narrowcast_message.py b/tests/api/test_narrowcast_message.py
index 0f01d06..24ff784 100644
--- a/tests/api/test_narrowcast_message.py
+++ b/tests/api/test_narrowcast_message.py
@@ -217,6 +217,90 @@ class TestNarrowcastMessage(unittest.TestCase):
         )
         self.assertEqual('request_id_test', response.request_id)
 
+    @responses.activate
+    def test_narrowcast_text_message_with_retry_key(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/narrowcast',
+            json={}, status=200,
+            headers={
+                'X-Line-Request-Id': 'request_id_test',
+                'X-Line-Retry-Key': '123e4567-e89b-12d3-a456-426614174000'
+            },
+        )
+
+        response = self.tested.narrowcast(
+            self.text_message,
+            recipient=And(
+                AudienceRecipient(group_id=5614991017776),
+                Not(AudienceRecipient(group_id=4389303728991))
+            ),
+            filter=Filter(
+                demographic=Or(
+                    And(
+                        GenderFilter(one_of=["male", "female"])
+                    )
+                )
+            ),
+            limit=Limit(max=100),
+            retry_key='123e4567-e89b-12d3-a456-426614174000',
+        )
+
+        request = responses.calls[0].request
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/narrowcast')
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.headers['X-Line-Retry-Key'],
+            '123e4567-e89b-12d3-a456-426614174000'
+        )
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                "messages": self.message,
+                "recipient": {
+                    "type": "operator",
+                    "and": [
+                        {
+                            'audienceGroupId': 5614991017776,
+                            'type': 'audience'
+                        },
+                        {
+                            "type": "operator",
+                            "not": {
+                                "type": "audience",
+                                "audienceGroupId": 4389303728991
+                            }
+                        }
+                    ]
+                },
+                "filter": {
+                    "demographic": {
+                        "type": "operator",
+                        "or": [
+                            {
+                                "type": "operator",
+                                "and": [
+                                    {
+                                        "type": "gender",
+                                        "oneOf": [
+                                            "male",
+                                            "female"
+                                        ]
+                                    }
+                                ]
+                            }
+                        ]
+                    }
+                },
+                "limit": {
+                    "max": 100
+                }
+            }
+        )
+        self.assertEqual('request_id_test', response.request_id)
+
     @responses.activate
     def test_get_progress_status_narrowcast(self):
         responses.add(
diff --git a/tests/api/test_send_text_message.py b/tests/api/test_send_text_message.py
index 005d424..62244b1 100644
--- a/tests/api/test_send_text_message.py
+++ b/tests/api/test_send_text_message.py
@@ -30,7 +30,6 @@ from linebot.models import (
 class TestSendTestMessage(unittest.TestCase):
     def setUp(self):
         self.tested = LineBotApi('channel_secret')
-
         # test data
         self.text_message = TextSendMessage(text='Hello, world')
         self.message = [{"type": "text", "text": "Hello, world"}]
@@ -59,6 +58,40 @@ class TestSendTestMessage(unittest.TestCase):
             }
         )
 
+    @responses.activate
+    def test_push_text_message_with_retry_key(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/push',
+            json={}, status=200,
+            headers={'X-Line-Retry-Key': '123e4567-e89b-12d3-a456-426614174000'}
+        )
+
+        self.tested.push_message(
+            'to',
+            self.text_message,
+            retry_key='123e4567-e89b-12d3-a456-426614174000'
+        )
+
+        request = responses.calls[0].request
+
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/push')
+        self.assertEqual(
+            request.headers['X-Line-Retry-Key'],
+            '123e4567-e89b-12d3-a456-426614174000'
+        )
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                "to": "to",
+                'notificationDisabled': False,
+                "messages": self.message
+            }
+        )
+
     @responses.activate
     def test_reply_text_message(self):
         responses.add(
@@ -107,6 +140,40 @@ class TestSendTestMessage(unittest.TestCase):
             }
         )
 
+    @responses.activate
+    def test_multicast_text_message_with_retry_key(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/multicast',
+            json={}, status=200,
+            headers={'X-Line-Retry-Key': '123e4567-e89b-12d3-a456-426614174000'}
+
+        )
+
+        self.tested.multicast(
+            ['to1', 'to2'],
+            self.text_message,
+            retry_key='123e4567-e89b-12d3-a456-426614174000'
+        )
+
+        request = responses.calls[0].request
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/multicast')
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.headers['X-Line-Retry-Key'],
+            '123e4567-e89b-12d3-a456-426614174000'
+        )
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                "to": ['to1', 'to2'],
+                'notificationDisabled': False,
+                "messages": self.message
+            }
+        )
+
     @responses.activate
     def test_broadcast_text_message(self):
         responses.add(
@@ -148,6 +215,41 @@ class TestSendTestMessage(unittest.TestCase):
         )
         self.assertEqual('request_id_test', response.request_id)
 
+    @responses.activate
+    def test_broadcast_text_message_with_retry_key(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/broadcast',
+            json={}, status=200,
+            headers={
+                'X-Line-Request-Id': 'request_id_test',
+                'X-Line-Retry-Key': '123e4567-e89b-12d3-a456-426614174000'
+            }
+        )
+
+        response = self.tested.broadcast(
+            self.text_message,
+            retry_key='123e4567-e89b-12d3-a456-426614174000'
+        )
+
+        request = responses.calls[0].request
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/broadcast')
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.headers['X-Line-Retry-Key'],
+            '123e4567-e89b-12d3-a456-426614174000'
+        )
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                'notificationDisabled': False,
+                "messages": self.message
+            }
+        )
+        self.assertEqual('request_id_test', response.request_id)
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/models/test_flex_message.py b/tests/models/test_flex_message.py
index ad752fc..9fe7c29 100644
--- a/tests/models/test_flex_message.py
+++ b/tests/models/test_flex_message.py
@@ -61,8 +61,6 @@ class TestFlexMessage(SerializeTestCase):
             'header':
                 BoxComponent(layout='vertical',
                              contents=[TextComponent(text='Header text')]),
-            'hero':
-                ImageComponent(uri='https://example.com/flex/images/image.jpg'),
             'body':
                 BoxComponent(layout='vertical',
                              contents=[TextComponent(text='Body text')]),
@@ -79,10 +77,17 @@ class TestFlexMessage(SerializeTestCase):
                                       separator_color='#00ffff')
                 )
         }
-        self.assertEqual(
-            self.serialize_as_dict(arg, type=self.BUBBLE),
-            BubbleContainer(**arg).as_json_dict()
-        )
+        heros = [
+            ImageComponent(uri='https://example.com/flex/images/image.jpg'),
+            BoxComponent(layout='vertical',
+                         contents=[TextComponent(text='Body text')]),
+        ]
+        for hero in heros:
+            arg['hero'] = hero
+            self.assertEqual(
+                self.serialize_as_dict(arg, type=self.BUBBLE),
+                BubbleContainer(**arg).as_json_dict()
+            )
 
     def test_bubble_style(self):
         arg = {
diff --git a/tests/models/test_text_message.py b/tests/models/test_text_message.py
index 3b934ad..ed416a2 100644
--- a/tests/models/test_text_message.py
+++ b/tests/models/test_text_message.py
@@ -29,11 +29,13 @@ class TestTextMessage(SerializeTestCase):
             'emojis': [
                 Emojis(
                     index=0,
+                    length=6,
                     product_id='5ac1bfd5040ab15980c9b435',
                     emoji_id='001'
                 ),
                 Emojis(
                     index=13,
+                    length=6,
                     product_id='5ac1bfd5040ab15980c9b435',
                     emoji_id='002'
                 ),
diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py
index 07abb25..98d5f14 100644
--- a/tests/test_exceptions.py
+++ b/tests/test_exceptions.py
@@ -40,6 +40,26 @@ class TestExceptions(unittest.TestCase):
             + 'headers={}'.format(headers)
         )
 
+    def test_accepted_str(self):
+        headers = {
+            'X-Line-Request-Id': '123e4567-e89b-12d3-a456-426655440002',
+            'X-Line-Accepted-Request-Id': '123e4567-e89b-12d3-a456-426655440001'
+        }
+        line_bot_api_error = LineBotApiError(
+            status_code=409,
+            request_id='123e4567-e89b-12d3-a456-426655440002',
+            accepted_request_id='123e4567-e89b-12d3-a456-426655440001',
+            headers=headers,
+            error=Error(message='The retry key is already accepted')
+        )
+        self.assertEqual(
+            line_bot_api_error.__str__(),
+            'LineBotApiError: status_code=409, request_id=123e4567-e89b-12d3-a456-426655440002, '
+            'accepted_request_id=123e4567-e89b-12d3-a456-426655440001, '
+            'error_response={"details": [], "message": "The retry key is already accepted"}, '
+            + 'headers={}'.format(headers)
+        )
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_webhook.py b/tests/test_webhook.py
index 51b884f..9839a65 100644
--- a/tests/test_webhook.py
+++ b/tests/test_webhook.py
@@ -17,6 +17,7 @@ from __future__ import unicode_literals, absolute_import
 import os
 import unittest
 from builtins import open
+import inspect
 
 from linebot import (
     SignatureValidator, WebhookParser, WebhookHandler
@@ -29,6 +30,7 @@ from linebot.models import (
     LocationMessage, StickerMessage, FileMessage,
     SourceUser, SourceRoom, SourceGroup,
     DeviceLink, DeviceUnlink, ScenarioResult, ActionResult)
+from linebot.utils import PY3
 
 
 class TestSignatureValidator(unittest.TestCase):
@@ -527,5 +529,88 @@ class TestWebhookHandler(unittest.TestCase):
         self.handler.handle(body, 'signature')
 
 
+class TestInvokeWebhookHandler(unittest.TestCase):
+    def setUp(self):
+        def wrap(func):
+            def wrapper(*args):
+                if PY3:
+                    arg_spec = inspect.getfullargspec(func)
+                else:
+                    arg_spec = inspect.getargspec(func)
+                return func(*args[0:len(arg_spec.args)])
+            return wrapper
+
+        def func_with_0_args():
+            assert True
+
+        def func_with_1_arg(arg):
+            assert arg
+
+        def func_with_2_args(arg1, arg2):
+            assert arg1 and arg2
+
+        def func_with_1_arg_with_default(arg=False):
+            assert arg
+
+        def func_with_2_args_with_default(arg1=False, arg2=False):
+            assert arg1 and arg2
+
+        def func_with_1_arg_and_1_arg_with_default(arg1, arg2=False):
+            assert arg1 and arg2
+
+        @wrap
+        def wrapped_func_with_0_args():
+            assert True
+
+        @wrap
+        def wrapped_func_with_1_arg(arg):
+            assert arg
+
+        @wrap
+        def wrapped_func_with_2_args(arg1, arg2):
+            assert arg1 and arg2
+
+        @wrap
+        def wrapped_func_with_1_arg_with_default(arg=False):
+            assert arg
+
+        @wrap
+        def wrapped_func_with_2_args_with_default(arg1=False, arg2=False):
+            assert arg1 and arg2
+
+        @wrap
+        def wrapped_func_with_1_arg_and_1_arg_with_default(
+                arg1, arg2=False):
+            assert arg1 and arg2
+
+        self.functions = [
+            func_with_0_args,
+            func_with_1_arg,
+            func_with_2_args,
+            func_with_1_arg_with_default,
+            func_with_2_args_with_default,
+            func_with_1_arg_and_1_arg_with_default,
+            wrapped_func_with_0_args,
+            wrapped_func_with_1_arg,
+            wrapped_func_with_2_args,
+            wrapped_func_with_1_arg_with_default,
+            wrapped_func_with_2_args_with_default,
+            wrapped_func_with_1_arg_and_1_arg_with_default,
+        ]
+
+    def test_invoke_func(self):
+        class PayloadMock(object):
+            def __init__(self):
+                self.destination = True
+
+        event = True
+        payload = PayloadMock()
+
+        for func in self.functions:
+            WebhookHandler._WebhookHandler__invoke_func(
+                func, event, payload
+            )
+
+
 if __name__ == '__main__':
     unittest.main()