├── commands ├── __init__.py ├── view_command.py ├── help_command.py ├── base_command.py ├── done_command.py ├── delete_command.py └── add_command.py ├── .env_example ├── screenshots ├── add_task_example.png └── task_lifecycle_example.png ├── config.py ├── divar_panel.py ├── command_handler.py ├── .gitignore ├── todo_db.py ├── README.md └── divar_client.py /commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env_example: -------------------------------------------------------------------------------- 1 | DIVAR_APP_SLUG="your_app_slug_here" 2 | DIVAR_API_KEY="your_api_key_here" -------------------------------------------------------------------------------- /screenshots/add_task_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Revisto/divar-interview-todo-bot/master/screenshots/add_task_example.png -------------------------------------------------------------------------------- /screenshots/task_lifecycle_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Revisto/divar-interview-todo-bot/master/screenshots/task_lifecycle_example.png -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from dotenv import load_dotenv 3 | 4 | load_dotenv() 5 | 6 | 7 | class Config: 8 | BASE_URL = os.getenv("BASE_URL", "http://localhost:8000") 9 | 10 | DIVAR_API_KEY = os.getenv("DIVAR_API_KEY") 11 | DIVAR_APP_SLUG = os.getenv("DIVAR_APP_SLUG") 12 | DIVAR_OAUTH_SECRET = os.getenv("DIVAR_OAUTH_SECRET", "") 13 | DIVAR_REDIRECT_URI = f"{BASE_URL}/divar/oauth/callback" 14 | -------------------------------------------------------------------------------- /commands/view_command.py: -------------------------------------------------------------------------------- 1 | from .base_command import AbstractCommand 2 | import todo_db 3 | 4 | 5 | class ViewCommand(AbstractCommand): 6 | COMMAND_NAME = "/view" 7 | 8 | def execute( 9 | self, 10 | conversation_id: str, 11 | text: str, 12 | original_text: str, 13 | current_state: dict | None, 14 | ) -> str: 15 | return todo_db.get_tasks_string(conversation_id) 16 | 17 | def get_command_name(self) -> str | None: 18 | return self.COMMAND_NAME 19 | 20 | def get_handled_state(self) -> str | None: 21 | return None 22 | -------------------------------------------------------------------------------- /commands/help_command.py: -------------------------------------------------------------------------------- 1 | from .base_command import AbstractCommand 2 | from divar_client import DivarClient 3 | 4 | 5 | class HelpCommand(AbstractCommand): 6 | COMMAND_NAME = "/help" 7 | 8 | def execute( 9 | self, 10 | conversation_id: str, 11 | text: str, 12 | original_text: str, 13 | current_state: dict | None, 14 | ) -> str: 15 | return ( 16 | "Available commands:\n" 17 | "/add - Add a new task\n" 18 | "/add - Add a new task (interactive)\n" 19 | "/view - View all tasks\n" 20 | "/delete - Delete a task by number\n" 21 | "/done - Mark a task as done by number\n" 22 | "/help - Show this help message" 23 | ) 24 | 25 | def get_command_name(self) -> str | None: 26 | return self.COMMAND_NAME 27 | 28 | def get_handled_state(self) -> str | None: 29 | return None 30 | -------------------------------------------------------------------------------- /commands/base_command.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from divar_client import DivarClient 3 | 4 | 5 | class AbstractCommand(ABC): 6 | def __init__(self, divar_client: DivarClient): 7 | self.divar_client = divar_client 8 | # todo_db is used directly by commands for data persistence and state management. 9 | 10 | @abstractmethod 11 | def execute( 12 | self, 13 | conversation_id: str, 14 | text: str, 15 | original_text: str, 16 | current_state: dict | None, 17 | ) -> str: 18 | """ 19 | Executes the command and returns the response text. 20 | The command is responsible for managing its own state transitions via todo_db. 21 | """ 22 | pass 23 | 24 | @abstractmethod 25 | def get_command_name(self) -> str | None: 26 | """ 27 | Returns the primary command string (e.g., "/add") that triggers this command. 28 | Returns None if this command is primarily triggered by a state or is a default handler. 29 | """ 30 | pass 31 | 32 | @abstractmethod 33 | def get_handled_state(self) -> str | None: 34 | """ 35 | Returns the conversation state name (e.g., "awaiting_task_description") 36 | that this command can process. 37 | Returns None if this command is not primarily triggered by a specific state. 38 | """ 39 | pass 40 | -------------------------------------------------------------------------------- /divar_panel.py: -------------------------------------------------------------------------------- 1 | from flask import ( 2 | Flask, 3 | request, 4 | jsonify, 5 | ) 6 | from divar_client import DivarClient 7 | import logging 8 | from command_handler import CommandHandler 9 | 10 | # Configure logging 11 | logging.basicConfig(level=logging.INFO) 12 | logger = logging.getLogger(__name__) 13 | 14 | app = Flask(__name__) 15 | 16 | app.secret_key = "a_very_secret_key_for_flask_flashing" 17 | 18 | # Global DivarClient instance for the web app 19 | divar_client = DivarClient() 20 | 21 | # Global CommandHandler instance 22 | command_handler = CommandHandler(divar_client) 23 | 24 | 25 | @app.route("/", methods=["POST"]) 26 | def chat_callback(): 27 | webhook_data = request.json 28 | logger.info( 29 | f"Received Divar webhook: headers={request.headers}, body={webhook_data}" 30 | ) 31 | 32 | if webhook_data.get("type") == "NEW_CHATBOT_MESSAGE": 33 | message_data = webhook_data.get("new_chatbot_message", {}) 34 | conversation_id = message_data.get("conversation", {}).get("id") 35 | sender_type = message_data.get("sender", {}).get("type") 36 | text = message_data.get("text", "").strip().lower() 37 | original_text = message_data.get( 38 | "text", "" 39 | ).strip() # Keep original for descriptions 40 | 41 | if not conversation_id or sender_type != "HUMAN": 42 | logger.warning( 43 | "Ignoring non-human message or message without conversation ID." 44 | ) 45 | return jsonify({"status": "ignored"}), 200 46 | 47 | # Delegate message handling to CommandHandler 48 | command_handler.handle_message(conversation_id, text, original_text) 49 | 50 | return jsonify({"status": "processed"}), 200 51 | 52 | return jsonify({"status": "unsupported_type"}), 400 53 | 54 | 55 | if __name__ == "__main__": 56 | app.run(port=8000, debug=True) 57 | -------------------------------------------------------------------------------- /commands/done_command.py: -------------------------------------------------------------------------------- 1 | from .base_command import AbstractCommand 2 | import todo_db 3 | 4 | 5 | class DoneCommand(AbstractCommand): 6 | COMMAND_NAME = "/done" 7 | STATE_AWAITING_TASK_NUMBER = "awaiting_task_to_mark_done" 8 | 9 | def execute( 10 | self, 11 | conversation_id: str, 12 | text: str, 13 | original_text: str, 14 | current_state: dict | None, 15 | ) -> str: 16 | if ( 17 | current_state 18 | and current_state.get("name") == self.STATE_AWAITING_TASK_NUMBER 19 | ): 20 | try: 21 | task_num = int(text) 22 | if todo_db.mark_task_item_done(conversation_id, task_num): 23 | response_text = f"Task {task_num} marked as done." 24 | else: 25 | response_text = f"Invalid task number: {task_num}. Task list:\n{todo_db.get_tasks_string(conversation_id)}" 26 | todo_db.clear_conversation_state(conversation_id) 27 | return response_text 28 | except ValueError: 29 | todo_db.clear_conversation_state(conversation_id) 30 | return "Invalid input. Please send a valid task number. Marking as done cancelled." 31 | 32 | # Initial /done command 33 | tasks_string = todo_db.get_tasks_string(conversation_id) 34 | if ( 35 | "You have no tasks" in tasks_string 36 | ): # Assuming this is the "no tasks" message from get_tasks_string 37 | return "No tasks to mark as done." 38 | else: 39 | todo_db.set_conversation_state( 40 | conversation_id, self.STATE_AWAITING_TASK_NUMBER 41 | ) 42 | return f"Which task number to mark as done?\n{tasks_string}" 43 | 44 | def get_command_name(self) -> str | None: 45 | return self.COMMAND_NAME 46 | 47 | def get_handled_state(self) -> str | None: 48 | return self.STATE_AWAITING_TASK_NUMBER 49 | -------------------------------------------------------------------------------- /commands/delete_command.py: -------------------------------------------------------------------------------- 1 | from .base_command import AbstractCommand 2 | import todo_db 3 | 4 | 5 | class DeleteCommand(AbstractCommand): 6 | COMMAND_NAME = "/delete" 7 | STATE_AWAITING_TASK_NUMBER = "awaiting_task_to_delete" 8 | 9 | def execute( 10 | self, 11 | conversation_id: str, 12 | text: str, 13 | original_text: str, 14 | current_state: dict | None, 15 | ) -> str: 16 | if ( 17 | current_state 18 | and current_state.get("name") == self.STATE_AWAITING_TASK_NUMBER 19 | ): 20 | try: 21 | task_num = int( 22 | text 23 | ) # Use the already lowercased 'text' for number parsing 24 | if todo_db.delete_task_item(conversation_id, task_num): 25 | response_text = f"Task {task_num} deleted." 26 | else: 27 | response_text = f"Invalid task number: {task_num}. Task list:\n{todo_db.get_tasks_string(conversation_id)}" 28 | todo_db.clear_conversation_state(conversation_id) 29 | return response_text 30 | except ValueError: 31 | todo_db.clear_conversation_state( 32 | conversation_id 33 | ) # Clear state on bad input 34 | return "Invalid input. Please send a valid task number. Deletion cancelled." 35 | 36 | # Initial /delete command 37 | tasks_string = todo_db.get_tasks_string(conversation_id) 38 | if "You have no tasks" in tasks_string: 39 | return "No tasks to delete." 40 | else: 41 | todo_db.set_conversation_state( 42 | conversation_id, self.STATE_AWAITING_TASK_NUMBER 43 | ) 44 | return f"Which task number to delete?\n{tasks_string}" 45 | 46 | def get_command_name(self) -> str | None: 47 | return self.COMMAND_NAME 48 | 49 | def get_handled_state(self) -> str | None: 50 | return self.STATE_AWAITING_TASK_NUMBER 51 | -------------------------------------------------------------------------------- /commands/add_command.py: -------------------------------------------------------------------------------- 1 | from .base_command import AbstractCommand 2 | import todo_db 3 | 4 | 5 | class AddCommand(AbstractCommand): 6 | COMMAND_NAME = "/add" 7 | STATE_AWAITING_DESCRIPTION = "awaiting_task_description" 8 | 9 | def execute( 10 | self, 11 | conversation_id: str, 12 | text: str, 13 | original_text: str, 14 | current_state: dict | None, 15 | ) -> str: 16 | if ( 17 | current_state 18 | and current_state.get("name") == self.STATE_AWAITING_DESCRIPTION 19 | ): 20 | if not original_text: # User sent an empty message for task description 21 | todo_db.clear_conversation_state(conversation_id) 22 | return "Task addition cancelled as no description was provided. Type /add again to start over." 23 | todo_db.add_task_item(conversation_id, original_text) 24 | todo_db.clear_conversation_state(conversation_id) 25 | return f'Task added: "{original_text}"' 26 | 27 | if text.startswith(f"{self.COMMAND_NAME} "): 28 | task_description = original_text[len(self.COMMAND_NAME) + 1 :].strip() 29 | if task_description: 30 | todo_db.add_task_item(conversation_id, task_description) 31 | return f'Task added: "{task_description}"' 32 | else: # Command was like "/add " with nothing after 33 | todo_db.set_conversation_state( 34 | conversation_id, self.STATE_AWAITING_DESCRIPTION 35 | ) 36 | return "Okay, what is the task?" 37 | elif text == self.COMMAND_NAME: # Just "/add" 38 | todo_db.set_conversation_state( 39 | conversation_id, self.STATE_AWAITING_DESCRIPTION 40 | ) 41 | return "Okay, what is the task?" 42 | 43 | # Fallback, should ideally not be reached if CommandHandler routes correctly 44 | return "Error: AddCommand was called inappropriately." 45 | 46 | def get_command_name(self) -> str | None: 47 | return self.COMMAND_NAME 48 | 49 | def get_handled_state(self) -> str | None: 50 | return self.STATE_AWAITING_DESCRIPTION 51 | -------------------------------------------------------------------------------- /command_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import todo_db 3 | from divar_client import DivarClient 4 | 5 | # Import command classes 6 | from commands.add_command import AddCommand 7 | from commands.delete_command import DeleteCommand 8 | from commands.done_command import DoneCommand 9 | from commands.help_command import HelpCommand 10 | from commands.view_command import ViewCommand 11 | from commands.base_command import AbstractCommand 12 | 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class CommandHandler: 18 | def __init__(self, divar_client: DivarClient): 19 | self.divar_client = divar_client 20 | self.commands_by_name: dict[str, AbstractCommand] = {} 21 | self.commands_by_state: dict[str, AbstractCommand] = {} 22 | 23 | # Instantiate and register commands 24 | add_cmd = AddCommand(divar_client) 25 | delete_cmd = DeleteCommand(divar_client) 26 | done_cmd = DoneCommand(divar_client) 27 | help_cmd = HelpCommand(divar_client) 28 | view_cmd = ViewCommand(divar_client) 29 | self.default_cmd = HelpCommand(divar_client) # Default 30 | 31 | all_commands = [add_cmd, delete_cmd, done_cmd, help_cmd, view_cmd] 32 | 33 | for cmd in all_commands: 34 | cmd_name = cmd.get_command_name() 35 | if cmd_name: 36 | if cmd_name in self.commands_by_name: 37 | logger.warning(f"Duplicate command name registration: {cmd_name}") 38 | self.commands_by_name[cmd_name] = cmd 39 | 40 | handled_state = cmd.get_handled_state() 41 | if handled_state: 42 | if handled_state in self.commands_by_state: 43 | logger.warning( 44 | f"Duplicate state handler registration: {handled_state}" 45 | ) 46 | self.commands_by_state[handled_state] = cmd 47 | 48 | # Ensure help command is registered if it has a name 49 | if ( 50 | help_cmd.get_command_name() 51 | and help_cmd.get_command_name() not in self.commands_by_name 52 | ): 53 | self.commands_by_name[help_cmd.get_command_name()] = help_cmd 54 | 55 | def handle_message(self, conversation_id: str, text: str, original_text: str): 56 | current_state = todo_db.get_conversation_state(conversation_id) 57 | 58 | # Check for commands like "/add description" first, then "/add" 59 | parts = text.split(" ", 1) 60 | command_text_key = parts[0] # e.g. "/add" 61 | 62 | # Refined selection logic: 63 | # 1. If a state is active and a command handles that state, use it. 64 | # 2. Else, if the input text matches a command name, use that command. 65 | # 3. Else, use default_cmd. (Note: current code uses HelpCommand) 66 | 67 | command_to_execute = self.default_cmd # Default 68 | 69 | if current_state and current_state.get("name") in self.commands_by_state: 70 | command_to_execute = self.commands_by_state[current_state.get("name")] 71 | elif command_text_key in self.commands_by_name: 72 | command_to_execute = self.commands_by_name[command_text_key] 73 | 74 | # Special case: if user types /help, it should always work, overriding current state processing 75 | # unless the state itself is part of a help flow (not the case here). 76 | if ( 77 | command_text_key == HelpCommand.COMMAND_NAME 78 | ): # Accessing COMMAND_NAME directly 79 | command_to_execute = self.commands_by_name[HelpCommand.COMMAND_NAME] 80 | 81 | response_text = command_to_execute.execute( 82 | conversation_id, text, original_text, current_state 83 | ) 84 | 85 | if response_text: 86 | try: 87 | self.divar_client.send_message_to_conversation( 88 | conversation_id, response_text 89 | ) 90 | logger.info(f"Sent response to {conversation_id}: {response_text}") 91 | except Exception as e: 92 | logger.error( 93 | f"Failed to send message to Divar for conversation {conversation_id}: {e}" 94 | ) 95 | -------------------------------------------------------------------------------- /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | 176 | # Cursor 177 | # Cursor is an AI-powered code editor.`.cursorignore` specifies files/directories to 178 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 179 | # refer to https://docs.cursor.com/context/ignore-files 180 | .cursorignore 181 | .cursorindexingignore 182 | 183 | -------------------------------------------------------------------------------- /todo_db.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import logging 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | TASKS_DB_FILE = "tasks.json" 8 | STATES_DB_FILE = "conversation_states.json" 9 | 10 | 11 | # Helper to load JSON data from a file 12 | def _load_json(filepath): 13 | if not os.path.exists(filepath): 14 | return {} 15 | try: 16 | with open(filepath, "r") as f: 17 | return json.load(f) 18 | except (json.JSONDecodeError, FileNotFoundError): 19 | return {} 20 | 21 | 22 | # Helper to save JSON data to a file 23 | def _save_json(filepath, data): 24 | try: 25 | with open(filepath, "w") as f: 26 | json.dump(data, f, indent=4) 27 | except IOError as e: 28 | logger.error(f"Error saving JSON to {filepath}: {e}") 29 | 30 | 31 | # --- Task Management --- 32 | 33 | 34 | def get_tasks(conversation_id: str) -> list: 35 | """Retrieve all tasks for a given conversation_id.""" 36 | all_tasks_data = _load_json(TASKS_DB_FILE) 37 | return all_tasks_data.get(conversation_id, []) 38 | 39 | 40 | def save_tasks(conversation_id: str, tasks_list: list): 41 | """Save all tasks for a given conversation_id.""" 42 | all_tasks_data = _load_json(TASKS_DB_FILE) 43 | all_tasks_data[conversation_id] = tasks_list 44 | _save_json(TASKS_DB_FILE, all_tasks_data) 45 | 46 | 47 | def add_task_item(conversation_id: str, description: str): 48 | """Add a new task for a conversation.""" 49 | tasks = get_tasks(conversation_id) 50 | tasks.append({"description": description, "done": False, "id": len(tasks) + 1}) 51 | save_tasks(conversation_id, tasks) 52 | logger.info(f"Task added for {conversation_id}: {description}") 53 | 54 | 55 | def delete_task_item(conversation_id: str, task_number: int) -> bool: 56 | """Delete a task by its 1-based index.""" 57 | tasks = get_tasks(conversation_id) 58 | if 0 < task_number <= len(tasks): 59 | deleted_task = tasks.pop(task_number - 1) 60 | # Re-assign IDs if necessary or keep them sparse 61 | save_tasks(conversation_id, tasks) 62 | logger.info( 63 | f"Task {task_number} deleted for {conversation_id}: {deleted_task['description']}" 64 | ) 65 | return True 66 | logger.warning( 67 | f"Invalid task number {task_number} for deletion for {conversation_id}" 68 | ) 69 | return False 70 | 71 | 72 | def mark_task_item_done(conversation_id: str, task_number: int) -> bool: 73 | """Mark a task as done by its 1-based index.""" 74 | tasks = get_tasks(conversation_id) 75 | if 0 < task_number <= len(tasks): 76 | tasks[task_number - 1]["done"] = True 77 | save_tasks(conversation_id, tasks) 78 | logger.info( 79 | f"Task {task_number} marked done for {conversation_id}: {tasks[task_number - 1]['description']}" 80 | ) 81 | return True 82 | logger.warning( 83 | f"Invalid task number {task_number} for marking done for {conversation_id}" 84 | ) 85 | return False 86 | 87 | 88 | def get_tasks_string(conversation_id: str) -> str: 89 | """Get a formatted string of tasks for a conversation.""" 90 | tasks = get_tasks(conversation_id) 91 | if not tasks: 92 | return "You have no tasks. Add one with /add ." 93 | 94 | task_lines = [] 95 | for i, task in enumerate(tasks): 96 | status = "[X]" if task.get("done") else "[ ]" 97 | task_lines.append(f"{i + 1}. {status} {task['description']}") 98 | return "\n".join(task_lines) 99 | 100 | 101 | # --- Conversation State Management --- 102 | 103 | 104 | def get_conversation_state(conversation_id: str) -> dict | None: 105 | """Retrieve the state for a given conversation_id.""" 106 | states_data = _load_json(STATES_DB_FILE) 107 | return states_data.get(conversation_id) 108 | 109 | 110 | def set_conversation_state( 111 | conversation_id: str, state_name: str | None, data: dict = None 112 | ): 113 | """Set the state for a given conversation_id. If state_name is None, clears the state.""" 114 | states_data = _load_json(STATES_DB_FILE) 115 | if state_name is None: 116 | if conversation_id in states_data: 117 | del states_data[conversation_id] 118 | logger.info(f"State cleared for conversation {conversation_id}") 119 | else: 120 | states_data[conversation_id] = {"name": state_name, "data": data or {}} 121 | logger.info( 122 | f"State set for conversation {conversation_id}: {state_name} with data {data}" 123 | ) 124 | _save_json(STATES_DB_FILE, states_data) 125 | 126 | 127 | def clear_conversation_state(conversation_id: str): 128 | """Clear the state for a given conversation_id.""" 129 | set_conversation_state(conversation_id, None) 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🤖 Divar Kenar To-Do List Chatbot 2 | 3 | This project is a to-do list chatbot designed to integrate with Kenar Divar's experimental chatbot feature. It was developed as part of the 4th stage of the Divar interview process. 4 | 5 | The chatbot allows users to manage a personal to-do list by sending commands in a chat interface. 💬 6 | 7 | ![](https://api2.zoomit.ir/media/divar-6595223c494c1ffd7082c70a?w=1920&q=80) 8 | 9 | ## ✨ Features 10 | 11 | * **➕ Add Tasks:** Users can add new tasks to their list. 12 | * `/add `: Adds a task directly. 13 | * `/add`: Prompts the user to enter the task description. 14 | 15 | Example of adding a task: 16 | ![Adding a task example](screenshots/add_task_example.png) 17 | 18 | * **👀 View Tasks:** Users can view their current list of tasks with their status (done/pending). 19 | * `/view` 20 | * **✅ Mark Tasks as Done:** Users can mark existing tasks as completed. 21 | * `/done`: Prompts the user to select a task number to mark as done. 22 | * **🗑️ Delete Tasks:** Users can remove tasks from their list. 23 | * `/delete`: Prompts the user to select a task number to delete. 24 | 25 | Example of viewing, marking a task as done, and viewing again: 26 | ![Task lifecycle example](screenshots/task_lifecycle_example.png) 27 | 28 | * **❓ Help:** Provides a list of available commands. 29 | * `/help` 30 | * **🧠 Conversation State Management:** The chatbot remembers the context of multi-step operations (e.g., waiting for a task number after `/delete`). 31 | * **💾 JSON-based Data Persistence:** Task lists and conversation states are stored in local JSON files (`tasks.json`, `conversation_states.json`). 32 | 33 | ## 🛠️ Technology Stack 34 | 35 | * **Python 3** 🐍 36 | * **Flask:** For handling incoming webhooks from the Divar platform. 🌐 37 | * **Requests:** For making API calls. 📞 38 | 39 | ## 📐 Design Principles 40 | 41 | The project attempts to follow SOLID principles, particularly: 42 | * **Single Responsibility Principle (SRP):** 43 | * `divar_panel.py`: Handles web requests and basic webhook validation. 44 | * `command_handler.py`: Routes incoming messages to the appropriate command logic. 45 | * `commands/` (directory): Each command (add, delete, view, etc.) is encapsulated in its own class. 46 | * `todo_db.py`: Manages data persistence for tasks and conversation states. 47 | * `divar_client.py`: Intended for interactions with the Divar API. 48 | * **Open-Closed Principle (OCP):** 49 | * The `CommandHandler` and the `commands/` structure are designed so that new commands can be added by creating new command classes without modifying the core `CommandHandler` routing logic. Each command inherits from `AbstractCommand`. 50 | 51 | ## 📁 Project Structure 52 | 53 | ``` 54 | . 55 | ├── command_handler.py # Routes messages to specific command classes 56 | ├── commands/ # Directory for individual command logic 57 | │ ├── __init__.py 58 | │ ├── base_command.py # Abstract base class for all commands 59 | │ ├── add_command.py 60 | │ ├── delete_command.py 61 | │ ├── done_command.py 62 | │ ├── help_command.py 63 | │ └── view_command.py 64 | ├── config.py # For API keys and configuration ⚙️ 65 | ├── conversation_states.json # Stores active conversation states (created at runtime) 66 | ├── divar_client.py # Client for interacting with Divar APIs 📲 67 | ├── divar_panel.py # Flask app, entry point for webhooks 🚀 68 | ├── README.md # This file 📄 69 | ├── requirements.txt # Python package dependencies 📦 70 | ├── tasks.json # Stores user to-do lists (created at runtime) 71 | └── todo_db.py # Handles database operations (JSON file interaction) 🗄️ 72 | ``` 73 | 74 | ## 🚀 Setup and Running 75 | 76 | 1. **Prerequisites:** 77 | * Python 3.x 78 | * pip (Python package installer) 79 | 80 | 2. **Clone the repository (if applicable):** 81 | ```bash 82 | git clone 83 | cd 84 | ``` 85 | 86 | 3. **Install dependencies:** 87 | Create a virtual environment (recommended): 88 | ```bash 89 | python -m venv venv 90 | source venv/bin/activate # On Windows use `venv\Scripts\activate` 91 | ``` 92 | Install the required packages: 93 | ```bash 94 | pip install -r requirements.txt 95 | ``` 96 | 97 | 4. **Configuration (`.env` file):** 98 | Create a `.env` file in the root directory by copying the `.env_example` file: 99 | ```bash 100 | cp .env_example .env 101 | ``` 102 | Then, edit the `.env` file with your Divar API credentials: 103 | ```env 104 | DIVAR_APP_SLUG="your_app_slug" 105 | DIVAR_API_KEY="your_api_key" # Used by the chatbot for sending messages 106 | ``` 107 | 108 | 5. **Run the Flask application:** 109 | ```bash 110 | python divar_panel.py 111 | ``` 112 | The application will start (by default on `http://localhost:8000`). 113 | 114 | 6. **Webhook Setup (Ngrok or similar):** 115 | To receive webhooks from the Divar platform, you'll need to expose your local Flask server to the internet. Tools like [ngrok](https://ngrok.com/) can be used for this. 116 | ```bash 117 | ngrok http 8000 118 | ``` 119 | Ngrok will provide a public URL (e.g., `https://your-unique-id.ngrok-free.app`). This URL (specifically the `/` endpoint) should be configured as the webhook endpoint in the Divar Kenar chatbot settings. 🔗 120 | 121 | ## ⚙️ How it Works 122 | 123 | 1. The Divar platform sends a POST request (webhook) to the `/` endpoint of the running Flask application (`divar_panel.py`) when a new message is sent to the chatbot. 124 | 2. `divar_panel.py` receives the request, validates it, and extracts message details. 125 | 3. It passes the message to `CommandHandler`. 126 | 4. `CommandHandler` determines the appropriate command to execute based on the message text (e.g., `/add`) or the current conversation state (e.g., if the bot is waiting for a task number). 127 | 5. The selected command class (from the `commands/` directory) executes its logic, interacting with `todo_db.py` to manage tasks and conversation states. 128 | 6. The command returns a response string. 129 | 7. `CommandHandler` uses `divar_client.send_message_to_conversation()` to send the response back to the user via the Divar API. 130 | -------------------------------------------------------------------------------- /divar_client.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from config import Config 3 | import logging 4 | from datetime import datetime, timedelta 5 | 6 | logging.basicConfig(level=logging.INFO) 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class DivarClient: 11 | def __init__(self): 12 | self.client_id = Config.DIVAR_APP_SLUG 13 | self.client_secret = Config.DIVAR_OAUTH_SECRET 14 | self.redirect_uri = Config.DIVAR_REDIRECT_URI 15 | self.api_key = Config.DIVAR_API_KEY 16 | self.base_url = "https://api.divar.ir" 17 | self.open_api_base_url = "https://open-api.divar.ir" 18 | self.scopes = [ 19 | "USER_POSTS_ADDON_CREATE", 20 | "USER_ADDON_CREATE", 21 | "USER_POSTS_GET", 22 | "USER_PHONE", 23 | "offline_access", 24 | "CHAT_BOT_USER_MESSAGE_SEND", 25 | "CHAT_SUPPLIER_ALL_CONVERSATIONS_MESSAGE_SEND", 26 | "CHAT_SUPPLIER_ALL_CONVERSATIONS_READ", 27 | "NOTIFICATION_ACCESS_REVOCATION", 28 | ] 29 | 30 | self.access_token = None 31 | self.refresh_token = None 32 | self.token_expires_at = None 33 | 34 | def get_oauth_redirect_url(self, state: str) -> str: 35 | endpoint = f"/oauth2/auth" 36 | url = f"{self.base_url}{endpoint}" 37 | params = { 38 | "response_type": "code", 39 | "client_id": self.client_id, 40 | "redirect_uri": self.redirect_uri, 41 | "scope": "+".join(self.scopes), 42 | "state": state, 43 | } 44 | query_string = "&".join([f"{key}={value}" for key, value in params.items()]) 45 | return f"{url}?{query_string}" 46 | 47 | def get_access_token(self) -> str: 48 | if ( 49 | self.access_token 50 | and self.token_expires_at 51 | and datetime.now() < self.token_expires_at 52 | ): 53 | logger.info("Access token is valid and not expired.") 54 | return self.access_token 55 | 56 | if self.refresh_token: 57 | logger.info("Access token expired, attempting to refresh.") 58 | return self.refresh_access_token() 59 | 60 | raise Exception("No valid access token available. Please authenticate.") 61 | 62 | def refresh_access_token(self) -> str: 63 | endpoint = f"/oauth2/token" 64 | url = f"{self.base_url}{endpoint}" 65 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 66 | payload = { 67 | "code": self.access_token, 68 | "client_id": self.client_id, 69 | "client_secret": self.client_secret, 70 | "grant_type": "refresh_token", 71 | "redirect_uri": self.redirect_uri, 72 | "refresh_token": self.refresh_token, 73 | } 74 | response = requests.post(url, headers=headers, data=payload) 75 | response.raise_for_status() 76 | 77 | token_data = response.json() 78 | print(token_data) ## FIXME 79 | self.access_token = token_data.get("access_token") 80 | self.refresh_token = token_data.get("refresh_token") 81 | expires_in = token_data.get("expires_in") 82 | if expires_in: 83 | self.token_expires_at = datetime.now() + timedelta(seconds=int(expires_in)) 84 | 85 | logger.info("Successfully refreshed access token.") 86 | return self.access_token 87 | 88 | def exchange_code_for_token(self, code: str) -> dict: 89 | endpoint = f"/oauth2/token" 90 | url = f"{self.base_url}{endpoint}" 91 | headers = {"Content-Type": "application/x-www-form-urlencoded"} 92 | payload = { 93 | "code": code, 94 | "client_id": self.client_id, 95 | "client_secret": self.client_secret, 96 | "grant_type": "authorization_code", 97 | "redirect_uri": self.redirect_uri, 98 | } 99 | response = requests.post(url, headers=headers, data=payload) 100 | response.raise_for_status() 101 | 102 | token_data = response.json() 103 | 104 | self.access_token = token_data.get("access_token") 105 | self.refresh_token = token_data.get("refresh_token") 106 | expires_in = token_data.get("expires_in") 107 | if expires_in: 108 | self.token_expires_at = datetime.now() + timedelta(seconds=int(expires_in)) 109 | 110 | logger.info("Successfully obtained and stored access token.") 111 | return token_data 112 | 113 | def _get_authenticated_headers_v2(self) -> dict: 114 | if not self.access_token or not self.api_key: 115 | raise Exception("Access token or Api Key not available.") 116 | headers = dict() 117 | headers["x-api-key"] = self.api_key 118 | headers["x-access-token"] = self.get_access_token() 119 | return headers 120 | 121 | def _get_authenticated_headers_v1(self) -> dict: 122 | if not self.access_token or not self.api_key: 123 | raise Exception("Access token or Api Key not available.") 124 | headers = dict() 125 | headers["x-api-key"] = self.api_key 126 | headers["Authorization"] = f"Bearer {self.get_access_token()}" 127 | return headers 128 | 129 | def subscribe_to_event( 130 | self, event_type: str, event_resource_id: str = None, metadata: dict = None 131 | ) -> dict: 132 | endpoint = f"/v1/open-platform/events/subscriptions" 133 | url = f"{self.open_api_base_url}{endpoint}" 134 | 135 | payload = {"event_type": event_type} 136 | if event_resource_id: 137 | payload["event_resource_id"] = event_resource_id 138 | if metadata: 139 | payload["metadata"] = metadata 140 | 141 | # Prepare headers 142 | headers = self._get_authenticated_headers_v1() 143 | 144 | logger.info( 145 | f"Subscribing to event: {event_type}, resource: {event_resource_id or 'all'} at {url}" 146 | ) 147 | 148 | response = requests.post(url, json=payload, headers=headers) 149 | print(response.content) 150 | response.raise_for_status() 151 | 152 | if response.status_code == 204 or not response.content: 153 | logger.info( 154 | f"Subscription to {event_type} successful with no content in response." 155 | ) 156 | return None 157 | 158 | return response.json() 159 | 160 | def get_conversation_by_id(self, conversation_id: str) -> dict: 161 | endpoint = f"/v1/open-platform/chat/conversations/{conversation_id}" 162 | url = f"{self.base_url}{endpoint}" 163 | 164 | headers = self._get_authenticated_headers_v2() 165 | 166 | logger.info(f"Getting conversation by ID: {conversation_id} from {url}") 167 | 168 | response = requests.get(url, headers=headers) 169 | print(response.content) 170 | response.raise_for_status() 171 | 172 | if response.status_code == 204 or not response.content: 173 | logger.info( 174 | f"Successfully fetched conversation {conversation_id} with no content." 175 | ) 176 | return None 177 | 178 | return response.json() 179 | 180 | def send_message_to_conversation( 181 | self, 182 | conversation_id: str, 183 | text_message: str, 184 | buttons: dict = None, 185 | ) -> dict: 186 | """ 187 | Send a text message to a conversation using the experimental bot API. 188 | Requires CHAT_BOT_SEND_MESSAGE permission. 189 | """ 190 | endpoint = f"/experimental/open-platform/chat/bot/conversations/{conversation_id}/messages" 191 | url = f"{self.open_api_base_url}{endpoint}" 192 | headers = { 193 | "Content-Type": "application/json", 194 | "X-Api-Key": self.api_key, 195 | } 196 | 197 | payload = { 198 | "type": "TEXT", 199 | "text_message": text_message, 200 | } 201 | if buttons: 202 | payload["buttons"] = buttons 203 | 204 | logger.info( 205 | f"Sending bot message to conversation {conversation_id} at {url} with payload: {payload}" 206 | ) 207 | 208 | response = requests.post(url, json=payload, headers=headers) 209 | print(response.content) 210 | response.raise_for_status() 211 | 212 | if response.status_code == 200 and response.content: 213 | return response.json() 214 | elif response.status_code == 200: 215 | logger.info( 216 | f"Message sent to {conversation_id} successfully with no JSON response body." 217 | ) 218 | return { 219 | "status": "success", 220 | "message": "Message sent, no content returned.", 221 | } 222 | 223 | logger.warning( 224 | f"Message to {conversation_id} resulted in status {response.status_code} with no JSON content." 225 | ) 226 | return {"status": response.status_code, "content": response.text} 227 | --------------------------------------------------------------------------------