diff --git a/.gitignore b/.gitignore index 60a7f9b1a..4d586cfb3 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ nosetests.xml coverage.xml *,cover .hypothesis/ +.pytest_cache/ # Translations *.mo diff --git a/examples/flask-kitchensink/app.py b/examples/flask-kitchensink/app.py index 3608b424a..ead9608d9 100644 --- a/examples/flask-kitchensink/app.py +++ b/examples/flask-kitchensink/app.py @@ -19,14 +19,13 @@ import sys import tempfile from argparse import ArgumentParser - from flask import Flask, request, abort from linebot import ( LineBotApi, WebhookHandler ) from linebot.exceptions import ( - InvalidSignatureError + LineBotApiError, InvalidSignatureError ) from linebot.models import ( MessageEvent, TextMessage, TextSendMessage, @@ -37,7 +36,10 @@ CarouselTemplate, CarouselColumn, PostbackEvent, StickerMessage, StickerSendMessage, LocationMessage, LocationSendMessage, ImageMessage, VideoMessage, AudioMessage, FileMessage, - UnfollowEvent, FollowEvent, JoinEvent, LeaveEvent, BeaconEvent + UnfollowEvent, FollowEvent, JoinEvent, LeaveEvent, BeaconEvent, + FlexSendMessage, BubbleContainer, ImageComponent, BoxComponent, + TextComponent, SpacerComponent, IconComponent, ButtonComponent, + SeparatorComponent, ) app = Flask(__name__) @@ -81,6 +83,11 @@ def callback(): # handle webhook body try: handler.handle(body, signature) + except LineBotApiError as e: + print("Got exception from LINE Messaging API: %s\n" % e.message) + for m in e.error.details: + print(" %s: %s" % (m.property, m.message)) + print("\n") except InvalidSignatureError: abort(400) @@ -166,6 +173,111 @@ def handle_text_message(event): line_bot_api.reply_message(event.reply_token, template_message) elif text == 'imagemap': pass + elif text == 'flex': + bubble = BubbleContainer( + direction='ltr', + hero=ImageComponent( + url='https://example.com/cafe.jpg', + size='full', + aspect_ratio='20:13', + aspect_mode='cover', + action=URIAction(uri='http://example.com', label='label') + ), + body=BoxComponent( + layout='vertical', + contents=[ + # title + TextComponent(text='Brown Cafe', weight='bold', size='xl'), + # review + BoxComponent( + layout='baseline', + margin='md', + contents=[ + IconComponent(size='sm', url='https://example.com/gold_star.png'), + IconComponent(size='sm', url='https://example.com/grey_star.png'), + IconComponent(size='sm', url='https://example.com/gold_star.png'), + IconComponent(size='sm', url='https://example.com/gold_star.png'), + IconComponent(size='sm', url='https://example.com/grey_star.png'), + TextComponent(text='4.0', size='sm', color='#999999', margin='md', + flex=0) + ] + ), + # info + BoxComponent( + layout='vertical', + margin='lg', + spacing='sm', + contents=[ + BoxComponent( + layout='baseline', + spacing='sm', + contents=[ + TextComponent( + text='Place', + color='#aaaaaa', + size='sm', + flex=1 + ), + TextComponent( + text='Shinjuku, Tokyo', + wrap=True, + color='#666666', + size='sm', + flex=5 + ) + ], + ), + BoxComponent( + layout='baseline', + spacing='sm', + contents=[ + TextComponent( + text='Time', + color='#aaaaaa', + size='sm', + flex=1 + ), + TextComponent( + text="10:00 - 23:00", + wrap=True, + color='#666666', + size='sm', + flex=5, + ), + ], + ), + ], + ) + ], + ), + footer=BoxComponent( + layout='vertical', + spacing='sm', + contents=[ + # callAction, separator, websiteAction + SpacerComponent(size='sm'), + # callAction + ButtonComponent( + style='link', + height='sm', + action=URIAction(label='CALL', uri='tel:000000'), + ), + # separator + SeparatorComponent(), + # websiteAction + ButtonComponent( + style='link', + height='sm', + action=URIAction(label='WEBSITE', uri="https://example.com") + ) + ] + ), + ) + message = FlexSendMessage(alt_text="hello", contents=bubble) + line_bot_api.reply_message( + event.reply_token, + message + ) else: line_bot_api.reply_message( event.reply_token, TextSendMessage(text=event.message.text)) diff --git a/linebot/models/__init__.py b/linebot/models/__init__.py index 8434b5be0..d322b9159 100644 --- a/linebot/models/__init__.py +++ b/linebot/models/__init__.py @@ -103,3 +103,20 @@ ImageCarouselTemplate, ImageCarouselColumn, ) +from .flex_message import ( # noqa + FlexSendMessage, + FlexContainer, + BubbleContainer, + BubbleStyle, + BlockStyle, + CarouselContainer, + FlexComponent, + BoxComponent, + ButtonComponent, + FillerComponent, + IconComponent, + ImageComponent, + SeparatorComponent, + SpacerComponent, + TextComponent +) diff --git a/linebot/models/base.py b/linebot/models/base.py index ebfd5e5e1..457ea1bfb 100644 --- a/linebot/models/base.py +++ b/linebot/models/base.py @@ -90,8 +90,7 @@ def as_json_dict(self): elif hasattr(value, 'as_json_dict'): data[camel_key] = value.as_json_dict() - - else: + elif value is not None: data[camel_key] = value return data diff --git a/linebot/models/flex_message.py b/linebot/models/flex_message.py new file mode 100644 index 000000000..a4bbcc6c8 --- /dev/null +++ b/linebot/models/flex_message.py @@ -0,0 +1,463 @@ +# -*- coding: utf-8 -*- + +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""linebot.models.flex_message module.""" + +from __future__ import unicode_literals + +from abc import ABCMeta + +from future.utils import with_metaclass + +from .actions import get_action +from .base import Base +from .send_messages import SendMessage + + +class FlexSendMessage(SendMessage): + """FlexSendMessage. + + https://developers.line.me/en/docs/messaging-api/reference/#flex-message + + Flex Messages are messages with a customizable layout. + You can customize the layout freely by combining multiple elements. + """ + + def __init__(self, alt_text=None, contents=None, **kwargs): + """__init__ method. + + :param str alt_text: Alternative text + :param contents: Flex Message container object + :type contents: :py:class:`linebot.models.flex_message.FlexContainer` + :param kwargs: + """ + super(FlexSendMessage, self).__init__(**kwargs) + + self.type = 'flex' + self.alt_text = alt_text + self.contents = contents + self.contents = self.get_or_new_from_json_dict_with_types( + contents, { + 'bubble': BubbleContainer, + 'carousel': CarouselContainer + } + ) + + +class FlexContainer(with_metaclass(ABCMeta, Base)): + """FlexContainer. + + https://developers.line.me/en/docs/messaging-api/reference/#container + + A container is the top-level structure of a Flex Message. + """ + + def __init__(self, **kwargs): + """__init__ method. + + :param kwargs: + """ + super(FlexContainer, self).__init__(**kwargs) + + self.type = None + + +class BubbleContainer(FlexContainer): + """BubbleContainer. + + https://developers.line.me/en/docs/messaging-api/reference/#bubble-container + + This is a container that contains one message bubble. + It can contain four blocks: header, hero, body, and footer. + """ + + def __init__(self, direction=None, header=None, hero=None, body=None, footer=None, styles=None, + **kwargs): + """__init__ method. + + :param str direction: Text directionality and the order of components + in horizontal boxes in the container + :param header: Header block + :type header: :py:class:`linebot.models.flex_message.BoxComponent` + :param hero: Hero block + :type hero: :py:class:`linebot.models.flex_message.ImageComponent` + :param body: Body block + :type body: :py:class:`linebot.models.flex_message.BoxComponent` + :param footer: Footer block + :type footer: :py:class:`linebot.models.flex_message.BoxComponent` + :param styles: Style of each block + :type styles: :py:class:`linebot.models.flex_message.BubbleStyle` + :param kwargs: + """ + super(BubbleContainer, self).__init__(**kwargs) + + self.type = 'bubble' + self.direction = direction + self.header = self.get_or_new_from_json_dict(header, BoxComponent) + self.hero = self.get_or_new_from_json_dict(hero, ImageComponent) + self.body = self.get_or_new_from_json_dict(body, BoxComponent) + self.footer = self.get_or_new_from_json_dict(footer, BoxComponent) + self.styles = self.get_or_new_from_json_dict(styles, BubbleStyle) + + +class BubbleStyle(with_metaclass(ABCMeta, Base)): + """BubbleStyle. + + https://developers.line.me/en/docs/messaging-api/reference/#objects-for-the-block-style + """ + + def __init__(self, header=None, hero=None, body=None, footer=None, **kwargs): + """__init__ method. + + :param header: Style of the header block + :type header: :py:class:`linebot.models.flex_message.BlockStyle` + :param hero: Style of the hero block + :type hero: :py:class:`linebot.models.flex_message.BlockStyle` + :param body: Style of the body block + :type body: :py:class:`linebot.models.flex_message.BlockStyle` + :param footer: Style of the footer block + :type footer: :py:class:`linebot.models.flex_message.BlockStyle` + :param kwargs: + """ + super(BubbleStyle, self).__init__(**kwargs) + + self.type = 'carousel' + self.header = self.get_or_new_from_json_dict(header, BlockStyle) + self.hero = self.get_or_new_from_json_dict(hero, BlockStyle) + self.body = self.get_or_new_from_json_dict(body, BlockStyle) + self.footer = self.get_or_new_from_json_dict(footer, BlockStyle) + + +class BlockStyle(with_metaclass(ABCMeta, Base)): + """BlockStyle. + + https://developers.line.me/en/docs/messaging-api/reference/#objects-for-the-block-style + """ + + def __init__(self, background_color=None, separator=None, separator_color=None, **kwargs): + """__init__ method. + + :param str background_color: Background color of the block. Use a hexadecimal color code + :param bool separator: True to place a separator above the block + True will be ignored for the first block in a container + because you cannot place a separator above the first block. + The default value is False + :param str separator_color: Color of the separator. Use a hexadecimal color code + :param kwargs: + """ + super(BlockStyle, self).__init__(**kwargs) + self.background_color = background_color + self.separator = separator + self.separator_color = separator_color + + +class CarouselContainer(FlexContainer): + """CarouselContainer. + + https://developers.line.me/en/docs/messaging-api/reference/#carousel-container + + This is a container that contains multiple bubble containers, or message bubbles. + The bubbles will be shown in order by scrolling horizontally. + """ + + def __init__(self, contents=None, **kwargs): + """__init__ method. + + :param contents: Array of bubble containers + :type contents: list[T <= :py:class:`linebot.models.flex_message.BubbleContainer`] + :param kwargs: + """ + super(CarouselContainer, self).__init__(**kwargs) + + self.type = 'carousel' + + new_contents = [] + if contents: + for it in contents: + new_contents.append(self.get_or_new_from_json_dict( + it, BubbleContainer + )) + self.contents = new_contents + + +class FlexComponent(with_metaclass(ABCMeta, Base)): + """FlexComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#component + + Components are objects that compose a Flex Message container. + """ + + def __init__(self, **kwargs): + """__init__ method. + + :param kwargs: + """ + super(FlexComponent, self).__init__(**kwargs) + + self.type = None + + +class BoxComponent(FlexComponent): + """BoxComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#box-component + + This is a component that defines the layout of child components. + You can also include a box in a box. + """ + + def __init__(self, layout=None, contents=None, flex=None, spacing=None, margin=None, **kwargs): + """__init__ method. + + :param str layout: The placement style of components in this box + :param contents: Components in this box + :param float flex: The ratio of the width or height of this box within the parent box + :param str spacing: Minimum space between components in this box + :param str margin: Minimum space between this box + and the previous component in the parent box + :param kwargs: + """ + super(BoxComponent, self).__init__(**kwargs) + self.type = 'box' + self.layout = layout + self.flex = flex + self.spacing = spacing + self.margin = margin + + new_contents = [] + if contents: + for it in contents: + new_contents.append(self.get_or_new_from_json_dict_with_types( + it, { + 'box': BoxComponent, + 'button': ButtonComponent, + 'filler': FillerComponent, + 'icon': IconComponent, + 'image': ImageComponent, + 'separator': SeparatorComponent, + 'spacer': SpacerComponent, + 'text': TextComponent + } + )) + self.contents = new_contents + + +class ButtonComponent(FlexComponent): + """ButtonComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#button-component + + This component draws a button. + When the user taps a button, a specified action is performed. + """ + + def __init__(self, action=None, flex=None, margin=None, height=None, style=None, color=None, + gravity=None, **kwargs): + """__init__ method. + + :param action: Action performed when this button is tapped + :type action: list[T <= :py:class:`linebot.models.actions.Action`] + :param float flex: The ratio of the width or height of this component within the parent box + :param str margin: Minimum space between this component + and the previous component in the parent box + :param str height: Height of the button + :param str style: Style of the button + :param str color: Character color when the style property is link. + Background color when the style property is primary or secondary. + Use a hexadecimal color code + :param str gravity: Vertical alignment style + :param kwargs: + """ + super(ButtonComponent, self).__init__(**kwargs) + self.type = 'button' + self.action = get_action(action) + self.flex = flex + self.margin = margin + self.height = height + self.style = style + self.color = color + self.gravity = gravity + + +class FillerComponent(FlexComponent): + """FillerComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#filler-component + + This is an invisible component to fill extra space between components. + """ + + def __init__(self, **kwargs): + """__init__ method. + + :param kwargs: + """ + super(FillerComponent, self).__init__(**kwargs) + self.type = 'filler' + + +class IconComponent(FlexComponent): + """IconComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#icon-component + + This component draws an icon. + """ + + def __init__(self, url=None, margin=None, size=None, aspect_ratio=None, **kwargs): + """__init__ method. + + :param str url: Image URL + Protocol: HTTPS + Image format: JPEG or PNG + :param str margin: Minimum space between this component + and the previous component in the parent box + :param str size: Maximum size of the icon width + :param str aspect_ratio: Aspect ratio of the icon + :param kwargs: + """ + super(IconComponent, self).__init__(**kwargs) + self.type = 'icon' + self.url = url + self.margin = margin + self.size = size + self.aspect_ratio = aspect_ratio + + +class ImageComponent(FlexComponent): + """ImageComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#image-component + + This component draws an image. + """ + + def __init__(self, url=None, flex=None, margin=None, align=None, gravity=None, size=None, + aspect_ratio=None, aspect_mode=None, background_color=None, action=None, + **kwargs): + """__init__ method. + + :param str url: Image URL + Protocol: HTTPS + Image format: JPEG or PNG + :param float flex: The ratio of the width or height of this component within the parent box + :param str margin: Minimum space between this component + and the previous component in the parent box + :param str align: Horizontal alignment style + :param str gravity: Vertical alignment style + :param str size: Maximum size of the image width + :param str aspect_ratio: Aspect ratio of the image + :param str aspect_mode: Style of the image + :param str background_color: Background color of the image. Use a hexadecimal color code. + :param action: Action performed when this image is tapped + :type action: list[T <= :py:class:`linebot.models.actions.Action`] + :param kwargs: + """ + super(ImageComponent, self).__init__(**kwargs) + self.type = 'image' + self.url = url + self.flex = flex + self.margin = margin + self.align = align + self.gravity = gravity + self.size = size + self.aspect_ratio = aspect_ratio + self.aspect_mode = aspect_mode + self.background_color = background_color + self.action = get_action(action) + + +class SeparatorComponent(FlexComponent): + """SeparatorComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#separator-component + + This component draws a separator between components in the parent box. + """ + + def __init__(self, margin=None, color=None, **kwargs): + """__init__ method. + + :param str margin: Minimum space between this component + and the previous component in the parent box + :param str color: Color of the separator. Use a hexadecimal color code + :param kwargs: + """ + super(SeparatorComponent, self).__init__(**kwargs) + self.type = 'separator' + self.margin = margin + self.color = color + + +class SpacerComponent(FlexComponent): + """SpacerComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#spacer-component + + This is an invisible component that places a fixed-size space + at the beginning or end of the box + """ + + def __init__(self, size=None, **kwargs): + """__init__ method. + + :param str size: Size of the space + :param kwargs: + """ + super(SpacerComponent, self).__init__(**kwargs) + self.type = 'spacer' + self.size = size + + +class TextComponent(FlexComponent): + """TextComponent. + + https://developers.line.me/en/docs/messaging-api/reference/#text-component + + This component draws text. You can format the text. + """ + + def __init__(self, text=None, flex=None, margin=None, size=None, align=None, gravity=None, + wrap=None, weight=None, + color=None, action=None, **kwargs): + r"""__init__ method. + + :param str text: Text + :param float flex: The ratio of the width or height of this component within the parent box + :param str margin: Minimum space between this component + and the previous component in the parent box + :param str size: Font size + :param str align: Horizontal alignment style + :param str gravity: Vertical alignment style + :param bool wrap: rue to wrap text. The default value is False. + If set to True, you can use a new line character (\n) to begin on a new line. + :param str weight: Font weight + :param str color: Font color + :param action: Action performed when this image is tapped + :type action: list[T <= :py:class:`linebot.models.actions.Action`] + :param kwargs: + """ + super(TextComponent, self).__init__(**kwargs) + self.type = 'text' + self.text = text + self.flex = flex + self.margin = margin + self.size = size + self.align = align + self.gravity = gravity + self.wrap = wrap + self.weight = weight + self.color = color + self.action = get_action(action) diff --git a/tests/api/test_send_template_message.py b/tests/api/test_send_template_message.py index 4e42c8556..e224d11f8 100644 --- a/tests/api/test_send_template_message.py +++ b/tests/api/test_send_template_message.py @@ -64,9 +64,6 @@ def setUp(self): "type": "buttons", "thumbnailImageUrl": "https://example.com/image.jpg", - "imageAspectRatio": None, - "imageSize": None, - "imageBackgroundColor": None, "title": "Menu", "text": "Please select", "actions": [ @@ -213,7 +210,6 @@ def setUp(self): { "thumbnailImageUrl": "https://example.com/item1.jpg", - "imageBackgroundColor": None, "title": "this is menu1", "text": "description1", "actions": [ @@ -263,7 +259,6 @@ def setUp(self): { "thumbnailImageUrl": "https://example.com/item3.jpg", - "imageBackgroundColor": None, "title": "this is menu3", "text": "description3", "actions": [ @@ -297,8 +292,6 @@ def setUp(self): ] } ], - "imageAspectRatio": None, - "imageSize": None } }] @@ -346,7 +339,6 @@ def setUp(self): "type": "postback", "label": "postback1", "data": "action=buy&itemid=1", - "text": None } }, { diff --git a/tests/models/test_base.py b/tests/models/test_base.py index 97567301d..d493fc73e 100644 --- a/tests/models/test_base.py +++ b/tests/models/test_base.py @@ -33,39 +33,39 @@ class TestBase(unittest.TestCase): def test_as_json_string(self): self.assertEqual( Hoge().as_json_string(), - '{"content": null, "hogeBar": null, "title": null}') + '{}') self.assertEqual( Hoge(title='title').as_json_string(), - '{"content": null, "hogeBar": null, "title": "title"}') + '{"title": "title"}') self.assertEqual( Hoge(title='title', content='content').as_json_string(), - '{"content": "content", "hogeBar": null, "title": "title"}') + '{"content": "content", "title": "title"}') self.assertEqual( Hoge(title='title', content={"hoge": "hoge"}).as_json_string(), - '{"content": {"hoge": "hoge"}, "hogeBar": null, "title": "title"}') + '{"content": {"hoge": "hoge"}, "title": "title"}') self.assertEqual( Hoge(title=[1, 2]).as_json_string(), - '{"content": null, "hogeBar": null, "title": [1, 2]}') + '{"title": [1, 2]}') self.assertEqual( Hoge(hoge_bar='hoge_bar').as_json_string(), - '{"content": null, "hogeBar": "hoge_bar", "title": null}') + '{"hogeBar": "hoge_bar"}') def test_as_json_dict(self): self.assertEqual( Hoge().as_json_dict(), - {'content': None, 'hogeBar': None, 'title': None}) + {}) self.assertEqual( Hoge(title='title').as_json_dict(), - {'content': None, 'hogeBar': None, 'title': 'title'}) + {'title': 'title'}) self.assertEqual( Hoge(title='title', content='content').as_json_dict(), - {'content': 'content', 'hogeBar': None, 'title': 'title'}) + {'content': 'content', 'title': 'title'}) self.assertEqual( Hoge(title='title', content={"hoge": "hoge"}).as_json_dict(), - {'content': {'hoge': 'hoge'}, 'hogeBar': None, 'title': 'title'}) + {'content': {'hoge': 'hoge'}, 'title': 'title'}) self.assertEqual( Hoge(title=[1, 2]).as_json_dict(), - {'content': None, 'hogeBar': None, 'title': [1, 2]}) + {'title': [1, 2]}) def test_new_from_json_dict(self): self.assertEqual(