From 6a3800f6136a4ab893cab286c9712e550476cdab Mon Sep 17 00:00:00 2001 From: egalvis Date: Wed, 5 Jul 2023 15:50:02 -0500 Subject: [PATCH 1/3] Added interactive input, buttons and buttons list --- menuflow/flow.py | 18 +- menuflow/matrix.py | 4 +- menuflow/nodes/__init__.py | 1 + menuflow/nodes/interactive_input.py | 79 +++++++ menuflow/repository/__init__.py | 2 + menuflow/repository/nodes/__init__.py | 1 + .../repository/nodes/interactive_input.py | 208 ++++++++++++++++++ 7 files changed, 309 insertions(+), 4 deletions(-) create mode 100644 menuflow/nodes/interactive_input.py create mode 100644 menuflow/repository/nodes/interactive_input.py diff --git a/menuflow/flow.py b/menuflow/flow.py index f289602..c2d4d8c 100644 --- a/menuflow/flow.py +++ b/menuflow/flow.py @@ -1,13 +1,23 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Dict, List +from typing import Dict, List from mautrix.types import SerializableAttrs from mautrix.util.logging import TraceLogger from .middlewares import HTTPMiddleware -from .nodes import CheckTime, Email, HTTPRequest, Input, Location, Media, Message, Switch +from .nodes import ( + CheckTime, + Email, + HTTPRequest, + Input, + Location, + Media, + Message, + Switch, + InteractiveInput, +) from .repository import Flow as FlowModel from .room import Room @@ -143,6 +153,10 @@ def node( if node_data.get("middleware"): middleware = self.middleware(node_data.get("middleware"), room) node_initialized.middleware = middleware + elif node_data.get("type") == "interactive_input": + node_initialized = InteractiveInput( + interactive_input_data=node_data, room=room, default_variables=self.flow_variables + ) else: return diff --git a/menuflow/matrix.py b/menuflow/matrix.py index 164a534..da1275f 100644 --- a/menuflow/matrix.py +++ b/menuflow/matrix.py @@ -19,7 +19,7 @@ from .config import Config from .db.room import RoomState from .flow import Flow -from .nodes import Base, Input +from .nodes import Base, Input, InteractiveInput from .repository import Flow as FlowModel from .room import Room from .user import User @@ -227,7 +227,7 @@ async def algorithm(self, room: Room, evt: Optional[MessageEvent] = None) -> Non self.log.debug(f"The [room: {room.room_id}] [node: {node.id}] [state: {room.state}]") - if type(node) == Input: + if type(node) in (Input, InteractiveInput): await node.run(evt=evt) if room.state == RoomState.INPUT: return diff --git a/menuflow/nodes/__init__.py b/menuflow/nodes/__init__.py index 113a195..be1883c 100644 --- a/menuflow/nodes/__init__.py +++ b/menuflow/nodes/__init__.py @@ -7,3 +7,4 @@ from .media import Media from .message import Message from .switch import Switch +from .interactive_input import InteractiveInput diff --git a/menuflow/nodes/interactive_input.py b/menuflow/nodes/interactive_input.py new file mode 100644 index 0000000..583b5e6 --- /dev/null +++ b/menuflow/nodes/interactive_input.py @@ -0,0 +1,79 @@ +from mautrix.types import MessageEvent + +from ..db.room import RoomState +from .input import Input +from ..repository import InteractiveInput as InteractiveInputModel +from ..repository import InteractiveMessage +from ..room import Room +from typing import Dict, Any, Optional +from ..utils import Util + + +class InteractiveInput(Input): + def __init__( + self, interactive_input_data: InteractiveInputModel, room: Room, default_variables: Dict + ) -> None: + Input.__init__( + self, + input_node_data=interactive_input_data, + room=room, + default_variables=default_variables, + ) + self.content = interactive_input_data + + @property + def interactive_message(self) -> Dict[str, Any]: + return self.render_data(self.content.get("interactive_message", {})) + + @property + def interactive_message_type(self) -> str: + if self.interactive_message.get("type") == "list": + return "m.interactive.list_reply" + else: + return "m.interactive.quick_reply" + + @property + def interactive_message_content(self) -> InteractiveMessage: + interactive_message = InteractiveMessage.from_dict( + msgtype=self.interactive_message_type, + interactive_message=self.interactive_message, + ) + interactive_message.trim_reply_fallback() + return interactive_message + + async def run(self, evt: Optional[MessageEvent]): + """If the room is in input mode, then set the variable. + Otherwise, show the message and enter input mode + + Parameters + ---------- + client : MatrixClient + The MatrixClient object. + evt : Optional[MessageEvent] + The event that triggered the node. + + """ + + if self.room.state == RoomState.INPUT: + if not evt or not self.variable: + self.log.warning("A problem occurred to trying save the variable") + return + + await self.input_text(content=evt.content) + + if self.inactivity_options: + await Util.cancel_task(task_name=self.room.room_id) + else: + # This is the case where the room is not in the input state + # and the node is an input node. + # In this case, the message is shown and the menu is updated to the node's id + # and the room state is set to input. + self.log.debug(f"Room {self.room.room_id} enters input node {self.id}") + await self.room.matrix_client.send_message_event( + room_id=self.room.room_id, + event_type="m.room.message", + content=self.interactive_message_content, + ) + await self.room.update_menu(node_id=self.id, state=RoomState.INPUT) + if self.inactivity_options: + await self.inactivity_task() diff --git a/menuflow/repository/__init__.py b/menuflow/repository/__init__.py index 76745bc..63cebfc 100644 --- a/menuflow/repository/__init__.py +++ b/menuflow/repository/__init__.py @@ -11,4 +11,6 @@ Media, Message, Switch, + InteractiveInput, + InteractiveMessage, ) diff --git a/menuflow/repository/nodes/__init__.py b/menuflow/repository/nodes/__init__.py index df15ac5..cd1c343 100644 --- a/menuflow/repository/nodes/__init__.py +++ b/menuflow/repository/nodes/__init__.py @@ -6,3 +6,4 @@ from .media import Media from .message import Message from .switch import Case, Switch +from .interactive_input import InteractiveInput, InteractiveMessage diff --git a/menuflow/repository/nodes/interactive_input.py b/menuflow/repository/nodes/interactive_input.py new file mode 100644 index 0000000..8a9eee8 --- /dev/null +++ b/menuflow/repository/nodes/interactive_input.py @@ -0,0 +1,208 @@ +from .input import Input +from attr import dataclass, ib +from mautrix.types import SerializableAttrs, BaseMessageEventContent +from typing import List, Dict, Optional + + +@dataclass +class ContentQuickReplay(SerializableAttrs): + type: str = ib(default=None, metadata={"json": "type"}) + header: str = ib(default=None, metadata={"json": "header"}) + text: str = ib(default=None, metadata={"json": "text"}) + caption: str = ib(default=None, metadata={"json": "caption"}) + filename: str = ib(default=None, metadata={"json": "2"}) + url: str = ib(default=None, metadata={"json": "url"}) + + +@dataclass +class InteractiveMessageOption(SerializableAttrs): + type: str = ib(default=None, metadata={"json": "type"}) + title: str = ib(default=None, metadata={"json": "title"}) + description: str = ib(default=None, metadata={"json": "description"}) + postback_text: str = ib(default=None, metadata={"json": "postback_text"}) + + +@dataclass +class ItemListReplay(SerializableAttrs): + title: str = ib(default=None, metadata={"json": "title"}) + subtitle: str = ib(default=None, metadata={"json": "subtitle"}) + options: List[InteractiveMessageOption] = ib(metadata={"json": "options"}, factory=list) + + @classmethod + def from_dict(cls, data: dict): + return cls( + title=data.get("title"), + subtitle=data.get("subtitle"), + options=[InteractiveMessageOption(**option) for option in data.get("options", [])], + ) + + +@dataclass +class GlobalButtonsListReplay(SerializableAttrs): + type: str = ib(default=None, metadata={"json": "type"}) + title: str = ib(default=None, metadata={"json": "title"}) + + +@dataclass +class InteractiveMessageContent(SerializableAttrs): + type: str = ib(default=None, metadata={"json": "type"}) + content: ContentQuickReplay = ib(default=None, metadata={"json": "content"}) + options: List[InteractiveMessageOption] = ib(metadata={"json": "options"}, factory=list) + title: str = ib(default=None, metadata={"json": "title"}) + body: str = ib(default=None, metadata={"json": "body"}) + msgid: str = ib(default=None, metadata={"json": "msgid"}) + global_buttons: List[GlobalButtonsListReplay] = ib( + metadata={"json": "global_buttons"}, factory=list + ) + items: List[ItemListReplay] = ib(metadata={"json": "items"}, factory=list) + + @classmethod + def from_dict(cls, data: Dict): + if data["type"] == "quick_reply": + return cls( + type=data["type"], + content=ContentQuickReplay(**data["content"]), + options=[InteractiveMessageOption(**option) for option in data["options"]], + ) + elif data["type"] == "list": + return cls( + type=data["type"], + title=data["title"], + body=data["body"], + global_buttons=[ + GlobalButtonsListReplay(**item) for item in data["global_buttons"] + ], + items=[ItemListReplay.from_dict(item) for item in data["items"]], + ) + + +@dataclass +class InteractiveMessage(SerializableAttrs, BaseMessageEventContent): + msgtype: str = ib(default="none", metadata={"json": "msgtype"}) + body: str = ib(default=None, metadata={"json": "body"}) + interactive_message: InteractiveMessageContent = ib( + factory=InteractiveMessageContent, metadata={"json": "interactive_message"} + ) + + @classmethod + def from_dict(cls, msgtype: str, interactive_message: Dict, body: Optional[str] = ""): + return cls( + msgtype=msgtype, + body=body, + interactive_message=InteractiveMessageContent.from_dict(interactive_message), + ) + + +@dataclass +class InteractiveInput(Input): + """ + ## Interactive Input + An interactive input type node allows sending button and + button list messages to whatsapp using Gupshup Bridge. + + Nota: This node is only available for whatsapp, matrix does not have support. + + content: + + ```yaml + - id: i1 + type: interactive_input + variable: opt + validation: '{{ opt }}' + validation_attempts: 3 + inactivity_options: + chat_timeout: 20 #seconds + warning_message: "Message" + time_between_attempts: 10 #seconds + attempts: 3 + interactive_message: + type: "quick_reply" + content: + type: "image | text | video | document" + # If type = image | video | document set url parameter + url: "https://images.unsplash.com/photo-1575936123452-b67c3203c357?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxzZWFyY2h8Mnx8aW1hZ2V8ZW58MHx8MHx8fDA%3D&w=1000&q=80" + # If type = text set header parameter + header: "Gracias por comunicarte con iKono Telecomunicaciones." + text: "Para nosotros es un gusto poder ayudarte 😀" + caption: "Por favor selecciona una de las siguientes opciones:" + options: + - type: "text", + title: "Sales" + - type: "text", + title: "Support" + - type: "text", + title: "Development" + cases: + - id: "sales" + o_connection: "m1" + - id: "support" + o_connection: "m2" + - id: "development" + o_connection: "m3" + - id: "default" + o_connection: "m4" + - id: "timeout" + o_connection: "m5" + - id: "attempt_exceeded" + o_connection: "m6" + + - id: i1 + type: interactive_input + variable: opt + validation: '{{ opt }}' + validation_attempts: 3 + inactivity_options: + chat_timeout: 20 #seconds + warning_message: "Message" + time_between_attempts: 10 #seconds + attempts: 3 + interactive_message: + type: "list" + title: "title text" + body: "body text" + msgid: "list1" + global_buttons: + - type: "text", + title: "Global button" + items: + - title: "first Section" + subtitle: "first Subtitle" + options: + - type: "text" + title: "section 1 row 1" + description: "first row of first section description" + postback_text: "1" + - type: "text" + title: "section 1 row 2" + description: "second row of first section description" + postback_text: "2" + - title: "Second Section" + subtitle: "Second Subtitle" + options: + - type: "text" + title: "section 2 row 1" + description: "first row of first section description" + postback_text: "3" + - type: "text" + title: "section 2 row 2" + description: "second row of first section description" + postback_text: "4" + cases: + - id: "1" + o_connection: "m1" + - id: "2" + o_connection: "m2" + - id: "3" + o_connection: "m3" + - id: "4" + o_connection: "m4" + - id: "default" + o_connection: "m5" + - id: "timeout" + o_connection: "m6" + - id: "attempt_exceeded" + o_connection: "m7" + ``` + """ + + interactive_message: InteractiveMessageContent = ib(factory=InteractiveMessageContent) From ba40e4de185276d0194e3ff790704412b3c8dec0 Mon Sep 17 00:00:00 2001 From: egalvis Date: Wed, 5 Jul 2023 15:51:10 -0500 Subject: [PATCH 2/3] Apply isort --- menuflow/flow.py | 2 +- menuflow/nodes/__init__.py | 2 +- menuflow/nodes/interactive_input.py | 5 +++-- menuflow/repository/__init__.py | 4 ++-- menuflow/repository/nodes/__init__.py | 2 +- menuflow/repository/nodes/interactive_input.py | 8 +++++--- 6 files changed, 13 insertions(+), 10 deletions(-) diff --git a/menuflow/flow.py b/menuflow/flow.py index c2d4d8c..4de33dc 100644 --- a/menuflow/flow.py +++ b/menuflow/flow.py @@ -12,11 +12,11 @@ Email, HTTPRequest, Input, + InteractiveInput, Location, Media, Message, Switch, - InteractiveInput, ) from .repository import Flow as FlowModel from .room import Room diff --git a/menuflow/nodes/__init__.py b/menuflow/nodes/__init__.py index be1883c..1ee1c65 100644 --- a/menuflow/nodes/__init__.py +++ b/menuflow/nodes/__init__.py @@ -3,8 +3,8 @@ from .email import Email from .http_request import HTTPRequest from .input import Input +from .interactive_input import InteractiveInput from .location import Location from .media import Media from .message import Message from .switch import Switch -from .interactive_input import InteractiveInput diff --git a/menuflow/nodes/interactive_input.py b/menuflow/nodes/interactive_input.py index 583b5e6..9cd399d 100644 --- a/menuflow/nodes/interactive_input.py +++ b/menuflow/nodes/interactive_input.py @@ -1,12 +1,13 @@ +from typing import Any, Dict, Optional + from mautrix.types import MessageEvent from ..db.room import RoomState -from .input import Input from ..repository import InteractiveInput as InteractiveInputModel from ..repository import InteractiveMessage from ..room import Room -from typing import Dict, Any, Optional from ..utils import Util +from .input import Input class InteractiveInput(Input): diff --git a/menuflow/repository/__init__.py b/menuflow/repository/__init__.py index 63cebfc..38ebffc 100644 --- a/menuflow/repository/__init__.py +++ b/menuflow/repository/__init__.py @@ -7,10 +7,10 @@ HTTPRequest, InactivityOptions, Input, + InteractiveInput, + InteractiveMessage, Location, Media, Message, Switch, - InteractiveInput, - InteractiveMessage, ) diff --git a/menuflow/repository/nodes/__init__.py b/menuflow/repository/nodes/__init__.py index cd1c343..c88902e 100644 --- a/menuflow/repository/nodes/__init__.py +++ b/menuflow/repository/nodes/__init__.py @@ -2,8 +2,8 @@ from .email import Email from .http_request import HTTPRequest from .input import InactivityOptions, Input +from .interactive_input import InteractiveInput, InteractiveMessage from .location import Location from .media import Media from .message import Message from .switch import Case, Switch -from .interactive_input import InteractiveInput, InteractiveMessage diff --git a/menuflow/repository/nodes/interactive_input.py b/menuflow/repository/nodes/interactive_input.py index 8a9eee8..cd94951 100644 --- a/menuflow/repository/nodes/interactive_input.py +++ b/menuflow/repository/nodes/interactive_input.py @@ -1,7 +1,9 @@ -from .input import Input +from typing import Dict, List, Optional + from attr import dataclass, ib -from mautrix.types import SerializableAttrs, BaseMessageEventContent -from typing import List, Dict, Optional +from mautrix.types import BaseMessageEventContent, SerializableAttrs + +from .input import Input @dataclass From e158a3a5debce2d28d39940ff5adc2ac38177d35 Mon Sep 17 00:00:00 2001 From: egalvis Date: Mon, 31 Jul 2023 11:51:50 -0500 Subject: [PATCH 3/3] Corrected the order of imports --- menuflow/flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/menuflow/flow.py b/menuflow/flow.py index 17f528a..89d1108 100644 --- a/menuflow/flow.py +++ b/menuflow/flow.py @@ -3,7 +3,6 @@ import logging from typing import Dict, Optional - from mautrix.types import SerializableAttrs from mautrix.util.logging import TraceLogger