chatgraph 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of chatgraph might be problematic. Click here for more details.

@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Irisson Lima
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.
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.1
2
+ Name: chatgraph
3
+ Version: 0.1.0
4
+ Summary: A user-friendly chatbot library
5
+ Author: Irisson N. Lima
6
+ Author-email: irisson.lima@verdecard.com.br
7
+ Requires-Python: >=3.12,<4.0
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Requires-Dist: pika (>=1.3.2,<2.0.0)
11
+ Description-Content-Type: text/markdown
12
+
13
+ # chatgraph
14
+ ChatGraph is a user-friendly chatbot library that offers seamless integration with RabbitMQ. It is designed to simplify the development process by providing intuitive interfaces and robust messaging capabilities, making it easy to build and deploy chatbots leveraging RabbitMQ's powerful messaging infrastructure.
15
+
@@ -0,0 +1,2 @@
1
+ # chatgraph
2
+ ChatGraph is a user-friendly chatbot library that offers seamless integration with RabbitMQ. It is designed to simplify the development process by providing intuitive interfaces and robust messaging capabilities, making it easy to build and deploy chatbots leveraging RabbitMQ's powerful messaging infrastructure.
@@ -0,0 +1,20 @@
1
+ from .auth.credentials import Credential
2
+ from .bot.chatbot_model import ChatbotApp
3
+ from .bot.chatbot_router import ChatbotRouter
4
+ from .messages.rabbitMQ_message_consumer import RabbitMessageConsumer
5
+ from .types.message_types import Message
6
+ from .types.output_state import ChatbotResponse, RedirectResponse
7
+ from .types.route import Route
8
+ from .types.user_state import SimpleUserState
9
+
10
+ __all__ = [
11
+ 'ChatbotApp',
12
+ 'Credential',
13
+ 'SimpleUserState',
14
+ 'Message',
15
+ 'ChatbotRouter',
16
+ 'ChatbotResponse',
17
+ 'RedirectResponse',
18
+ 'RabbitMessageConsumer',
19
+ 'Route',
20
+ ]
@@ -0,0 +1,33 @@
1
+ import os
2
+
3
+
4
+ class Credential:
5
+ def __init__(
6
+ self, username: str | None = None, password: str | None = None
7
+ ):
8
+ self.__username = username
9
+ self.__password = password
10
+
11
+ @property
12
+ def password(self):
13
+ if not self.__password:
14
+ raise ValueError('Senha vazia!')
15
+ return self.__password
16
+
17
+ @property
18
+ def username(self):
19
+ if not self.__username:
20
+ raise ValueError('Usuário vazio!')
21
+ return self.__username
22
+
23
+ @classmethod
24
+ def dot_env_credentials(
25
+ cls, user_env: str = 'CHATBOT_USER', pass_env: str = 'CHATBOT_PASS'
26
+ ) -> 'Credential':
27
+ username = os.getenv(user_env)
28
+ password = os.getenv(pass_env)
29
+
30
+ if not username or not password:
31
+ raise ValueError('Corrija as variáveis de ambiente!')
32
+
33
+ return cls(username=username, password=password)
@@ -0,0 +1,155 @@
1
+ import inspect
2
+ import json
3
+ from functools import wraps
4
+ from logging import debug
5
+
6
+ import pika
7
+ import pika.exceptions
8
+
9
+ from ..auth.credentials import Credential
10
+ from ..error.chatbot_error import ChatbotError, ChatbotMessageError
11
+ from ..types.message_types import Message
12
+ from ..types.user_state import SimpleUserState, UserState
13
+ from .chatbot_router import ChatbotRouter
14
+
15
+
16
+ class ChatbotApp:
17
+ def __init__(
18
+ self,
19
+ amqp_url: str,
20
+ queue_consume: str,
21
+ credentials: Credential,
22
+ user_state: UserState,
23
+ prefetch_count: int = 1,
24
+ virtual_host: str = '/',
25
+ ):
26
+ self.__virtual_host = virtual_host
27
+ self.__prefetch_count = prefetch_count
28
+ self.__user_state = user_state
29
+ self.__queue_consume = queue_consume
30
+ self.__amqp_url = amqp_url
31
+ self.__credentials = pika.PlainCredentials(
32
+ credentials.username, credentials.password
33
+ )
34
+ self.__routes = {}
35
+
36
+ def include_router(self, router: ChatbotRouter, prefix: str):
37
+ if 'START' not in router.routes.keys():
38
+ raise ChatbotError('Erro ao incluir rota, START não encontrado!')
39
+
40
+ prefixed_routes = {
41
+ (
42
+ f'START{prefix.upper()}'
43
+ if key.upper() == 'START'
44
+ else f'START{prefix.upper()}{key.upper()}'
45
+ ): value
46
+ for key, value in router.routes.items()
47
+ }
48
+ self.__routes.update(prefixed_routes)
49
+
50
+ def route(self, state: str, default_message: str):
51
+ def decorator(func):
52
+ params = dict()
53
+ signature = inspect.signature(func)
54
+ output_param = signature.return_annotation
55
+
56
+ for name, param in signature.parameters.items():
57
+ param_type = (
58
+ param.annotation
59
+ if param.annotation != inspect.Parameter.empty
60
+ else 'Any'
61
+ )
62
+ params[param_type] = name
63
+ debug(f'Parameter: {name}, Type: {param_type}')
64
+
65
+ if Message not in params.keys():
66
+ raise ChatbotError(
67
+ 'Função não recebe Message, parâmetro obrigatório!'
68
+ )
69
+
70
+ self.__routes[state.upper()] = {
71
+ 'function': func,
72
+ 'default_message': default_message,
73
+ 'params': params,
74
+ 'return': output_param,
75
+ }
76
+
77
+ @wraps(func)
78
+ def wrapper(*args, **kwargs):
79
+ return func(*args, **kwargs)
80
+
81
+ return wrapper
82
+
83
+ return decorator
84
+
85
+ def start(self):
86
+ try: # Verificar se recursão não vai estourar
87
+ connection = pika.BlockingConnection(
88
+ pika.ConnectionParameters(
89
+ host=self.__amqp_url,
90
+ virtual_host=self.__virtual_host,
91
+ credentials=self.__credentials,
92
+ )
93
+ )
94
+ channel = connection.channel()
95
+
96
+ channel.basic_qos(prefetch_count=self.__prefetch_count)
97
+ channel.basic_consume(
98
+ queue=self.__queue_consume,
99
+ on_message_callback=self.on_request,
100
+ )
101
+
102
+ debug('[x] Aguardando solicitações RPC')
103
+ channel.start_consuming()
104
+ except pika.exceptions.StreamLostError as e:
105
+ debug(e)
106
+ self.start()
107
+
108
+ def on_request(self, ch, method, props, body):
109
+ message = body.decode()
110
+ response = self.process_message(message)
111
+
112
+ ch.basic_publish(
113
+ exchange='',
114
+ routing_key=props.reply_to,
115
+ properties=pika.BasicProperties(
116
+ correlation_id=props.correlation_id
117
+ ),
118
+ body=str(response),
119
+ )
120
+ ch.basic_ack(delivery_tag=method.delivery_tag)
121
+
122
+ def process_message(self, message: str):
123
+ data = json.loads(message)
124
+ message_imported = self.__transform_message(data)
125
+
126
+ customer_id = message_imported.customer_id
127
+
128
+ menu = self.__user_state.get_menu(customer_id)
129
+ handler = self.__routes.get(menu, None)
130
+
131
+ if not handler:
132
+ raise ChatbotMessageError(
133
+ customer_id, f'Rota não encontrada para {menu}!'
134
+ )
135
+ func = handler['function']
136
+ message_name = handler['params'].get(Message)
137
+ user_state_name = handler['params'].get(UserState, None)
138
+
139
+ kwargs = {message_name: message_imported}
140
+ if user_state_name:
141
+ kwargs[user_state_name] = SimpleUserState(
142
+ menu, list(self.__routes.keys())
143
+ )
144
+ return func(**kwargs)
145
+
146
+ def __transform_message(self, message: dict) -> Message:
147
+ return Message(
148
+ type=message.get('type', ''),
149
+ text=message.get('text', ''),
150
+ customer_id=message.get('customer_id', ''),
151
+ channel=message.get('channel', ''),
152
+ customer_phone=message.get('customer_phone', ''),
153
+ company_phone=message.get('company_phone', ''),
154
+ status=message.get('status'),
155
+ )
@@ -0,0 +1,117 @@
1
+ import inspect
2
+ from abc import ABC
3
+ from functools import wraps
4
+ from logging import debug
5
+
6
+ from ..error.chatbot_error import ChatbotError, ChatbotMessageError
7
+ from ..messages.base_message_consumer import MessageConsumer
8
+ from ..types.message_types import Message
9
+ from ..types.route import Route
10
+ from ..types.user_state import UserState
11
+ from .chatbot_router import ChatbotRouter
12
+ from ..types.output_state import ChatbotResponse, RedirectResponse
13
+
14
+ class ChatbotApp(ABC):
15
+ def __init__(
16
+ self,
17
+ user_state: UserState,
18
+ message_consumer: MessageConsumer,
19
+ ):
20
+ self.__message_consumer = message_consumer
21
+ self.__user_state = user_state
22
+ self.__routes = {}
23
+
24
+ def include_router(self, router: ChatbotRouter, prefix: str):
25
+ if 'START' not in router.routes.keys():
26
+ raise ChatbotError('Erro ao incluir rota, START não encontrado!')
27
+
28
+ prefixed_routes = {
29
+ (
30
+ f'START{prefix.upper()}'
31
+ if key.upper() == 'START'
32
+ else f'START{prefix.upper()}{key.upper().replace("START", "")}'
33
+ ): value
34
+ for key, value in router.routes.items()
35
+ }
36
+ self.__routes.update(prefixed_routes)
37
+
38
+ def route(self, route_name: str):
39
+ if not 'START' in route_name:
40
+ route_name = f'START{route_name}'
41
+
42
+ def decorator(func):
43
+ params = dict()
44
+ signature = inspect.signature(func)
45
+ output_param = signature.return_annotation
46
+
47
+ for name, param in signature.parameters.items():
48
+ param_type = (
49
+ param.annotation
50
+ if param.annotation != inspect.Parameter.empty
51
+ else 'Any'
52
+ )
53
+ params[param_type] = name
54
+ debug(f'Parameter: {name}, Type: {param_type}')
55
+
56
+
57
+ self.__routes[route_name.strip().upper()] = {
58
+ 'function': func,
59
+ 'params': params,
60
+ 'return': output_param,
61
+ }
62
+
63
+ @wraps(func)
64
+ def wrapper(*args, **kwargs):
65
+ return func(*args, **kwargs)
66
+
67
+ return wrapper
68
+
69
+ return decorator
70
+
71
+ def start(self):
72
+ self.__message_consumer.start_consume(self.process_message)
73
+
74
+ def process_message(self, message: Message):
75
+ customer_id = message.customer_id
76
+
77
+ menu = self.__user_state.get_menu(customer_id)
78
+ menu = menu.upper()
79
+ handler = self.__routes.get(menu, None)
80
+
81
+ if not handler:
82
+ raise ChatbotMessageError(
83
+ customer_id, f'Rota não encontrada para {menu}!'
84
+ )
85
+ func = handler['function']
86
+ message_name = handler['params'].get(Message, None)
87
+ route_state_name = handler['params'].get(Route, None)
88
+
89
+ kwargs = dict()
90
+ if message_name:
91
+ kwargs[message_name] = message
92
+ if route_state_name:
93
+ kwargs[route_state_name] = Route(menu, list(self.__routes.keys()))
94
+
95
+ message_response = func(**kwargs)
96
+
97
+ if type(message_response) in (str, float, int):
98
+ return message_response
99
+ elif type(message_response) == ChatbotResponse:
100
+ route = self.__adjust_route(message_response.route, menu)
101
+ self.__user_state.set_menu(customer_id, route)
102
+ return message_response.message
103
+ elif type(message_response) == RedirectResponse:
104
+ route = self.__adjust_route(message_response.route, menu)
105
+ self.__user_state.set_menu(customer_id, route)
106
+ return self.process_message(message)
107
+ else:
108
+ raise ChatbotError('Tipo de retorno inválido!')
109
+
110
+ def __adjust_route(self, route: str, absolute_route:str) -> str:
111
+ if not route:
112
+ return absolute_route
113
+
114
+ if not 'START' in route:
115
+ route = absolute_route+route
116
+
117
+ return route
@@ -0,0 +1,58 @@
1
+ import inspect
2
+ from functools import wraps
3
+ from logging import debug
4
+
5
+ from ..error.chatbot_error import ChatbotError
6
+ from ..types.message_types import Message
7
+
8
+
9
+ class ChatbotRouter:
10
+ def __init__(self):
11
+ self.routes = {}
12
+
13
+ def route(self, route_name: str):
14
+ if not 'START' in route_name:
15
+ route_name = f'START{route_name}'
16
+
17
+ def decorator(func):
18
+ params = dict()
19
+ signature = inspect.signature(func)
20
+ output_param = signature.return_annotation
21
+
22
+ for name, param in signature.parameters.items():
23
+ param_type = (
24
+ param.annotation
25
+ if param.annotation != inspect.Parameter.empty
26
+ else 'Any'
27
+ )
28
+ params[param_type] = name
29
+ debug(f'Parameter: {name}, Type: {param_type}')
30
+
31
+
32
+ self.routes[route_name.strip().upper()] = {
33
+ 'function': func,
34
+ 'params': params,
35
+ 'return': output_param,
36
+ }
37
+
38
+ @wraps(func)
39
+ def wrapper(*args, **kwargs):
40
+ return func(*args, **kwargs)
41
+
42
+ return wrapper
43
+
44
+ return decorator
45
+
46
+ def include_router(self, router: 'ChatbotRouter', prefix: str):
47
+ if 'START' not in router.routes.keys():
48
+ raise ChatbotError('Erro ao incluir rota, START não encontrado!')
49
+
50
+ prefixed_routes = {
51
+ (
52
+ f'{prefix.upper()}'
53
+ if key.upper() == 'START'
54
+ else f'START{prefix.upper()}{key.upper().replace("START", "")}'
55
+ ): value
56
+ for key, value in router.routes.items()
57
+ }
58
+ self.routes.update(prefixed_routes)
@@ -0,0 +1,17 @@
1
+ class ChatbotError(Exception):
2
+ def __init__(self, message: str):
3
+ self.message = message
4
+ super().__init__(self.message)
5
+
6
+ def __str__(self):
7
+ return f'ChatbotError: {self.message}'
8
+
9
+
10
+ class ChatbotMessageError(Exception):
11
+ def __init__(self, customer_id: str, message: str):
12
+ self.customer_id = customer_id
13
+ self.message = f'{message} ID recebido: {customer_id}'
14
+ super().__init__(self.message)
15
+
16
+ def __str__(self):
17
+ return f'InvalidCustomerIDError: {self.message}'
@@ -0,0 +1,7 @@
1
+ class RouteError(Exception):
2
+ def __init__(self, message: str):
3
+ self.message = message
4
+ super().__init__(self.message)
5
+
6
+ def __str__(self):
7
+ return f'RouteError: {self.message}'
@@ -0,0 +1,5 @@
1
+ from .base_message_consumer import MessageConsumer
2
+
3
+ __all__ = [
4
+ 'MessageConsumer',
5
+ ]
@@ -0,0 +1,8 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, Callable
3
+
4
+
5
+ class MessageConsumer(ABC):
6
+ @abstractmethod
7
+ def start_consume(self, process_message: Callable) -> Any:
8
+ pass
@@ -0,0 +1,78 @@
1
+ import json
2
+ from logging import debug, info
3
+
4
+ import pika
5
+
6
+ from ..auth.credentials import Credential
7
+ from ..types.message_types import Message
8
+ from .base_message_consumer import MessageConsumer
9
+
10
+
11
+ class RabbitMessageConsumer(MessageConsumer):
12
+ def __init__(
13
+ self,
14
+ amqp_url: str,
15
+ queue_consume: str,
16
+ credentials: Credential,
17
+ prefetch_count: int = 1,
18
+ virtual_host: str = '/',
19
+ ) -> None:
20
+ self.__virtual_host = virtual_host
21
+ self.__prefetch_count = prefetch_count
22
+ self.__queue_consume = queue_consume
23
+ self.__amqp_url = amqp_url
24
+ self.__credentials = pika.PlainCredentials(
25
+ credentials.username, credentials.password
26
+ )
27
+
28
+ def start_consume(self, process_message: callable):
29
+ try: # Verificar se recursão não vai estourar
30
+ connection = pika.BlockingConnection(
31
+ pika.ConnectionParameters(
32
+ host=self.__amqp_url,
33
+ virtual_host=self.__virtual_host,
34
+ credentials=self.__credentials,
35
+ )
36
+ )
37
+ channel = connection.channel()
38
+
39
+ channel.basic_qos(prefetch_count=self.__prefetch_count)
40
+ channel.basic_consume(
41
+ queue=self.__queue_consume,
42
+ on_message_callback=lambda c, m, p, b: self.on_request(
43
+ c, m, p, b, process_message
44
+ ),
45
+ )
46
+
47
+ info('[x] Aguardando solicitações RPC')
48
+ channel.start_consuming()
49
+ except pika.exceptions.StreamLostError as e:
50
+ debug(e)
51
+ self.start_consume(process_message)
52
+
53
+ def on_request(self, ch, method, props, body, process_message):
54
+ message = body.decode()
55
+ message_json = json.loads(message)
56
+ pure_message = self.__transform_message(message_json)
57
+ response = process_message(pure_message)
58
+
59
+ ch.basic_publish(
60
+ exchange='',
61
+ routing_key=props.reply_to,
62
+ properties=pika.BasicProperties(
63
+ correlation_id=props.correlation_id
64
+ ),
65
+ body=str(response),
66
+ )
67
+ ch.basic_ack(delivery_tag=method.delivery_tag)
68
+
69
+ def __transform_message(self, message: dict) -> Message:
70
+ return Message(
71
+ type=message.get('type', ''),
72
+ text=message.get('text', ''),
73
+ customer_id=message.get('customer_id', ''),
74
+ channel=message.get('channel', ''),
75
+ customer_phone=message.get('customer_phone', ''),
76
+ company_phone=message.get('company_phone', ''),
77
+ status=message.get('status'),
78
+ )
@@ -0,0 +1,13 @@
1
+ from dataclasses import dataclass
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class Message:
7
+ type: str
8
+ text: str
9
+ customer_id: str
10
+ channel: str
11
+ customer_phone: str
12
+ company_phone: str
13
+ status: Optional[str] = None
@@ -0,0 +1,15 @@
1
+ from typing import Union
2
+
3
+ messageTypes = Union[str, float, int, None]
4
+
5
+
6
+ class ChatbotResponse:
7
+ def __init__(
8
+ self, message: messageTypes = None, route: str = None
9
+ ) -> None:
10
+ self.message = message
11
+ self.route = route
12
+
13
+ class RedirectResponse:
14
+ def __init__(self, route: str) -> None:
15
+ self.route = route
@@ -0,0 +1,33 @@
1
+ from ..error.route_error import RouteError
2
+
3
+
4
+ class Route:
5
+ def __init__(self, current: str, routes: list[str]):
6
+ self.current = current
7
+ self.routes = routes
8
+
9
+ def get_previous(self) -> str:
10
+ """
11
+ Retorna o caminho anterior ao caminho atual.
12
+ """
13
+ if self.current == 'START':
14
+ raise RouteError('Não há caminho anterior ao START')
15
+
16
+ previous_route = '/'.join(self.current.split('/')[:-1])
17
+ return previous_route
18
+
19
+ def get_next(self, next_part: str) -> str:
20
+ """
21
+ Monta e retorna o próximo caminho com base na parte fornecida.
22
+ """
23
+ next_part = next_part.strip().upper()
24
+ next_route = f"{self.current.rstrip('/')}{next_part}"
25
+ if next_route not in self.routes:
26
+ raise RouteError(f'Rota não encontrada: {next_route}')
27
+ return next_route
28
+
29
+ def __str__(self):
30
+ return f'Route(current={self.current})'
31
+
32
+ def __repr__(self):
33
+ return self.__str__()
@@ -0,0 +1,26 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class UserState(ABC):
5
+ @abstractmethod
6
+ def get_menu(self, customer_id: str):
7
+ pass
8
+
9
+ @abstractmethod
10
+ def set_menu(self, customer_id: str, menu: str):
11
+ pass
12
+
13
+
14
+ class SimpleUserState(UserState):
15
+ def __init__(self):
16
+ self.states = {}
17
+
18
+ def get_menu(self, customer_id: str) -> str:
19
+ menu = self.states.get(customer_id, 'START')
20
+ if menu == 'START':
21
+ self.set_menu(customer_id, menu)
22
+ return menu
23
+
24
+ def set_menu(self, customer_id: str, menu: str|None=None) -> str:
25
+ if menu:
26
+ self.states[customer_id] = menu.upper()
@@ -0,0 +1,43 @@
1
+ [tool.poetry]
2
+ name = "chatgraph"
3
+ version = "0.1.0"
4
+ description = "A user-friendly chatbot library"
5
+ authors = ["Irisson N. Lima <irisson.lima@verdecard.com.br>"]
6
+ readme = "README.md"
7
+
8
+ [tool.poetry.dependencies]
9
+ python = "^3.12"
10
+ pika = "^1.3.2"
11
+
12
+
13
+ [tool.poetry.group.dev.dependencies]
14
+ ruff = "^0.5.1"
15
+ pytest = "^8.2.2"
16
+ pytest-cov = "^5.0.0"
17
+ taskipy = "^1.13.0"
18
+
19
+ [tool.pytest.ini_options]
20
+ pythonpath = "."
21
+ addopts = "-p no:warnings"
22
+
23
+ [tool.ruff]
24
+ line-length = 79
25
+ extend-exclude = ['migrations']
26
+
27
+ [tool.ruff.lint]
28
+ preview = true
29
+ select = ['I', 'F', 'E', 'W', 'PL', 'PT']
30
+
31
+ [tool.ruff.format]
32
+ preview = true
33
+ quote-style = 'single'
34
+
35
+ [tool.taskipy.tasks]
36
+ test = 'pytest --cov=chatbot --cov-report=html'
37
+ start_cov = 'start htmlcov/index.html'
38
+ lint = 'ruff check . && check . --diff'
39
+ format = 'ruff format . && ruff check . --fix'
40
+
41
+ [build-system]
42
+ requires = ["poetry-core"]
43
+ build-backend = "poetry.core.masonry.api"