Repository: line/line-bot-sdk-python
Pull request #198 (2019-07-14 06:34:24)

New Messaging API endpoints for friend statistics 
#196 

Add the following new methods in LineBotApi class:
- get_insight_message_delivery(self, date, timeout=None)
- get_insight_followers(self, date, timeout=None)
- get_insight_demographic(self, timeout=None)


Patch:
diff --git a/README.rst b/README.rst
index de2e4d6..6cc509c 100644
--- a/README.rst
+++ b/README.rst
@@ -460,6 +460,42 @@ https://developers.line.biz/en/reference/messaging-api/#revoke-channel-access-to
 
     line_bot_api.revoke_channel_token(<access_token>)
 
+get\_insight\_message\_delivery(self, date, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Get the number of messages sent on a specified day.
+
+https://developers.line.biz/en/reference/messaging-api/#get-number-of-delivery-messages
+
+.. code:: python
+
+    insight = line_bot_api.get_insight_message_delivery('20191231')
+    print(insight.api_broadcast)
+
+get\_insight\_followers(self, date, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Get the number of users who have added the bot on or before a specified date.
+
+https://developers.line.biz/en/reference/messaging-api/#get-number-of-followers
+
+.. code:: python
+
+    insight = line_bot_api.get_insight_followers('20191231')
+    print(insight.followers)
+
+get\_insight\_demographic(self, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Retrieve the demographic attributes for a bot's friends.
+
+https://developers.line.biz/en/reference/messaging-api/#get-demographic
+
+.. code:: python
+
+    insight = line_bot_api.get_insight_demographic()
+    print(insight.genders)
+
 ※ Error handling
 ^^^^^^^^^^^^^^^^
 
diff --git a/docs/source/linebot.models.rst b/docs/source/linebot.models.rst
index 6dbca33..7828f09 100644
--- a/docs/source/linebot.models.rst
+++ b/docs/source/linebot.models.rst
@@ -32,6 +32,11 @@ linebot.models.imagemap module
 
 .. automodule:: linebot.models.imagemap
 
+linebot.models.insight module
+------------------------------
+
+.. automodule:: linebot.models.insight
+
 linebot.models.messages module
 ------------------------------
 
diff --git a/examples/flask-kitchensink/app.py b/examples/flask-kitchensink/app.py
index c11d447..8fb48ef 100644
--- a/examples/flask-kitchensink/app.py
+++ b/examples/flask-kitchensink/app.py
@@ -14,6 +14,7 @@
 
 from __future__ import unicode_literals
 
+import datetime
 import errno
 import os
 import sys
@@ -366,6 +367,37 @@ def handle_text_message(event):
                 TextSendMessage(text='link_token: ' + link_token_response.link_token)
             ]
         )
+    elif text == 'insight_message_delivery':
+        today = datetime.date.today().strftime("%Y%m%d")
+        response = line_bot_api.get_insight_message_delivery(today)
+        if response.status == 'ready':
+            messages = [
+                TextSendMessage(text='broadcast: ' + str(response.broadcast)),
+                TextSendMessage(text='targeting: ' + str(response.targeting)),
+            ]
+        else:
+            messages = [TextSendMessage(text='status: ' + response.status)]
+        line_bot_api.reply_message(event.reply_token, messages)
+    elif text == 'insight_followers':
+        today = datetime.date.today().strftime("%Y%m%d")
+        response = line_bot_api.get_insight_followers(today)
+        if response.status == 'ready':
+            messages = [
+                TextSendMessage(text='followers: ' + str(response.followers)),
+                TextSendMessage(text='targetedReaches: ' + str(response.targeted_reaches)),
+                TextSendMessage(text='blocks: ' + str(response.blocks)),
+            ]
+        else:
+            messages = [TextSendMessage(text='status: ' + response.status)]
+        line_bot_api.reply_message(event.reply_token, messages)
+    elif text == 'insight_demographic':
+        response = line_bot_api.get_insight_demographic()
+        if response.available:
+            messages = ["{gender}: {percentage}".format(gender=it.gender, percentage=it.percentage)
+                        for it in response.genders]
+        else:
+            messages = [TextSendMessage(text='available: false')]
+        line_bot_api.reply_message(event.reply_token, messages)
     else:
         line_bot_api.reply_message(
             event.reply_token, TextSendMessage(text=event.message.text))
diff --git a/examples/flask-kitchensink/requirements.txt b/examples/flask-kitchensink/requirements.txt
index 15ef862..45de483 100644
--- a/examples/flask-kitchensink/requirements.txt
+++ b/examples/flask-kitchensink/requirements.txt
@@ -1,3 +1,3 @@
 line-bot-sdk
 flask
-werkzeug
\ No newline at end of file
+werkzeug>=0.15.4
diff --git a/linebot/api.py b/linebot/api.py
index e5bfa01..4542be8 100644
--- a/linebot/api.py
+++ b/linebot/api.py
@@ -26,6 +26,7 @@ from .models import (
     MessageQuotaConsumptionResponse, IssueLinkTokenResponse, IssueChannelTokenResponse,
     MessageDeliveryBroadcastResponse, MessageDeliveryMulticastResponse,
     MessageDeliveryPushResponse, MessageDeliveryReplyResponse,
+    InsightMessageDeliveryResponse, InsightFollowersResponse, InsightDemographicResponse,
 )
 
 
@@ -875,6 +876,63 @@ class LineBotApi(object):
             timeout=timeout
         )
 
+    def get_insight_message_delivery(self, date, timeout=None):
+        """Get the number of messages sent on a specified day.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-number-of-delivery-messages
+
+        :param str date: Date for which to retrieve number of sent messages.
+        :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)
+        """
+        response = self._get(
+            '/v2/bot/insight/message/delivery?date={date}'.format(date=date),
+            timeout=timeout
+        )
+
+        return InsightMessageDeliveryResponse.new_from_json_dict(response.json)
+
+    def get_insight_followers(self, date, timeout=None):
+        """Get the number of users who have added the bot on or before a specified date.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-number-of-followers
+
+        :param str date: Date for which to retrieve the number of followers.
+        :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)
+        """
+        response = self._get(
+            '/v2/bot/insight/followers?date={date}'.format(date=date),
+            timeout=timeout
+        )
+
+        return InsightFollowersResponse.new_from_json_dict(response.json)
+
+    def get_insight_demographic(self, timeout=None):
+        """Retrieve the demographic attributes for a bot's friends.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-demographic
+
+        :param str date: Date for which to retrieve the number of followers.
+        :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)
+        """
+        response = self._get(
+            '/v2/bot/insight/demographic',
+            timeout=timeout
+        )
+
+        return InsightDemographicResponse.new_from_json_dict(response.json)
+
     def _get(self, path, params=None, headers=None, stream=False, timeout=None):
         url = self.endpoint + path
 
diff --git a/linebot/models/__init__.py b/linebot/models/__init__.py
index ce6ce1d..a83fcf8 100644
--- a/linebot/models/__init__.py
+++ b/linebot/models/__init__.py
@@ -81,6 +81,14 @@ from .imagemap import (  # noqa
     Video,
     ExternalLink,
 )
+from .insight import (  # noqa
+    DemographicInsight,
+    AgeInsight,
+    AreaInsight,
+    AppTypeInsight,
+    GenderInsight,
+    SubscriptionPeriodInsight,
+)
 from .messages import (  # noqa
     Message,
     TextMessage,
@@ -105,6 +113,9 @@ from .responses import (  # noqa
     Content as MessageContent,  # backward compatibility,
     IssueLinkTokenResponse,
     IssueChannelTokenResponse,
+    InsightMessageDeliveryResponse,
+    InsightFollowersResponse,
+    InsightDemographicResponse,
 )
 from .rich_menu import (  # noqa
     RichMenu,
diff --git a/linebot/models/insight.py b/linebot/models/insight.py
new file mode 100644
index 0000000..399a290
--- /dev/null
+++ b/linebot/models/insight.py
@@ -0,0 +1,111 @@
+# -*- 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.insight module."""
+
+from __future__ import unicode_literals
+
+from abc import ABCMeta
+
+from future.utils import with_metaclass
+
+from .base import Base
+
+
+class DemographicInsight(with_metaclass(ABCMeta, Base)):
+    """Abstract Base Class of DemographicInsight."""
+
+    def __init__(self, percentage=None, **kwargs):
+        """__init__ method.
+
+        :param float percentage: Percentage.
+        :param kwargs:
+        """
+        super(DemographicInsight, self).__init__(**kwargs)
+        self.percentage = percentage
+
+
+class GenderInsight(DemographicInsight):
+    """GenderInsight."""
+
+    def __init__(self, percentage=None, gender=None, **kwargs):
+        """__init__ method.
+
+        :param float percentage: Percentage.
+        :param str gender: Gender
+        :param kwargs:
+        """
+        super(GenderInsight, self).__init__(percentage=percentage, **kwargs)
+
+        self.gender = gender
+
+
+class AgeInsight(DemographicInsight):
+    """AgeInsight."""
+
+    def __init__(self, percentage=None, age=None, **kwargs):
+        """__init__ method.
+
+        :param float percentage: Percentage.
+        :param str age: Age
+        :param kwargs:
+        """
+        super(AgeInsight, self).__init__(percentage=percentage, **kwargs)
+
+        self.age = age
+
+
+class AreaInsight(DemographicInsight):
+    """AreaInsight."""
+
+    def __init__(self, percentage=None, area=None, **kwargs):
+        """__init__ method.
+
+        :param float percentage: Percentage.
+        :param str area: Area
+        :param kwargs:
+        """
+        super(AreaInsight, self).__init__(percentage=percentage, **kwargs)
+
+        self.area = area
+
+
+class AppTypeInsight(DemographicInsight):
+    """AppTypeInsight."""
+
+    def __init__(self, percentage=None, app_type=None, **kwargs):
+        """__init__ method.
+
+        :param float percentage: Percentage.
+        :param str app_type: OS
+        :param kwargs:
+        """
+        super(AppTypeInsight, self).__init__(percentage=percentage, **kwargs)
+
+        self.app_type = app_type
+
+
+class SubscriptionPeriodInsight(DemographicInsight):
+    """SubscriptionPeriodInsight."""
+
+    def __init__(self, percentage=None, subscription_period=None, **kwargs):
+        """__init__ method.
+
+        :param float percentage: Percentage.
+        :param str subscription_period: Friendship duration
+        :param kwargs:
+        """
+        super(SubscriptionPeriodInsight, self).__init__(percentage=percentage, **kwargs)
+
+        self.subscription_period = subscription_period
diff --git a/linebot/models/responses.py b/linebot/models/responses.py
index dc644e2..d46059a 100644
--- a/linebot/models/responses.py
+++ b/linebot/models/responses.py
@@ -17,6 +17,10 @@
 from __future__ import unicode_literals
 
 from .base import Base
+from .insight import (
+    SubscriptionPeriodInsight, AppTypeInsight,
+    AgeInsight, GenderInsight, AreaInsight
+)
 from .rich_menu import RichMenuSize, RichMenuArea
 
 
@@ -294,3 +298,97 @@ class IssueChannelTokenResponse(Base):
         self.access_token = access_token
         self.expires_in = expires_in
         self.token_type = token_type
+
+
+class InsightMessageDeliveryResponse(Base):
+    """InsightMessageDeliveryResponse."""
+
+    def __init__(self, status=None, broadcast=None, targeting=None, auto_response=None,
+                 welcome_response=None, chat=None, api_broadcast=None, api_push=None,
+                 api_multicast=None, api_reply=None, **kwargs):
+        """__init__ method.
+
+        :param str status: Calculation status. One of `ready`, `unready`, or `out_of_service`.
+        :param int broadcast: Number of broadcast messages sent.
+        :param int targeting: Number of targeted/segmented messages sent.
+        :param int auto_response: Number of auto-response messages sent.
+        :param int welcome_response: Number of greeting messages sent.
+        :param int chat: Number of messages sent from LINE Official Account Manager Chat screen.
+        :param int api_broadcast: Number of broadcast messages sent with
+            the Send broadcast message Messaging API operation.
+        :param int api_push: Number of push messages sent
+            with the Send push message Messaging API operation.
+        :param int api_multicast: Number of multicast messages sent with
+            the Send multicast message Messaging API operation.
+        :param int api_reply: Number of replies sent
+            with the Send reply message Messaging API operation.
+        :param int success: The number of messages sent with the Messaging API
+            on the date specified in date.
+        :param kwargs:
+        """
+        super(InsightMessageDeliveryResponse, self).__init__(**kwargs)
+
+        self.status = status
+        self.broadcast = broadcast
+        self.targeting = targeting
+        self.auto_response = auto_response
+        self.welcome_response = welcome_response
+        self.chat = chat
+        self.api_broadcast = api_broadcast
+        self.api_push = api_push
+        self.api_multicast = api_multicast
+        self.api_reply = api_reply
+
+
+class InsightFollowersResponse(Base):
+    """InsightFollowersResponse."""
+
+    def __init__(self, status=None, followers=None, targeted_reaches=None, blocks=None, **kwargs):
+        """__init__ method.
+
+        :param str status: Calculation status. One of `ready`, `unready`, or `out_of_service`.
+        :param int followers: The number of times, as of the specified date,
+            that a user added this LINE official account as a friend for the first time.
+        :param int targeted_reaches: The number of users, as of the specified date,
+            that the official account can reach through targeted messages based
+            on gender, age, and/or region.
+        :param int blocks: The number of users blocking the account as of the specified date.
+        :param kwargs:
+        """
+        super(InsightFollowersResponse, self).__init__(**kwargs)
+
+        self.status = status
+        self.followers = followers
+        self.targeted_reaches = targeted_reaches
+        self.blocks = blocks
+
+
+class InsightDemographicResponse(Base):
+    """InsightDemographicResponse."""
+
+    def __init__(self, available=None, genders=None, ages=None,
+                 areas=None, app_types=None, subscription_periods=None, **kwargs):
+        """__init__ method.
+
+        :param bool available: `true` if friend demographic information is available.
+        :param genders: Percentage per gender.
+        :type genders: list[T <= :py:class:`linebot.models.GenderInsight`]
+        :param ages: Percentage per age group.
+        :type ages: list[T <= :py:class:`linebot.models.AgeInsight`]
+        :param areas: Percentage per area.
+        :type areas: list[T <= :py:class:`linebot.models.AreaInsight`]
+        :param app_types: Percentage by OS.
+        :type app_types: list[T <= :py:class:`linebot.models.AppTypeInsight`]
+        :param subscription_periods: Percentage per friendship duration.
+        :type subscription_periods: list[T <= :py:class:`linebot.models.SubscriptionPeriodInsight`]
+        :param kwargs:
+        """
+        super(InsightDemographicResponse, self).__init__(**kwargs)
+
+        self.available = available
+        self.genders = [self.get_or_new_from_json_dict(it, GenderInsight) for it in genders]
+        self.ages = [self.get_or_new_from_json_dict(it, AgeInsight) for it in ages]
+        self.areas = [self.get_or_new_from_json_dict(it, AreaInsight) for it in areas]
+        self.app_types = [self.get_or_new_from_json_dict(it, AppTypeInsight) for it in app_types]
+        self.subscription_periods = [self.get_or_new_from_json_dict(it, SubscriptionPeriodInsight)
+                                     for it in subscription_periods]
diff --git a/setup.py b/setup.py
index 09838c3..81ba43a 100644
--- a/setup.py
+++ b/setup.py
@@ -80,10 +80,10 @@ setup(
         "Programming Language :: Python :: 2",
         "Programming Language :: Python :: 2.7",
         "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.3",
         "Programming Language :: Python :: 3.4",
         "Programming Language :: Python :: 3.5",
         "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
         "Topic :: Software Development"
     ]
 )
diff --git a/tox.ini b/tox.ini
index 0815ab7..ff2a9d5 100644
--- a/tox.ini
+++ b/tox.ini
@@ -19,6 +19,7 @@ deps =
     six
     flake8
     flake8-docstrings
+    pydocstyle<4
 commands = flake8 linebot/
 
 [testenv:py37-flake8-other]


Test patch:
diff --git a/tests/api/test_get_insight.py b/tests/api/test_get_insight.py
new file mode 100644
index 0000000..1d0af1c
--- /dev/null
+++ b/tests/api/test_get_insight.py
@@ -0,0 +1,192 @@
+# -*- 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
+)
+from linebot.models import (
+    GenderInsight, AreaInsight, AgeInsight, AppTypeInsight, SubscriptionPeriodInsight
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+        self.date = '20190101'
+
+    @responses.activate
+    def test_get_insight_message_delivery(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/insight/message/delivery?date={date}'.format(date=self.date),
+            json={
+                'status': 'ready',
+                'broadcast': 100,
+                'targeting': 200,
+                'autoResponse': 300,
+                'welcomeResponse': 400,
+                'chat': 500,
+                'apiBroadcast': 600,
+                'apiPush': 700,
+                'apiMulticast': 800,
+            },
+            status=200
+        )
+
+        res = self.tested.get_insight_message_delivery(self.date)
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual('ready', res.status)
+        self.assertEqual(100, res.broadcast)
+        self.assertEqual(200, res.targeting)
+        self.assertEqual(300, res.auto_response)
+        self.assertEqual(400, res.welcome_response)
+        self.assertEqual(500, res.chat)
+        self.assertEqual(600, res.api_broadcast)
+        self.assertEqual(700, res.api_push)
+        self.assertEqual(800, res.api_multicast)
+        self.assertEqual(None, res.api_reply)
+
+    @responses.activate
+    def test_get_insight_followers(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/insight/followers?date={date}'.format(date=self.date),
+            json={
+                'status': 'ready',
+                'followers': 100,
+                'targetedReaches': 200,
+                'blocks': 300
+            },
+            status=200
+        )
+
+        res = self.tested.get_insight_followers(self.date)
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual('ready', res.status)
+        self.assertEqual(100, res.followers)
+        self.assertEqual(200, res.targeted_reaches)
+        self.assertEqual(300, res.blocks)
+
+    @responses.activate
+    def test_get_insight_demographic(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/insight/demographic',
+            json={
+                'available': True,
+                'genders': [
+                    {
+                        'gender': 'unknown',
+                        'percentage': 37.6
+                    },
+                    {
+                        'gender': 'male',
+                        'percentage': 31.8
+                    },
+                    {
+                        'gender': 'female',
+                        'percentage': 30.6
+                    }
+                ],
+                'ages': [
+                    {
+                        'age': 'unknown',
+                        'percentage': 37.6
+                    },
+                    {
+                        'age': 'from50',
+                        'percentage': 17.3
+                    },
+                ],
+                'areas': [
+                    {
+                        'area': 'unknown',
+                        'percentage': 50.5
+                    },
+                    {
+                        'area': 'Tokyo',
+                        'percentage': 49.5
+                    },
+                ],
+                'appTypes': [
+                    {
+                        'appType': 'ios',
+                        'percentage': 62.4
+                    },
+                    {
+                        'appType': 'android',
+                        'percentage': 27.7
+                    },
+                    {
+                        'appType': 'others',
+                        'percentage': 9.9
+                    }
+                ],
+                'subscriptionPeriods': [
+                    {
+                        'subscriptionPeriod': 'over365days',
+                        'percentage': 96.4
+                    },
+                    {
+                        'subscriptionPeriod': 'within365days',
+                        'percentage': 1.9
+                    },
+                    {
+                        'subscriptionPeriod': 'within180days',
+                        'percentage': 1.2
+                    },
+                    {
+                        'subscriptionPeriod': 'within90days',
+                        'percentage': 0.5
+                    },
+                    {
+                        'subscriptionPeriod': 'within30days',
+                        'percentage': 0.1
+                    },
+                    {
+                        'subscriptionPeriod': 'within7days',
+                        'percentage': 0
+                    }
+                ]
+            },
+            status=200
+        )
+
+        res = self.tested.get_insight_demographic()
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual(True, res.available)
+        self.assertIn(GenderInsight(gender='male', percentage=31.8), res.genders)
+        self.assertIn(AgeInsight(age='from50', percentage=17.3), res.ages)
+        self.assertIn(AreaInsight(area='Tokyo', percentage=49.5), res.areas)
+        self.assertIn(AppTypeInsight(app_type='ios', percentage=62.4), res.app_types)
+        self.assertIn(
+            SubscriptionPeriodInsight(subscription_period='over365days', percentage=96.4),
+            res.subscription_periods
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()