Repository: line/line-bot-sdk-python

Issue:
Support get statistics per aggregation unit
News: https://developers.line.biz/en/news/2021/03/10/messaging-api-update-march-2021/#202103-02
Doc: https://developers.line.biz/en/reference/partner-docs/#get-statistics-per-unit

Discussion:
louis70109: @Yang-33 Thanks for your PR, could you please fix the conflict files?
Yang-33: @louis70109 Thank you. I merged recent changes into this PR. Could you take a look ? 

Patch:
diff --git a/README.rst b/README.rst
index e7563e6..a41a7b3 100644
--- a/README.rst
+++ b/README.rst
@@ -114,8 +114,8 @@ https://developers.line.biz/en/reference/messaging-api/#send-reply-message
 
     line_bot_api.reply_message(reply_token, TextSendMessage(text='Hello World!'))
 
-push\_message(self, to, messages, notification_disabled=False, timeout=None)
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+push\_message(self, to, messages, notification_disabled=False, custom_aggregation_units=None, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 Send messages to users, groups, and rooms at any time.
 
@@ -125,8 +125,8 @@ https://developers.line.biz/en/reference/messaging-api/#send-push-message
 
     line_bot_api.push_message(to, TextSendMessage(text='Hello World!'))
 
-multicast(self, to, messages, notification_disabled=False, timeout=None)
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+multicast(self, to, messages, notification_disabled=False, custom_aggregation_units=None, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 Send push messages to multiple users at any time. Messages cannot be sent to groups or rooms.
 
@@ -590,8 +590,45 @@ https://developers.line.biz/en/reference/messaging-api/#get-message-event
     broadcast_response = line_bot_api.broadcast(TextSendMessage(text='Hello World!'))
     insight = line_bot_api.get_insight_message_event(broadcast_response.request_id)
     print(insight.overview)
-    
-get\_bot_info(self, timeout=None)
+
+get\_statistics\_per\_unit(self, custom_aggregation_unit, from_date, to_date, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Return statistics about how users interact with push and multicast messages.
+
+https://developers.line.biz/en/reference/partner-docs/#get-statistics-per-unit
+
+.. code:: python
+
+    unit_name = 'promotion_a'
+    line_bot_api.push_message('to', TextSendMessage(text='Hello World!'), custom_aggregation_units=unit_name)
+    insight = line_bot_api.get_statistics_per_unit(unit_name, '20210301', '20210331')
+    print(insight.overview)
+
+get\_number\_of\_units\_used\_this\_month(self, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Return the number of aggregation units used this month.
+
+https://developers.line.biz/en/reference/partner-docs/#get-number-of-units-used-this-month
+
+.. code:: python
+    usage = line_bot_api.get_number_of_units_used_this_month()
+    print(usage.num_of_custom_aggregation_units)
+
+get\_name\_list\_of\_units\_used\_this\_month(self, limit=100, start=None, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Return the name list of units used this month for statistics aggregation.
+
+https://developers.line.biz/en/reference/partner-docs/#get-name-list-of-units-used-this-month
+
+.. code:: python
+    name_list = line_bot_api.get_name_list_of_units_used_this_month()
+    print(name_list.custom_aggregation_units)
+    print(name_list.next)
+
+get\_bot\_info(self, timeout=None)
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
 Get bot's basic information.
diff --git a/examples/fastapi-echo/main.py b/examples/fastapi-echo/main.py
index abd1cf7..9fdd56a 100644
--- a/examples/fastapi-echo/main.py
+++ b/examples/fastapi-echo/main.py
@@ -19,7 +19,6 @@ import aiohttp
 
 from fastapi import Request, FastAPI, HTTPException
 
-
 from linebot import (
     AsyncLineBotApi, WebhookParser
 )
@@ -41,13 +40,13 @@ if channel_access_token is None:
     print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.')
     sys.exit(1)
 
-
 app = FastAPI()
 session = aiohttp.ClientSession()
 async_http_client = AiohttpAsyncHttpClient(session)
 line_bot_api = AsyncLineBotApi(channel_access_token, async_http_client)
 parser = WebhookParser(channel_secret)
 
+
 @app.post("/callback")
 async def handle_callback(request: Request):
     signature = request.headers['X-Line-Signature']
@@ -71,5 +70,5 @@ async def handle_callback(request: Request):
             event.reply_token,
             TextSendMessage(text=event.message.text)
         )
-        
+
     return 'OK'
diff --git a/linebot/api.py b/linebot/api.py
index ee3e6d9..60543fc 100644
--- a/linebot/api.py
+++ b/linebot/api.py
@@ -32,7 +32,9 @@ from .models import (
 )
 from .models.responses import (
     Group, UserIds, RichMenuAliasResponse, RichMenuAliasListResponse, ChannelAccessTokens,
-    IssueChannelTokenResponseV2, VerifyChannelTokenResponseV2, ValidAccessTokenKeyIDsResponse
+    IssueChannelTokenResponseV2, VerifyChannelTokenResponseV2, ValidAccessTokenKeyIDsResponse,
+    InsightMessageEventOfCustomAggregationUnitResponse, AggregationInfoResponse,
+    AggregationNameListResponse
 )
 
 
@@ -114,7 +116,8 @@ class LineBotApi(object):
 
     def push_message(
             self, to, messages,
-            retry_key=None, notification_disabled=False, timeout=None):
+            retry_key=None, notification_disabled=False,
+            custom_aggregation_units=None, timeout=None):
         """Call push message API.
 
         https://developers.line.biz/en/reference/messaging-api/#send-push-message
@@ -129,6 +132,11 @@ class LineBotApi(object):
         :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 custom_aggregation_units: (optional) Name of aggregation unit. Case-sensitive.
+            Max unit: 1
+            Max aggregation unit name length: 30 characters
+            Supported character types: Half-width alphanumeric characters and underscore
+        :type custom_aggregation_units: str | list[str]
         :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.
@@ -147,11 +155,17 @@ class LineBotApi(object):
             'notificationDisabled': notification_disabled,
         }
 
+        if custom_aggregation_units is not None:
+            if not isinstance(custom_aggregation_units, (list, tuple)):
+                custom_aggregation_units = [custom_aggregation_units]
+            data['customAggregationUnits'] = custom_aggregation_units
+
         self._post(
             '/v2/bot/message/push', data=json.dumps(data), timeout=timeout
         )
 
-    def multicast(self, to, messages, retry_key=None, notification_disabled=False, timeout=None):
+    def multicast(self, to, messages, retry_key=None, notification_disabled=False,
+                  custom_aggregation_units=None, timeout=None):
         """Call multicast API.
 
         https://developers.line.biz/en/reference/messaging-api/#send-multicast-message
@@ -169,6 +183,11 @@ class LineBotApi(object):
         :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 custom_aggregation_units: (optional) Name of aggregation unit. Case-sensitive.
+            Max unit: 1
+            Max aggregation unit name length: 30 characters
+            Supported character types: Half-width alphanumeric characters and underscore
+        :type custom_aggregation_units: str | list[str]
         :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.
@@ -187,6 +206,11 @@ class LineBotApi(object):
             'notificationDisabled': notification_disabled,
         }
 
+        if custom_aggregation_units is not None:
+            if not isinstance(custom_aggregation_units, (list, tuple)):
+                custom_aggregation_units = [custom_aggregation_units]
+            data['customAggregationUnits'] = custom_aggregation_units
+
         self._post(
             '/v2/bot/message/multicast', data=json.dumps(data), timeout=timeout
         )
@@ -1718,6 +1742,75 @@ class LineBotApi(object):
                              timeout=timeout)
         return ValidAccessTokenKeyIDsResponse.new_from_json_dict(response.json)
 
+    def get_statistics_per_unit(self, custom_aggregation_unit, from_date, to_date, timeout=None):
+        """Return statistics about how users interact with push and multicast messages.
+
+        https://developers.line.biz/en/reference/partner-docs/#get-statistics-per-unit
+
+        :param str custom_aggregation_unit: Name of aggregation unit specified when sending
+            the message like `push_message(...)` and `multicast(...)`.
+        :param str from_date: Start date of aggregation period.
+            The format is `yyyyMMdd` (Timezone is UTC+9).
+        :param str to_date: End date of aggregation period.
+            The end date can be specified for up to 30 days later.
+            The format is `yyyyMMdd` (Timezone is UTC+9).
+        :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.InsightMessageEventOfCustomAggregationUnitResponse`
+        """
+        response = self._get(
+            '/v2/bot/insight/message/event/aggregation?'
+            'customAggregationUnit={custom_aggregation_unit}&from={from_date}&to={to_date}'.format(
+                custom_aggregation_unit=custom_aggregation_unit,
+                from_date=from_date, to_date=to_date),
+            timeout=timeout
+        )
+
+        return InsightMessageEventOfCustomAggregationUnitResponse.new_from_json_dict(response.json)
+
+    def get_number_of_units_used_this_month(self, timeout=None):
+        """Return the number of aggregation units used this month.
+
+        https://developers.line.biz/en/reference/partner-docs/#get-number-of-units-used-this-month
+
+        :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.AggregationInfoResponse`
+        """
+        response = self._get('/v2/bot/message/aggregation/info', timeout=timeout)
+        return AggregationInfoResponse.new_from_json_dict(response.json)
+
+    def get_name_list_of_units_used_this_month(self, limit=100, start=None, timeout=None):
+        """Return the name list of units used this month for statistics aggregation.
+
+        :param int limit: Maximum number of aggregation units you can get per request.
+            If you don't specify a value, or if you specify a value greater than or equal to 100,
+            the maximum is 100.
+        :param str start: Get the next array of name list of units
+        :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.AggregationNameListResponse`
+        """
+        params = {'limit': limit} if start is None else {'limit': limit, 'start': start}
+
+        response = self._get(
+            '/v2/bot/message/aggregation/list',
+            params=params,
+            timeout=timeout
+        )
+
+        return AggregationNameListResponse.new_from_json_dict(response.json)
+
     def _get(self, path, endpoint=None, params=None, headers=None, stream=False, timeout=None):
         url = (endpoint or self.endpoint) + path
 
diff --git a/linebot/async_api.py b/linebot/async_api.py
index cc182dc..3857490 100644
--- a/linebot/async_api.py
+++ b/linebot/async_api.py
@@ -23,7 +23,6 @@
 
 """linebot.async_api module."""
 
-
 import json
 
 from .__about__ import __version__
@@ -65,6 +64,13 @@ from .models.responses import (
     UserIds,
     RichMenuAliasResponse,
     RichMenuAliasListResponse,
+    ChannelAccessTokens,
+    IssueChannelTokenResponseV2,
+    VerifyChannelTokenResponseV2,
+    ValidAccessTokenKeyIDsResponse,
+    InsightMessageEventOfCustomAggregationUnitResponse,
+    AggregationInfoResponse,
+    AggregationNameListResponse,
 )
 
 
@@ -140,7 +146,13 @@ class AsyncLineBotApi(object):
         )
 
     async def push_message(
-        self, to, messages, retry_key=None, notification_disabled=False, timeout=None
+        self,
+        to,
+        messages,
+        retry_key=None,
+        notification_disabled=False,
+        custom_aggregation_units=None,
+        timeout=None,
     ):
         """Call push message API.
 
@@ -156,6 +168,11 @@ class AsyncLineBotApi(object):
         :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 custom_aggregation_units: (optional) Name of aggregation unit. Case-sensitive.
+            Max unit: 1
+            Max aggregation unit name length: 30 characters
+            Supported character types: Half-width alphanumeric characters and underscore
+        :type custom_aggregation_units: str | list[str]
         :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.
@@ -174,10 +191,21 @@ class AsyncLineBotApi(object):
             "notificationDisabled": notification_disabled,
         }
 
+        if custom_aggregation_units is not None:
+            if not isinstance(custom_aggregation_units, (list, tuple)):
+                custom_aggregation_units = [custom_aggregation_units]
+            data["customAggregationUnits"] = custom_aggregation_units
+
         await self._post("/v2/bot/message/push", data=json.dumps(data), timeout=timeout)
 
     async def multicast(
-        self, to, messages, retry_key=None, notification_disabled=False, timeout=None
+        self,
+        to,
+        messages,
+        retry_key=None,
+        notification_disabled=False,
+        custom_aggregation_units=None,
+        timeout=None,
     ):
         """Call multicast API.
 
@@ -196,6 +224,11 @@ class AsyncLineBotApi(object):
         :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 custom_aggregation_units: (optional) Name of aggregation unit. Case-sensitive.
+            Max unit: 1
+            Max aggregation unit name length: 30 characters
+            Supported character types: Half-width alphanumeric characters and underscore
+        :type custom_aggregation_units: str | list[str]
         :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.
@@ -214,6 +247,11 @@ class AsyncLineBotApi(object):
             "notificationDisabled": notification_disabled,
         }
 
+        if custom_aggregation_units is not None:
+            if not isinstance(custom_aggregation_units, (list, tuple)):
+                custom_aggregation_units = [custom_aggregation_units]
+            data["customAggregationUnits"] = custom_aggregation_units
+
         await self._post(
             "/v2/bot/message/multicast", data=json.dumps(data), timeout=timeout
         )
@@ -1632,11 +1670,13 @@ class AsyncLineBotApi(object):
 
         return TestWebhookResponse.new_from_json_dict((await response.json))
 
-    async def get_followers_ids(self, start=None, timeout=None):
+    async def get_followers_ids(self, limit=300, start=None, timeout=None):
         """Get a list of users who added your LINE Official Account as a friend.
 
         https://developers.line.biz/en/reference/messaging-api/#get-follower-ids
 
+        :param int limit: The maximum number of user IDs to retrieve in a single request.
+            The default value is 300.
         :param str start: Get the next array of user IDs.
         :param timeout: (optional) How long to wait for the server
             to send data before giving up, as a float,
@@ -1645,7 +1685,7 @@ class AsyncLineBotApi(object):
         :type timeout: float | tuple(float, float)
         :rtype: :py:class:`linebot.models.responses.UserIds`
         """
-        params = None if start is None else {"start": start}
+        params = {"limit": limit} if start is None else {"limit": limit, "start": start}
 
         response = await self._get(
             "/v2/bot/followers/ids", params=params, timeout=timeout
@@ -1653,6 +1693,219 @@ class AsyncLineBotApi(object):
 
         return UserIds.new_from_json_dict((await response.json))
 
+    async def issue_channel_access_token_v2_1(
+        self,
+        client_assertion,
+        grant_type="client_credentials",
+        client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+        timeout=None,
+    ):
+        """Issues a channel access token v2.1.
+
+        https://developers.line.biz/en/reference/messaging-api/#issue-channel-access-token-v2-1
+
+        :param str client_assertion: Client assertion.
+        :param str grant_type: `client_credentials`
+        :param str client_assertion_type: `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
+        :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.async_http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`linebot.models.responses.IssueChannelTokenResponseV2`
+        """
+        response = await self._post(
+            "/oauth2/v2.1/token",
+            data={
+                "grant_type": grant_type,
+                "client_assertion_type": client_assertion_type,
+                "client_assertion": client_assertion,
+            },
+            headers={"Content-Type": "application/x-www-form-urlencoded"},
+            timeout=timeout,
+        )
+
+        return IssueChannelTokenResponseV2.new_from_json_dict((await response.json))
+
+    async def revoke_channel_access_token_v2_1(
+        self, client_id, client_secret, access_token, timeout=None
+    ):
+        """Revokes a channel access token v2.1.
+
+        https://developers.line.biz/en/reference/messaging-api/#revoke-channel-access-token-v2-1
+
+        :param str client_id: Client id.
+        :param str client_secret: Channel secret.
+        :param str access_token: Channel access token.
+        :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.async_http_client.timeout
+        :type timeout: float | tuple(float, float)
+        """
+        await self._post(
+            "/oauth2/v2.1/revoke",
+            data={
+                "client_id": client_id,
+                "client_secret": client_secret,
+                "access_token": access_token,
+            },
+            timeout=timeout,
+        )
+
+    async def get_channel_access_tokens_v2_1(
+        self,
+        client_assertion,
+        client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+        timeout=None,
+    ):
+        """Get issued channel access tokens v2.1.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-issued-channel-access-tokens-v2-1
+
+        :param str client_assertion: Client assertion.
+        :param str client_assertion_type: `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
+        :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.async_http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`linebot.models.responses.ChannelAccessTokens`
+        """
+        response = await self._get(
+            "/oauth2/v2.1/tokens",
+            params={
+                "client_assertion": client_assertion,
+                "client_assertion_type": client_assertion_type,
+            },
+            timeout=timeout,
+        )
+        return ChannelAccessTokens.new_from_json_dict((await response.json))
+
+    async def verify_channel_access_token_v2_1(self, access_token, timeout=None):
+        """Validate channel access token v2.1.
+
+        https://developers.line.biz/en/reference/messaging-api/#verfiy-channel-access-token-v2-1
+
+        :param str access_token: Channel access token.
+        :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.async_http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`linebot.models.responses.VerifyChannelTokenResponseV2`
+        """
+        response = await self._get(
+            "/oauth2/v2.1/verify",
+            params={"access_token": access_token},
+            timeout=timeout,
+        )
+        return VerifyChannelTokenResponseV2.new_from_json_dict((await response.json))
+
+    async def get_channel_token_key_ids_v2_1(
+        self,
+        client_assertion,
+        client_assertion_type="urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+        timeout=None,
+    ):
+        """Get all valid channel access token key IDs v2.1.
+
+        https://developers.line.biz/en/reference/messaging-api/#get-all-valid-channel-access-token-key-ids-v2-1
+
+        :param str client_assertion: Client assertion.
+        :param str client_assertion_type: `urn:ietf:params:oauth:client-assertion-type:jwt-bearer`.
+        :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.async_http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:`linebot.models.responses.VerifyChannelTokenResponseV2`
+        """
+        response = await self._get(
+            "/oauth2/v2.1/tokens/kid",
+            params={
+                "client_assertion": client_assertion,
+                "client_assertion_type": client_assertion_type,
+            },
+            timeout=timeout,
+        )
+        return ValidAccessTokenKeyIDsResponse.new_from_json_dict((await response.json))
+
+    async def get_statistics_per_unit(
+        self, custom_aggregation_unit, from_date, to_date, timeout=None
+    ):
+        """Return statistics about how users interact with push and multicast messages.
+
+        https://developers.line.biz/en/reference/partner-docs/#get-statistics-per-unit
+
+        :param str custom_aggregation_unit: Name of aggregation unit specified when sending
+            the message like `push_message(...)` and `multicast(...)`.
+        :param str from_date: Start date of aggregation period.
+            The format is `yyyyMMdd` (Timezone is UTC+9).
+        :param str to_date: End date of aggregation period.
+            The end date can be specified for up to 30 days later.
+            The format is `yyyyMMdd` (Timezone is UTC+9).
+        :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.async_http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class:
+            `linebot.models.responses.InsightMessageEventOfCustomAggregationUnitResponse`
+        """
+        response = await self._get(
+            "/v2/bot/insight/message/event/aggregation?"
+            "customAggregationUnit={custom_aggregation_unit}&from={from_date}&to={to_date}".format(
+                custom_aggregation_unit=custom_aggregation_unit,
+                from_date=from_date,
+                to_date=to_date,
+            ),
+            timeout=timeout,
+        )
+
+        return InsightMessageEventOfCustomAggregationUnitResponse.new_from_json_dict(
+            (await response.json)
+        )
+
+    async def get_number_of_units_used_this_month(self, timeout=None):
+        """Return the number of aggregation units used this month.
+
+        https://developers.line.biz/en/reference/partner-docs/#get-number-of-units-used-this-month
+
+        :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.async_http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class: `linebot.models.responses.AggregationInfoResponse`
+        """
+        response = await self._get("/v2/bot/message/aggregation/info", timeout=timeout)
+        return AggregationInfoResponse.new_from_json_dict((await response.json))
+
+    async def get_name_list_of_units_used_this_month(
+        self, limit=100, start=None, timeout=None
+    ):
+        """Return the name list of units used this month for statistics aggregation.
+
+        :param int limit: Maximum number of aggregation units you can get per request.
+            If you don't specify a value, or if you specify a value greater than or equal to 100,
+            the maximum is 100.
+        :param str start: Get the next array of name list of units
+        :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.async_http_client.timeout
+        :type timeout: float | tuple(float, float)
+        :rtype: :py:class: `linebot.models.responses.AggregationNameListResponse`
+        """
+        params = {"limit": limit} if start is None else {"limit": limit, "start": start}
+
+        response = await self._get(
+            "/v2/bot/message/aggregation/list", params=params, timeout=timeout
+        )
+
+        return AggregationNameListResponse.new_from_json_dict((await response.json))
+
     async def _get(self, path, endpoint=None, params=None, headers=None, timeout=None):
         url = (endpoint or self.endpoint) + path
 
diff --git a/linebot/models/insight.py b/linebot/models/insight.py
index fd700c9..38e53cd 100644
--- a/linebot/models/insight.py
+++ b/linebot/models/insight.py
@@ -141,6 +141,29 @@ class MessageStatistics(Base):
         self.unique_media_played_100_percent = unique_media_played_100_percent
 
 
+class MessageStatisticsOfCustomAggregationUnit(Base):
+    """MessageStatisticsOfCustomAggregationUnit."""
+
+    def __init__(self, unique_impression=None, unique_click=None, unique_media_played=None,
+                 unique_media_played_100_percent=None, **kwargs):
+        """__init__ method.
+
+        :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(MessageStatisticsOfCustomAggregationUnit, self).__init__(**kwargs)
+
+        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."""
 
diff --git a/linebot/models/responses.py b/linebot/models/responses.py
index f05d1b9..0167fea 100644
--- a/linebot/models/responses.py
+++ b/linebot/models/responses.py
@@ -18,7 +18,7 @@ from .base import Base
 from .insight import (
     SubscriptionPeriodInsight, AppTypeInsight, AgeInsight,
     GenderInsight, AreaInsight, MessageInsight, ClickInsight,
-    MessageStatistics, JobInsight,
+    MessageStatistics, JobInsight, MessageStatisticsOfCustomAggregationUnit,
 )
 from .rich_menu import RichMenuSize, RichMenuArea, RichMenuAlias
 
@@ -527,6 +527,67 @@ class InsightMessageEventResponse(Base):
         self.clicks = [self.get_or_new_from_json_dict(it, ClickInsight) for it in clicks]
 
 
+class InsightMessageEventOfCustomAggregationUnitResponse(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.MessageStatisticsOfCustomAggregationUnit`
+        :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(InsightMessageEventOfCustomAggregationUnitResponse, self).__init__(**kwargs)
+
+        self.overview = self.get_or_new_from_json_dict(
+            overview, MessageStatisticsOfCustomAggregationUnit)
+        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 AggregationInfoResponse(Base):
+    """The number of aggregation units used this month.
+
+    https://developers.line.biz/en/reference/partner-docs/#get-number-of-units-used-this-month
+    """
+
+    def __init__(self, num_of_custom_aggregation_units=None, **kwargs):
+        """__init__ method.
+
+        :param int num_of_custom_aggregation_units: Number of aggregation units used this month.
+        :param kwargs:
+        """
+        super(AggregationInfoResponse, self).__init__(**kwargs)
+
+        self.num_of_custom_aggregation_units = num_of_custom_aggregation_units
+
+
+class AggregationNameListResponse(Base):
+    """The name list of units used this month for statistics aggregation.
+
+    https://developers.line.biz/en/reference/partner-docs/#get-name-list-of-units-used-this-month
+    """
+
+    def __init__(self, custom_aggregation_units=None, next=None, **kwargs):
+        """__init__ method.
+
+        :param custom_aggregation_units: name list of aggregation units used this month.
+            Max: 100 unit names
+        :type custom_aggregation_units: list[str]
+        :param str next: continuationToken.
+            A continuation token to get the next array of unit names.
+        :param kwargs:
+        """
+        super(AggregationNameListResponse, self).__init__(**kwargs)
+
+        self.custom_aggregation_units = custom_aggregation_units
+        self.next = next
+
+
 class NarrowcastResponse(Base):
     """NarrowcastResponse.
 
diff --git a/requirements-dev.txt b/requirements-dev.txt
index a82ff64..3037045 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -6,4 +6,4 @@ sphinx>=2
 sphinx_rtd_theme
 pypandoc
 twine
-black==21.12b0
+black==22.1.0
diff --git a/setup.py b/setup.py
index aa4e84c..e11cee3 100644
--- a/setup.py
+++ b/setup.py
@@ -125,9 +125,6 @@ class CodegenCommand(Command):
             async_source = re.sub(
                 "response.json", "(await response.json)", async_source
             )
-            async_source = re.sub(
-                "response.json", "(await response.json)", async_source
-            )
             async_source = re.sub(
                 "from .http_client import HttpClient, RequestsHttpClient",
                 "from .async_http_client import AsyncHttpClient, AiohttpAsyncHttpClient",
@@ -151,9 +148,6 @@ class CodegenCommand(Command):
             async_source = re.sub(
                 "self.__check_error", "await self.__check_error", async_source
             )
-            async_source = re.sub(
-                "self.__check_error", "await self.__check_error", async_source
-            )
             async_source = re.sub(
                 "class LineBotApi",
                 "class AsyncLineBotApi", async_source


Test patch:
diff --git a/tests/api/test_get_custom_aggregation_units.py b/tests/api/test_get_custom_aggregation_units.py
new file mode 100644
index 0000000..84c5e36
--- /dev/null
+++ b/tests/api/test_get_custom_aggregation_units.py
@@ -0,0 +1,122 @@
+# -*- 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_number_of_units_used_this_month(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/aggregation/info',
+            json={
+                'numOfCustomAggregationUnits': 22
+            },
+            status=200
+        )
+
+        usage = self.tested.get_number_of_units_used_this_month()
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/aggregation/info')
+        self.assertEqual(usage.num_of_custom_aggregation_units, 22)
+
+    @responses.activate
+    def test_get_name_list_of_units_used_this_month(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/aggregation/list',
+            json={
+                'customAggregationUnits': ['promotion_a', 'promotion_b']
+            },
+            status=200
+        )
+
+        name_list = self.tested.get_name_list_of_units_used_this_month(30)
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/aggregation/list?limit=30')
+        self.assertEqual(name_list.custom_aggregation_units,
+                         ['promotion_a', 'promotion_b'])
+        self.assertEqual(name_list.next, None)
+
+    @responses.activate
+    def test_get_name_list_of_units_used_this_month_with_start(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/aggregation/list',
+            json={
+                'customAggregationUnits': ['promotion_a', 'promotion_b'],
+                'next': 'continuationToken2'
+            },
+            status=200
+        )
+
+        name_list = self.tested.get_name_list_of_units_used_this_month(start='continuationToken1')
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/aggregation/list?limit=100&start=continuationToken1')
+        self.assertEqual(name_list.custom_aggregation_units, ['promotion_a', 'promotion_b'])
+        self.assertEqual(name_list.next, 'continuationToken2')
+
+    @responses.activate
+    def test_get_name_list_of_units_used_this_month_with_start_and_limit(self):
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/aggregation/list',
+            json={
+                'customAggregationUnits': ['promotion_a', 'promotion_b'],
+                'next': 'continuationToken2'
+            },
+            status=200
+        )
+
+        name_list = self.tested.get_name_list_of_units_used_this_month(limit=2,
+                                                                       start='continuationToken1')
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'GET')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/message/aggregation/list?limit=2&start=continuationToken1')
+        self.assertEqual(name_list.custom_aggregation_units,
+                         ['promotion_a', 'promotion_b'])
+        self.assertEqual(name_list.next, 'continuationToken2')
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/test_get_insight.py b/tests/api/test_get_insight.py
index e0cf4c0..b3da7c6 100644
--- a/tests/api/test_get_insight.py
+++ b/tests/api/test_get_insight.py
@@ -257,6 +257,85 @@ class TestLineBotApi(unittest.TestCase):
         self.assertEqual(res.clicks[0].url, json['clicks'][0]['url'])
         self.assertEqual(res.clicks[0].unique_click_of_request, json['clicks'][0]['uniqueClickOfRequest'])
 
+    @responses.activate
+    def test_get_statistics_per_unit(self):
+        json = {
+            'overview': {
+                "uniqueImpression": 40,
+                "uniqueClick": 30,
+                "uniqueMediaPlayed": 25,
+                "uniqueMediaPlayed100Percent": None
+            },
+            'messages': [
+                {
+                    'seq': 1,
+                    'impression': 42,
+                    'mediaPlayed': 30,
+                    'mediaPlayed25Percent': None,
+                    'mediaPlayed50Percent': None,
+                    'mediaPlayed75Percent': None,
+                    'mediaPlayed100Percent': None,
+                    'uniqueMediaPlayed': 25,
+                    'uniqueMediaPlayed25Percent': None,
+                    'uniqueMediaPlayed50Percent': None,
+                    'uniqueMediaPlayed75Percent': None,
+                    'uniqueMediaPlayed100Percent': None
+                }
+            ],
+            'clicks': [
+                {
+                    'seq': 1,
+                    'url': 'https://developers.line.biz/',
+                    'click': 35,
+                    'uniqueClick': 25,
+                    'uniqueClickOfRequest': None
+                },
+                {
+                    'seq': 1,
+                    'url': 'https://www.line-community.me/',
+                    'click': 20,
+                    'uniqueClick': None,
+                    'uniqueClickOfRequest': None
+                }
+            ]
+        }
+        request_param = {
+            'custom_aggregation_unit': 'promotion_a',
+            'from': '20210301',
+            'to': '20210331'
+        }
+        responses.add(
+            responses.GET,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/insight/message/event/aggregation'
+            '?customAggregationUnit={custom_aggregation_unit}'
+            '&from={from_date}&to={to_date}'.format(
+                custom_aggregation_unit=request_param['custom_aggregation_unit'],
+                from_date=request_param['from'], to_date=request_param['to']),
+            status=200,
+            json=json,
+        )
+
+        res = self.tested.get_statistics_per_unit(
+            request_param['custom_aggregation_unit'], request_param['from'], request_param['to'])
+        request = responses.calls[0].request
+        self.assertEqual('GET', request.method)
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT +
+            '/v2/bot/insight/message/event/aggregation'
+            '?customAggregationUnit={custom_aggregation_unit}'
+            '&from={from_date}&to={to_date}'.format(
+                custom_aggregation_unit=request_param['custom_aggregation_unit'],
+                from_date=request_param['from'], to_date=request_param['to'])
+        )
+
+        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/api/test_send_text_message_with_statistics_per_aggregation_unit.py b/tests/api/test_send_text_message_with_statistics_per_aggregation_unit.py
new file mode 100644
index 0000000..da5391b
--- /dev/null
+++ b/tests/api/test_send_text_message_with_statistics_per_aggregation_unit.py
@@ -0,0 +1,168 @@
+# -*- 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
+)
+
+
+class TestLineBotApi(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"}]
+
+    @responses.activate
+    def test_push_text_message_with_statistics_per_aggregation_unit(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/push',
+            json={}, status=200
+        )
+
+        self.tested.push_message('to',
+                                 TextSendMessage(text='Hello, world'),
+                                 custom_aggregation_units="promotion_a")
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/push')
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                "to": "to",
+                'notificationDisabled': False,
+                "messages": [
+                    {
+                        "type": "text",
+                        "text": "Hello, world"
+                    }
+                ],
+                "customAggregationUnits": ["promotion_a"]
+            }
+        )
+
+    @responses.activate
+    def test_push_text_message_with_statistics_per_aggregation_unit_array(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/push',
+            json={}, status=200
+        )
+
+        self.tested.push_message('to',
+                                 TextSendMessage(text='Hello, world'),
+                                 custom_aggregation_units=["promotion_a"])
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/push')
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                "to": "to",
+                'notificationDisabled': False,
+                "messages": [
+                    {
+                        "type": "text",
+                        "text": "Hello, world"
+                    }
+                ],
+                "customAggregationUnits": ["promotion_a"]
+            }
+        )
+
+    @responses.activate
+    def test_multicast_text_message_with_statistics_per_aggregation_unit(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/multicast',
+            json={}, status=200
+        )
+
+        self.tested.multicast(['to'],
+                              TextSendMessage(text='Hello, world'),
+                              custom_aggregation_units="promotion_a")
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/multicast')
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                "to": ["to"],
+                'notificationDisabled': False,
+                "messages": [
+                    {
+                        "type": "text",
+                        "text": "Hello, world"
+                    }
+                ],
+                "customAggregationUnits": ["promotion_a"]
+            }
+        )
+
+    @responses.activate
+    def test_multicast_text_message_with_statistics_per_aggregation_unit_array(self):
+        responses.add(
+            responses.POST,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/multicast',
+            json={}, status=200
+        )
+
+        self.tested.multicast(['to'],
+                              TextSendMessage(text='Hello, world'),
+                              custom_aggregation_units=["promotion_a"])
+
+        request = responses.calls[0].request
+        self.assertEqual(request.method, 'POST')
+        self.assertEqual(
+            request.url,
+            LineBotApi.DEFAULT_API_ENDPOINT + '/v2/bot/message/multicast')
+        self.assertEqual(
+            json.loads(request.body),
+            {
+                "to": ["to"],
+                'notificationDisabled': False,
+                "messages": [
+                    {
+                        "type": "text",
+                        "text": "Hello, world"
+                    }
+                ],
+                "customAggregationUnits": ["promotion_a"]
+            }
+        )
+
+
+if __name__ == '__main__':
+    unittest.main()