Repository: line/line-bot-sdk-python
Pull request #241 (2020-02-12 16:08:26)

support narrowcast api
- for #236 
Ref: https://developers.line.biz/en/reference/messaging-api/#send-narrowcast-message

clsung: Some comments will be updated when official English translation is released.

> This section will be translated into English soon.
okue: Now, you can update!

> Some comments will be updated when official English translation is released.
> 
> > This section will be translated into English soon.
okue: Thanks @clsung!

Patch:
diff --git a/linebot/api.py b/linebot/api.py
index cbf9679..77f2034 100644
--- a/linebot/api.py
+++ b/linebot/api.py
@@ -27,7 +27,8 @@ from .models import (
     MessageDeliveryBroadcastResponse, MessageDeliveryMulticastResponse,
     MessageDeliveryPushResponse, MessageDeliveryReplyResponse,
     InsightMessageDeliveryResponse, InsightFollowersResponse, InsightDemographicResponse,
-    InsightMessageEventResponse, BroadcastResponse,
+    InsightMessageEventResponse, BroadcastResponse, NarrowcastResponse,
+    MessageProgressNarrowcastResponse,
 )
 
 
@@ -210,6 +211,70 @@ 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):
+        """Call narrowcast API.
+
+        https://developers.line.biz/en/reference/messaging-api/#send-narrowcast-message
+
+        Sends push messages to multiple users at any time.
+        Messages cannot be sent to groups or rooms.
+
+        :param messages: Messages.
+            Max: 5
+        :type messages: T <= :py:class:`linebot.models.send_messages.SendMessage` |
+            list[T <= :py:class:`linebot.models.send_messages.SendMessage`]
+        :param recipient: audience object of recipient
+        :type recipient: T <= :py:class:`linebot.models.recipient.AudienceRecipient`
+        :param filter: demographic filter of recipient
+        :type filter: T <= :py:class:`linebot.models.filter.DemographicFilter`
+        :param limit: limit on this narrowcast
+        :type limit: T <= :py:class:`linebot.models.limit.Limit`
+        :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.NarrowcastResponse`
+        """
+        if not isinstance(messages, (list, tuple)):
+            messages = [messages]
+
+        data = {
+            'messages': [message.as_json_dict() for message in messages],
+            'recipient': recipient.as_json_dict(),
+            'filter': filter.as_json_dict(),
+            'limit': limit.as_json_dict(),
+        }
+
+        response = self._post(
+            '/v2/bot/message/narrowcast', data=json.dumps(data), timeout=timeout
+        )
+
+        return NarrowcastResponse(request_id=response.headers.get('X-Line-Request-Id'))
+
+    def get_progress_status_narrowcast(self, request_id, timeout=None):
+        """Get progress status of narrowcast messages sent.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-narrowcast-progress-status
+
+        Gets the number of messages sent with the /bot/message/progress/narrowcast endpoint.
+
+        :param str request_id: request ID of narrowcast.
+        :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.MessageDeliveryBroadcastResponse`
+        """
+        response = self._get(
+            '/v2/bot/message/progress/narrowcast?requestId={request_id}'.format(
+                request_id=request_id),
+            timeout=timeout
+        )
+
+        return MessageProgressNarrowcastResponse.new_from_json_dict(response.json)
+
     def get_message_delivery_broadcast(self, date, timeout=None):
         """Get number of sent broadcast messages.
 
diff --git a/linebot/models/__init__.py b/linebot/models/__init__.py
index 84601b1..e173ec9 100644
--- a/linebot/models/__init__.py
+++ b/linebot/models/__init__.py
@@ -54,6 +54,16 @@ from .events import (  # noqa
     Beacon,
     Link,
 )
+from .filter import(  # noqa
+    Filter,
+    DemographicFilter,
+    GenderFilter,
+    AppTypeFilter,
+    AreaFilter,
+    AgeFilter,
+    SubscriptionPeriodFilter,
+)
+
 from .flex_message import (  # noqa
     FlexSendMessage,
     FlexContainer,
@@ -93,6 +103,11 @@ from .insight import (  # noqa
     MessageInsight,
     ClickInsight,
 )
+
+from .limit import (  # noqa
+    Limit,
+)
+
 from .messages import (  # noqa
     Message,
     TextMessage,
@@ -103,6 +118,17 @@ from .messages import (  # noqa
     StickerMessage,
     FileMessage,
 )
+
+from .operator import (  # noqa
+    And,
+    Or,
+    Not
+)
+
+from .recipient import (  # noqa
+    AudienceRecipient
+)
+
 from .responses import (  # noqa
     Profile,
     MemberIds,
@@ -122,6 +148,8 @@ from .responses import (  # noqa
     InsightDemographicResponse,
     InsightMessageEventResponse,
     BroadcastResponse,
+    NarrowcastResponse,
+    MessageProgressNarrowcastResponse,
 )
 from .rich_menu import (  # noqa
     RichMenu,
diff --git a/linebot/models/filter.py b/linebot/models/filter.py
new file mode 100644
index 0000000..e755eb2
--- /dev/null
+++ b/linebot/models/filter.py
@@ -0,0 +1,165 @@
+# -*- 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.filter module."""
+
+from __future__ import unicode_literals
+
+from abc import ABCMeta
+
+from future.utils import with_metaclass
+
+from .base import Base
+
+
+class Filter(with_metaclass(ABCMeta, Base)):
+    """Filter.
+
+    https://developers.line.biz/en/reference/messaging-api/#narrowcast-demographic-filter
+
+    A filter is the top-level structure of a demographic element.
+    """
+
+    def __init__(self, demographic=None, **kwargs):
+        """__init__ method.
+
+        :param demographic: Combination of different criteria using logical
+            operator objects.
+        :type demographic: :py:class:`linebot.model.DemographicFilter` |
+            :py:class:`linebot.model.Operator`
+        :param kwargs:
+        """
+        super(Filter, self).__init__(**kwargs)
+
+        self.demographic = demographic
+
+
+class DemographicFilter(Filter):
+    """DemographicFilter.
+
+    https://developers.line.biz/en/reference/messaging-api/#narrowcast-demographic-filter
+
+    Demographic filter objects represent criteria (e.g. age, gender, OS, region,
+    and friendship duration) on which to filter the list of recipients.
+    You can filter recipients based on a combination of different criteria using
+    logical operator objects.
+    """
+
+    def __init__(self, **kwargs):
+        """__init__ method.
+
+        :param kwargs:
+        """
+        super(DemographicFilter, self).__init__(**kwargs)
+
+        self.type = None
+
+
+class GenderFilter(DemographicFilter):
+    """GenderFilter."""
+
+    def __init__(self, one_of=[], **kwargs):
+        """__init__ method.
+
+        :param one_of: Send messages to users of a given gender. One of:
+            male: Users who identify as male
+            female: Users who identify as female
+        :type one_of: list[str]
+        """
+        super(GenderFilter, self).__init__(**kwargs)
+
+        self.type = "gender"
+        self.one_of = one_of
+
+
+class AppTypeFilter(DemographicFilter):
+    """AppTypeFilter."""
+
+    def __init__(self, one_of=[], **kwargs):
+        """__init__ method.
+
+        :param one_of: Send messages to users of the specified OS. One of:
+            ios: Users who using iOS.
+            android: Users who using Android.
+        :type one_of: list[str]
+        """
+        super(AppTypeFilter, self).__init__(**kwargs)
+
+        self.type = "appType"
+        self.one_of = one_of
+
+
+class AreaFilter(DemographicFilter):
+    """AreaFilter."""
+
+    def __init__(self, one_of=[], **kwargs):
+        """__init__ method.
+
+        :param one_of: Send messages to users in the specified region.
+        :type one_of: list[str]
+        """
+        super(AreaFilter, self).__init__(**kwargs)
+
+        self.type = "area"
+        self.one_of = one_of
+
+
+class AgeFilter(DemographicFilter):
+    """AgeFilter.
+
+    This lets you filter recipients with a given age range.
+    """
+
+    def __init__(self, gte=None, lt=None, **kwargs):
+        """__init__ method.
+
+        Be sure to specify either gte, lt, or both.
+
+        :param gte: Send messages to users at least as old as the specified age.
+        :type gte: str
+        :param lt: Send messages to users younger than the specified age.
+            You can specify the same values as for the gte property.
+        :type lt: str
+        """
+        super(AgeFilter, self).__init__(**kwargs)
+
+        self.type = "age"
+        self.gte = gte
+        self.lt = lt
+
+
+class SubscriptionPeriodFilter(DemographicFilter):
+    """SubscriptionPeriodFilter.
+
+    This lets you filter recipients with a given range of friendship durations.
+    """
+
+    def __init__(self, gte=None, lt=None, **kwargs):
+        """__init__ method.
+
+        Be sure to specify either gte, lt, or both.
+
+        :param gte: Send messages to users who have been friends of yours for
+            at least the specified number of days
+        :type gte: str
+        :param lt: Send messages to users who have been friends of yours for
+            less than the specified number of days.
+            You can specify the same values as for the gte property.
+        :type lt: str
+        """
+        super(SubscriptionPeriodFilter, self).__init__(**kwargs)
+
+        self.type = "subscriptionPeriod"
+        self.gte = gte
+        self.lt = lt
diff --git a/linebot/models/limit.py b/linebot/models/limit.py
new file mode 100644
index 0000000..7f96767
--- /dev/null
+++ b/linebot/models/limit.py
@@ -0,0 +1,40 @@
+# -*- 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.recipient module."""
+
+from __future__ import unicode_literals
+
+from abc import ABCMeta
+
+from future.utils import with_metaclass
+
+from .base import Base
+
+
+class Limit(with_metaclass(ABCMeta, Base)):
+    """Limit.
+
+    https://developers.line.biz/en/reference/messaging-api/#send-narrowcast-message
+
+    """
+
+    def __init__(self, max=None, **kwargs):
+        """__init__ method.
+
+        :param kwargs:
+        """
+        super(Limit, self).__init__(**kwargs)
+
+        self.max = max
diff --git a/linebot/models/operator.py b/linebot/models/operator.py
new file mode 100644
index 0000000..e0861e7
--- /dev/null
+++ b/linebot/models/operator.py
@@ -0,0 +1,97 @@
+# -*- 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.filter module."""
+
+from __future__ import unicode_literals
+
+from abc import ABCMeta
+
+from future.utils import with_metaclass
+
+from .base import Base
+
+
+class Operator(with_metaclass(ABCMeta, Base)):
+    """Operator.
+
+    https://developers.line.biz/en/reference/messaging-api/#narrowcast-demographic-filter
+
+    Use logical AND, OR, and NOT operators to combine multiple recipient objects or
+    demographic filter objects together.
+    You can specify up to 10 recipient objects or demographic filter objects per request.
+    """
+
+    def __init__(self, **kwargs):
+        """__init__ method.
+
+        :param kwargs:
+        """
+        super(Operator, self).__init__(**kwargs)
+
+        self.type = "operator"
+
+
+class And(Operator):
+    """And.
+
+    Create a new recipient object or demographic filter object by taking the
+    logical conjunction (AND) of the specified array of objects.
+    """
+
+    def __init__(self, *args, **kwargs):
+        """__init__ method.
+
+        :param args:
+        :param kwargs:
+        """
+        super(And, self).__init__(**kwargs)
+
+        setattr(self, 'and', args)
+
+
+class Or(Operator):
+    """Or.
+
+    Create a new recipient object or demographic filter object by taking the
+    logical disjunction (OR) of the specified array of objects.
+    """
+
+    def __init__(self, *args, **kwargs):
+        """__init__ method.
+
+        :param args:
+        :param kwargs:
+        """
+        super(Or, self).__init__(**kwargs)
+
+        setattr(self, 'or', args)
+
+
+class Not(Operator):
+    """Not.
+
+    Create a new recipient object or demographic filter object that excludes
+    in the specified object.
+    """
+
+    def __init__(self, arg, **kwargs):
+        """__init__ method.
+
+        :param arg:
+        :param kwargs:
+        """
+        super(Not, self).__init__(**kwargs)
+
+        setattr(self, 'not', arg)
diff --git a/linebot/models/recipient.py b/linebot/models/recipient.py
new file mode 100644
index 0000000..4855d91
--- /dev/null
+++ b/linebot/models/recipient.py
@@ -0,0 +1,58 @@
+# -*- 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.limit module."""
+
+from __future__ import unicode_literals
+
+from abc import ABCMeta
+
+from future.utils import with_metaclass
+
+from .base import Base
+
+
+class Recipient(with_metaclass(ABCMeta, Base)):
+    """Recipient.
+
+    https://developers.line.biz/en/reference/messaging-api/#narrowcast-recipient
+
+    Recipient objects represent audiences. You can specify recipients based on
+    a combination of criteria using logical operator objects.
+    """
+
+    def __init__(self, **kwargs):
+        """__init__ method.
+
+        :param kwargs:
+        """
+        super(Recipient, self).__init__(**kwargs)
+
+        self.type = None
+
+
+class AudienceRecipient(Recipient):
+    """AudienceRecipient."""
+
+    def __init__(self, group_id=None, **kwargs):
+        """__init__ method.
+
+        :param int group_id: The audience ID. Create audiences with the
+            Manage Audience API.
+        :param kwargs:
+        """
+        super(AudienceRecipient, self).__init__(**kwargs)
+
+        self.type = "audience"
+        self.audience_group_id = group_id
diff --git a/linebot/models/responses.py b/linebot/models/responses.py
index 7e69970..aad1f49 100644
--- a/linebot/models/responses.py
+++ b/linebot/models/responses.py
@@ -276,6 +276,34 @@ class MessageDeliveryMulticastResponse(Base):
         self.success = success
 
 
+class MessageProgressNarrowcastResponse(Base):
+    """MessageProgressNarrowcastResponse."""
+
+    def __init__(self, phase=None, success_count=None, failure_count=None,
+                 target_count=None, failed_description=None, error_code=None, **kwargs):
+        """__init__ method.
+
+        :param str phase: Progress status. One of `waiting`, `sending`,
+            `succeeded`, or `failed`.
+        :param int success_count: Number of narrowcast messages sent successful.
+        :param int failure_count: Number of narrowcast messages sent failed.
+        :param int target_count: Number of targeted messages sent.
+        :param str failed_description: Reaseon why narrowcast failed, useful when
+            phase is `failed`.
+        :param int error_code: Summary of the error. One of `1` or `2`. `1`
+            means internal error, whereas `2` indicates too few targets.
+        :param kwargs:
+        """
+        super(MessageProgressNarrowcastResponse, self).__init__(**kwargs)
+
+        self.phase = phase
+        self.success_count = success_count
+        self.failure_count = failure_count
+        self.target_count = target_count
+        self.failed_description = failed_description
+        self.error_code = error_code
+
+
 class IssueLinkTokenResponse(Base):
     """IssueLinkTokenResponse.
 
@@ -428,3 +456,20 @@ class InsightMessageEventResponse(Base):
         self.overview = self.get_or_new_from_json_dict(overview, MessageStatistics)
         self.messages = [self.get_or_new_from_json_dict(it, MessageInsight) for it in messages]
         self.clicks = [self.get_or_new_from_json_dict(it, ClickInsight) for it in clicks]
+
+
+class NarrowcastResponse(Base):
+    """NarrowcastResponse.
+
+    https://developers.line.biz/en/reference/messaging-api/#send-narrowcast-message
+    """
+
+    def __init__(self, request_id=None, **kwargs):
+        """__init__ method.
+
+        :param str request_id: Request ID. A unique ID is generated for each request
+        :param kwargs:
+        """
+        super(NarrowcastResponse, self).__init__(**kwargs)
+
+        self.request_id = request_id


Test patch:
diff --git a/tests/api/test_narrowcast_message.py b/tests/api/test_narrowcast_message.py
new file mode 100644
index 0000000..0f01d06
--- /dev/null
+++ b/tests/api/test_narrowcast_message.py
@@ -0,0 +1,271 @@
+# -*- 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 json
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+from linebot.models import (
+    TextSendMessage,
+    Limit,
+    And,
+    Or,
+    Not,
+    Filter,
+    GenderFilter,
+    AppTypeFilter,
+    AreaFilter,
+    AgeFilter,
+    AudienceRecipient,
+    SubscriptionPeriodFilter,
+)
+
+
+class TestNarrowcastMessage(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+        self.maxDiff = None
+        self.request_id = 'f70dd685-499a-4231-a441-f24b8d4fba21'
+
+        # test data
+        self.text_message = TextSendMessage(text='Hello, world')
+        self.message = [{"type": "text", "text": "Hello, world"}]
+
+    @responses.activate
+    def test_narrowcast_simple_text_message(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/narrowcast',
+            json={}, status=200,
+            headers={'X-Line-Request-Id': 'request_id_test'},
+        )
+
+        self.tested.narrowcast(
+            self.text_message,
+            recipient=AudienceRecipient(group_id=5614991017776),
+            filter=Filter(demographic=AgeFilter(gte="age_35", lt="age_40")),
+            limit=Limit(max=10),
+        )
+
+        request = responses.calls[0].request
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/narrowcast')
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                "messages": self.message,
+                "recipient": {
+                    'audienceGroupId': 5614991017776,
+                    'type': 'audience'
+                },
+                "filter": {
+                    "demographic": {
+                        "type": "age",
+                        "gte": "age_35",
+                        "lt": "age_40"
+                    }
+                },
+                "limit": {
+                    "max": 10
+                }
+            }
+        )
+
+    @responses.activate
+    def test_narrowcast_text_message(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/narrowcast',
+            json={}, status=200,
+            headers={'X-Line-Request-Id': 'request_id_test'},
+        )
+
+        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"]),
+                        AgeFilter(gte="age_20", lt="age_25"),
+                        AppTypeFilter(one_of=["android", "ios"]),
+                        AreaFilter(one_of=["jp_23", "jp_05"]),
+                        SubscriptionPeriodFilter(gte="day_7", lt="day_30")
+                    ),
+                    And(
+                        AgeFilter(gte="age_35", lt="age_40"),
+                        Not(GenderFilter(one_of=["male"]))
+                    )
+                )
+            ),
+            limit=Limit(max=100),
+        )
+
+        request = responses.calls[0].request
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/narrowcast')
+        self.assertEqual(request.method, 'POST')
+        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"
+                                        ]
+                                    },
+                                    {
+                                        "type": "age",
+                                        "gte": "age_20",
+                                        "lt": "age_25"
+                                    },
+                                    {
+                                        "type": "appType",
+                                        "oneOf": [
+                                            "android",
+                                            "ios"
+                                        ]
+                                    },
+                                    {
+                                        "type": "area",
+                                        "oneOf": [
+                                            "jp_23",
+                                            "jp_05"
+                                        ]
+                                    },
+                                    {
+                                        "type": "subscriptionPeriod",
+                                        "gte": "day_7",
+                                        "lt": "day_30"
+                                    }
+                                ]
+                            },
+                            {
+                                "type": "operator",
+                                "and": [
+                                    {
+                                        "type": "age",
+                                        "gte": "age_35",
+                                        "lt": "age_40"
+                                    },
+                                    {
+                                        "type": "operator",
+                                        "not": {
+                                            "type": "gender",
+                                            "oneOf": [
+                                                "male"
+                                            ]
+                                        }
+                                    }
+                                ]
+                            }
+                        ]
+                    }
+                },
+                "limit": {
+                    "max": 100
+                }
+            }
+        )
+        self.assertEqual('request_id_test', response.request_id)
+
+    @responses.activate
+    def test_get_progress_status_narrowcast(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/progress/narrowcast?requestId={request_id}'.format(
+                request_id=self.request_id),
+            json={'phase': 'waiting'}, status=200,
+        )
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/progress/narrowcast?requestId={request_id}'.format(
+                request_id=self.request_id),
+            json={
+                'phase': 'succeeded',
+                'successCount': 10,
+                'failureCount': 0,
+                'targetCount': 10,
+            }, status=200,
+        )
+
+        res = self.tested.get_progress_status_narrowcast(self.request_id)
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/progress/narrowcast?requestId={request_id}'.format(
+                request_id=self.request_id)
+        )
+
+        self.assertEqual(res.phase, 'waiting')
+
+        res = self.tested.get_progress_status_narrowcast(self.request_id)
+        request = responses.calls[1].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/progress/narrowcast?requestId={request_id}'.format(
+                request_id=self.request_id)
+        )
+
+        self.assertEqual(res.phase, 'succeeded')
+        self.assertEqual(res.success_count, 10)
+        self.assertEqual(res.failure_count, 0)
+        self.assertEqual(res.target_count, 10)
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/models/serialize_test_case.py b/tests/models/serialize_test_case.py
index d9fdeea..7784dcd 100644
--- a/tests/models/serialize_test_case.py
+++ b/tests/models/serialize_test_case.py
@@ -36,6 +36,11 @@ class SerializeTestCase(unittest.TestCase):
     URI = 'uri'
     LOCATION = 'location'
     FLEX = 'flex'
+    GENDER = "gender"
+    APP_TYPE = "appType"
+    AGE = "age"
+    AREA = "area"
+    SUBSCRIPTION_PERIOD = "subscriptionPeriod"
     SPACER = 'spacer'
     SPAN = 'span'
     BUBBLE = 'bubble'
diff --git a/tests/models/test_filter.py b/tests/models/test_filter.py
new file mode 100644
index 0000000..cd852c9
--- /dev/null
+++ b/tests/models/test_filter.py
@@ -0,0 +1,79 @@
+# -*- 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
+
+from linebot.models import (
+    GenderFilter,
+    AppTypeFilter,
+    AreaFilter,
+    AgeFilter,
+    SubscriptionPeriodFilter
+)
+from tests.models.serialize_test_case import SerializeTestCase
+
+
+class TestFilter(SerializeTestCase):
+    def test_gender_filter(self):
+        arg = {
+            "one_of": ["male", "female"]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.GENDER),
+            GenderFilter(**arg).as_json_dict()
+        )
+
+    def test_app_type_filter(self):
+        arg = {
+            "one_of": ["ios", "android"]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.APP_TYPE),
+            AppTypeFilter(**arg).as_json_dict()
+        )
+
+    def test_age_filter(self):
+        arg = {
+            "gte": "age_35",
+            "lt": "age_40",
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.AGE),
+            AgeFilter(**arg).as_json_dict()
+        )
+
+    def test_area_filter(self):
+        arg = {
+            "one_of": ["jp_34", "jp_05"]
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.AREA),
+            AreaFilter(**arg).as_json_dict()
+        )
+
+    def test_subscription_period_filter(self):
+        arg = {
+            "gte": "day_7",
+            "lt": "day_30",
+        }
+        self.assertEqual(
+            self.serialize_as_dict(arg, type=self.SUBSCRIPTION_PERIOD),
+            SubscriptionPeriodFilter(**arg).as_json_dict()
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()