├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── api_check.py ├── examples └── sanic-echo │ ├── README.rst │ ├── app.py │ ├── app_with_handler.py │ └── requirements.txt ├── linebotx ├── __init__.py ├── api.py ├── http_client.py └── webhook.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Shivelight 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Better alternative: https://github.com/uezo/aiolinebot 2 | 3 | 🤖 line-bot-sdk-python-extra 4 | ============================ 5 | 6 | .. image:: https://img.shields.io/pypi/v/line-bot-sdk-extra.svg 7 | :target: https://pypi.python.org/pypi/line-bot-sdk-extra 8 | :alt: PyPI - Version 9 | 10 | .. image:: https://img.shields.io/pypi/status/line-bot-sdk-extra.svg 11 | :target: https://pypi.python.org/pypi/line-bot-sdk-extra 12 | :alt: PyPI - Status 13 | 14 | .. image:: https://img.shields.io/pypi/pyversions/line-bot-sdk-extra.svg 15 | :target: https://pypi.python.org/pypi/line-bot-sdk-extra 16 | :alt: PyPI - Python Version 17 | 18 | .. image:: https://img.shields.io/pypi/l/line-bot-sdk-extra.svg 19 | :target: https://pypi.python.org/pypi/line-bot-sdk-extra 20 |   :alt: PyPI - License 21 | 22 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 23 | :target: https://github.com/ambv/black 24 | :alt: Code Style - Black 25 | 26 | .. image:: https://img.shields.io/badge/Ko--fi-donate-blue.svg 27 | :target: https://ko-fi.com/shivelight 28 | :alt: Ko-fi - Donate 29 | 30 | 31 | Extra feature for `LINE Messaging API SDK for Python `_. 32 | 33 | 34 | Installation 35 | ------------ 36 | 37 | :: 38 | 39 | pip install line-bot-sdk-extra 40 | 41 | or:: 42 | 43 | python setup.py install 44 | 45 | To use the package:: 46 | 47 | >>> import linebotx 48 | 49 | 50 | Features 51 | -------- 52 | 53 | Asynchronous API 54 | ^^^^^^^^^^^^^^^^ 55 | 56 | Allows you to write non-blocking code which makes your bot respond much faster with little changes. 57 | 58 | Synchronous: 59 | 60 | .. code-block:: python 61 | 62 | from linebot import LineBotApi, WebhookHandler 63 | line_bot_api = LineBotApi('YOUR_CHANNEL_ACCESS_TOKEN') 64 | handler = WebhookHandler('YOUR_CHANNEL_SECRET') 65 | 66 | 67 | Asynchronous: 68 | 69 | .. code-block:: python 70 | 71 | from linebotx import LineBotApiAsync, WebhookHandlerAsync 72 | line_bot_api = LineBotApiAsync('YOUR_CHANNEL_ACCESS_TOKEN') 73 | handler = WebhookHandlerAsync('YOUR_CHANNEL_SECRET') 74 | 75 | 76 | Equivalent Counterpart 77 | """""""""""""""""""""" 78 | 79 | +---------------------+----------------+ 80 | | linebotx | linebot | 81 | +=====================+================+ 82 | | LineBotApiAsync | LineBotApi | 83 | +---------------------+----------------+ 84 | | AioHttpClient | HttpClient | 85 | +---------------------+----------------+ 86 | | AioHttpResponse | HttpResponse | 87 | +---------------------+----------------+ 88 | | WebhookHandlerAsync | WebhookHandler | 89 | +---------------------+----------------+ 90 | 91 | **NOTE:** Every public method is coroutine and should be awaited. For example: 92 | 93 | .. code-block:: python 94 | 95 | @app.route("/callback", methods=['POST']) 96 | async def callback(): 97 | ... 98 | await handler.handle(body, signature) 99 | ... 100 | 101 | 102 | @handler.add(MessageEvent, message=TextMessage) 103 | async def handle_message(event): 104 | await line_bot_api.reply_message( 105 | event.reply_token, 106 | TextSendMessage(text=event.message.text)) 107 | 108 | 109 | Additional Methods 110 | """""""""""""""""" 111 | 112 | coroutine :code:`LineBotApiAsync.close()` 113 | Close underlying http client. 114 | 115 | coroutine :code:`AioHttpClient.close()` 116 | See `aiohttp.ClientSession.close() `_. 117 | 118 | 119 | Timeout 120 | """"""" 121 | 122 | To set a timeout you can pass `aiohttp.ClientTimeout `_ object instead of numeric value. 123 | 124 | 125 | Examples 126 | """""""" 127 | 128 | - `sanic-echo `_ - Sample echo-bot using sanic_. 129 | 130 | 131 | Contributing 132 | ------------ 133 | 134 | If you would like to contribute, please check for open issues or open a new issue if you have ideas, changes, or bugs to report. 135 | 136 | 137 | References 138 | ---------- 139 | 140 | This project is just a small addition to the original SDK, please refer to `line-bot-sdk-python `_ or the `docs `_. 141 | 142 | .. _sanic: https://github.com/huge-success/sanic 143 | .. _line-bot-sdk-python: https://github.com/line/line-bot-sdk-python 144 | -------------------------------------------------------------------------------- /api_check.py: -------------------------------------------------------------------------------- 1 | from linebot.api import LineBotApi 2 | from linebotx.api import LineBotApiAsync 3 | 4 | linebot_funcs = [ 5 | x 6 | for x in dir(LineBotApi) 7 | if callable(getattr(LineBotApi, x)) and not x.startswith("_") 8 | ] 9 | linebotx_funcs = [ 10 | x 11 | for x in dir(LineBotApiAsync) 12 | if callable(getattr(LineBotApiAsync, x)) and not x.startswith("_") 13 | ] 14 | missing_func = [x for x in linebot_funcs if x not in linebotx_funcs] 15 | 16 | print("Missing methods (linebotx):\n * {}".format("\n * ".join(missing_func))) 17 | -------------------------------------------------------------------------------- /examples/sanic-echo/README.rst: -------------------------------------------------------------------------------- 1 | Sanic Echo 2 | ========== 3 | 4 | Sample echo-bot using `Sanic `_ 5 | 6 | Getting started 7 | ~~~~~~~~~~~~~~~ 8 | 9 | :: 10 | 11 | $ export LINE_CHANNEL_SECRET=YOUR_LINE_CHANNEL_SECRET 12 | $ export LINE_CHANNEL_ACCESS_TOKEN=YOUR_LINE_CHANNEL_ACCESS_TOKEN 13 | $ pip install -r requirements.txt 14 | 15 | Run WebhookParser sample:: 16 | 17 | $ python app.py 18 | 19 | Run WebhookHandler sample:: 20 | 21 | $ python app_with_handler.py 22 | -------------------------------------------------------------------------------- /examples/sanic-echo/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from argparse import ArgumentParser 4 | 5 | from sanic import Sanic, response 6 | from sanic.log import logger 7 | from sanic.exceptions import abort 8 | from linebot import WebhookParser 9 | from linebot.exceptions import InvalidSignatureError 10 | from linebot.models import MessageEvent, TextMessage, TextSendMessage 11 | 12 | from linebotx import AioHttpClient, LineBotApiAsync 13 | 14 | app = Sanic(__name__) 15 | 16 | # get channel_secret and channel_access_token from your environment variable 17 | channel_secret = os.getenv("LINE_CHANNEL_SECRET", None) 18 | channel_access_token = os.getenv("LINE_CHANNEL_ACCESS_TOKEN", None) 19 | if channel_secret is None: 20 | print("Specify LINE_CHANNEL_SECRET as environment variable.") 21 | sys.exit(1) 22 | if channel_access_token is None: 23 | print("Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.") 24 | sys.exit(1) 25 | 26 | parser = WebhookParser(channel_secret) 27 | 28 | 29 | @app.listener("before_server_start") 30 | async def setup_bot(app, loop): 31 | transport = AioHttpClient(loop=loop) 32 | app.bot = LineBotApiAsync(channel_access_token, http_client=transport) 33 | 34 | 35 | @app.listener('after_server_stop') 36 | async def close_bot(app, loop): 37 | await app.bot.close() 38 | 39 | 40 | @app.route("/callback", methods=["POST"]) 41 | async def callback(request): 42 | signature = request.headers["X-Line-Signature"] 43 | 44 | # get request body as text 45 | body = request.body.decode() 46 | logger.info("Request body: " + body) 47 | 48 | # parse webhook body 49 | try: 50 | events = parser.parse(body, signature) 51 | except InvalidSignatureError: 52 | abort(400) 53 | 54 | # if event is MessageEvent and message is TextMessage, then echo text 55 | for event in events: 56 | if not isinstance(event, MessageEvent): 57 | continue 58 | if not isinstance(event.message, TextMessage): 59 | continue 60 | 61 | await app.bot.reply_message( 62 | event.reply_token, TextSendMessage(text=event.message.text) 63 | ) 64 | 65 | return response.text("OK") 66 | 67 | 68 | if __name__ == "__main__": 69 | arg_parser = ArgumentParser( 70 | usage="Usage: python " + __file__ + " [--port ] [--help]" 71 | ) 72 | arg_parser.add_argument("-p", "--port", type=int, default=8000, help="port") 73 | arg_parser.add_argument("-d", "--debug", default=False, help="debug") 74 | options = arg_parser.parse_args() 75 | 76 | app.run(debug=options.debug, port=options.port) 77 | -------------------------------------------------------------------------------- /examples/sanic-echo/app_with_handler.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from argparse import ArgumentParser 4 | 5 | from sanic import Sanic, response 6 | from sanic.log import logger 7 | from sanic.exceptions import abort 8 | from linebot.exceptions import InvalidSignatureError 9 | from linebot.models import MessageEvent, TextMessage, TextSendMessage 10 | 11 | from linebotx import AioHttpClient, LineBotApiAsync, WebhookHandlerAsync 12 | 13 | app = Sanic(__name__) 14 | 15 | # get channel_secret and channel_access_token from your environment variable 16 | channel_secret = os.getenv('LINE_CHANNEL_SECRET', None) 17 | channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None) 18 | if channel_secret is None: 19 | print('Specify LINE_CHANNEL_SECRET as environment variable.') 20 | sys.exit(1) 21 | if channel_access_token is None: 22 | print('Specify LINE_CHANNEL_ACCESS_TOKEN as environment variable.') 23 | sys.exit(1) 24 | 25 | handler = WebhookHandlerAsync(channel_secret) 26 | 27 | 28 | @app.listener("before_server_start") 29 | async def setup_bot(app, loop): 30 | transport = AioHttpClient(loop=loop) 31 | app.bot = LineBotApiAsync(channel_access_token, http_client=transport) 32 | 33 | 34 | @app.listener('after_server_stop') 35 | async def close_bot(app, loop): 36 | await app.bot.close() 37 | 38 | 39 | @app.route("/callback", methods=['POST']) 40 | async def callback(request): 41 | # get X-Line-Signature header value 42 | signature = request.headers['X-Line-Signature'] 43 | 44 | # get request body as text 45 | body = request.body.decode() 46 | logger.info("Request body: " + body) 47 | 48 | # handle webhook body 49 | try: 50 | await handler.handle(body, signature) 51 | except InvalidSignatureError: 52 | abort(400) 53 | 54 | return response.text("OK") 55 | 56 | 57 | @handler.add(MessageEvent, message=TextMessage) 58 | async def message_text(event): 59 | await app.bot.reply_message( 60 | event.reply_token, 61 | TextSendMessage(text=event.message.text) 62 | ) 63 | 64 | 65 | if __name__ == "__main__": 66 | arg_parser = ArgumentParser( 67 | usage='Usage: python ' + __file__ + ' [--port ] [--help]' 68 | ) 69 | arg_parser.add_argument('-p', '--port', default=8000, help='port') 70 | arg_parser.add_argument('-d', '--debug', default=False, help='debug') 71 | options = arg_parser.parse_args() 72 | 73 | app.run(debug=options.debug, port=options.port) 74 | -------------------------------------------------------------------------------- /examples/sanic-echo/requirements.txt: -------------------------------------------------------------------------------- 1 | line-bot-sdk-extra 2 | sanic 3 | -------------------------------------------------------------------------------- /linebotx/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import LineBotApiAsync # noqa 2 | from .http_client import AioHttpClient, AioHttpResponse # noqa 3 | from .webhook import WebhookHandlerAsync # noqa 4 | -------------------------------------------------------------------------------- /linebotx/api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from linebot.__about__ import __version__ 4 | from linebot.exceptions import LineBotApiError 5 | from linebot.http_client import HttpClient 6 | from linebot.models import ( 7 | Content, 8 | Error, 9 | MemberIds, 10 | MessageDeliveryBroadcastResponse, 11 | MessageDeliveryMulticastResponse, 12 | MessageDeliveryPushResponse, 13 | MessageDeliveryReplyResponse, 14 | MessageQuotaResponse, 15 | Profile, 16 | RichMenuResponse, 17 | MessageQuotaConsumptionResponse, 18 | IssueLinkTokenResponse, 19 | IssueChannelTokenResponse, 20 | ) 21 | 22 | from .http_client import AioHttpClient 23 | 24 | 25 | class LineBotApiAsync(object): 26 | 27 | DEFAULT_API_ENDPOINT = "https://api.line.me" 28 | 29 | def __init__( 30 | self, 31 | channel_access_token, 32 | endpoint=DEFAULT_API_ENDPOINT, 33 | timeout=HttpClient.DEFAULT_TIMEOUT, 34 | http_client=None, 35 | ): 36 | self.endpoint = endpoint 37 | self.headers = { 38 | "Authorization": "Bearer " + channel_access_token, 39 | "User-Agent": "line-bot-sdk-python/" + __version__, 40 | } 41 | 42 | if http_client: 43 | self.http_client = http_client 44 | else: 45 | self.http_client = AioHttpClient(timeout=timeout) 46 | 47 | async def close(self): 48 | await self.http_client.close() 49 | 50 | async def reply_message(self, reply_token, messages, timeout=None): 51 | if not isinstance(messages, (list, tuple)): 52 | messages = [messages] 53 | 54 | data = { 55 | "replyToken": reply_token, 56 | "messages": [message.as_json_dict() for message in messages], 57 | } 58 | 59 | await self._post( 60 | "/v2/bot/message/reply", data=json.dumps(data), timeout=timeout 61 | ) 62 | 63 | async def push_message(self, to, messages, timeout=None): 64 | if not isinstance(messages, (list, tuple)): 65 | messages = [messages] 66 | 67 | data = {"to": to, "messages": [message.as_json_dict() for message in messages]} 68 | 69 | await self._post("/v2/bot/message/push", data=json.dumps(data), timeout=timeout) 70 | 71 | async def get_rich_menu(self, rich_menu_id, timeout=None): 72 | response = await self._get( 73 | "/v2/bot/richmenu/{rich_menu_id}".format(rich_menu_id=rich_menu_id), 74 | timeout=timeout, 75 | ) 76 | 77 | json_resp = await response.json 78 | return RichMenuResponse.new_from_json_dict(json_resp) 79 | 80 | async def delete_rich_menu(self, rich_menu_id, timeout=None): 81 | await self._delete( 82 | "/v2/bot/richmenu/{rich_menu_id}".format(rich_menu_id=rich_menu_id), 83 | timeout=timeout, 84 | ) 85 | 86 | async def create_rich_menu(self, rich_menu, timeout=None): 87 | response = await self._post( 88 | "/v2/bot/richmenu", data=rich_menu.as_json_string(), timeout=timeout 89 | ) 90 | 91 | json_resp = await response.json 92 | return json_resp.get("richMenuId") 93 | 94 | async def link_rich_menu_to_user(self, user_id, rich_menu_id, timeout=None): 95 | await self._post( 96 | "/v2/bot/user/{user_id}/richmenu/{rich_menu_id}".format( 97 | user_id=user_id, rich_menu_id=rich_menu_id 98 | ), 99 | timeout=timeout, 100 | ) 101 | 102 | async def link_rich_menu_to_users(self, user_ids, rich_menu_id, timeout=None): 103 | await self._post( 104 | "/v2/bot/richmenu/bulk/link", 105 | data=json.dumps({"userIds": user_ids, "richMenuId": rich_menu_id}), 106 | timeout=timeout, 107 | ) 108 | 109 | async def get_rich_menu_list(self, timeout=None): 110 | response = await self._get("/v2/bot/richmenu/list", timeout=timeout) 111 | 112 | lst_result = [] 113 | json_resp = await response.json 114 | for richmenu in json_resp["richmenus"]: 115 | lst_result.append(RichMenuResponse.new_from_json_dict(richmenu)) 116 | 117 | return lst_result 118 | 119 | async def set_rich_menu_image( 120 | self, rich_menu_id, content_type, content, timeout=None 121 | ): 122 | await self._post( 123 | "/v2/bot/richmenu/{rich_menu_id}/content".format(rich_menu_id=rich_menu_id), 124 | data=content, 125 | headers={"Content-Type": content_type}, 126 | timeout=timeout, 127 | ) 128 | 129 | async def get_rich_menu_id_of_user(self, user_id, timeout=None): 130 | response = await self._get( 131 | "/v2/bot/user/{user_id}/richmenu".format(user_id=user_id), timeout=timeout 132 | ) 133 | 134 | json_resp = await response.json 135 | return json_resp.get("richMenuId") 136 | 137 | async def unlink_rich_menu_from_user(self, user_id, timeout=None): 138 | await self._delete( 139 | "/v2/bot/user/{user_id}/richmenu".format(user_id=user_id), timeout=timeout 140 | ) 141 | 142 | async def unlink_rich_menu_from_users(self, user_ids, timeout=None): 143 | await self._post( 144 | "/v2/bot/richmenu/bulk/unlink", 145 | data=json.dumps({"userIds": user_ids}), 146 | timeout=timeout, 147 | ) 148 | 149 | async def get_rich_menu_image(self, rich_menu_id, timeout=None): 150 | response = await self._get( 151 | "/v2/bot/richmenu/{rich_menu_id}/content".format(rich_menu_id=rich_menu_id), 152 | timeout=timeout, 153 | ) 154 | 155 | return Content(response) 156 | 157 | async def multicast(self, to, messages, timeout=None): 158 | if not isinstance(messages, (list, tuple)): 159 | messages = [messages] 160 | 161 | data = {"to": to, "messages": [message.as_json_dict() for message in messages]} 162 | 163 | await self._post( 164 | "/v2/bot/message/multicast", data=json.dumps(data), timeout=timeout 165 | ) 166 | 167 | async def get_profile(self, user_id, timeout=None): 168 | response = await self._get( 169 | "/v2/bot/profile/{user_id}".format(user_id=user_id), timeout=timeout 170 | ) 171 | 172 | json_resp = await response.json 173 | return Profile.new_from_json_dict(json_resp) 174 | 175 | async def get_group_member_profile(self, group_id, user_id, timeout=None): 176 | response = await self._get( 177 | "/v2/bot/group/{group_id}/member/{user_id}".format( 178 | group_id=group_id, user_id=user_id 179 | ), 180 | timeout=timeout, 181 | ) 182 | 183 | json_resp = await response.json 184 | return Profile.new_from_json_dict(json_resp) 185 | 186 | async def get_room_member_profile(self, room_id, user_id, timeout=None): 187 | response = await self._get( 188 | "/v2/bot/room/{room_id}/member/{user_id}".format( 189 | room_id=room_id, user_id=user_id 190 | ), 191 | timeout=timeout, 192 | ) 193 | 194 | json_resp = await response.json 195 | return Profile.new_from_json_dict(json_resp) 196 | 197 | async def get_group_member_ids(self, group_id, start=None, timeout=None): 198 | params = None if start is None else {"start": start} 199 | 200 | response = await self._get( 201 | "/v2/bot/group/{group_id}/members/ids".format(group_id=group_id), 202 | params=params, 203 | timeout=timeout, 204 | ) 205 | 206 | json_resp = await response.json 207 | return MemberIds.new_from_json_dict(json_resp) 208 | 209 | async def get_room_member_ids(self, room_id, start=None, timeout=None): 210 | params = None if start is None else {"start": start} 211 | 212 | response = await self._get( 213 | "/v2/bot/room/{room_id}/members/ids".format(room_id=room_id), 214 | params=params, 215 | timeout=timeout, 216 | ) 217 | 218 | json_resp = await response.json 219 | return MemberIds.new_from_json_dict(json_resp) 220 | 221 | async def get_message_content(self, message_id, timeout=None): 222 | response = await self._get( 223 | "/v2/bot/message/{message_id}/content".format(message_id=message_id), 224 | stream=True, 225 | timeout=timeout, 226 | ) 227 | 228 | return Content(response) 229 | 230 | async def leave_group(self, group_id, timeout=None): 231 | await self._post( 232 | "/v2/bot/group/{group_id}/leave".format(group_id=group_id), timeout=timeout 233 | ) 234 | 235 | async def leave_room(self, room_id, timeout=None): 236 | await self._post( 237 | "/v2/bot/room/{room_id}/leave".format(room_id=room_id), timeout=timeout 238 | ) 239 | 240 | async def broadcast(self, messages, notification_disabled=False, timeout=None): 241 | if not isinstance(messages, (list, tuple)): 242 | messages = [messages] 243 | 244 | data = { 245 | "messages": [message.as_json_dict() for message in messages], 246 | "notificationDisabled": notification_disabled, 247 | } 248 | 249 | await self._post( 250 | "/v2/bot/message/broadcast", data=json.dumps(data), timeout=timeout 251 | ) 252 | 253 | async def cancel_default_rich_menu(self, timeout=None): 254 | await self._delete("/v2/bot/user/all/richmenu", timeout=timeout) 255 | 256 | async def set_default_rich_menu(self, rich_menu_id, timeout=None): 257 | await self._post( 258 | "/v2/bot/user/all/richmenu/{rich_menu_id}".format( 259 | rich_menu_id=rich_menu_id 260 | ), 261 | timeout=timeout, 262 | ) 263 | 264 | async def get_default_rich_menu(self, timeout=None): 265 | response = await self._get("/v2/bot/user/all/richmenu", timeout=timeout) 266 | 267 | json_resp = await response.json 268 | return json_resp.get("richMenuId") 269 | 270 | async def get_message_delivery_broadcast(self, date, timeout=None): 271 | response = await self._get( 272 | "/v2/bot/message/delivery/broadcast?date={date}".format(date=date), 273 | timeout=timeout, 274 | ) 275 | 276 | json_resp = await response.json 277 | return MessageDeliveryBroadcastResponse.new_from_json_dict(json_resp) 278 | 279 | async def get_message_delivery_reply(self, date, timeout=None): 280 | response = await self._get( 281 | "/v2/bot/message/delivery/reply?date={date}".format(date=date), 282 | timeout=timeout, 283 | ) 284 | 285 | json_resp = await response.json 286 | return MessageDeliveryReplyResponse.new_from_json_dict(json_resp) 287 | 288 | async def get_message_delivery_push(self, date, timeout=None): 289 | response = await self._get( 290 | "/v2/bot/message/delivery/push?date={date}".format(date=date), 291 | timeout=timeout, 292 | ) 293 | 294 | json_resp = await response.json 295 | return MessageDeliveryPushResponse.new_from_json_dict(json_resp) 296 | 297 | async def get_message_delivery_multicast(self, date, timeout=None): 298 | response = await self._get( 299 | "/v2/bot/message/delivery/multicast?date={date}".format(date=date), 300 | timeout=timeout, 301 | ) 302 | 303 | json_resp = await response.json 304 | return MessageDeliveryMulticastResponse.new_from_json_dict(json_resp) 305 | 306 | async def get_message_quota(self, timeout=None): 307 | response = await self._get("/v2/bot/message/quota", timeout=timeout) 308 | 309 | json_resp = await response.json 310 | return MessageQuotaResponse.new_from_json_dict(json_resp) 311 | 312 | async def get_message_quota_consumption(self, timeout=None): 313 | response = await self._get("/v2/bot/message/quota/consumption", timeout=timeout) 314 | 315 | json_resp = await response.json 316 | return MessageQuotaConsumptionResponse.new_from_json_dict(json_resp) 317 | 318 | async def issue_link_token(self, user_id, timeout=None): 319 | response = await self._post( 320 | "/v2/bot/user/{user_id}/linkToken".format(user_id=user_id), timeout=timeout 321 | ) 322 | 323 | json_resp = await response.json 324 | return IssueLinkTokenResponse.new_from_json_dict(json_resp) 325 | 326 | async def issue_channel_token( 327 | self, client_id, client_secret, grant_type="client_credentials", timeout=None 328 | ): 329 | response = await self._post( 330 | "/v2/oauth/accessToken", 331 | data={ 332 | "client_id": client_id, 333 | "client_secret": client_secret, 334 | "grant_type": grant_type, 335 | }, 336 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 337 | timeout=timeout, 338 | ) 339 | 340 | json_resp = await response.json 341 | return IssueChannelTokenResponse.new_from_json_dict(json_resp) 342 | 343 | async def revoke_channel_token(self, access_token, timeout=None): 344 | await self._post( 345 | "/v2/oauth/revoke", 346 | data={"access_token": access_token}, 347 | headers={"Content-Type": "application/x-www-form-urlencoded"}, 348 | timeout=timeout, 349 | ) 350 | 351 | async def _get(self, path, params=None, headers=None, stream=False, timeout=None): 352 | url = self.endpoint + path 353 | 354 | if headers is None: 355 | headers = {} 356 | headers.update(self.headers) 357 | 358 | response = await self.http_client.get( 359 | url, headers=headers, params=params, stream=stream, timeout=timeout 360 | ) 361 | 362 | await self.__check_error(response) 363 | return response 364 | 365 | async def _post(self, path, data=None, headers=None, timeout=None): 366 | url = self.endpoint + path 367 | 368 | if headers is None: 369 | headers = {"Content-Type": "application/json"} 370 | headers.update(self.headers) 371 | 372 | response = await self.http_client.post( 373 | url, headers=headers, data=data, timeout=timeout 374 | ) 375 | await self.__check_error(response) 376 | return response 377 | 378 | async def _delete(self, path, data=None, headers=None, timeout=None): 379 | url = self.endpoint + path 380 | 381 | if headers is None: 382 | headers = {} 383 | headers.update(self.headers) 384 | 385 | response = await self.http_client.delete( 386 | url, headers=headers, data=data, timeout=timeout 387 | ) 388 | 389 | await self.__check_error(response) 390 | return response 391 | 392 | @staticmethod 393 | async def __check_error(response): 394 | if 200 <= response.status_code < 300: 395 | pass 396 | else: 397 | error = Error.new_from_json_dict((await response.json)) 398 | raise LineBotApiError(response.status_code, error) 399 | -------------------------------------------------------------------------------- /linebotx/http_client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | 5 | from linebot.http_client import HttpClient, HttpResponse 6 | 7 | 8 | class AioHttpClient(HttpClient): 9 | """HttpClient implemented by aiohttp.""" 10 | 11 | DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=30) 12 | 13 | def __init__(self, timeout=DEFAULT_TIMEOUT, loop=None): 14 | if loop is None: 15 | self.loop = asyncio.get_event_loop() 16 | else: 17 | self.loop = loop 18 | 19 | self.session = aiohttp.ClientSession(loop=loop) 20 | self.timeout = timeout 21 | 22 | async def get(self, url, headers=None, params=None, stream=False, timeout=None): 23 | if timeout is None: 24 | timeout = self.timeout 25 | 26 | async with self.session.get( 27 | url, headers=headers, params=params, stream=stream, timeout=timeout 28 | ) as response: 29 | return AioHttpResponse(response) 30 | 31 | async def post(self, url, headers=None, data=None, timeout=None): 32 | if timeout is None: 33 | timeout = self.timeout 34 | 35 | async with self.session.post(url, headers=headers, data=data) as response: 36 | return AioHttpResponse(response) 37 | 38 | async def delete(self, url, headers=None, data=None, timeout=None): 39 | if timeout is None: 40 | timeout = self.timeout 41 | 42 | response = await self.session.delete( 43 | url, headers=headers, data=data, timeout=timeout 44 | ) 45 | 46 | return AioHttpResponse(response) 47 | 48 | async def close(self): 49 | await self.session.close() 50 | 51 | 52 | class AioHttpResponse(HttpResponse): 53 | """HttpResponse implemented by aiohttp lib's response.""" 54 | 55 | def __init__(self, response): 56 | self.response = response 57 | 58 | @property 59 | def status_code(self): 60 | return self.response.status 61 | 62 | @property 63 | def headers(self): 64 | return self.response.headers 65 | 66 | @property 67 | async def text(self): 68 | return await self.response.text() 69 | 70 | @property 71 | async def content(self): 72 | return await self.response.read() 73 | 74 | @property 75 | async def json(self): 76 | return await self.response.json() 77 | 78 | def iter_content(self, chunk_size=1024): 79 | return self.response.content.iter_chunked(chunk_size) 80 | -------------------------------------------------------------------------------- /linebotx/webhook.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | 4 | from linebot.models.events import MessageEvent 5 | from linebot.webhook import WebhookHandler 6 | from linebot.utils import LOGGER 7 | 8 | 9 | class WebhookHandlerAsync(WebhookHandler): 10 | def __init__(self, channel_secret): 11 | super().__init__(channel_secret) 12 | 13 | def add(self, event, message=None): 14 | def decorator(func): 15 | if not asyncio.iscoroutinefunction(func): 16 | raise TypeError("Handler must be a coroutine function") 17 | 18 | if isinstance(message, (list, tuple)): 19 | for it in message: 20 | self.__add_handler(func, event, message=it) 21 | else: 22 | self.__add_handler(func, event, message=message) 23 | 24 | return func 25 | 26 | return decorator 27 | 28 | def default(self): 29 | def decorator(func): 30 | if not asyncio.iscoroutinefunction(func): 31 | raise TypeError("Handler must be a coroutine function") 32 | 33 | self._default = func 34 | return func 35 | 36 | return decorator 37 | 38 | async def handle(self, body, signature): 39 | payload = self.parser.parse(body, signature, as_payload=True) 40 | 41 | for event in payload.events: 42 | func = None 43 | key = None 44 | 45 | if isinstance(event, MessageEvent): 46 | key = self.__get_handler_key(event.__class__, event.message.__class__) 47 | func = self._handlers.get(key, None) 48 | 49 | if func is None: 50 | key = self.__get_handler_key(event.__class__) 51 | func = self._handlers.get(key, None) 52 | 53 | if func is None: 54 | func = self._default 55 | 56 | if func is None: 57 | LOGGER.info("No handler of " + key + " and no default handler") 58 | else: 59 | args_count = self.__get_args_count(func) 60 | if args_count == 0: 61 | await func() 62 | elif args_count == 1: 63 | await func(event) 64 | else: 65 | await func(event, payload.destination) 66 | 67 | def __add_handler(self, func, event, message=None): 68 | key = self.__get_handler_key(event, message=message) 69 | self._handlers[key] = func 70 | 71 | @staticmethod 72 | def __get_args_count(func): 73 | arg_spec = inspect.getfullargspec(func) 74 | return len(arg_spec.args) 75 | 76 | @staticmethod 77 | def __get_handler_key(event, message=None): 78 | if message is None: 79 | return event.__name__ 80 | else: 81 | return event.__name__ + "_" + message.__name__ 82 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | line-bot-sdk 2 | aiohttp 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Note: To use the 'upload' functionality of this file, you must: 5 | # $ pip install twine 6 | 7 | import io 8 | import os 9 | import sys 10 | from shutil import rmtree 11 | 12 | from setuptools import find_packages, setup, Command 13 | 14 | # Package meta-data. 15 | NAME = 'line-bot-sdk-extra' 16 | DESCRIPTION = 'Extra Async Support for LINE Messaging API SDK' 17 | URL = 'https://github.com/Shivelight/line-bot-sdk-extra' 18 | EMAIL = 'cwkfr@protonmail.com' 19 | AUTHOR = 'Arkie' 20 | REQUIRES_PYTHON = '>=3.6.0' 21 | VERSION = '0.1.2' 22 | 23 | # What packages are required for this module to be executed? 24 | REQUIRED = [ 25 | 'line-bot-sdk', 'aiohttp' 26 | ] 27 | 28 | # What packages are optional? 29 | EXTRAS = { 30 | # 'fancy feature': ['django'], 31 | } 32 | 33 | # The rest you shouldn't have to touch too much :) 34 | # ------------------------------------------------ 35 | # Except, perhaps the License and Trove Classifiers! 36 | # If you do change the License, remember to change the Trove Classifier for that! 37 | 38 | here = os.path.abspath(os.path.dirname(__file__)) 39 | 40 | # Import the README and use it as the long-description. 41 | # Note: this will only work if 'README.md' is present in your MANIFEST.in file! 42 | try: 43 | with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: 44 | long_description = '\n' + f.read() 45 | except FileNotFoundError: 46 | long_description = DESCRIPTION 47 | 48 | # Load the package's __version__.py module as a dictionary. 49 | about = {} 50 | if not VERSION: 51 | project_slug = NAME.lower().replace("-", "_").replace(" ", "_") 52 | with open(os.path.join(here, project_slug, '__version__.py')) as f: 53 | exec(f.read(), about) 54 | else: 55 | about['__version__'] = VERSION 56 | 57 | 58 | class UploadCommand(Command): 59 | """Support setup.py upload.""" 60 | 61 | description = 'Build and publish the package.' 62 | user_options = [] 63 | 64 | @staticmethod 65 | def status(s): 66 | """Prints things in bold.""" 67 | print('\033[1m{0}\033[0m'.format(s)) 68 | 69 | def initialize_options(self): 70 | pass 71 | 72 | def finalize_options(self): 73 | pass 74 | 75 | def run(self): 76 | try: 77 | self.status('Removing previous builds…') 78 | rmtree(os.path.join(here, 'dist')) 79 | except OSError: 80 | pass 81 | 82 | self.status('Building Source and Wheel (universal) distribution…') 83 | os.system('{0} setup.py sdist bdist_wheel'.format(sys.executable)) 84 | 85 | self.status('Uploading the package to PyPI via Twine…') 86 | os.system('twine upload dist/*') 87 | 88 | self.status('Pushing git tags…') 89 | os.system('git tag v{0}'.format(about['__version__'])) 90 | os.system('git push --tags') 91 | 92 | sys.exit() 93 | 94 | 95 | # Where the magic happens: 96 | setup( 97 | name=NAME, 98 | version=about['__version__'], 99 | description=DESCRIPTION, 100 | long_description=long_description, 101 | author=AUTHOR, 102 | author_email=EMAIL, 103 | python_requires=REQUIRES_PYTHON, 104 | url=URL, 105 | packages=find_packages(exclude=('tests',)), 106 | # If your package is a single module, use this instead of 'packages': 107 | # py_modules=['mypackage'], 108 | 109 | # entry_points={ 110 | # 'console_scripts': ['mycli=mymodule:cli'], 111 | # }, 112 | install_requires=REQUIRED, 113 | extras_require=EXTRAS, 114 | include_package_data=True, 115 | license='MIT', 116 | classifiers=[ 117 | # Trove classifiers 118 | # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers 119 | 'Development Status :: 4 - Beta', 120 | 'Intended Audience :: Developers', 121 | 'License :: OSI Approved :: MIT License', 122 | 'Programming Language :: Python', 123 | 'Programming Language :: Python :: 3', 124 | 'Programming Language :: Python :: 3.6', 125 | 'Programming Language :: Python :: 3.7', 126 | 'Programming Language :: Python :: Implementation :: CPython', 127 | 'Topic :: Software Development', 128 | 'Topic :: Software Development :: Libraries', 129 | 130 | ], 131 | # $ setup.py publish support. 132 | cmdclass={ 133 | 'upload': UploadCommand, 134 | }, 135 | ) 136 | --------------------------------------------------------------------------------