Repository: line/line-bot-sdk-python
Pull request #174 (2019-06-12 08:15:54)

Support OAuth
#160
New APIs:

**issue_access_token(self, client_id, client_secret, grant_type='client_credentials', timeout=None)**
Issues a short-lived channel access token.

https://developers.line.biz/en/reference/messaging-api/#issue-channel-access-token

**revoke_access_token(self, access_token, timeout=None)**
Revokes a channel access token.

https://developers.line.biz/en/reference/messaging-api/#revoke-channel-access-token


Patch:
diff --git a/README.rst b/README.rst
index 5490fbe..e9f1d0d 100644
--- a/README.rst
+++ b/README.rst
@@ -437,6 +437,29 @@ https://developers.line.biz/en/reference/messaging-api/#issue-link-token
     link_token_response = line_bot_api.issue_link_token(<user_id>)
     print(link_token_response)
 
+issue\_channel\_token(self, client_id, client_secret, grant_type='client_credentials', timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Issues a short-lived channel access token.
+
+https://developers.line.biz/en/reference/messaging-api/#issue-channel-access-token
+
+.. code:: python
+
+    channel_token_response = line_bot_api.issue_channel_token(<client_id>, <client_secret>)
+    print(access_token_response)
+
+revoke\_channel\_token(self, access_token, timeout=None)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Revokes a channel access token.
+
+https://developers.line.biz/en/reference/messaging-api/#revoke-channel-access-token
+
+.. code:: python
+
+    line_bot_api.revoke_channel_token(<access_token>)
+
 ※ Error handling
 ^^^^^^^^^^^^^^^^
 
@@ -569,7 +592,7 @@ TemplateSendMessage - ButtonsTemplate
             actions=[
                 PostbackAction(
                     label='postback',
-                    text='postback text',
+                    display_text='postback text',
                     data='action=buy&itemid=1'
                 ),
                 MessageAction(
@@ -596,7 +619,7 @@ TemplateSendMessage - ConfirmTemplate
             actions=[
                 PostbackAction(
                     label='postback',
-                    text='postback text',
+                    display_text='postback text',
                     data='action=buy&itemid=1'
                 ),
                 MessageAction(
@@ -623,7 +646,7 @@ TemplateSendMessage - CarouselTemplate
                     actions=[
                         PostbackAction(
                             label='postback1',
-                            text='postback text1',
+                            display_text='postback text1',
                             data='action=buy&itemid=1'
                         ),
                         MessageAction(
@@ -643,7 +666,7 @@ TemplateSendMessage - CarouselTemplate
                     actions=[
                         PostbackAction(
                             label='postback2',
-                            text='postback text2',
+                            display_text='postback text2',
                             data='action=buy&itemid=2'
                         ),
                         MessageAction(
@@ -673,7 +696,7 @@ TemplateSendMessage - ImageCarouselTemplate
                     image_url='https://example.com/item1.jpg',
                     action=PostbackAction(
                         label='postback1',
-                        text='postback text1',
+                        display_text='postback text1',
                         data='action=buy&itemid=1'
                     )
                 ),
@@ -681,7 +704,7 @@ TemplateSendMessage - ImageCarouselTemplate
                     image_url='https://example.com/item2.jpg',
                     action=PostbackAction(
                         label='postback2',
-                        text='postback text2',
+                        display_text='postback text2',
                         data='action=buy&itemid=2'
                     )
                 )
diff --git a/examples/flask-kitchensink/app.py b/examples/flask-kitchensink/app.py
index 650afec..48395d5 100644
--- a/examples/flask-kitchensink/app.py
+++ b/examples/flask-kitchensink/app.py
@@ -109,7 +109,7 @@ def handle_text_message(event):
             line_bot_api.reply_message(
                 event.reply_token, [
                     TextSendMessage(text='Display name: ' + profile.display_name),
-                    TextSendMessage(text='Status message: ' + profile.status_message)
+                    TextSendMessage(text='Status message: ' + str(profile.status_message))
                 ]
             )
         else:
diff --git a/linebot/api.py b/linebot/api.py
index fd7bb45..ea6569e 100644
--- a/linebot/api.py
+++ b/linebot/api.py
@@ -23,7 +23,8 @@ from .exceptions import LineBotApiError
 from .http_client import HttpClient, RequestsHttpClient
 from .models import (
     Error, Profile, MemberIds, Content, RichMenuResponse, MessageQuotaResponse,
-    MessageQuotaConsumptionResponse, MessageDeliveryBroadcastResponse, IssueLinkTokenResponse
+    MessageQuotaConsumptionResponse, MessageDeliveryBroadcastResponse, IssueLinkTokenResponse,
+    IssueChannelTokenResponse,
 )
 
 
@@ -744,7 +745,13 @@ class LineBotApi(object):
         https://developers.line.biz/en/reference/messaging-api/#issue-link-token
 
         :param str user_id: User ID for the LINE account to be linked
+        :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.IssueLinkTokenResponse`
+        :return: IssueLinkTokenResponse instance
         """
         response = self._post(
             '/v2/bot/user/{user_id}/linkToken'.format(
@@ -755,6 +762,55 @@ class LineBotApi(object):
 
         return IssueLinkTokenResponse.new_from_json_dict(response.json)
 
+    def issue_channel_token(self, client_id, client_secret,
+                            grant_type='client_credentials', timeout=None):
+        """Issues a short-lived channel access token.
+
+        https://developers.line.biz/en/reference/messaging-api/#issue-channel-access-token
+
+        :param str client_id: Channel ID.
+        :param str client_secret: Channel secret.
+        :param str grant_type: `client_credentials`
+        :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.IssueChannelTokenResponse`
+        :return: IssueChannelTokenResponse instance
+        """
+        response = self._post(
+            '/v2/oauth/accessToken',
+            data={
+                'client_id': client_id,
+                'client_secret': client_secret,
+                'grant_type': grant_type,
+            },
+            headers={'Content-Type': 'application/x-www-form-urlencoded'},
+            timeout=timeout
+        )
+
+        return IssueChannelTokenResponse.new_from_json_dict(response.json)
+
+    def revoke_channel_token(self, access_token, timeout=None):
+        """Revokes a channel access token.
+
+        https://developers.line.biz/en/reference/messaging-api/#revoke-channel-access-token
+
+        :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.http_client.timeout
+        :type timeout: float | tuple(float, float)
+        """
+        self._post(
+            '/v2/oauth/revoke',
+            data={'access_token': access_token},
+            headers={'Content-Type': 'application/x-www-form-urlencoded'},
+            timeout=timeout
+        )
+
     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 07cdcdd..cb52705 100644
--- a/linebot/models/__init__.py
+++ b/linebot/models/__init__.py
@@ -101,6 +101,7 @@ from .responses import (  # noqa
     MessageDeliveryBroadcastResponse,
     Content as MessageContent,  # backward compatibility,
     IssueLinkTokenResponse,
+    IssueChannelTokenResponse,
 )
 from .rich_menu import (  # noqa
     RichMenu,
diff --git a/linebot/models/responses.py b/linebot/models/responses.py
index 2161133..d1415e4 100644
--- a/linebot/models/responses.py
+++ b/linebot/models/responses.py
@@ -224,3 +224,25 @@ class IssueLinkTokenResponse(Base):
         super(IssueLinkTokenResponse, self).__init__(**kwargs)
 
         self.link_token = link_token
+
+
+class IssueChannelTokenResponse(Base):
+    """IssueAccessTokenResponse.
+
+    https://developers.line.biz/en/reference/messaging-api/#issue-channel-access-token
+    """
+
+    def __init__(self, access_token=None, expires_in=None, token_type=None, **kwargs):
+        """__init__ method.
+
+        :param str access_token: Short-lived channel access token.
+        :param int expires_in: Time until channel access token expires in seconds
+            from time the token is issued.
+        :param str token_type: Bearer.
+        :param kwargs:
+        """
+        super(IssueChannelTokenResponse, self).__init__(**kwargs)
+
+        self.access_token = access_token
+        self.expires_in = expires_in
+        self.token_type = token_type


Test patch:
diff --git a/tests/api/test_issue_channel_token.py b/tests/api/test_issue_channel_token.py
new file mode 100644
index 0000000..b0d5dbc
--- /dev/null
+++ b/tests/api/test_issue_channel_token.py
@@ -0,0 +1,76 @@
+# -*- 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 sys
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+PY3 = sys.version_info[0] == 3
+if PY3:
+    from urllib import parse
+else:
+    import urlparse as parse
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+        self.endpoint = LineBotApi.DEFAULT_API_ENDPOINT + '/v2/oauth/accessToken'
+        self.access_token = "W1TeHCgfH2Liwa....."
+        self.expires_in = 2592000
+        self.token_type = "Bearer"
+        self.client_id = 'client_id'
+        self.client_secret = 'client_secret'
+
+    @responses.activate
+    def test_issue_line_token(self):
+        responses.add(
+            responses.POST,
+            self.endpoint,
+            json={
+                "access_token": self.access_token,
+                "expires_in": self.expires_in,
+                "token_type": self.token_type
+            },
+            status=200
+        )
+
+        issue_access_token_response = self.tested.issue_channel_token(
+            self.client_id,
+            self.client_secret
+        )
+
+        request = responses.calls[0].request
+        self.assertEqual('POST', request.method)
+        self.assertEqual(self.endpoint, request.url)
+        self.assertEqual('application/x-www-form-urlencoded', request.headers['content-type'])
+        self.assertEqual(self.access_token, issue_access_token_response.access_token)
+        self.assertEqual(self.expires_in, issue_access_token_response.expires_in)
+        self.assertEqual(self.token_type, issue_access_token_response.token_type)
+
+        encoded_body = parse.parse_qs(request.body)
+        self.assertEqual('client_credentials', encoded_body['grant_type'][0])
+        self.assertEqual(self.client_id, encoded_body['client_id'][0])
+        self.assertEqual(self.client_secret, encoded_body['client_secret'][0])
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/tests/api/test_revoke_channel_token.py b/tests/api/test_revoke_channel_token.py
new file mode 100644
index 0000000..64543ce
--- /dev/null
+++ b/tests/api/test_revoke_channel_token.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+#  Licensed under the Apache License, Version 2.0 (the "License"); you may
+#  not use this file except in compliance with the License. You may obtain
+#  a copy of the License at
+#
+#       https://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+#  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+#  License for the specific language governing permissions and limitations
+#  under the License.
+
+from __future__ import unicode_literals, absolute_import
+
+import unittest
+
+import responses
+
+from linebot import (
+    LineBotApi
+)
+
+
+class TestLineBotApi(unittest.TestCase):
+    def setUp(self):
+        self.tested = LineBotApi('channel_secret')
+        self.endpoint = LineBotApi.DEFAULT_API_ENDPOINT + '/v2/oauth/revoke'
+        self.access_token = "W1TeHCgfH2Liwa....."
+
+    @responses.activate
+    def test_issue_line_token(self):
+        responses.add(
+            responses.POST,
+            self.endpoint,
+            status=200
+        )
+
+        self.tested.revoke_channel_token(self.access_token)
+
+        request = responses.calls[0].request
+        self.assertEqual('POST', request.method)
+        self.assertEqual(self.endpoint, request.url)
+        self.assertEqual('application/x-www-form-urlencoded', request.headers['content-type'])
+        self.assertEqual('access_token={}'.format(self.access_token), request.body)
+
+
+if __name__ == '__main__':
+    unittest.main()