Repository: line/line-bot-sdk-python
Pull request #216 (2019-10-23 17:43:30)

Support a new Messaging API endpoint for friend statistics
#215

- implement a new function `get_insight_message_event` in `linebot/api.py`
- modify `to_snake_case` in `linebot/utils.py`
  - The snake case of "uniqueMediaPlayed100Percent" should be "unique_media_played_100_percent"


Patch:
diff --git a/README.rst b/README.rst
index 085607d..a6bb727 100644
--- a/README.rst
+++ b/README.rst
@@ -496,6 +496,18 @@ https://developers.line.biz/en/reference/messaging-api/#get-demographic
     insight = line_bot_api.get_insight_demographic()
     print(insight.genders)
 
+get\_insight\_message\_event(self, request_id, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Return statistics about how users interact with broadcast messages.
+
+https://developers.line.biz/en/reference/messaging-api/#get-message-event
+
+.. code:: python
+
+    insight = line_bot_api.get_insight_message_event()
+    print(insight.overview)
+
 ※ Error handling
 ^^^^^^^^^^^^^^^^
 
diff --git a/linebot/api.py b/linebot/api.py
index 4542be8..87615d9 100644
--- a/linebot/api.py
+++ b/linebot/api.py
@@ -27,6 +27,7 @@ from .models import (
     MessageDeliveryBroadcastResponse, MessageDeliveryMulticastResponse,
     MessageDeliveryPushResponse, MessageDeliveryReplyResponse,
     InsightMessageDeliveryResponse, InsightFollowersResponse, InsightDemographicResponse,
+    InsightMessageEventResponse,
 )
 
 
@@ -933,6 +934,25 @@ class LineBotApi(object):
 
         return InsightDemographicResponse.new_from_json_dict(response.json)
 
+    def get_insight_message_event(self, request_id, timeout=None):
+        """Return statistics about how users interact with broadcast messages.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-message-event
+
+        :param str request_id: Request ID of broadcast message.
+        :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/event?requestId={request_id}'.format(request_id=request_id),
+            timeout=timeout
+        )
+
+        return InsightMessageEventResponse.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 e13b920..3d24f44 100644
--- a/linebot/models/__init__.py
+++ b/linebot/models/__init__.py
@@ -89,6 +89,9 @@ from .insight import (  # noqa
     AppTypeInsight,
     GenderInsight,
     SubscriptionPeriodInsight,
+    MessageStatistics,
+    MessageInsight,
+    ClickInsight,
 )
 from .messages import (  # noqa
     Message,
@@ -117,6 +120,7 @@ from .responses import (  # noqa
     InsightMessageDeliveryResponse,
     InsightFollowersResponse,
     InsightDemographicResponse,
+    InsightMessageEventResponse,
 )
 from .rich_menu import (  # noqa
     RichMenu,
diff --git a/linebot/models/insight.py b/linebot/models/insight.py
index 399a290..ee162aa 100644
--- a/linebot/models/insight.py
+++ b/linebot/models/insight.py
@@ -109,3 +109,106 @@ class SubscriptionPeriodInsight(DemographicInsight):
         super(SubscriptionPeriodInsight, self).__init__(percentage=percentage, **kwargs)
 
         self.subscription_period = subscription_period
+
+
+class MessageStatistics(Base):
+    """MessageStatistics."""
+
+    def __init__(self, request_id=None, timestamp=None, delivered=None,
+                 unique_impression=None, unique_click=None, unique_media_played=None,
+                 unique_media_played_100_percent=None, **kwargs):
+        """__init__ method.
+
+        :param str request_id: Request ID.
+        :param int timestamp: UNIX timestamp for message delivery time.
+        :param int delivered: Number of messages delivered. This property shows values
+        of less than 20.
+        :param int unique_impression: Number of people who opened the message,
+        meaning they displayed at least 1 bubble.
+        :param int unique_click: Number of people who opened any URL in the message.
+        :param int unique_media_played: Number of people who started playing any video
+        or audio in the message.
+        :param int unique_media_played_100_percent: Number of people who played the entirety of
+        any video or audio in the message.
+        """
+        super(MessageStatistics, self).__init__(**kwargs)
+
+        self.request_id = request_id
+        self.timestamp = timestamp
+        self.delivered = delivered
+        self.unique_impression = unique_impression
+        self.unique_click = unique_click
+        self.unique_media_played = unique_media_played
+        self.unique_media_played_100_percent = unique_media_played_100_percent
+
+
+class MessageInsight(Base):
+    """MessageInsight."""
+
+    def __init__(self, seq=None, impression=None, media_played=None,
+                 media_played_25_percent=None, media_played_50_percent=None,
+                 media_played_75_percent=None, media_played_100_percent=None,
+                 unique_media_played=None, unique_media_played_25_percent=None,
+                 unique_media_played_50_percent=None, unique_media_played_75_percent=None,
+                 unique_media_played_100_percent=None, **kwargs):
+        """__init__ method.
+
+        :param int seq: Bubble's serial number.
+        :param int impression: Number of times the bubble was displayed.
+        :param int media_played: Number of times audio or video in the bubble started playing.
+        :param int media_played_25_percent: Number of times audio or video
+        in the bubble was played from start to 25%.
+        :param int media_played_50_percent: Number of times audio or video
+        in the bubble was played from start to 50%.
+        :param int media_played_75_percent: Number of times audio or video
+        in the bubble was played from start to 75%.
+        :param int media_played_100_percent: Number of times audio or video
+        in the bubble was played in its entirety.
+        :param int unique_media_played: Number of people that started playing
+        audio or video in the bubble.
+        :param int unique_media_played_25_percent: Number of people that played
+        audio or video in the bubble from start to 25%.
+        :param int unique_media_played_50_percent: Number of people that played
+        audio or video in the bubble from start to 50%.
+        :param int unique_media_played_75_percent: Number of people that played
+        audio or video in the bubble from start to 75%.
+        :param int unique_media_played_100_percent: Number of people that played
+        audio or video in the bubble in its entirety.
+        """
+        super(MessageInsight, self).__init__(**kwargs)
+
+        self.seq = seq
+        self.impression = impression
+        self.media_played = media_played
+        self.media_played_25_percent = media_played_25_percent
+        self.media_played_50_percent = media_played_50_percent
+        self.media_played_75_percent = media_played_75_percent
+        self.media_played_100_percent = media_played_100_percent
+        self.unique_media_played = unique_media_played
+        self.unique_media_played_25_percent = unique_media_played_25_percent
+        self.unique_media_played_50_percent = unique_media_played_50_percent
+        self.unique_media_played_75_percent = unique_media_played_75_percent
+        self.unique_media_played_100_percent = unique_media_played_100_percent
+
+
+class ClickInsight(Base):
+    """ClickInsight."""
+
+    def __init__(self, seq=None, url=None, click=None, unique_click=None,
+                 unique_click_of_request=None, **kwargs):
+        """__init__ method.
+
+        :param int seq: The URL's serial number.
+        :param str url: URL.
+        :param int click: Number of times the URL was opened.
+        :param int unique_click: Number of people that opened the URL.
+        :param int unique_click_of_request: Number of people who opened this url
+        through any link in the message.
+        """
+        super(ClickInsight, self).__init__(**kwargs)
+
+        self.seq = seq
+        self.url = url
+        self.click = click
+        self.unique_click = unique_click
+        self.unique_click_of_request = unique_click_of_request
diff --git a/linebot/models/responses.py b/linebot/models/responses.py
index d46059a..c854202 100644
--- a/linebot/models/responses.py
+++ b/linebot/models/responses.py
@@ -19,7 +19,8 @@ from __future__ import unicode_literals
 from .base import Base
 from .insight import (
     SubscriptionPeriodInsight, AppTypeInsight,
-    AgeInsight, GenderInsight, AreaInsight
+    AgeInsight, GenderInsight, AreaInsight,
+    MessageInsight, ClickInsight, MessageStatistics,
 )
 from .rich_menu import RichMenuSize, RichMenuArea
 
@@ -392,3 +393,24 @@ class InsightDemographicResponse(Base):
         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]
+
+
+class InsightMessageEventResponse(Base):
+    """InsightMessageEventResponse."""
+
+    def __init__(self, overview=None, messages=None, clicks=None, **kwargs):
+        """__init__ method.
+
+        :param overview: Summary of message statistics.
+        :type overview: T <= :py:class:`linebot.models.MessageStatistics`
+        :param messages: Array of information about individual message bubbles.
+        :type messages: list[T <= :py:class:`linebot.models.MessageInsight`]
+        :param clicks: Array of information about URLs in the message.
+        :type clicks: list[T <= :py:class:`linebot.models.ClickInsight`]
+        :param kwargs:
+        """
+        super(InsightMessageEventResponse, self).__init__(**kwargs)
+
+        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]
diff --git a/linebot/utils.py b/linebot/utils.py
index 9d86036..6d3bd76 100644
--- a/linebot/utils.py
+++ b/linebot/utils.py
@@ -12,12 +12,13 @@
 #  License for the specific language governing permissions and limitations
 #  under the License.
 
-"""linebot.http_client module."""
+"""linebot.utils module."""
 
 from __future__ import unicode_literals
 
 import logging
 import re
+
 import sys
 
 LOGGER = logging.getLogger('linebot')
@@ -31,8 +32,10 @@ def to_snake_case(text):
     :param str text:
     :rtype: str
     """
-    s1 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', text)
-    return re.sub('([a-z0-9])([A-Z])', r'\1_\2', s1).lower()
+    s1 = re.sub('(.)([A-Z])', r'\1_\2', text)
+    s2 = re.sub('(.)([0-9]+)', r'\1_\2', s1)
+    s3 = re.sub('([0-9])([a-z])', r'\1_\2', s2)
+    return s3.lower()
 
 
 def to_camel_case(text):


Test patch:
diff --git a/tests/api/test_get_insight.py b/tests/api/test_get_insight.py
index 1d0af1c..e0cf4c0 100644
--- a/tests/api/test_get_insight.py
+++ b/tests/api/test_get_insight.py
@@ -30,6 +30,7 @@ class TestLineBotApi(unittest.TestCase):
     def setUp(self):
         self.tested = LineBotApi('channel_secret')
         self.date = '20190101'
+        self.request_id = 'f70dd685-499a-4231-a441-f24b8d4fba21'
 
     @responses.activate
     def test_get_insight_message_delivery(self):
@@ -187,6 +188,75 @@ class TestLineBotApi(unittest.TestCase):
             res.subscription_periods
         )
 
+    @responses.activate
+    def test_get_insight_message_event(self):
+        json = {
+            'overview': {
+                'requestId': 'f70dd685-499a-4231-a441-f24b8d4fba21',
+                'timestamp': 1568214000,
+                'delivered': 32,
+                'uniqueImpression': 4,
+                'uniqueClick': None,
+                'uniqueMediaPlayed': 2,
+                'uniqueMediaPlayed100Percent': -1
+            },
+            'messages': [
+                {
+                    'seq': 1,
+                    'impression': 18,
+                    'mediaPlayed': 11,
+                    'mediaPlayed25Percent': -1,
+                    'mediaPlayed50Percent': -1,
+                    'mediaPlayed75Percent': -1,
+                    'mediaPlayed100Percent': -1,
+                    'uniqueMediaPlayed': 2,
+                    'uniqueMediaPlayed25Percent': -1,
+                    'uniqueMediaPlayed50Percent': -1,
+                    'uniqueMediaPlayed75Percent': -1,
+                    'uniqueMediaPlayed100Percent': -1
+                }
+            ],
+            'clicks': [
+                {
+                    'seq': 1,
+                    'url': 'https://www.yahoo.co.jp/',
+                    'click': -1,
+                    'uniqueClick': -1,
+                    'uniqueClickOfRequest': -1
+                },
+                {
+                    'seq': 1,
+                    'url': 'https://www.google.com/?hl=ja',
+                    'click': -1,
+                    'uniqueClick': -1,
+                    'uniqueClickOfRequest': -1
+                }
+            ]
+        }
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/insight/message/event?requestId={request_id}'.format(request_id=self.request_id),
+            status=200,
+            json=json,
+        )
+
+        res = self.tested.get_insight_message_event(self.request_id)
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/insight/message/event?requestId={request_id}'.format(request_id=self.request_id)
+        )
+
+        self.assertEqual(res.overview.timestamp, json['overview']['timestamp'])
+        self.assertEqual(res.overview.unique_media_played_100_percent, json['overview']['uniqueMediaPlayed100Percent'])
+        self.assertEqual(res.messages[0].seq, json['messages'][0]['seq'])
+        self.assertEqual(res.messages[0].media_played_50_percent, json['messages'][0]['mediaPlayed50Percent'])
+        self.assertEqual(res.clicks[0].url, json['clicks'][0]['url'])
+        self.assertEqual(res.clicks[0].unique_click_of_request, json['clicks'][0]['uniqueClickOfRequest'])
+
 
 if __name__ == '__main__':
     unittest.main()
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 0e0c534..1ff3c54 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -21,10 +21,14 @@ from linebot.utils import to_camel_case, to_snake_case, safe_compare_digest
 
 class TestUtils(unittest.TestCase):
     def test_to_snake_case(self):
-        self.assertEqual(to_snake_case('hogeBar'), 'hoge_bar')
+        self.assertEqual(to_snake_case('hogeBarFuga'), 'hoge_bar_fuga')
+        self.assertEqual(to_snake_case('uniqueMediaPlayed100Percent'), 'unique_media_played_100_percent')
+        self.assertEqual(to_snake_case('festival20Days'), 'festival_20_days')
+        self.assertEqual(to_snake_case('festival20days'), 'festival_20_days')
 
     def test_to_camel_case(self):
         self.assertEqual(to_camel_case('hoge_bar'), 'hogeBar')
+        self.assertEqual(to_camel_case('unique_media_played_100_percent'), 'uniqueMediaPlayed100Percent')
 
     def test_safe_compare_digest_true(self):
         self.assertTrue(safe_compare_digest('/gg9a+LvFevTH1sd7', '/gg9a+LvFevTH1sd7'))