From 42a1cb50fff3aaf1de497ed2e1acea7cb4bd4287 Mon Sep 17 00:00:00 2001 From: George Green Date: Wed, 17 Dec 2025 14:06:25 +0100 Subject: [PATCH 01/13] separate class for org operations --- pytest.ini | 1 + src/commands/post_to_journal.py | 90 +---- src/org_api.py | 155 +++++++++ tests/test_org_api.py | 594 ++++++++++++++++++++++++++++++++ 4 files changed, 756 insertions(+), 84 deletions(-) create mode 100644 src/org_api.py create mode 100644 tests/test_org_api.py diff --git a/pytest.ini b/pytest.ini index 407ad4a..f1cd256 100644 --- a/pytest.ini +++ b/pytest.ini @@ -40,3 +40,4 @@ markers = text: Tests for text message handling picture: Tests for picture message handling file: Tests for file message handling + orgapi: Tests for OrgApi class functionality diff --git a/src/commands/post_to_journal.py b/src/commands/post_to_journal.py index 7685b3f..b4890a7 100644 --- a/src/commands/post_to_journal.py +++ b/src/commands/post_to_journal.py @@ -10,6 +10,7 @@ from github import Github, Auth from telegram import Message from ..utils import get_text_from_message +from ..org_api import OrgApi logger = logging.getLogger(__name__) @@ -143,6 +144,7 @@ class PostReplyToEntry(BasePostToGitJournal): def __init__(self, github_token=None, repo_name=None, file_path=None, todo_file_path=None): super().__init__(github_token, repo_name, file_path) self.todo_file_path = todo_file_path + self.org_api = OrgApi(self.repo) def _find_original_entry(self, original_message_link: str, file_path: str) -> tuple[int, int] | None: """ @@ -153,28 +155,7 @@ def _find_original_entry(self, original_message_link: str, file_path: str) -> tu :param file_path: The file to search in (journal.org or todo.org) :return: Tuple of (line_number, org_level) or None """ - try: - contents = self.repo.get_contents(file_path, ref="main") - decoded_content = contents.decoded_content.decode("utf-8") - lines = decoded_content.split("\n") - - for i, line in enumerate(lines): - if original_message_link in line: - # Determine the org-mode level (count asterisks at the start) - match = re.match(r'^(\*+)\s', line) - if match: - org_level = len(match.group(1)) - logger.info( - f"Found original entry at line {i} with level {org_level} in {file_path}" - ) - return (i, org_level) - - logger.info(f"Original message not found in {file_path}") - return None - - except Exception as e: - logger.warning(f"Could not search {file_path}: {e}") - return None + return self.org_api.find_original_entry(original_message_link, file_path) def _find_top_level_entry(self, file_path: str, start_line: int, current_level: int) -> tuple[int, int]: """ @@ -187,31 +168,7 @@ def _find_top_level_entry(self, file_path: str, start_line: int, current_level: :param current_level: Current org-mode level :return: Tuple of (line_number, org_level) for the top-level entry """ - contents = self.repo.get_contents(file_path, ref="main") - decoded_content = contents.decoded_content.decode("utf-8") - lines = decoded_content.split("\n") - - # Check if current entry is itself a reply - if "Reply:" not in lines[start_line]: - # Not a reply, this is the top-level entry - return (start_line, current_level) - - # Search backwards for the first non-reply entry with lower level - for i in range(start_line - 1, -1, -1): - match = re.match(r'^(\*+)\s', lines[i]) - if match: - line_level = len(match.group(1)) - # Found an entry with lower level - if line_level < current_level: - # Check if it's not a reply - if "Reply:" not in lines[i]: - logger.info( - f"Found top-level entry at line {i} with level {line_level} in {file_path}" - ) - return (i, line_level) - - # If we didn't find a parent, the current entry is top-level - return (start_line, current_level) + return self.org_api.find_top_level_entry(file_path, start_line, current_level) def _insert_reply_after_entry( self, @@ -230,43 +187,8 @@ def _insert_reply_after_entry( :param reply_text: The formatted reply text :param commit_message: Git commit message """ - contents = self.repo.get_contents(file_path, ref="main") - decoded_content = contents.decoded_content.decode("utf-8") - lines = decoded_content.split("\n") - - # Find the end of the original entry (next entry of same or higher level, or end of file) - insert_position = line_number + 1 - - # Look for the next line that starts with asterisks of equal or lesser count - for i in range(line_number + 1, len(lines)): - match = re.match(r'^(\*+)\s', lines[i]) - if match and len(match.group(1)) <= org_level: - insert_position = i - break - insert_position = i + 1 - - # Insert the reply at the calculated position - lines.insert(insert_position, reply_text) - - new_content = "\n".join(lines) - - # Update the file in the repository - self.repo.update_file( - path=contents.path, - message=commit_message, - content=new_content, - sha=contents.sha, - branch="main", - ) - - logger.info( - f"Inserted reply at line {insert_position} in {file_path}", - extra={ - "action": "insert_reply", - "file": file_path, - "line": insert_position, - "org_level": org_level + 1 - } + self.org_api.insert_reply_after_entry( + file_path, line_number, org_level, reply_text, commit_message ) def run(self, message: Message, file_path=None): diff --git a/src/org_api.py b/src/org_api.py new file mode 100644 index 0000000..c8d7a7b --- /dev/null +++ b/src/org_api.py @@ -0,0 +1,155 @@ +""" +OrgApi - A class for manipulating org-mode files. + +This class provides methods for: +- Finding entries in org files by message links +- Finding top-level (non-reply) entries +- Inserting replies at the correct position in the org hierarchy +""" + +import logging +import re +from typing import Optional, Tuple + +logger = logging.getLogger(__name__) + + +class OrgApi: + """API for manipulating org-mode files in a GitHub repository.""" + + def __init__(self, repo): + """ + Initialize OrgApi with a GitHub repository object. + + :param repo: GitHub repository object with get_contents and update_file methods + """ + self.repo = repo + + def find_original_entry( + self, original_message_link: str, file_path: str + ) -> Optional[Tuple[int, int]]: + """ + Searches for the original message in the specified file. + Returns (line_number, org_level) if found, None otherwise. + + :param original_message_link: The Telegram message link to search for + :param file_path: The file to search in (journal.org or todo.org) + :return: Tuple of (line_number, org_level) or None + """ + try: + contents = self.repo.get_contents(file_path, ref="main") + decoded_content = contents.decoded_content.decode("utf-8") + lines = decoded_content.split("\n") + + for i, line in enumerate(lines): + if original_message_link in line: + # Determine the org-mode level (count asterisks at the start) + match = re.match(r'^(\*+)\s', line) + if match: + org_level = len(match.group(1)) + logger.info( + f"Found original entry at line {i} with level {org_level} in {file_path}" + ) + return (i, org_level) + + logger.info(f"Original message not found in {file_path}") + return None + + except Exception as e: + logger.warning(f"Could not search {file_path}: {e}") + return None + + def find_top_level_entry( + self, file_path: str, start_line: int, current_level: int + ) -> Tuple[int, int]: + """ + Find the top-level (non-reply) entry by searching backwards from the current position. + This ensures all replies are at the same level, regardless of whether replying to + an original entry or to another reply. + + :param file_path: The file to search in + :param start_line: Line number to start searching from + :param current_level: Current org-mode level + :return: Tuple of (line_number, org_level) for the top-level entry + """ + contents = self.repo.get_contents(file_path, ref="main") + decoded_content = contents.decoded_content.decode("utf-8") + lines = decoded_content.split("\n") + + # Check if current entry is itself a reply + if "Reply:" not in lines[start_line]: + # Not a reply, this is the top-level entry + return (start_line, current_level) + + # Search backwards for the first non-reply entry with lower level + for i in range(start_line - 1, -1, -1): + match = re.match(r'^(\*+)\s', lines[i]) + if match: + line_level = len(match.group(1)) + # Found an entry with lower level + if line_level < current_level: + # Check if it's not a reply + if "Reply:" not in lines[i]: + logger.info( + f"Found top-level entry at line {i} with level {line_level} in {file_path}" + ) + return (i, line_level) + + # If we didn't find a parent, the current entry is top-level + return (start_line, current_level) + + def insert_reply_after_entry( + self, + file_path: str, + line_number: int, + org_level: int, + reply_text: str, + commit_message: str + ): + """ + Inserts a reply as a subheader after the original entry. + + :param file_path: The file to modify + :param line_number: Line number where original entry was found + :param org_level: Org-mode level of the original entry + :param reply_text: The formatted reply text + :param commit_message: Git commit message + """ + contents = self.repo.get_contents(file_path, ref="main") + decoded_content = contents.decoded_content.decode("utf-8") + lines = decoded_content.split("\n") + + # Find the end of the original entry (next entry of same or higher level, or end of file) + insert_position = line_number + 1 + + # Look for the next line that starts with asterisks of equal or lesser count + for i in range(line_number + 1, len(lines)): + match = re.match(r'^(\*+)\s', lines[i]) + if match and len(match.group(1)) <= org_level: + insert_position = i + break + insert_position = i + 1 + + # Insert the reply at the calculated position + lines.insert(insert_position, reply_text) + + new_content = "\n".join(lines) + + # Update the file in the repository + self.repo.update_file( + path=contents.path, + message=commit_message, + content=new_content, + sha=contents.sha, + branch="main", + ) + + logger.info( + f"Inserted reply at line {insert_position} in {file_path}", + extra={ + "action": "insert_reply", + "file": file_path, + "line": insert_position, + "org_level": org_level + 1 + } + ) diff --git a/tests/test_org_api.py b/tests/test_org_api.py new file mode 100644 index 0000000..e742417 --- /dev/null +++ b/tests/test_org_api.py @@ -0,0 +1,594 @@ +""" +Unit tests for OrgApi functionality. + +Tests cover: +- Finding original entries by message link +- Finding top-level (non-reply) entries +- Inserting replies at correct positions in org hierarchy +- Edge cases and error handling +""" + +import logging +from unittest.mock import MagicMock, Mock +from typing import Any, Dict + +import pytest + +from src.org_api import OrgApi + +logger = logging.getLogger(__name__) + + +class TestOrgApi: + """Test suite for OrgApi class.""" + + @pytest.fixture + def mock_repo(self) -> MagicMock: + """Create a basic mock repository.""" + logger.info("Setting up mock repository") + repo = MagicMock() + return repo + + @pytest.fixture + def org_api(self, mock_repo: MagicMock) -> OrgApi: + """Create an OrgApi instance with a mock repository.""" + logger.info("Creating OrgApi instance") + return OrgApi(mock_repo) + + @pytest.fixture + def simple_org_content(self) -> str: + """Return a simple org file content for testing.""" + return """#+TITLE: Test Journal +* Entry: [[https://t.me/c/1234567890/100][2025-12-17 10:00]] +This is the original message. +* Entry: [[https://t.me/c/1234567890/101][2025-12-17 10:05]] +Another entry.""" + + @pytest.fixture + def nested_org_content(self) -> str: + """Return an org file with nested entries (replies).""" + return """#+TITLE: Test Journal +* Entry: [[https://t.me/c/1234567890/100][2025-12-17 10:00]] +Original message content. +** Reply: [[https://t.me/c/1234567890/200][2025-12-17 11:00]] +First reply. +** Reply: [[https://t.me/c/1234567890/300][2025-12-17 12:00]] +Second reply. +* Entry: [[https://t.me/c/1234567890/101][2025-12-17 13:00]] +Different entry.""" + + @pytest.fixture + def todo_org_content(self) -> str: + """Return a TODO org file content.""" + return """#+TITLE: Test TODOs +** TODO Review pull request [[https://t.me/c/1234567890/500][2025-12-17 09:00]] +Some details about the PR. +** TODO Another task +More details.""" + + @pytest.mark.unit + @pytest.mark.orgapi + def test_find_original_entry_found( + self, + org_api: OrgApi, + mock_repo: MagicMock, + simple_org_content: str, + ) -> None: + """ + Test finding an original entry that exists in the file. + + Expected behavior: + - Return (line_number, org_level) tuple + - Correctly identify line number (0-indexed) + - Correctly count org-mode level (asterisks) + """ + logger.info("=" * 80) + logger.info("TEST: Find original entry - found") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_repo.get_contents.return_value = mock_contents + + # Execute + message_link = "https://t.me/c/1234567890/100" + result = org_api.find_original_entry(message_link, "test.org") + + # Verify + logger.info(f"Result: {result}") + assert result is not None, "Should find the entry" + + line_number, org_level = result + logger.info(f"Found at line {line_number} with level {org_level}") + + assert line_number == 1, "Entry should be at line 1 (0-indexed)" + assert org_level == 1, "Entry should have org-level 1 (*)" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_find_original_entry_not_found( + self, + org_api: OrgApi, + mock_repo: MagicMock, + simple_org_content: str, + ) -> None: + """ + Test finding an original entry that doesn't exist. + + Expected behavior: + - Return None when entry is not found + """ + logger.info("=" * 80) + logger.info("TEST: Find original entry - not found") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_repo.get_contents.return_value = mock_contents + + # Execute + message_link = "https://t.me/c/9999999999/999" + result = org_api.find_original_entry(message_link, "test.org") + + # Verify + logger.info(f"Result: {result}") + assert result is None, "Should return None when entry not found" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_find_original_entry_with_todo_level( + self, + org_api: OrgApi, + mock_repo: MagicMock, + todo_org_content: str, + ) -> None: + """ + Test finding an entry with level 2 (** TODO). + + Expected behavior: + - Correctly identify org-level 2 + """ + logger.info("=" * 80) + logger.info("TEST: Find original entry - TODO with level 2") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = todo_org_content.encode('utf-8') + mock_repo.get_contents.return_value = mock_contents + + # Execute + message_link = "https://t.me/c/1234567890/500" + result = org_api.find_original_entry(message_link, "test_todo.org") + + # Verify + logger.info(f"Result: {result}") + assert result is not None, "Should find the TODO entry" + + line_number, org_level = result + logger.info(f"Found at line {line_number} with level {org_level}") + + assert org_level == 2, "TODO entry should have org-level 2 (**)" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_find_original_entry_exception_handling( + self, + org_api: OrgApi, + mock_repo: MagicMock, + ) -> None: + """ + Test handling exceptions when reading file fails. + + Expected behavior: + - Return None on exception + - Log warning + """ + logger.info("=" * 80) + logger.info("TEST: Find original entry - exception handling") + logger.info("=" * 80) + + # Setup mock to raise exception + mock_repo.get_contents.side_effect = Exception("File not found") + + # Execute + message_link = "https://t.me/c/1234567890/100" + result = org_api.find_original_entry(message_link, "nonexistent.org") + + # Verify + logger.info(f"Result: {result}") + assert result is None, "Should return None on exception" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_find_top_level_entry_non_reply( + self, + org_api: OrgApi, + mock_repo: MagicMock, + simple_org_content: str, + ) -> None: + """ + Test finding top-level entry when current entry is not a reply. + + Expected behavior: + - Return the same line and level (it's already top-level) + """ + logger.info("=" * 80) + logger.info("TEST: Find top-level entry - non-reply") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_repo.get_contents.return_value = mock_contents + + # Execute - line 1 is "* Entry:" (not a reply) + result = org_api.find_top_level_entry("test.org", 1, 1) + + # Verify + logger.info(f"Result: {result}") + line_number, org_level = result + + assert line_number == 1, "Should return same line for non-reply entry" + assert org_level == 1, "Should return same level for non-reply entry" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_find_top_level_entry_from_reply( + self, + org_api: OrgApi, + mock_repo: MagicMock, + nested_org_content: str, + ) -> None: + """ + Test finding top-level entry when starting from a reply. + + Expected behavior: + - Find the parent entry (non-reply with lower level) + """ + logger.info("=" * 80) + logger.info("TEST: Find top-level entry - from reply") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = nested_org_content.encode('utf-8') + mock_repo.get_contents.return_value = mock_contents + + # Execute - line 3 is "** Reply:" (first reply) + result = org_api.find_top_level_entry("test.org", 3, 2) + + # Verify + logger.info(f"Result: {result}") + line_number, org_level = result + + logger.info(f"Found top-level entry at line {line_number} with level {org_level}") + assert line_number == 1, "Should find parent entry at line 1" + assert org_level == 1, "Parent entry should have level 1" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_find_top_level_entry_from_second_reply( + self, + org_api: OrgApi, + mock_repo: MagicMock, + nested_org_content: str, + ) -> None: + """ + Test finding top-level entry from the second reply. + + Expected behavior: + - Should still find the original parent, not the first reply + """ + logger.info("=" * 80) + logger.info("TEST: Find top-level entry - from second reply") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = nested_org_content.encode('utf-8') + mock_repo.get_contents.return_value = mock_contents + + # Execute - line 5 is the second "** Reply:" + result = org_api.find_top_level_entry("test.org", 5, 2) + + # Verify + logger.info(f"Result: {result}") + line_number, org_level = result + + assert line_number == 1, "Should find parent entry at line 1" + assert org_level == 1, "Parent entry should have level 1" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_insert_reply_after_entry_simple( + self, + org_api: OrgApi, + mock_repo: MagicMock, + simple_org_content: str, + ) -> None: + """ + Test inserting a reply after a simple entry. + + Expected behavior: + - Insert reply at correct position + - Update file with correct content + - Call update_file with proper parameters + """ + logger.info("=" * 80) + logger.info("TEST: Insert reply after entry - simple") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.sha = "mock_sha_123" + mock_contents.path = "test.org" + mock_repo.get_contents.return_value = mock_contents + mock_repo.update_file.return_value = {"commit": {"sha": "new_sha"}} + + # Execute - insert after line 1 (first entry, level 1) + reply_text = "** Reply: [[https://t.me/c/1234567890/200][2025-12-17 11:00]]\nThis is a reply." + org_api.insert_reply_after_entry( + "test.org", + 1, # line_number + 1, # org_level + reply_text, + "Test commit message" + ) + + # Verify update_file was called + logger.info("Verifying update_file was called") + assert mock_repo.update_file.called, "Should call update_file" + + call_args = mock_repo.update_file.call_args + logger.debug(f"update_file called with: {call_args}") + + # Verify parameters + assert call_args[1]["path"] == "test.org" + assert call_args[1]["message"] == "Test commit message" + assert call_args[1]["sha"] == "mock_sha_123" + assert call_args[1]["branch"] == "main" + + # Verify content has the reply inserted + updated_content = call_args[1]["content"] + logger.info(f"Updated content:\n{updated_content}") + + assert "** Reply:" in updated_content, "Should contain reply header" + assert "This is a reply." in updated_content, "Should contain reply text" + + # Verify position - reply should come after original entry but before next entry + lines = updated_content.split("\n") + reply_line_idx = None + for i, line in enumerate(lines): + if "** Reply:" in line: + reply_line_idx = i + break + + assert reply_line_idx is not None, "Should find reply in content" + logger.info(f"Reply inserted at line {reply_line_idx}") + + # Reply should be after line 1 (original entry) + assert reply_line_idx > 1, "Reply should be after original entry" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_insert_reply_after_entry_with_content( + self, + org_api: OrgApi, + mock_repo: MagicMock, + ) -> None: + """ + Test inserting a reply after an entry that has content lines. + + Expected behavior: + - Reply should be inserted after all content of the original entry + - Reply should be before the next same-level entry + """ + logger.info("=" * 80) + logger.info("TEST: Insert reply after entry - with content") + logger.info("=" * 80) + + # Content with multiple lines under an entry + content_with_lines = """#+TITLE: Test Journal +* Entry: [[https://t.me/c/1234567890/100][2025-12-17 10:00]] +This is the original message. +It has multiple lines. +And even more content. +* Entry: [[https://t.me/c/1234567890/101][2025-12-17 10:05]] +Another entry.""" + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = content_with_lines.encode('utf-8') + mock_contents.sha = "mock_sha_456" + mock_contents.path = "test.org" + mock_repo.get_contents.return_value = mock_contents + mock_repo.update_file.return_value = {"commit": {"sha": "new_sha"}} + + # Execute + reply_text = "** Reply: [[https://t.me/c/1234567890/200][2025-12-17 11:00]]\nReply text." + org_api.insert_reply_after_entry( + "test.org", + 1, # line_number (first entry) + 1, # org_level + reply_text, + "Insert reply commit" + ) + + # Verify + call_args = mock_repo.update_file.call_args + updated_content = call_args[1]["content"] + logger.info(f"Updated content:\n{updated_content}") + + lines = updated_content.split("\n") + + # Find positions + first_entry_idx = None + reply_idx = None + second_entry_idx = None + + for i, line in enumerate(lines): + if "* Entry:" in line and "100" in line: + first_entry_idx = i + elif "** Reply:" in line: + reply_idx = i + elif "* Entry:" in line and "101" in line: + second_entry_idx = i + + logger.info(f"First entry at line {first_entry_idx}") + logger.info(f"Reply at line {reply_idx}") + logger.info(f"Second entry at line {second_entry_idx}") + + # Verify positions + assert first_entry_idx is not None, "Should find first entry" + assert reply_idx is not None, "Should find reply" + assert second_entry_idx is not None, "Should find second entry" + + # Reply should be after first entry and all its content + assert reply_idx > first_entry_idx, "Reply should be after first entry" + # Reply should be before second entry + assert reply_idx < second_entry_idx, "Reply should be before second entry" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_insert_reply_at_end_of_file( + self, + org_api: OrgApi, + mock_repo: MagicMock, + ) -> None: + """ + Test inserting a reply after the last entry in the file. + + Expected behavior: + - Reply should be appended at the end + """ + logger.info("=" * 80) + logger.info("TEST: Insert reply - at end of file") + logger.info("=" * 80) + + # Content with only one entry + single_entry_content = """#+TITLE: Test Journal +* Entry: [[https://t.me/c/1234567890/100][2025-12-17 10:00]] +This is the only entry.""" + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = single_entry_content.encode('utf-8') + mock_contents.sha = "mock_sha_789" + mock_contents.path = "test.org" + mock_repo.get_contents.return_value = mock_contents + mock_repo.update_file.return_value = {"commit": {"sha": "new_sha"}} + + # Execute + reply_text = "** Reply: [[https://t.me/c/1234567890/200][2025-12-17 11:00]]\nReply at end." + org_api.insert_reply_after_entry( + "test.org", + 1, # line_number + 1, # org_level + reply_text, + "Reply at end commit" + ) + + # Verify + call_args = mock_repo.update_file.call_args + updated_content = call_args[1]["content"] + logger.info(f"Updated content:\n{updated_content}") + + # Reply should be in the content + assert "** Reply:" in updated_content, "Should contain reply" + assert "Reply at end." in updated_content, "Should contain reply text" + + # Verify it's at the end + lines = updated_content.split("\n") + reply_idx = None + for i, line in enumerate(lines): + if "** Reply:" in line: + reply_idx = i + break + + assert reply_idx is not None, "Should find reply" + # Reply should be near the end (allowing for blank lines) + assert reply_idx >= len(lines) - 3, "Reply should be near end of file" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_insert_reply_maintains_structure( + self, + org_api: OrgApi, + mock_repo: MagicMock, + nested_org_content: str, + ) -> None: + """ + Test that inserting a reply maintains the org structure. + + Expected behavior: + - Existing replies should not be affected + - New reply should be inserted in correct position + - All org-mode levels should be preserved + """ + logger.info("=" * 80) + logger.info("TEST: Insert reply - maintains structure") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = nested_org_content.encode('utf-8') + mock_contents.sha = "mock_sha_structure" + mock_contents.path = "test.org" + mock_repo.get_contents.return_value = mock_contents + mock_repo.update_file.return_value = {"commit": {"sha": "new_sha"}} + + # Execute - insert another reply to the first entry + reply_text = "** Reply: [[https://t.me/c/1234567890/400][2025-12-17 14:00]]\nThird reply." + org_api.insert_reply_after_entry( + "test.org", + 1, # line_number (first entry) + 1, # org_level + reply_text, + "Third reply commit" + ) + + # Verify + call_args = mock_repo.update_file.call_args + updated_content = call_args[1]["content"] + logger.info(f"Updated content:\n{updated_content}") + + # Count replies - should now have 3 + reply_count = updated_content.count("** Reply:") + logger.info(f"Number of ** Reply: entries: {reply_count}") + assert reply_count == 3, "Should have 3 replies now" + + # Verify all original content is preserved + assert "https://t.me/c/1234567890/100" in updated_content, "Original entry preserved" + assert "https://t.me/c/1234567890/200" in updated_content, "First reply preserved" + assert "https://t.me/c/1234567890/300" in updated_content, "Second reply preserved" + assert "https://t.me/c/1234567890/101" in updated_content, "Second entry preserved" + + # Verify new reply is present + assert "https://t.me/c/1234567890/400" in updated_content, "New reply added" + assert "Third reply." in updated_content, "New reply text added" + + logger.info("Test PASSED") From 29e15c1e5efa7b70822c294626ec3c84eb206631 Mon Sep 17 00:00:00 2001 From: George Green Date: Mon, 22 Dec 2025 16:52:23 +0100 Subject: [PATCH 02/13] wip --- src/commands/post_to_journal.py | 52 ++++------- src/org_api.py | 73 ++++++++++++++++ tests/test_org_api.py | 148 ++++++++++++++++++++++++++++++++ 3 files changed, 236 insertions(+), 37 deletions(-) diff --git a/src/commands/post_to_journal.py b/src/commands/post_to_journal.py index b4890a7..f8e89ee 100644 --- a/src/commands/post_to_journal.py +++ b/src/commands/post_to_journal.py @@ -16,7 +16,7 @@ class BasePostToGitJournal: - def __init__(self, github_token=None, repo_name=None, file_path=None): + def __init__(self, github_token=None, repo_name=None, file_path=None, org_api=None): # Validating config self.token = github_token if not self.token: @@ -34,38 +34,18 @@ def __init__(self, github_token=None, repo_name=None, file_path=None): # Get the specific repo and file self.repo = self.client.get_repo(self.repo_name) + # Use provided org_api or create a new one + self.org_api = org_api if org_api is not None else OrgApi(self.repo) + def _append_text_to_file( self, new_text: str, commit_message: str, filename: str = None ): - logger.info( - "Appending text to file.", - extra={ - "action": "append_text", - "commit_message": commit_message, - "new_text": new_text, - }, - ) - - contents = self.repo.get_contents( - self.file_path, ref="main" - ) # Assuming you're working on the 'main' branch - - # Decode the content and append new text - decoded_content = contents.decoded_content.decode("utf-8") - if filename: - # [[file:pics/minecraft_sorter_scheme_b.png]] - image_text = f"#+attr_html: :width 600px\n[[file:{filename}]]" - new_content = "\n".join([decoded_content, new_text, image_text]) - else: - new_content = "\n".join([decoded_content, new_text]) - - # Update the file in the repository - self.repo.update_file( - path=contents.path, - message=commit_message, - content=new_content, - sha=contents.sha, - branch="main", + """Wrapper method for backward compatibility. Uses org_api internally.""" + self.org_api.append_text_to_file( + self.file_path, + new_text, + commit_message, + image_filename=filename ) def run(self, message: Message, file_path=None): @@ -81,11 +61,10 @@ def run(self, message: Message, file_path=None): with open(file_path, "rb") as file: file_bytes = file.read() filename = "pics/telegram/" + file_path.split("/")[-1] - self.repo.create_file( - path=filename, - message="Image from telegram", + self.org_api.create_file( + file_path=filename, content=file_bytes, - branch="main", + commit_message="Image from telegram" ) message_id = message.message_id @@ -141,10 +120,9 @@ class PostReplyToEntry(BasePostToGitJournal): and adding the reply as a subheader (child entry). """ - def __init__(self, github_token=None, repo_name=None, file_path=None, todo_file_path=None): - super().__init__(github_token, repo_name, file_path) + def __init__(self, github_token=None, repo_name=None, file_path=None, todo_file_path=None, org_api=None): + super().__init__(github_token, repo_name, file_path, org_api=org_api) self.todo_file_path = todo_file_path - self.org_api = OrgApi(self.repo) def _find_original_entry(self, original_message_link: str, file_path: str) -> tuple[int, int] | None: """ diff --git a/src/org_api.py b/src/org_api.py index c8d7a7b..2e22513 100644 --- a/src/org_api.py +++ b/src/org_api.py @@ -153,3 +153,76 @@ def insert_reply_after_entry( "org_level": org_level + 1 } ) + + def create_file( + self, + file_path: str, + content: bytes, + commit_message: str + ): + """ + Creates a new file in the repository. + + :param file_path: The path where the file should be created + :param content: The file content as bytes + :param commit_message: Git commit message + """ + logger.info( + f"Creating file: {file_path}", + extra={ + "action": "create_file", + "file_path": file_path, + "commit_message": commit_message, + }, + ) + + self.repo.create_file( + path=file_path, + message=commit_message, + content=content, + branch="main", + ) + + def append_text_to_file( + self, + file_path: str, + new_text: str, + commit_message: str, + image_filename: Optional[str] = None + ): + """ + Appends text to an org file in the repository. + + :param file_path: The file to append to + :param new_text: The text to append + :param commit_message: Git commit message + :param image_filename: Optional image filename to include as org-mode link + """ + logger.info( + "Appending text to file.", + extra={ + "action": "append_text", + "commit_message": commit_message, + "new_text": new_text, + }, + ) + + contents = self.repo.get_contents(file_path, ref="main") + + # Decode the content and append new text + decoded_content = contents.decoded_content.decode("utf-8") + if image_filename: + # [[file:pics/minecraft_sorter_scheme_b.png]] + image_text = f"#+attr_html: :width 600px\n[[file:{image_filename}]]" + new_content = "\n".join([decoded_content, new_text, image_text]) + else: + new_content = "\n".join([decoded_content, new_text]) + + # Update the file in the repository + self.repo.update_file( + path=contents.path, + message=commit_message, + content=new_content, + sha=contents.sha, + branch="main", + ) diff --git a/tests/test_org_api.py b/tests/test_org_api.py index e742417..54ba89c 100644 --- a/tests/test_org_api.py +++ b/tests/test_org_api.py @@ -592,3 +592,151 @@ def test_insert_reply_maintains_structure( assert "Third reply." in updated_content, "New reply text added" logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_create_file( + self, + org_api: OrgApi, + mock_repo: MagicMock, + ) -> None: + """ + Test creating a new file in the repository. + + Expected behavior: + - Call repo.create_file with correct parameters + """ + logger.info("=" * 80) + logger.info("TEST: Create file") + logger.info("=" * 80) + + # Setup mock + mock_repo.create_file.return_value = {"commit": {"sha": "new_file_sha"}} + + # Execute + file_content = b"Test file content" + org_api.create_file( + file_path="pics/telegram/test.png", + content=file_content, + commit_message="Test file upload" + ) + + # Verify + logger.info("Verifying create_file was called") + assert mock_repo.create_file.called, "Should call repo.create_file" + + call_args = mock_repo.create_file.call_args + logger.debug(f"create_file called with: {call_args}") + + # Verify parameters + assert call_args[1]["path"] == "pics/telegram/test.png" + assert call_args[1]["message"] == "Test file upload" + assert call_args[1]["content"] == file_content + assert call_args[1]["branch"] == "main" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_append_text_to_file_without_image( + self, + org_api: OrgApi, + mock_repo: MagicMock, + simple_org_content: str, + ) -> None: + """ + Test appending text to a file without an image. + + Expected behavior: + - Read existing content + - Append new text + - Update file with new content + """ + logger.info("=" * 80) + logger.info("TEST: Append text to file - without image") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.sha = "mock_sha_append" + mock_contents.path = "test.org" + mock_repo.get_contents.return_value = mock_contents + mock_repo.update_file.return_value = {"commit": {"sha": "updated_sha"}} + + # Execute + new_text = "* New Entry: This is a new entry" + org_api.append_text_to_file( + file_path="test.org", + new_text=new_text, + commit_message="Append new entry" + ) + + # Verify + logger.info("Verifying update_file was called") + assert mock_repo.update_file.called, "Should call repo.update_file" + + call_args = mock_repo.update_file.call_args + logger.debug(f"update_file called with: {call_args}") + + # Verify the content has new text appended + updated_content = call_args[1]["content"] + logger.info(f"Updated content:\n{updated_content}") + + assert new_text in updated_content, "New text should be in updated content" + assert simple_org_content in updated_content, "Original content should be preserved" + + logger.info("Test PASSED") + + @pytest.mark.unit + @pytest.mark.orgapi + def test_append_text_to_file_with_image( + self, + org_api: OrgApi, + mock_repo: MagicMock, + simple_org_content: str, + ) -> None: + """ + Test appending text to a file with an image reference. + + Expected behavior: + - Read existing content + - Append new text and image reference + - Update file with new content including org-mode image link + """ + logger.info("=" * 80) + logger.info("TEST: Append text to file - with image") + logger.info("=" * 80) + + # Setup mock + mock_contents = MagicMock() + mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.sha = "mock_sha_append_img" + mock_contents.path = "test.org" + mock_repo.get_contents.return_value = mock_contents + mock_repo.update_file.return_value = {"commit": {"sha": "updated_sha_img"}} + + # Execute + new_text = "* New Entry with Image" + image_filename = "pics/telegram/test_image.png" + org_api.append_text_to_file( + file_path="test.org", + new_text=new_text, + commit_message="Append entry with image", + image_filename=image_filename + ) + + # Verify + logger.info("Verifying update_file was called") + assert mock_repo.update_file.called, "Should call repo.update_file" + + call_args = mock_repo.update_file.call_args + updated_content = call_args[1]["content"] + logger.info(f"Updated content:\n{updated_content}") + + # Verify text and image reference are present + assert new_text in updated_content, "New text should be in updated content" + assert f"[[file:{image_filename}]]" in updated_content, "Image link should be in updated content" + assert "#+attr_html: :width 600px" in updated_content, "Image attributes should be in updated content" + + logger.info("Test PASSED") From 3714f983fb6280ec4f900e9298173fe16b010337 Mon Sep 17 00:00:00 2001 From: George Green Date: Tue, 23 Dec 2025 22:41:02 +0100 Subject: [PATCH 03/13] move file methods to api clasd --- src/commands/post_to_journal.py | 69 +++++---------------------------- tests/test_reply_posting.py | 20 +++++----- 2 files changed, 19 insertions(+), 70 deletions(-) diff --git a/src/commands/post_to_journal.py b/src/commands/post_to_journal.py index f8e89ee..556ef2a 100644 --- a/src/commands/post_to_journal.py +++ b/src/commands/post_to_journal.py @@ -37,17 +37,6 @@ def __init__(self, github_token=None, repo_name=None, file_path=None, org_api=No # Use provided org_api or create a new one self.org_api = org_api if org_api is not None else OrgApi(self.repo) - def _append_text_to_file( - self, new_text: str, commit_message: str, filename: str = None - ): - """Wrapper method for backward compatibility. Uses org_api internally.""" - self.org_api.append_text_to_file( - self.file_path, - new_text, - commit_message, - image_filename=filename - ) - def run(self, message: Message, file_path=None): """ Adds a message to a file on github. File should exists on the github. @@ -71,7 +60,12 @@ def run(self, message: Message, file_path=None): chat_id = message.chat.id commit_message = f"Message {message_id} from chat {chat_id}" new_text = self._get_org_item(message) - self._append_text_to_file(new_text, commit_message, filename) + self.org_api.append_text_to_file( + self.file_path, + new_text, + commit_message, + image_filename=filename + ) return True @@ -124,51 +118,6 @@ def __init__(self, github_token=None, repo_name=None, file_path=None, todo_file_ super().__init__(github_token, repo_name, file_path, org_api=org_api) self.todo_file_path = todo_file_path - def _find_original_entry(self, original_message_link: str, file_path: str) -> tuple[int, int] | None: - """ - Searches for the original message in the specified file. - Returns (line_number, org_level) if found, None otherwise. - - :param original_message_link: The Telegram message link to search for - :param file_path: The file to search in (journal.org or todo.org) - :return: Tuple of (line_number, org_level) or None - """ - return self.org_api.find_original_entry(original_message_link, file_path) - - def _find_top_level_entry(self, file_path: str, start_line: int, current_level: int) -> tuple[int, int]: - """ - Find the top-level (non-reply) entry by searching backwards from the current position. - This ensures all replies are at the same level, regardless of whether replying to - an original entry or to another reply. - - :param file_path: The file to search in - :param start_line: Line number to start searching from - :param current_level: Current org-mode level - :return: Tuple of (line_number, org_level) for the top-level entry - """ - return self.org_api.find_top_level_entry(file_path, start_line, current_level) - - def _insert_reply_after_entry( - self, - file_path: str, - line_number: int, - org_level: int, - reply_text: str, - commit_message: str - ): - """ - Inserts a reply as a subheader after the original entry. - - :param file_path: The file to modify - :param line_number: Line number where original entry was found - :param org_level: Org-mode level of the original entry - :param reply_text: The formatted reply text - :param commit_message: Git commit message - """ - self.org_api.insert_reply_after_entry( - file_path, line_number, org_level, reply_text, commit_message - ) - def run(self, message: Message, file_path=None): """ Handles a reply message by finding the original entry and adding this as a subheader. @@ -207,7 +156,7 @@ def run(self, message: Message, file_path=None): for search_file, file_type in search_files: if search_file: - entry_location = self._find_original_entry(original_message_link, search_file) + entry_location = self.org_api.find_original_entry(original_message_link, search_file) if entry_location: logger.info(f"Found original entry in {file_type} file") file_to_update = search_file @@ -221,7 +170,7 @@ def run(self, message: Message, file_path=None): line_number, org_level = entry_location # Find the top-level (non-reply) entry to ensure all replies are at same level - top_line_number, top_org_level = self._find_top_level_entry( + top_line_number, top_org_level = self.org_api.find_top_level_entry( file_to_update, line_number, org_level ) @@ -242,7 +191,7 @@ def run(self, message: Message, file_path=None): # Insert the reply after the original entry # Note: We insert after the found entry (line_number) but use top_org_level # to determine where to insert (before next entry at same level as top-level) - self._insert_reply_after_entry( + self.org_api.insert_reply_after_entry( file_to_update, top_line_number, top_org_level, diff --git a/tests/test_reply_posting.py b/tests/test_reply_posting.py index 780c07c..2b01cbb 100644 --- a/tests/test_reply_posting.py +++ b/tests/test_reply_posting.py @@ -539,14 +539,14 @@ def test_find_top_level_entry_method( test_config: Dict[str, Any], ) -> None: """ - Test the _find_top_level_entry method directly. + Test the find_top_level_entry method from org_api directly. Verifies: - Returns same entry if it's not a reply - Finds parent entry if current entry is a reply """ logger.info("=" * 80) - logger.info("TEST: _find_top_level_entry method") + logger.info("TEST: find_top_level_entry method") logger.info("=" * 80) # Create a journal with nested structure @@ -579,7 +579,7 @@ def test_find_top_level_entry_method( # Test 1: Non-reply entry should return itself logger.info("Test 1: Non-reply entry") - line_num, level = reply_instance._find_top_level_entry( + line_num, level = reply_instance.org_api.find_top_level_entry( test_config["journal_file"], 1, 1 # Line 1 is "* Entry:" (the original) ) logger.info(f"Result: line {line_num}, level {level}") @@ -588,14 +588,14 @@ def test_find_top_level_entry_method( # Test 2: Reply entry should find its parent logger.info("Test 2: Reply entry") - line_num, level = reply_instance._find_top_level_entry( + line_num, level = reply_instance.org_api.find_top_level_entry( test_config["journal_file"], 3, 2 # Line 3 is "** Reply:" ) logger.info(f"Result: line {line_num}, level {level}") assert line_num == 1, "Should return parent entry line" assert level == 1, "Should return parent entry level" - logger.info("_find_top_level_entry method test PASSED") + logger.info("find_top_level_entry method test PASSED") @pytest.mark.unit @pytest.mark.reply @@ -605,7 +605,7 @@ def test_find_original_entry_method( test_config: Dict[str, Any], ) -> None: """ - Test the _find_original_entry method directly. + Test the find_original_entry method from org_api directly. Verifies: - Correctly identifies line number @@ -613,7 +613,7 @@ def test_find_original_entry_method( - Returns None when not found """ logger.info("=" * 80) - logger.info("TEST: _find_original_entry method") + logger.info("TEST: find_original_entry method") logger.info("=" * 80) with patch('src.commands.post_to_journal.Github', return_value=mock_github_client_with_journal_entry): @@ -626,7 +626,7 @@ def test_find_original_entry_method( # Test finding an entry that exists original_link = "https://t.me/c/1234567890/100" - result = reply_instance._find_original_entry(original_link, test_config["journal_file"]) + result = reply_instance.org_api.find_original_entry(original_link, test_config["journal_file"]) logger.info(f"Find result: {result}") assert result is not None, "Should find the entry" @@ -639,12 +639,12 @@ def test_find_original_entry_method( # Test finding an entry that doesn't exist nonexistent_link = "https://t.me/c/9999999999/999" - result_not_found = reply_instance._find_original_entry(nonexistent_link, test_config["journal_file"]) + result_not_found = reply_instance.org_api.find_original_entry(nonexistent_link, test_config["journal_file"]) logger.info(f"Find result for nonexistent: {result_not_found}") assert result_not_found is None, "Should return None when entry not found" - logger.info("_find_original_entry method test PASSED") + logger.info("find_original_entry method test PASSED") @pytest.mark.unit @pytest.mark.reply From fbffbd8bbe8674c006b5de994cb418112fe44825 Mon Sep 17 00:00:00 2001 From: George Green Date: Wed, 24 Dec 2025 22:46:48 +0100 Subject: [PATCH 04/13] refactored actions for files --- src/actions/__init__.py | 11 ++ src/actions/base_post_to_org_file.py | 66 +++++++++ src/actions/post_reply.py | 121 ++++++++++++++++ src/actions/post_to_journal.py | 34 +++++ src/actions/post_to_todo.py | 39 ++++++ src/commands/__init__.py | 1 - src/commands/post_to_journal.py | 202 --------------------------- src/config.py | 8 +- 8 files changed, 274 insertions(+), 208 deletions(-) create mode 100644 src/actions/__init__.py create mode 100644 src/actions/base_post_to_org_file.py create mode 100644 src/actions/post_reply.py create mode 100644 src/actions/post_to_journal.py create mode 100644 src/actions/post_to_todo.py delete mode 100644 src/commands/post_to_journal.py diff --git a/src/actions/__init__.py b/src/actions/__init__.py new file mode 100644 index 0000000..f646869 --- /dev/null +++ b/src/actions/__init__.py @@ -0,0 +1,11 @@ +# Expose all actions from python classes in this package + +from post_to_git_journal import PostToGitJournal +from post_to_todo import PostToTodo +from post_reply import PostReplyToEntry + +__all__ = [ + "PostToGitJournal", + "PostToTodo", + "PostReplyToEntry", +] diff --git a/src/actions/base_post_to_org_file.py b/src/actions/base_post_to_org_file.py new file mode 100644 index 0000000..de5ab36 --- /dev/null +++ b/src/actions/base_post_to_org_file.py @@ -0,0 +1,66 @@ +""" +in this task, I take the message from telegram command, and post it to my journal on github. +I will use the github api to do this. +""" + +import logging +import re +from datetime import datetime + +from github import Github, Auth +from telegram import Message +from ..utils import get_text_from_message +from ..org_api import OrgApi + +logger = logging.getLogger(__name__) + + +class BasePostToGitJournal: + def __init__(self, github_token=None, repo_name=None, file_path=None, org_api=None): + # Validating config + self.token = github_token + if not self.token: + logger.error("Github token is not provided.") + raise ValueError("Github token is not provided.") + self.repo_name = repo_name + self.file_path = file_path + if not self.file_path: + # warning in logs that default file path is used + logger.warning("File path is not provided. Using default file path.") + + self.client = Github(auth=(Auth.Token(self.token))) + # Initialize using an access token + + # Get the specific repo and file + self.repo = self.client.get_repo(self.repo_name) + + # Use provided org_api or create a new one + self.org_api = org_api if org_api is not None else OrgApi(self.repo) + + def run(self, message: Message, file_path=None): + """ + Adds a message to a file on github. File should exists on the github. + :param message: incoming telegram message + :return: status of operation + """ + + filename = None + if file_path: + # we got a file. Now it has to be uploaded to the repo as bytes + with open(file_path, "rb") as file: + file_bytes = file.read() + filename = "pics/telegram/" + file_path.split("/")[-1] + self.org_api.create_file( + file_path=filename, + content=file_bytes, + commit_message="Image from telegram", + ) + + message_id = message.message_id + chat_id = message.chat.id + commit_message = f"Message {message_id} from chat {chat_id}" + new_text = self._get_org_item(message) + self.org_api.append_text_to_file( + self.file_path, new_text, commit_message, image_filename=filename + ) + return True diff --git a/src/actions/post_reply.py b/src/actions/post_reply.py new file mode 100644 index 0000000..6f46511 --- /dev/null +++ b/src/actions/post_reply.py @@ -0,0 +1,121 @@ +""" +in this task, I take the message from telegram command, and post it to my journal on github. +I will use the github api to do this. +""" + +import logging +import re +from datetime import datetime + +from github import Github, Auth +from telegram import Message +from ..utils import get_text_from_message +from ..org_api import OrgApi + +from ..base_post_to_org_file import BasePostToGitJournal + +logger = logging.getLogger(__name__) + + +class PostReplyToEntry(BasePostToGitJournal): + """ + Handles reply messages by looking up the original message in journal/todo files + and adding the reply as a subheader (child entry). + """ + + def __init__( + self, + github_token=None, + repo_name=None, + file_path=None, + todo_file_path=None, + org_api=None, + ): + super().__init__(github_token, repo_name, file_path, org_api=org_api) + self.todo_file_path = todo_file_path + + def run(self, message: Message, file_path=None): + """ + Handles a reply message by finding the original entry and adding this as a subheader. + Falls back to regular journal entry if original message is not found. + + :param message: The reply message from Telegram + :param file_path: Optional file path for attachments + :return: Status of operation + """ + # Get the original message that this is replying to + original_message = message.reply_to_message + if not original_message: + # Not a reply, fall back to regular journal entry + logger.warning("PostReplyToEntry called without reply_to_message") + return PostToGitJournal(self.token, self.repo_name, self.file_path).run( + message, file_path + ) + + # Build the link to the original message + original_message_id = original_message.message_id + original_chat_id = original_message.chat.id + original_message_link = ( + f"https://t.me/c/{original_chat_id}/{original_message_id}" + ) + + logger.info( + f"Processing reply to message {original_message_id}", + extra={ + "original_message_id": original_message_id, + "original_link": original_message_link, + }, + ) + + # Search for the original entry in journal file first, then todo file + entry_location = None + search_files = [(self.file_path, "journal"), (self.todo_file_path, "todo")] + + for search_file, file_type in search_files: + if search_file: + entry_location = self.org_api.find_original_entry( + original_message_link, search_file + ) + if entry_location: + logger.info(f"Found original entry in {file_type} file") + file_to_update = search_file + break + + if not entry_location: + # Original message not found, add as regular journal entry + logger.info( + "Original entry not found, falling back to regular journal entry" + ) + return PostToGitJournal(self.token, self.repo_name, self.file_path).run( + message, file_path + ) + + line_number, org_level = entry_location + + # Find the top-level (non-reply) entry to ensure all replies are at same level + top_line_number, top_org_level = self.org_api.find_top_level_entry( + file_to_update, line_number, org_level + ) + + # Create the reply entry as a subheader (one level deeper than top-level) + reply_level = top_org_level + 1 + asterisks = "*" * reply_level + + message_id = message.message_id + chat_id = message.chat.id + now = datetime.now().strftime("%Y-%m-%d %H:%M") + message_link = f"https://t.me/c/{chat_id}/{message_id}" + message_text = get_text_from_message(message) + + reply_text = f"{asterisks} Reply: [[{message_link}][{now}]]\n{message_text}" + + commit_message = f"Reply to message {original_message_id} from chat {chat_id}" + + # Insert the reply after the original entry + # Note: We insert after the found entry (line_number) but use top_org_level + # to determine where to insert (before next entry at same level as top-level) + self.org_api.insert_reply_after_entry( + file_to_update, top_line_number, top_org_level, reply_text, commit_message + ) + + return True diff --git a/src/actions/post_to_journal.py b/src/actions/post_to_journal.py new file mode 100644 index 0000000..a74e137 --- /dev/null +++ b/src/actions/post_to_journal.py @@ -0,0 +1,34 @@ +""" +in this task, I take the message from telegram command, and post it to my journal on github. +I will use the github api to do this. +""" + +import logging +import re +from datetime import datetime + +from github import Github, Auth +from telegram import Message +from ..utils import get_text_from_message +from ..org_api import OrgApi + +from ..base_post_to_org_file import BasePostToGitJournal + +logger = logging.getLogger(__name__) + + +class PostToGitJournal(BasePostToGitJournal): + + @staticmethod + def _get_org_item(message: Message) -> str: + """ + In this method, I'm making an message for my org-mode journal. + It includes title "log entry" and link to the message. + Text of the message is written in the next line. + """ + message_id = message.message_id + chat_id = message.chat.id + now = datetime.now().strftime("%Y-%m-%d %H:%M") + message_link = f"https://t.me/c/{chat_id}/{message_id}" + message_text = get_text_from_message(message) + return f"* Entry: [[{message_link}][{now}]]\n{message_text}" diff --git a/src/actions/post_to_todo.py b/src/actions/post_to_todo.py new file mode 100644 index 0000000..06573bf --- /dev/null +++ b/src/actions/post_to_todo.py @@ -0,0 +1,39 @@ +""" +in this task, I take the message from telegram command, and post it to my journal on github. +I will use the github api to do this. +""" + +import logging +import re +from datetime import datetime + +from github import Github, Auth +from telegram import Message +from ..utils import get_text_from_message +from ..org_api import OrgApi + +from ..base_post_to_org_file import BasePostToGitJournal + +logger = logging.getLogger(__name__) + + +class PostToTodo(BasePostToGitJournal): + """ + This is a simple override to post TODOs to a different file + """ + + @staticmethod + def _get_org_item(message: Message) -> str: + """ + In this method, I'm making an message for my org-mode journal. + It includes title "log entry" and link to the message. + Text of the message is written in the next line. + """ + message_id = message.message_id + chat_id = message.chat.id + now = datetime.now().strftime("%Y-%m-%d %H:%M") + message_link = f"https://t.me/c/{chat_id}/{message_id}" + # trimming TODO from the message, I may want to use different tags later on + message_text = get_text_from_message(message) + message_text = message_text[5:] + return f"** TODO {message_text}\nCreated at: [{now}] from {message_link}" diff --git a/src/commands/__init__.py b/src/commands/__init__.py index 6d121ee..ff9f884 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -1,4 +1,3 @@ from .info import InfoCommand from .start import StartCommand from .webhook import WebhookCommand -from .post_to_journal import PostToGitJournal, PostToTodo, PostReplyToEntry diff --git a/src/commands/post_to_journal.py b/src/commands/post_to_journal.py deleted file mode 100644 index 556ef2a..0000000 --- a/src/commands/post_to_journal.py +++ /dev/null @@ -1,202 +0,0 @@ -""" -in this task, I take the message from telegram command, and post it to my journal on github. -I will use the github api to do this. -""" - -import logging -import re -from datetime import datetime - -from github import Github, Auth -from telegram import Message -from ..utils import get_text_from_message -from ..org_api import OrgApi - -logger = logging.getLogger(__name__) - - -class BasePostToGitJournal: - def __init__(self, github_token=None, repo_name=None, file_path=None, org_api=None): - # Validating config - self.token = github_token - if not self.token: - logger.error("Github token is not provided.") - raise ValueError("Github token is not provided.") - self.repo_name = repo_name - self.file_path = file_path - if not self.file_path: - # warning in logs that default file path is used - logger.warning("File path is not provided. Using default file path.") - - self.client = Github(auth=(Auth.Token(self.token))) - # Initialize using an access token - - # Get the specific repo and file - self.repo = self.client.get_repo(self.repo_name) - - # Use provided org_api or create a new one - self.org_api = org_api if org_api is not None else OrgApi(self.repo) - - def run(self, message: Message, file_path=None): - """ - Adds a message to a file on github. File should exists on the github. - :param message: incoming telegram message - :return: status of operation - """ - - filename = None - if file_path: - # we got a file. Now it has to be uploaded to the repo as bytes - with open(file_path, "rb") as file: - file_bytes = file.read() - filename = "pics/telegram/" + file_path.split("/")[-1] - self.org_api.create_file( - file_path=filename, - content=file_bytes, - commit_message="Image from telegram" - ) - - message_id = message.message_id - chat_id = message.chat.id - commit_message = f"Message {message_id} from chat {chat_id}" - new_text = self._get_org_item(message) - self.org_api.append_text_to_file( - self.file_path, - new_text, - commit_message, - image_filename=filename - ) - return True - - -class PostToTodo(BasePostToGitJournal): - """ - This is a simple override to post TODOs to a different file - """ - - @staticmethod - def _get_org_item(message: Message) -> str: - """ - In this method, I'm making an message for my org-mode journal. - It includes title "log entry" and link to the message. - Text of the message is written in the next line. - """ - message_id = message.message_id - chat_id = message.chat.id - now = datetime.now().strftime("%Y-%m-%d %H:%M") - message_link = f"https://t.me/c/{chat_id}/{message_id}" - # trimming TODO from the message, I may want to use different tags later on - message_text = get_text_from_message(message) - message_text = message_text[5:] - return f"** TODO {message_text}\nCreated at: [{now}] from {message_link}" - - -class PostToGitJournal(BasePostToGitJournal): - - @staticmethod - def _get_org_item(message: Message) -> str: - """ - In this method, I'm making an message for my org-mode journal. - It includes title "log entry" and link to the message. - Text of the message is written in the next line. - """ - message_id = message.message_id - chat_id = message.chat.id - now = datetime.now().strftime("%Y-%m-%d %H:%M") - message_link = f"https://t.me/c/{chat_id}/{message_id}" - message_text = get_text_from_message(message) - return f"* Entry: [[{message_link}][{now}]]\n{message_text}" - - -class PostReplyToEntry(BasePostToGitJournal): - """ - Handles reply messages by looking up the original message in journal/todo files - and adding the reply as a subheader (child entry). - """ - - def __init__(self, github_token=None, repo_name=None, file_path=None, todo_file_path=None, org_api=None): - super().__init__(github_token, repo_name, file_path, org_api=org_api) - self.todo_file_path = todo_file_path - - def run(self, message: Message, file_path=None): - """ - Handles a reply message by finding the original entry and adding this as a subheader. - Falls back to regular journal entry if original message is not found. - - :param message: The reply message from Telegram - :param file_path: Optional file path for attachments - :return: Status of operation - """ - # Get the original message that this is replying to - original_message = message.reply_to_message - if not original_message: - # Not a reply, fall back to regular journal entry - logger.warning("PostReplyToEntry called without reply_to_message") - return PostToGitJournal(self.token, self.repo_name, self.file_path).run(message, file_path) - - # Build the link to the original message - original_message_id = original_message.message_id - original_chat_id = original_message.chat.id - original_message_link = f"https://t.me/c/{original_chat_id}/{original_message_id}" - - logger.info( - f"Processing reply to message {original_message_id}", - extra={ - "original_message_id": original_message_id, - "original_link": original_message_link - } - ) - - # Search for the original entry in journal file first, then todo file - entry_location = None - search_files = [ - (self.file_path, "journal"), - (self.todo_file_path, "todo") - ] - - for search_file, file_type in search_files: - if search_file: - entry_location = self.org_api.find_original_entry(original_message_link, search_file) - if entry_location: - logger.info(f"Found original entry in {file_type} file") - file_to_update = search_file - break - - if not entry_location: - # Original message not found, add as regular journal entry - logger.info("Original entry not found, falling back to regular journal entry") - return PostToGitJournal(self.token, self.repo_name, self.file_path).run(message, file_path) - - line_number, org_level = entry_location - - # Find the top-level (non-reply) entry to ensure all replies are at same level - top_line_number, top_org_level = self.org_api.find_top_level_entry( - file_to_update, line_number, org_level - ) - - # Create the reply entry as a subheader (one level deeper than top-level) - reply_level = top_org_level + 1 - asterisks = "*" * reply_level - - message_id = message.message_id - chat_id = message.chat.id - now = datetime.now().strftime("%Y-%m-%d %H:%M") - message_link = f"https://t.me/c/{chat_id}/{message_id}" - message_text = get_text_from_message(message) - - reply_text = f"{asterisks} Reply: [[{message_link}][{now}]]\n{message_text}" - - commit_message = f"Reply to message {original_message_id} from chat {chat_id}" - - # Insert the reply after the original entry - # Note: We insert after the found entry (line_number) but use top_org_level - # to determine where to insert (before next entry at same level as top-level) - self.org_api.insert_reply_after_entry( - file_to_update, - top_line_number, - top_org_level, - reply_text, - commit_message - ) - - return True diff --git a/src/config.py b/src/config.py index 4d8e6d6..ce581ab 100644 --- a/src/config.py +++ b/src/config.py @@ -2,10 +2,8 @@ from typing import Callable from telegram import Bot -from .commands import ( - StartCommand, - WebhookCommand, - InfoCommand, +from .commands import StartCommand, WebhookCommand, InfoCommand +from .actions import ( PostToGitJournal, PostToTodo, PostReplyToEntry, @@ -36,7 +34,7 @@ def init_commands(get_bot: Callable[[], Bot]): github_token=github_token, repo_name=repo_name, file_path=file_path, - todo_file_path="todo.org" + todo_file_path="todo.org", ) # Default action is to post to journal From 8907387aff42d208bee972245d71fbfd636b2298 Mon Sep 17 00:00:00 2001 From: George Green Date: Thu, 25 Dec 2025 01:09:17 +0100 Subject: [PATCH 05/13] fixed tests --- src/__init__.py | 1 + src/actions/__init__.py | 6 +- src/actions/post_reply.py | 3 +- src/actions/post_to_journal.py | 2 +- src/actions/post_to_todo.py | 2 +- tests/test_journal_posting.py | 4 +- tests/test_message_sequence_integration.py | 96 ++++++++++----- tests/test_reply_posting.py | 135 +++++++++++++++------ tests/test_todo_posting.py | 4 +- 9 files changed, 174 insertions(+), 79 deletions(-) diff --git a/src/__init__.py b/src/__init__.py index e69de29..5495086 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1 @@ +# This file makes src a Python package diff --git a/src/actions/__init__.py b/src/actions/__init__.py index f646869..43d8ebb 100644 --- a/src/actions/__init__.py +++ b/src/actions/__init__.py @@ -1,8 +1,8 @@ # Expose all actions from python classes in this package -from post_to_git_journal import PostToGitJournal -from post_to_todo import PostToTodo -from post_reply import PostReplyToEntry +from .post_to_journal import PostToGitJournal +from .post_to_todo import PostToTodo +from .post_reply import PostReplyToEntry __all__ = [ "PostToGitJournal", diff --git a/src/actions/post_reply.py b/src/actions/post_reply.py index 6f46511..a26f7ca 100644 --- a/src/actions/post_reply.py +++ b/src/actions/post_reply.py @@ -12,7 +12,8 @@ from ..utils import get_text_from_message from ..org_api import OrgApi -from ..base_post_to_org_file import BasePostToGitJournal +from .base_post_to_org_file import BasePostToGitJournal +from .post_to_journal import PostToGitJournal logger = logging.getLogger(__name__) diff --git a/src/actions/post_to_journal.py b/src/actions/post_to_journal.py index a74e137..3a00231 100644 --- a/src/actions/post_to_journal.py +++ b/src/actions/post_to_journal.py @@ -12,7 +12,7 @@ from ..utils import get_text_from_message from ..org_api import OrgApi -from ..base_post_to_org_file import BasePostToGitJournal +from .base_post_to_org_file import BasePostToGitJournal logger = logging.getLogger(__name__) diff --git a/src/actions/post_to_todo.py b/src/actions/post_to_todo.py index 06573bf..5c4bd61 100644 --- a/src/actions/post_to_todo.py +++ b/src/actions/post_to_todo.py @@ -12,7 +12,7 @@ from ..utils import get_text_from_message from ..org_api import OrgApi -from ..base_post_to_org_file import BasePostToGitJournal +from .base_post_to_org_file import BasePostToGitJournal logger = logging.getLogger(__name__) diff --git a/tests/test_journal_posting.py b/tests/test_journal_posting.py index 88097d0..17e6f15 100644 --- a/tests/test_journal_posting.py +++ b/tests/test_journal_posting.py @@ -15,7 +15,7 @@ import pytest -from src.commands.post_to_journal import PostToGitJournal +from src.actions.post_to_journal import PostToGitJournal logger = logging.getLogger(__name__) @@ -56,7 +56,7 @@ def journal_instance( """Create a PostToGitJournal instance with mocked GitHub client.""" logger.info("Creating PostToGitJournal instance for testing") - with patch('src.commands.post_to_journal.Github', return_value=mock_github_client): + with patch('src.actions.base_post_to_org_file.Github', return_value=mock_github_client): instance = PostToGitJournal( github_token=test_config["github_token"], repo_name=test_config["github_repo"], diff --git a/tests/test_message_sequence_integration.py b/tests/test_message_sequence_integration.py index 742649a..ea86c1f 100644 --- a/tests/test_message_sequence_integration.py +++ b/tests/test_message_sequence_integration.py @@ -42,7 +42,9 @@ def _create_dummy_github_client(): # Patch Github before any imports -with patch('src.commands.post_to_journal.Github', return_value=_create_dummy_github_client()): +with patch( + "src.actions.base_post_to_org_file.Github", return_value=_create_dummy_github_client() +): from src.main import process_non_command @@ -66,7 +68,7 @@ def __init__(self): def get_contents(self, path, ref="main"): mock_contents = MagicMock() - mock_contents.decoded_content = self.content.encode('utf-8') + mock_contents.decoded_content = self.content.encode("utf-8") mock_contents.sha = self.sha mock_contents.path = path logger.debug(f"get_contents called, current content:\n{self.content}") @@ -128,7 +130,9 @@ def message_sequence(self) -> List[Dict[str, Any]]: }, ] - def _create_mock_message(self, msg_data: Dict[str, Any], previous_messages: List[Mock]) -> Mock: + def _create_mock_message( + self, msg_data: Dict[str, Any], previous_messages: List[Mock] + ) -> Mock: """ Create a mock Telegram message from the data dict. If reply_to_message_id is set, link it to the corresponding previous message. @@ -150,10 +154,14 @@ def _create_mock_message(self, msg_data: Dict[str, Any], previous_messages: List if "reply_to_message_id" in msg_data: # Find the original message in previous_messages reply_to_id = msg_data["reply_to_message_id"] - original_msg = next((m for m in previous_messages if m.message_id == reply_to_id), None) + original_msg = next( + (m for m in previous_messages if m.message_id == reply_to_id), None + ) if original_msg: message.reply_to_message = original_msg - logger.debug(f"Message {msg_data['message_id']} replies to {reply_to_id}") + logger.debug( + f"Message {msg_data['message_id']} replies to {reply_to_id}" + ) else: logger.warning(f"Could not find message {reply_to_id} to reply to") message.reply_to_message = None @@ -188,26 +196,26 @@ def test_message_sequence_full_flow( previous_messages = [] responses = [] - with patch('src.commands.post_to_journal.Github', return_value=mock_client): + with patch("src.actions.base_post_to_org_file.Github", return_value=mock_client): # Import here to ensure patch is applied - from src.commands.post_to_journal import PostToGitJournal, PostToTodo, PostReplyToEntry + from src.actions.post_to_journal import PostToGitJournal + from src.actions.post_to_todo import PostToTodo + from src.actions.post_reply import PostReplyToEntry # Recreate the action instances with mocked GitHub journal = PostToGitJournal( github_token="test_token", repo_name="test/repo", - file_path="journal.org" + file_path="journal.org", ) todo = PostToTodo( - github_token="test_token", - repo_name="test/repo", - file_path="todo.org" + github_token="test_token", repo_name="test/repo", file_path="todo.org" ) reply = PostReplyToEntry( github_token="test_token", repo_name="test/repo", file_path="journal.org", - todo_file_path="todo.org" + todo_file_path="todo.org", ) # Mock the actions dict @@ -219,14 +227,16 @@ def test_message_sequence_full_flow( # Process each message in sequence for i, msg_data in enumerate(message_sequence): - logger.info(f"\n--- Processing message {i+1}/{len(message_sequence)}: {msg_data['name']} ---") + logger.info( + f"\n--- Processing message {i+1}/{len(message_sequence)}: {msg_data['name']} ---" + ) # Create mock message message = self._create_mock_message(msg_data, previous_messages) previous_messages.append(message) # Process the message with mocked actions - with patch('src.main.actions', test_actions): + with patch("src.main.actions", test_actions): response = process_non_command(message, file_path=None) logger.info(f"Response: {response}") @@ -234,8 +244,9 @@ def test_message_sequence_full_flow( # Verify response expected_response = msg_data["expected_response"] - assert response == expected_response, \ - f"Message {msg_data['name']}: expected '{expected_response}', got '{response}'" + assert ( + response == expected_response + ), f"Message {msg_data['name']}: expected '{expected_response}', got '{response}'" logger.info(f"✓ Response matches expected: {response}") @@ -245,25 +256,41 @@ def test_message_sequence_full_flow( logger.info(f"Final content:\n{final_content}") # Verify all messages are in the file - assert "https://t.me/c/1234567890/100" in final_content, "Original entry should be in file" - assert "This is my original journal entry" in final_content, "Original text should be in file" - - assert "https://t.me/c/1234567890/200" in final_content, "First reply should be in file" - assert "This is a reply to the original entry" in final_content, "First reply text should be in file" - - assert "https://t.me/c/1234567890/300" in final_content, "Second reply should be in file" - assert "This is a reply to the first reply" in final_content, "Second reply text should be in file" + assert ( + "https://t.me/c/1234567890/100" in final_content + ), "Original entry should be in file" + assert ( + "This is my original journal entry" in final_content + ), "Original text should be in file" + + assert ( + "https://t.me/c/1234567890/200" in final_content + ), "First reply should be in file" + assert ( + "This is a reply to the original entry" in final_content + ), "First reply text should be in file" + + assert ( + "https://t.me/c/1234567890/300" in final_content + ), "Second reply should be in file" + assert ( + "This is a reply to the first reply" in final_content + ), "Second reply text should be in file" # Verify proper nesting - all replies should be at ** level reply_count = final_content.count("** Reply:") logger.info(f"Number of ** Reply: entries: {reply_count}") - assert reply_count == 2, f"Should have 2 replies at ** level, found {reply_count}" + assert ( + reply_count == 2 + ), f"Should have 2 replies at ** level, found {reply_count}" # Should NOT have *** level replies assert "*** Reply:" not in final_content, "Should not have *** level replies" # Verify all responses were generated - assert len(responses) == len(message_sequence), "Should have response for each message" + assert len(responses) == len( + message_sequence + ), "Should have response for each message" assert all(r is not None for r in responses), "All responses should be non-None" logger.info("\n✓ All messages processed correctly") @@ -289,14 +316,14 @@ def test_reply_response_not_none( mock_client = MagicMock() mock_client.get_repo.return_value = mock_github_repo_with_state - with patch('src.commands.post_to_journal.Github', return_value=mock_client): - from src.commands.post_to_journal import PostReplyToEntry + with patch("src.actions.base_post_to_org_file.Github", return_value=mock_client): + from src.actions.post_reply import PostReplyToEntry reply_instance = PostReplyToEntry( github_token="test_token", repo_name="test/repo", file_path="journal.org", - todo_file_path="todo.org" + todo_file_path="todo.org", ) # First, add an original entry @@ -330,17 +357,22 @@ def test_reply_response_not_none( reply_message.chat = reply_chat test_actions = { - "reply": {"function": reply_instance.run, "response": "Added reply to entry!"}, + "reply": { + "function": reply_instance.run, + "response": "Added reply to entry!", + }, } # Process the reply - with patch('src.main.actions', test_actions): + with patch("src.main.actions", test_actions): response = process_non_command(reply_message, file_path=None) logger.info(f"Response from reply: {response}") # Verify response is not None assert response is not None, "Reply should generate a response" - assert response == "Added reply to entry!", f"Expected 'Added reply to entry!', got '{response}'" + assert ( + response == "Added reply to entry!" + ), f"Expected 'Added reply to entry!', got '{response}'" logger.info("✓ Reply generated correct response") diff --git a/tests/test_reply_posting.py b/tests/test_reply_posting.py index 2b01cbb..213ced8 100644 --- a/tests/test_reply_posting.py +++ b/tests/test_reply_posting.py @@ -16,7 +16,8 @@ import pytest -from src.commands.post_to_journal import PostReplyToEntry, PostToGitJournal +from src.actions.post_reply import PostReplyToEntry +from src.actions.post_to_journal import PostToGitJournal logger = logging.getLogger(__name__) @@ -49,7 +50,7 @@ def mock_github_client_with_journal_entry(self) -> MagicMock: This is another entry.""" mock_contents = MagicMock() - mock_contents.decoded_content = journal_content.encode('utf-8') + mock_contents.decoded_content = journal_content.encode("utf-8") mock_contents.sha = "mock_sha_journal" mock_contents.path = "test_journal.org" @@ -87,10 +88,10 @@ def mock_github_client_with_todo_entry(self) -> MagicMock: def get_contents_side_effect(path, ref=None): mock_contents = MagicMock() if "journal" in path: - mock_contents.decoded_content = journal_content.encode('utf-8') + mock_contents.decoded_content = journal_content.encode("utf-8") mock_contents.path = "test_journal.org" else: # todo file - mock_contents.decoded_content = todo_content.encode('utf-8') + mock_contents.decoded_content = todo_content.encode("utf-8") mock_contents.path = "test_todo.org" mock_contents.sha = f"mock_sha_{path}" return mock_contents @@ -123,10 +124,10 @@ def mock_github_client_no_entry(self) -> MagicMock: def get_contents_side_effect(path, ref=None): mock_contents = MagicMock() if "journal" in path: - mock_contents.decoded_content = journal_content.encode('utf-8') + mock_contents.decoded_content = journal_content.encode("utf-8") mock_contents.path = "test_journal.org" else: # todo file - mock_contents.decoded_content = todo_content.encode('utf-8') + mock_contents.decoded_content = todo_content.encode("utf-8") mock_contents.path = "test_todo.org" mock_contents.sha = f"mock_sha_{path}" return mock_contents @@ -211,7 +212,10 @@ def test_reply_to_journal_entry( logger.info("TEST: Reply to journal entry") logger.info("=" * 80) - with patch('src.commands.post_to_journal.Github', return_value=mock_github_client_with_journal_entry): + with patch( + "src.actions.base_post_to_org_file.Github", + return_value=mock_github_client_with_journal_entry, + ): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -230,13 +234,17 @@ def test_reply_to_journal_entry( # Verify result logger.info(f"Result: {result}") - assert result is True, "Expected run() to return True for successful reply posting" + assert ( + result is True + ), "Expected run() to return True for successful reply posting" # Verify GitHub interactions logger.info("Verifying GitHub API interactions") # Should have called get_contents to search for original entry - assert reply_instance.repo.get_contents.called, "Should call get_contents to search" + assert ( + reply_instance.repo.get_contents.called + ), "Should call get_contents to search" # Should have called update_file to insert the reply reply_instance.repo.update_file.assert_called_once() @@ -250,18 +258,26 @@ def test_reply_to_journal_entry( logger.debug(f"Updated content:\n{updated_content}") # Verify the reply text appears - assert message.text in updated_content, "Reply text should be in updated content" + assert ( + message.text in updated_content + ), "Reply text should be in updated content" # Verify it's a nested entry (** Reply:) - assert "** Reply:" in updated_content, "Should contain nested reply header (** level)" + assert ( + "** Reply:" in updated_content + ), "Should contain nested reply header (** level)" # Verify it contains the reply message link - assert "https://t.me/c/1234567890/200" in updated_content, "Should contain reply message link" + assert ( + "https://t.me/c/1234567890/200" in updated_content + ), "Should contain reply message link" # Verify the commit message commit_message = update_call_args[1]["message"] logger.info(f"Commit message: {commit_message}") - assert "Reply to message 100" in commit_message, "Commit should reference original message" + assert ( + "Reply to message 100" in commit_message + ), "Commit should reference original message" logger.info("Reply to journal entry test PASSED") @@ -286,7 +302,10 @@ def test_reply_to_todo_entry( logger.info("=" * 80) # Patch Github at a persistent level to handle fallback scenarios - with patch('src.commands.post_to_journal.Github', return_value=mock_github_client_with_todo_entry) as mock_github: + with patch( + "src.actions.base_post_to_org_file.Github", + return_value=mock_github_client_with_todo_entry, + ) as mock_github: reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -305,7 +324,9 @@ def test_reply_to_todo_entry( # Verify result logger.info(f"Result: {result}") - assert result is True, "Expected run() to return True for successful reply posting" + assert ( + result is True + ), "Expected run() to return True for successful reply posting" # Verify update was called assert reply_instance.repo.update_file.called, "Should update file" @@ -316,10 +337,14 @@ def test_reply_to_todo_entry( logger.debug(f"Updated content:\n{updated_content}") # Verify the reply is nested correctly (*** level for TODO which is **) - assert "*** Reply:" in updated_content, "Should contain nested reply header (*** level)" + assert ( + "*** Reply:" in updated_content + ), "Should contain nested reply header (*** level)" # Verify reply text - assert message.text in updated_content, "Reply text should be in updated content" + assert ( + message.text in updated_content + ), "Reply text should be in updated content" logger.info("Reply to TODO entry test PASSED") @@ -343,7 +368,10 @@ def test_reply_original_not_found_fallback( logger.info("TEST: Reply with original message not found (fallback)") logger.info("=" * 80) - with patch('src.commands.post_to_journal.Github', return_value=mock_github_client_no_entry): + with patch( + "src.actions.base_post_to_org_file.Github", + return_value=mock_github_client_no_entry, + ): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -362,14 +390,18 @@ def test_reply_original_not_found_fallback( assert result is True, "Expected run() to return True (fallback to journal)" # Verify it called update_file (for the fallback journal entry) - assert reply_instance.repo.update_file.called, "Should update file with fallback entry" + assert ( + reply_instance.repo.update_file.called + ), "Should update file with fallback entry" update_call_args = reply_instance.repo.update_file.call_args updated_content = update_call_args[1]["content"] logger.debug(f"Updated content:\n{updated_content}") # Verify it's a regular journal entry, not a nested reply - assert "* Entry:" in updated_content, "Should create regular journal entry (fallback)" + assert ( + "* Entry:" in updated_content + ), "Should create regular journal entry (fallback)" # Should NOT have the nested Reply format assert "** Reply:" not in updated_content, "Should not be a nested reply" @@ -395,7 +427,10 @@ def test_reply_without_reply_to_message( logger.info("TEST: PostReplyToEntry without reply_to_message") logger.info("=" * 80) - with patch('src.commands.post_to_journal.Github', return_value=mock_github_client_no_entry): + with patch( + "src.actions.base_post_to_org_file.Github", + return_value=mock_github_client_no_entry, + ): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -470,7 +505,7 @@ def test_reply_to_reply_stays_at_same_level( client.get_repo.return_value = mock_repo mock_contents = MagicMock() - mock_contents.decoded_content = journal_content.encode('utf-8') + mock_contents.decoded_content = journal_content.encode("utf-8") mock_contents.sha = "mock_sha_reply" mock_contents.path = "test_journal.org" @@ -496,7 +531,7 @@ def test_reply_to_reply_stays_at_same_level( chat.id = 1234567890 message.chat = chat - with patch('src.commands.post_to_journal.Github', return_value=client): + with patch("src.actions.base_post_to_org_file.Github", return_value=client): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -504,7 +539,9 @@ def test_reply_to_reply_stays_at_same_level( todo_file_path=test_config["todo_file"], ) - logger.info(f"Replying to message {original_message.message_id} (which is itself a reply)") + logger.info( + f"Replying to message {original_message.message_id} (which is itself a reply)" + ) result = reply_instance.run(message=message, file_path=None) # Verify result @@ -524,11 +561,17 @@ def test_reply_to_reply_stays_at_same_level( assert reply_count == 2, "Should have 2 replies at ** level" # Should NOT have *** Reply: (no deeper nesting) - assert "*** Reply:" not in updated_content, "Should NOT have *** level replies" + assert ( + "*** Reply:" not in updated_content + ), "Should NOT have *** level replies" # Verify both replies are present - assert "https://t.me/c/1234567890/200" in updated_content, "First reply link should be present" - assert "https://t.me/c/1234567890/300" in updated_content, "Second reply link should be present" + assert ( + "https://t.me/c/1234567890/200" in updated_content + ), "First reply link should be present" + assert ( + "https://t.me/c/1234567890/300" in updated_content + ), "Second reply link should be present" logger.info("Reply to reply test PASSED - all replies stay at same level") @@ -563,13 +606,13 @@ def test_find_top_level_entry_method( client.get_repo.return_value = mock_repo mock_contents = MagicMock() - mock_contents.decoded_content = journal_content.encode('utf-8') + mock_contents.decoded_content = journal_content.encode("utf-8") mock_contents.sha = "mock_sha" mock_contents.path = "test_journal.org" mock_repo.get_contents.return_value = mock_contents - with patch('src.commands.post_to_journal.Github', return_value=client): + with patch("src.actions.base_post_to_org_file.Github", return_value=client): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -616,7 +659,10 @@ def test_find_original_entry_method( logger.info("TEST: find_original_entry method") logger.info("=" * 80) - with patch('src.commands.post_to_journal.Github', return_value=mock_github_client_with_journal_entry): + with patch( + "src.actions.base_post_to_org_file.Github", + return_value=mock_github_client_with_journal_entry, + ): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -626,7 +672,9 @@ def test_find_original_entry_method( # Test finding an entry that exists original_link = "https://t.me/c/1234567890/100" - result = reply_instance.org_api.find_original_entry(original_link, test_config["journal_file"]) + result = reply_instance.org_api.find_original_entry( + original_link, test_config["journal_file"] + ) logger.info(f"Find result: {result}") assert result is not None, "Should find the entry" @@ -639,7 +687,9 @@ def test_find_original_entry_method( # Test finding an entry that doesn't exist nonexistent_link = "https://t.me/c/9999999999/999" - result_not_found = reply_instance.org_api.find_original_entry(nonexistent_link, test_config["journal_file"]) + result_not_found = reply_instance.org_api.find_original_entry( + nonexistent_link, test_config["journal_file"] + ) logger.info(f"Find result for nonexistent: {result_not_found}") assert result_not_found is None, "Should return None when entry not found" @@ -666,7 +716,10 @@ def test_insert_position_calculation( logger.info("TEST: Reply insertion position") logger.info("=" * 80) - with patch('src.commands.post_to_journal.Github', return_value=mock_github_client_with_journal_entry): + with patch( + "src.actions.base_post_to_org_file.Github", + return_value=mock_github_client_with_journal_entry, + ): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -684,7 +737,7 @@ def test_insert_position_calculation( update_call_args = reply_instance.repo.update_file.call_args updated_content = update_call_args[1]["content"] - lines = updated_content.split('\n') + lines = updated_content.split("\n") logger.info(f"Updated content has {len(lines)} lines") # Find where the reply was inserted @@ -707,17 +760,25 @@ def test_insert_position_calculation( assert original_entry_index is not None, "Should find original entry" logger.info(f"Original entry at line {original_entry_index}") - assert reply_line_index > original_entry_index, "Reply should come after original entry" + assert ( + reply_line_index > original_entry_index + ), "Reply should come after original entry" # Verify it comes before the next top-level entry next_entry_index = None for i, line in enumerate(lines): - if i > original_entry_index and "* Entry:" in line and "https://t.me/c/1234567890/101" in line: + if ( + i > original_entry_index + and "* Entry:" in line + and "https://t.me/c/1234567890/101" in line + ): next_entry_index = i break if next_entry_index: logger.info(f"Next entry at line {next_entry_index}") - assert reply_line_index < next_entry_index, "Reply should come before next entry" + assert ( + reply_line_index < next_entry_index + ), "Reply should come before next entry" logger.info("Insertion position test PASSED") diff --git a/tests/test_todo_posting.py b/tests/test_todo_posting.py index 41074fb..1009231 100644 --- a/tests/test_todo_posting.py +++ b/tests/test_todo_posting.py @@ -15,7 +15,7 @@ import pytest -from src.commands.post_to_journal import PostToTodo +from src.actions.post_to_todo import PostToTodo logger = logging.getLogger(__name__) @@ -56,7 +56,7 @@ def todo_instance( """Create a PostToTodo instance with mocked GitHub client.""" logger.info("Creating PostToTodo instance for testing") - with patch('src.commands.post_to_journal.Github', return_value=mock_github_client): + with patch('src.actions.base_post_to_org_file.Github', return_value=mock_github_client): instance = PostToTodo( github_token=test_config["github_token"], repo_name=test_config["github_repo"], From fe48768c6c2dddb432ef7e720918663bd3504ec7 Mon Sep 17 00:00:00 2001 From: George Green Date: Mon, 29 Dec 2025 12:51:36 +0100 Subject: [PATCH 06/13] structurizr for diagrams --- .gitignore | 3 + docs/c4/docker-compose.yml | 9 + docs/c4/workspace.dsl | 268 ++++++++ docs/c4/workspace.json | 1199 ++++++++++++++++++++++++++++++++++++ taskfile.yml | 16 +- 5 files changed, 1491 insertions(+), 4 deletions(-) create mode 100644 docs/c4/docker-compose.yml create mode 100644 docs/c4/workspace.dsl create mode 100644 docs/c4/workspace.json diff --git a/.gitignore b/.gitignore index ec13b3e..e059bb4 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,6 @@ terraform.tfstate terraform.tfstate.backup *.env src/requirements.txt + + +.structurizr/ diff --git a/docs/c4/docker-compose.yml b/docs/c4/docker-compose.yml new file mode 100644 index 0000000..bb77dc0 --- /dev/null +++ b/docs/c4/docker-compose.yml @@ -0,0 +1,9 @@ +services: + structurizr: + #image: structurizr/onpremises + image: structurizr/lite + ports: + - "8080:8080" + volumes: + - .:/usr/local/structurizr + container_name: structurizr diff --git a/docs/c4/workspace.dsl b/docs/c4/workspace.dsl new file mode 100644 index 0000000..571fddd --- /dev/null +++ b/docs/c4/workspace.dsl @@ -0,0 +1,268 @@ +workspace "Org Bot" "Architecture model for Org Bot system" { + + model { + user = person "Beatiful you" "Telegram user interacting with the bot" + + terraform = softwareSystem "Terraform" "IaC for provisioning and managing cloud resources" + + orgBot = softwareSystem "Org Bot" "Telegram bot for managing org-mode notes and journal entries" { + + main = container "Main" "Entry point for the bot application" "Python" { + tags "Container" + + httpEntrypoint = component "HTTP Entrypoint" "GCP Cloud Function handler for incoming webhooks" "Python" { + tags "Component" + } + + botInitializer = component "Bot Initializer" "Creates and manages bot instances" "Python" { + tags "Component" + } + + messageHandler = component "Message Handler" "Handles incoming Telegram messages" "Python" { + tags "Component" + } + + messageProcessor = component "Message Processor" "Processes commands and non-command messages" "Python" { + tags "Component" + } + + messageSender = component "Message Sender" "Sends responses back to Telegram" "Python" { + tags "Component" + } + + sentryInit = component "Sentry Initialization" "Initializes error tracking" "Python" { + tags "Component" + } + } + + config = container "Config" "Configuration management" "Python" { + tags "Container" + + commandInit = component "Command Initialization" "Initializes bot commands" "Python" { + tags "Component" + } + + actionConfig = component "Action Configuration" "Configures journal, todo, and reply actions" "Python" { + tags "Component" + } + + envConfig = component "Environment Configuration" "Loads environment variables" "Python" { + tags "Component" + } + } + + auth = container "Auth" "Authentication and authorization" "Python" { + tags "Container" + + authCheck = component "Authorization Check" "Validates if message comes from authorized chat" "Python" { + tags "Component" + } + + ignoreCheck = component "Ignore Check" "Checks if message comes from ignored chat" "Python" { + tags "Component" + } + + unauthorizedForwarder = component "Unauthorized Forwarder" "Forwards unauthorized messages to admin" "Python" { + tags "Component" + } + } + + orgApi = container "Org API" "API for org-mode operations" "Python" { + tags "Container" + + entryFinder = component "Entry Finder" "Finds entries in org files by message links" "Python" { + tags "Component" + } + + topLevelFinder = component "Top Level Finder" "Finds top-level non-reply entries" "Python" { + tags "Component" + } + + replyInserter = component "Reply Inserter" "Inserts replies at correct org hierarchy position" "Python" { + tags "Component" + } + + fileCreator = component "File Creator" "Creates new files in repository" "Python" { + tags "Component" + } + + textAppender = component "Text Appender" "Appends text to org files" "Python" { + tags "Component" + } + } + + utils = container "Utils" "Utility functions" "Python" { + tags "Container" + + messageTextExtractor = component "Message Text Extractor" "Extracts text from various message types" "Python" { + tags "Component" + } + } + + baseCommand = container "Base Command" "Base class for bot commands" "Python" { + tags "Container" + + commandBase = component "Command Base" "Abstract base class for all commands" "Python" { + tags "Component" + } + } + + commands = container "Commands" "Bot command handlers module" "Python" { + tags "Module" + + startCommand = component "Start Command" "Handles /start command" "Python" { + tags "Component" + } + + infoCommand = component "Info Command" "Handles /info command" "Python" { + tags "Component" + } + + webhookCommand = component "Webhook Command" "Handles webhook operations" "Python" { + tags "Component" + } + + postToJournal = component "Post to Journal" "Handles posting entries to journal" "Python" { + tags "Component" + } + } + + tracing = container "Tracing" "Logging and tracing module" "Python" { + tags "Module" + + gcp_log = component "Log" "Logging utilities" "Python" { + tags "Component" + } + } + } + + # Relationships - System Level + user -> orgBot "Interacts with" + orgBot -> terraform "Deployed using" + + # Relationships - Container Level + main -> commands + main -> config "Get / commands" + main -> config "Get actions" + main -> auth + main -> orgApi + main -> utils + main -> gcp_log "Configure GCP structured logging" + + commands -> baseCommand "Extends" + commands -> tracing "Uses for logging" + commands -> orgApi + + startCommand -> baseCommand "Extends" + infoCommand -> baseCommand "Extends" + webhookCommand -> baseCommand "Extends" + postToJournal -> baseCommand "Extends" + + + orgApi -> config + auth -> config + + # Relationships - Component Level (Main) + httpEntrypoint -> messageHandler "Delegates to" + messageHandler -> authCheck "Checks authorization" + messageHandler -> messageProcessor "Processes message" + messageProcessor -> commandInit "Gets commands" + messageProcessor -> actionConfig "Gets actions" + messageProcessor -> messageTextExtractor "Extracts text" + messageHandler -> messageSender "Sends response" + messageSender -> botInitializer "Gets bot instance" + + # Relationships - Component Level (Config) + commandInit -> envConfig + actionConfig -> envConfig + + # Relationships - Component Level (Auth) + authCheck -> envConfig + authCheck -> unauthorizedForwarder "Forwards unauthorized" + ignoreCheck -> envConfig + + # Relationships - Component Level (Org API) + entryFinder -> topLevelFinder "Finds parent" + replyInserter -> entryFinder + replyInserter -> topLevelFinder + textAppender -> fileCreator "May create file" + + # Relationships - Component Level (Commands) + startCommand -> commandBase "Extends" + infoCommand -> commandBase "Extends" + webhookCommand -> commandBase "Extends" + postToJournal -> commandBase "Extends" + postToJournal -> textAppender + postToJournal -> replyInserter + } + + views { + systemContext orgBot "SystemContext" { + include * + } + + container orgBot "Containers" { + include * + } + + component main "MainComponents" { + include * + } + + component config "ConfigComponents" { + include * + } + + component auth "AuthComponents" { + include * + } + + component orgApi "OrgApiComponents" { + include * + } + + component utils "UtilsComponents" { + include * + } + + component baseCommand "BaseCommandComponents" { + include * + } + + component commands "CommandsComponents" { + include * + } + + component tracing "TracingComponents" { + include * + } + + styles { + element "Software System" { + background #1168bd + color #ffffff + } + element "Container" { + background #438dd5 + color #ffffff + } + element "Module" { + background #85bbf0 + color #000000 + } + element "Component" { + background #a5c9f5 + color #000000 + } + element "Person" { + shape person + background #08427b + color #ffffff + } + } + + theme https://static.structurizr.com/themes/google-cloud-platform-v1.5/theme.json + + } + +} diff --git a/docs/c4/workspace.json b/docs/c4/workspace.json new file mode 100644 index 0000000..e077386 --- /dev/null +++ b/docs/c4/workspace.json @@ -0,0 +1,1199 @@ +{ + "configuration" : { }, + "description" : "Architecture model for Org Bot system", + "documentation" : { }, + "id" : 1, + "lastModifiedAgent" : "structurizr-ui", + "lastModifiedDate" : "2025-12-29T11:50:53Z", + "model" : { + "people" : [ { + "description" : "Telegram user interacting with the bot", + "id" : "1", + "name" : "Beatiful you", + "properties" : { + "structurizr.dsl.identifier" : "user" + }, + "relationships" : [ { + "description" : "Interacts with", + "destinationId" : "3", + "id" : "36", + "sourceId" : "1", + "tags" : "Relationship" + } ], + "tags" : "Element,Person" + } ], + "softwareSystems" : [ { + "description" : "IaC for provisioning and managing cloud resources", + "documentation" : { }, + "id" : "2", + "name" : "Terraform", + "properties" : { + "structurizr.dsl.identifier" : "terraform" + }, + "tags" : "Element,Software System" + }, { + "containers" : [ { + "components" : [ { + "description" : "GCP Cloud Function handler for incoming webhooks", + "documentation" : { }, + "id" : "5", + "name" : "HTTP Entrypoint", + "properties" : { + "structurizr.dsl.identifier" : "httpEntrypoint" + }, + "relationships" : [ { + "description" : "Delegates to", + "destinationId" : "7", + "id" : "55", + "sourceId" : "5", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Creates and manages bot instances", + "documentation" : { }, + "id" : "6", + "name" : "Bot Initializer", + "properties" : { + "structurizr.dsl.identifier" : "botInitializer" + }, + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Handles incoming Telegram messages", + "documentation" : { }, + "id" : "7", + "name" : "Message Handler", + "properties" : { + "structurizr.dsl.identifier" : "messageHandler" + }, + "relationships" : [ { + "description" : "Checks authorization", + "destinationId" : "16", + "id" : "56", + "sourceId" : "7", + "tags" : "Relationship" + }, { + "description" : "Checks authorization", + "destinationId" : "15", + "id" : "57", + "linkedRelationshipId" : "56", + "sourceId" : "7" + }, { + "description" : "Processes message", + "destinationId" : "8", + "id" : "59", + "sourceId" : "7", + "tags" : "Relationship" + }, { + "description" : "Sends response", + "destinationId" : "9", + "id" : "68", + "sourceId" : "7", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Processes commands and non-command messages", + "documentation" : { }, + "id" : "8", + "name" : "Message Processor", + "properties" : { + "structurizr.dsl.identifier" : "messageProcessor" + }, + "relationships" : [ { + "description" : "Gets commands", + "destinationId" : "12", + "id" : "60", + "sourceId" : "8", + "tags" : "Relationship" + }, { + "description" : "Gets commands", + "destinationId" : "11", + "id" : "61", + "linkedRelationshipId" : "60", + "sourceId" : "8" + }, { + "description" : "Gets actions", + "destinationId" : "13", + "id" : "63", + "sourceId" : "8", + "tags" : "Relationship" + }, { + "description" : "Extracts text", + "destinationId" : "26", + "id" : "65", + "sourceId" : "8", + "tags" : "Relationship" + }, { + "description" : "Extracts text", + "destinationId" : "25", + "id" : "66", + "linkedRelationshipId" : "65", + "sourceId" : "8" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Sends responses back to Telegram", + "documentation" : { }, + "id" : "9", + "name" : "Message Sender", + "properties" : { + "structurizr.dsl.identifier" : "messageSender" + }, + "relationships" : [ { + "description" : "Gets bot instance", + "destinationId" : "6", + "id" : "69", + "sourceId" : "9", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Initializes error tracking", + "documentation" : { }, + "id" : "10", + "name" : "Sentry Initialization", + "properties" : { + "structurizr.dsl.identifier" : "sentryInit" + }, + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Entry point for the bot application", + "documentation" : { }, + "id" : "4", + "name" : "Main", + "properties" : { + "structurizr.dsl.identifier" : "main" + }, + "relationships" : [ { + "destinationId" : "29", + "id" : "38", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "description" : "Get / commands", + "destinationId" : "11", + "id" : "39", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "description" : "Get actions", + "destinationId" : "11", + "id" : "40", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "destinationId" : "15", + "id" : "41", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "destinationId" : "19", + "id" : "42", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "destinationId" : "25", + "id" : "43", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "description" : "Configure GCP structured logging", + "destinationId" : "35", + "id" : "44", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "description" : "Configure GCP structured logging", + "destinationId" : "34", + "id" : "45", + "linkedRelationshipId" : "44", + "sourceId" : "4" + }, { + "description" : "Checks authorization", + "destinationId" : "16", + "id" : "58", + "linkedRelationshipId" : "56", + "sourceId" : "4" + }, { + "description" : "Gets commands", + "destinationId" : "12", + "id" : "62", + "linkedRelationshipId" : "60", + "sourceId" : "4" + }, { + "description" : "Gets actions", + "destinationId" : "13", + "id" : "64", + "linkedRelationshipId" : "63", + "sourceId" : "4" + }, { + "description" : "Extracts text", + "destinationId" : "26", + "id" : "67", + "linkedRelationshipId" : "65", + "sourceId" : "4" + } ], + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Initializes bot commands", + "documentation" : { }, + "id" : "12", + "name" : "Command Initialization", + "properties" : { + "structurizr.dsl.identifier" : "commandInit" + }, + "relationships" : [ { + "destinationId" : "14", + "id" : "70", + "sourceId" : "12", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Configures journal, todo, and reply actions", + "documentation" : { }, + "id" : "13", + "name" : "Action Configuration", + "properties" : { + "structurizr.dsl.identifier" : "actionConfig" + }, + "relationships" : [ { + "destinationId" : "14", + "id" : "71", + "sourceId" : "13", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Loads environment variables", + "documentation" : { }, + "id" : "14", + "name" : "Environment Configuration", + "properties" : { + "structurizr.dsl.identifier" : "envConfig" + }, + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Configuration management", + "documentation" : { }, + "id" : "11", + "name" : "Config", + "properties" : { + "structurizr.dsl.identifier" : "config" + }, + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Validates if message comes from authorized chat", + "documentation" : { }, + "id" : "16", + "name" : "Authorization Check", + "properties" : { + "structurizr.dsl.identifier" : "authCheck" + }, + "relationships" : [ { + "destinationId" : "14", + "id" : "72", + "sourceId" : "16", + "tags" : "Relationship" + }, { + "destinationId" : "11", + "id" : "73", + "linkedRelationshipId" : "72", + "sourceId" : "16" + }, { + "description" : "Forwards unauthorized", + "destinationId" : "18", + "id" : "75", + "sourceId" : "16", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Checks if message comes from ignored chat", + "documentation" : { }, + "id" : "17", + "name" : "Ignore Check", + "properties" : { + "structurizr.dsl.identifier" : "ignoreCheck" + }, + "relationships" : [ { + "destinationId" : "14", + "id" : "76", + "sourceId" : "17", + "tags" : "Relationship" + }, { + "destinationId" : "11", + "id" : "77", + "linkedRelationshipId" : "76", + "sourceId" : "17" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Forwards unauthorized messages to admin", + "documentation" : { }, + "id" : "18", + "name" : "Unauthorized Forwarder", + "properties" : { + "structurizr.dsl.identifier" : "unauthorizedForwarder" + }, + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Authentication and authorization", + "documentation" : { }, + "id" : "15", + "name" : "Auth", + "properties" : { + "structurizr.dsl.identifier" : "auth" + }, + "relationships" : [ { + "destinationId" : "11", + "id" : "54", + "sourceId" : "15", + "tags" : "Relationship" + }, { + "destinationId" : "14", + "id" : "74", + "linkedRelationshipId" : "72", + "sourceId" : "15" + } ], + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Finds entries in org files by message links", + "documentation" : { }, + "id" : "20", + "name" : "Entry Finder", + "properties" : { + "structurizr.dsl.identifier" : "entryFinder" + }, + "relationships" : [ { + "description" : "Finds parent", + "destinationId" : "21", + "id" : "78", + "sourceId" : "20", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Finds top-level non-reply entries", + "documentation" : { }, + "id" : "21", + "name" : "Top Level Finder", + "properties" : { + "structurizr.dsl.identifier" : "topLevelFinder" + }, + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Inserts replies at correct org hierarchy position", + "documentation" : { }, + "id" : "22", + "name" : "Reply Inserter", + "properties" : { + "structurizr.dsl.identifier" : "replyInserter" + }, + "relationships" : [ { + "destinationId" : "20", + "id" : "79", + "sourceId" : "22", + "tags" : "Relationship" + }, { + "destinationId" : "21", + "id" : "80", + "sourceId" : "22", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Creates new files in repository", + "documentation" : { }, + "id" : "23", + "name" : "File Creator", + "properties" : { + "structurizr.dsl.identifier" : "fileCreator" + }, + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Appends text to org files", + "documentation" : { }, + "id" : "24", + "name" : "Text Appender", + "properties" : { + "structurizr.dsl.identifier" : "textAppender" + }, + "relationships" : [ { + "description" : "May create file", + "destinationId" : "23", + "id" : "81", + "sourceId" : "24", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "API for org-mode operations", + "documentation" : { }, + "id" : "19", + "name" : "Org API", + "properties" : { + "structurizr.dsl.identifier" : "orgApi" + }, + "relationships" : [ { + "destinationId" : "11", + "id" : "53", + "sourceId" : "19", + "tags" : "Relationship" + } ], + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Extracts text from various message types", + "documentation" : { }, + "id" : "26", + "name" : "Message Text Extractor", + "properties" : { + "structurizr.dsl.identifier" : "messageTextExtractor" + }, + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Utility functions", + "documentation" : { }, + "id" : "25", + "name" : "Utils", + "properties" : { + "structurizr.dsl.identifier" : "utils" + }, + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Abstract base class for all commands", + "documentation" : { }, + "id" : "28", + "name" : "Command Base", + "properties" : { + "structurizr.dsl.identifier" : "commandBase" + }, + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Base class for bot commands", + "documentation" : { }, + "id" : "27", + "name" : "Base Command", + "properties" : { + "structurizr.dsl.identifier" : "baseCommand" + }, + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Handles /start command", + "documentation" : { }, + "id" : "30", + "name" : "Start Command", + "properties" : { + "structurizr.dsl.identifier" : "startCommand" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "27", + "id" : "49", + "sourceId" : "30", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "82", + "sourceId" : "30", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Handles /info command", + "documentation" : { }, + "id" : "31", + "name" : "Info Command", + "properties" : { + "structurizr.dsl.identifier" : "infoCommand" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "27", + "id" : "50", + "sourceId" : "31", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "84", + "sourceId" : "31", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Handles webhook operations", + "documentation" : { }, + "id" : "32", + "name" : "Webhook Command", + "properties" : { + "structurizr.dsl.identifier" : "webhookCommand" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "27", + "id" : "51", + "sourceId" : "32", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "85", + "sourceId" : "32", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Handles posting entries to journal", + "documentation" : { }, + "id" : "33", + "name" : "Post to Journal", + "properties" : { + "structurizr.dsl.identifier" : "postToJournal" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "27", + "id" : "52", + "sourceId" : "33", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "86", + "sourceId" : "33", + "tags" : "Relationship" + }, { + "destinationId" : "24", + "id" : "87", + "sourceId" : "33", + "tags" : "Relationship" + }, { + "destinationId" : "19", + "id" : "88", + "linkedRelationshipId" : "87", + "sourceId" : "33" + }, { + "destinationId" : "22", + "id" : "90", + "sourceId" : "33", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Bot command handlers module", + "documentation" : { }, + "id" : "29", + "name" : "Commands", + "properties" : { + "structurizr.dsl.identifier" : "commands" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "27", + "id" : "46", + "sourceId" : "29", + "tags" : "Relationship" + }, { + "description" : "Uses for logging", + "destinationId" : "34", + "id" : "47", + "sourceId" : "29", + "tags" : "Relationship" + }, { + "destinationId" : "19", + "id" : "48", + "sourceId" : "29", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "83", + "linkedRelationshipId" : "82", + "sourceId" : "29" + }, { + "destinationId" : "24", + "id" : "89", + "linkedRelationshipId" : "87", + "sourceId" : "29" + }, { + "destinationId" : "22", + "id" : "91", + "linkedRelationshipId" : "90", + "sourceId" : "29" + } ], + "tags" : "Element,Container,Module", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Logging utilities", + "documentation" : { }, + "id" : "35", + "name" : "Log", + "properties" : { + "structurizr.dsl.identifier" : "gcp_log" + }, + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Logging and tracing module", + "documentation" : { }, + "id" : "34", + "name" : "Tracing", + "properties" : { + "structurizr.dsl.identifier" : "tracing" + }, + "tags" : "Element,Container,Module", + "technology" : "Python" + } ], + "description" : "Telegram bot for managing org-mode notes and journal entries", + "documentation" : { }, + "id" : "3", + "name" : "Org Bot", + "properties" : { + "structurizr.dsl.identifier" : "orgBot" + }, + "relationships" : [ { + "description" : "Deployed using", + "destinationId" : "2", + "id" : "37", + "sourceId" : "3", + "tags" : "Relationship" + } ], + "tags" : "Element,Software System" + } ] + }, + "name" : "Org Bot", + "properties" : { + "structurizr.inspection.info" : "0", + "structurizr.inspection.ignore" : "0", + "structurizr.inspection.error" : "81", + "structurizr.inspection.warning" : "0", + "structurizr.dsl" : "d29ya3NwYWNlICJPcmcgQm90IiAiQXJjaGl0ZWN0dXJlIG1vZGVsIGZvciBPcmcgQm90IHN5c3RlbSIgewoKICAgIG1vZGVsIHsKICAgICAgICB1c2VyID0gcGVyc29uICJCZWF0aWZ1bCB5b3UiICJUZWxlZ3JhbSB1c2VyIGludGVyYWN0aW5nIHdpdGggdGhlIGJvdCIKCiAgICAgICAgdGVycmFmb3JtID0gc29mdHdhcmVTeXN0ZW0gIlRlcnJhZm9ybSIgIklhQyBmb3IgcHJvdmlzaW9uaW5nIGFuZCBtYW5hZ2luZyBjbG91ZCByZXNvdXJjZXMiCgogICAgICAgIG9yZ0JvdCA9IHNvZnR3YXJlU3lzdGVtICJPcmcgQm90IiAiVGVsZWdyYW0gYm90IGZvciBtYW5hZ2luZyBvcmctbW9kZSBub3RlcyBhbmQgam91cm5hbCBlbnRyaWVzIiB7CgogICAgICAgICAgICBtYWluID0gY29udGFpbmVyICJNYWluIiAiRW50cnkgcG9pbnQgZm9yIHRoZSBib3QgYXBwbGljYXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBodHRwRW50cnlwb2ludCA9IGNvbXBvbmVudCAiSFRUUCBFbnRyeXBvaW50IiAiR0NQIENsb3VkIEZ1bmN0aW9uIGhhbmRsZXIgZm9yIGluY29taW5nIHdlYmhvb2tzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGJvdEluaXRpYWxpemVyID0gY29tcG9uZW50ICJCb3QgSW5pdGlhbGl6ZXIiICJDcmVhdGVzIGFuZCBtYW5hZ2VzIGJvdCBpbnN0YW5jZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZUhhbmRsZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgSGFuZGxlciIgIkhhbmRsZXMgaW5jb21pbmcgVGVsZWdyYW0gbWVzc2FnZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZVByb2Nlc3NvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBQcm9jZXNzb3IiICJQcm9jZXNzZXMgY29tbWFuZHMgYW5kIG5vbi1jb21tYW5kIG1lc3NhZ2VzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIG1lc3NhZ2VTZW5kZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgU2VuZGVyIiAiU2VuZHMgcmVzcG9uc2VzIGJhY2sgdG8gVGVsZWdyYW0iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgc2VudHJ5SW5pdCA9IGNvbXBvbmVudCAiU2VudHJ5IEluaXRpYWxpemF0aW9uIiAiSW5pdGlhbGl6ZXMgZXJyb3IgdHJhY2tpbmciICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbmZpZyA9IGNvbnRhaW5lciAiQ29uZmlnIiAiQ29uZmlndXJhdGlvbiBtYW5hZ2VtZW50IiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJDb250YWluZXIiCgogICAgICAgICAgICAgICAgY29tbWFuZEluaXQgPSBjb21wb25lbnQgIkNvbW1hbmQgSW5pdGlhbGl6YXRpb24iICJJbml0aWFsaXplcyBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgYWN0aW9uQ29uZmlnID0gY29tcG9uZW50ICJBY3Rpb24gQ29uZmlndXJhdGlvbiIgIkNvbmZpZ3VyZXMgam91cm5hbCwgdG9kbywgYW5kIHJlcGx5IGFjdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZW52Q29uZmlnID0gY29tcG9uZW50ICJFbnZpcm9ubWVudCBDb25maWd1cmF0aW9uIiAiTG9hZHMgZW52aXJvbm1lbnQgdmFyaWFibGVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBhdXRoID0gY29udGFpbmVyICJBdXRoIiAiQXV0aGVudGljYXRpb24gYW5kIGF1dGhvcml6YXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBhdXRoQ2hlY2sgPSBjb21wb25lbnQgIkF1dGhvcml6YXRpb24gQ2hlY2siICJWYWxpZGF0ZXMgaWYgbWVzc2FnZSBjb21lcyBmcm9tIGF1dGhvcml6ZWQgY2hhdCIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICBpZ25vcmVDaGVjayA9IGNvbXBvbmVudCAiSWdub3JlIENoZWNrIiAiQ2hlY2tzIGlmIG1lc3NhZ2UgY29tZXMgZnJvbSBpZ25vcmVkIGNoYXQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdW5hdXRob3JpemVkRm9yd2FyZGVyID0gY29tcG9uZW50ICJVbmF1dGhvcml6ZWQgRm9yd2FyZGVyIiAiRm9yd2FyZHMgdW5hdXRob3JpemVkIG1lc3NhZ2VzIHRvIGFkbWluIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBvcmdBcGkgPSBjb250YWluZXIgIk9yZyBBUEkiICJBUEkgZm9yIG9yZy1tb2RlIG9wZXJhdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBlbnRyeUZpbmRlciA9IGNvbXBvbmVudCAiRW50cnkgRmluZGVyIiAiRmluZHMgZW50cmllcyBpbiBvcmcgZmlsZXMgYnkgbWVzc2FnZSBsaW5rcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICB0b3BMZXZlbEZpbmRlciA9IGNvbXBvbmVudCAiVG9wIExldmVsIEZpbmRlciIgIkZpbmRzIHRvcC1sZXZlbCBub24tcmVwbHkgZW50cmllcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICByZXBseUluc2VydGVyID0gY29tcG9uZW50ICJSZXBseSBJbnNlcnRlciIgIkluc2VydHMgcmVwbGllcyBhdCBjb3JyZWN0IG9yZyBoaWVyYXJjaHkgcG9zaXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZmlsZUNyZWF0b3IgPSBjb21wb25lbnQgIkZpbGUgQ3JlYXRvciIgIkNyZWF0ZXMgbmV3IGZpbGVzIGluIHJlcG9zaXRvcnkiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdGV4dEFwcGVuZGVyID0gY29tcG9uZW50ICJUZXh0IEFwcGVuZGVyIiAiQXBwZW5kcyB0ZXh0IHRvIG9yZyBmaWxlcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQoKICAgICAgICAgICAgdXRpbHMgPSBjb250YWluZXIgIlV0aWxzIiAiVXRpbGl0eSBmdW5jdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBtZXNzYWdlVGV4dEV4dHJhY3RvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBUZXh0IEV4dHJhY3RvciIgIkV4dHJhY3RzIHRleHQgZnJvbSB2YXJpb3VzIG1lc3NhZ2UgdHlwZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGJhc2VDb21tYW5kID0gY29udGFpbmVyICJCYXNlIENvbW1hbmQiICJCYXNlIGNsYXNzIGZvciBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBjb21tYW5kQmFzZSA9IGNvbXBvbmVudCAiQ29tbWFuZCBCYXNlIiAiQWJzdHJhY3QgYmFzZSBjbGFzcyBmb3IgYWxsIGNvbW1hbmRzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBjb21tYW5kcyA9IGNvbnRhaW5lciAiQ29tbWFuZHMiICJCb3QgY29tbWFuZCBoYW5kbGVycyBtb2R1bGUiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIk1vZHVsZSIKCiAgICAgICAgICAgICAgICBzdGFydENvbW1hbmQgPSBjb21wb25lbnQgIlN0YXJ0IENvbW1hbmQiICJIYW5kbGVzIC9zdGFydCBjb21tYW5kIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGluZm9Db21tYW5kID0gY29tcG9uZW50ICJJbmZvIENvbW1hbmQiICJIYW5kbGVzIC9pbmZvIGNvbW1hbmQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgd2ViaG9va0NvbW1hbmQgPSBjb21wb25lbnQgIldlYmhvb2sgQ29tbWFuZCIgIkhhbmRsZXMgd2ViaG9vayBvcGVyYXRpb25zIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIHBvc3RUb0pvdXJuYWwgPSBjb21wb25lbnQgIlBvc3QgdG8gSm91cm5hbCIgIkhhbmRsZXMgcG9zdGluZyBlbnRyaWVzIHRvIGpvdXJuYWwiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIHRyYWNpbmcgPSBjb250YWluZXIgIlRyYWNpbmciICJMb2dnaW5nIGFuZCB0cmFjaW5nIG1vZHVsZSIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgdGFncyAiTW9kdWxlIgoKICAgICAgICAgICAgICAgIGdjcF9sb2cgPSBjb21wb25lbnQgIkxvZyIgIkxvZ2dpbmcgdXRpbGl0aWVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CiAgICAgICAgfQoKICAgICAgICAjIFJlbGF0aW9uc2hpcHMgLSBTeXN0ZW0gTGV2ZWwKICAgICAgICB1c2VyIC0+IG9yZ0JvdCAiSW50ZXJhY3RzIHdpdGgiCiAgICAgICAgb3JnQm90IC0+IHRlcnJhZm9ybSAiRGVwbG95ZWQgdXNpbmciCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbnRhaW5lciBMZXZlbAogICAgICAgIG1haW4gLT4gY29tbWFuZHMKICAgICAgICBtYWluIC0+IGNvbmZpZyAiR2V0IC8gY29tbWFuZHMiCiAgICAgICAgbWFpbiAtPiBjb25maWcgIkdldCBhY3Rpb25zIgogICAgICAgIG1haW4gLT4gYXV0aAogICAgICAgIG1haW4gLT4gb3JnQXBpCiAgICAgICAgbWFpbiAtPiB1dGlscwogICAgICAgIG1haW4gLT4gZ2NwX2xvZyAiQ29uZmlndXJlIEdDUCBzdHJ1Y3R1cmVkIGxvZ2dpbmciCgogICAgICAgIGNvbW1hbmRzIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgogICAgICAgIGNvbW1hbmRzIC0+IHRyYWNpbmcgIlVzZXMgZm9yIGxvZ2dpbmciCiAgICAgICAgY29tbWFuZHMgLT4gb3JnQXBpCgogICAgICAgIHN0YXJ0Q29tbWFuZCAtPiBiYXNlQ29tbWFuZCAiRXh0ZW5kcyIKICAgICAgICBpbmZvQ29tbWFuZCAtPiBiYXNlQ29tbWFuZCAiRXh0ZW5kcyIKICAgICAgICB3ZWJob29rQ29tbWFuZCAtPiBiYXNlQ29tbWFuZCAiRXh0ZW5kcyIKICAgICAgICBwb3N0VG9Kb3VybmFsIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgoKCiAgICAgICAgb3JnQXBpIC0+IGNvbmZpZwogICAgICAgIGF1dGggLT4gY29uZmlnCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoTWFpbikKICAgICAgICBodHRwRW50cnlwb2ludCAtPiBtZXNzYWdlSGFuZGxlciAiRGVsZWdhdGVzIHRvIgogICAgICAgIG1lc3NhZ2VIYW5kbGVyIC0+IGF1dGhDaGVjayAiQ2hlY2tzIGF1dGhvcml6YXRpb24iCiAgICAgICAgbWVzc2FnZUhhbmRsZXIgLT4gbWVzc2FnZVByb2Nlc3NvciAiUHJvY2Vzc2VzIG1lc3NhZ2UiCiAgICAgICAgbWVzc2FnZVByb2Nlc3NvciAtPiBjb21tYW5kSW5pdCAiR2V0cyBjb21tYW5kcyIKICAgICAgICBtZXNzYWdlUHJvY2Vzc29yIC0+IGFjdGlvbkNvbmZpZyAiR2V0cyBhY3Rpb25zIgogICAgICAgIG1lc3NhZ2VQcm9jZXNzb3IgLT4gbWVzc2FnZVRleHRFeHRyYWN0b3IgIkV4dHJhY3RzIHRleHQiCiAgICAgICAgbWVzc2FnZUhhbmRsZXIgLT4gbWVzc2FnZVNlbmRlciAiU2VuZHMgcmVzcG9uc2UiCiAgICAgICAgbWVzc2FnZVNlbmRlciAtPiBib3RJbml0aWFsaXplciAiR2V0cyBib3QgaW5zdGFuY2UiCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoQ29uZmlnKQogICAgICAgIGNvbW1hbmRJbml0IC0+IGVudkNvbmZpZwogICAgICAgIGFjdGlvbkNvbmZpZyAtPiBlbnZDb25maWcKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChBdXRoKQogICAgICAgIGF1dGhDaGVjayAtPiBlbnZDb25maWcKICAgICAgICBhdXRoQ2hlY2sgLT4gdW5hdXRob3JpemVkRm9yd2FyZGVyICJGb3J3YXJkcyB1bmF1dGhvcml6ZWQiCiAgICAgICAgaWdub3JlQ2hlY2sgLT4gZW52Q29uZmlnCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoT3JnIEFQSSkKICAgICAgICBlbnRyeUZpbmRlciAtPiB0b3BMZXZlbEZpbmRlciAiRmluZHMgcGFyZW50IgogICAgICAgIHJlcGx5SW5zZXJ0ZXIgLT4gZW50cnlGaW5kZXIKICAgICAgICByZXBseUluc2VydGVyIC0+IHRvcExldmVsRmluZGVyCiAgICAgICAgdGV4dEFwcGVuZGVyIC0+IGZpbGVDcmVhdG9yICJNYXkgY3JlYXRlIGZpbGUiCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoQ29tbWFuZHMpCiAgICAgICAgc3RhcnRDb21tYW5kIC0+IGNvbW1hbmRCYXNlICJFeHRlbmRzIgogICAgICAgIGluZm9Db21tYW5kIC0+IGNvbW1hbmRCYXNlICJFeHRlbmRzIgogICAgICAgIHdlYmhvb2tDb21tYW5kIC0+IGNvbW1hbmRCYXNlICJFeHRlbmRzIgogICAgICAgIHBvc3RUb0pvdXJuYWwgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgcG9zdFRvSm91cm5hbCAtPiB0ZXh0QXBwZW5kZXIKICAgICAgICBwb3N0VG9Kb3VybmFsIC0+IHJlcGx5SW5zZXJ0ZXIKICAgIH0KCiAgICB2aWV3cyB7CiAgICAgICAgc3lzdGVtQ29udGV4dCBvcmdCb3QgIlN5c3RlbUNvbnRleHQiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb250YWluZXIgb3JnQm90ICJDb250YWluZXJzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IG1haW4gIk1haW5Db21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGNvbmZpZyAiQ29uZmlnQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBhdXRoICJBdXRoQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBvcmdBcGkgIk9yZ0FwaUNvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgdXRpbHMgIlV0aWxzQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBiYXNlQ29tbWFuZCAiQmFzZUNvbW1hbmRDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGNvbW1hbmRzICJDb21tYW5kc0NvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgdHJhY2luZyAiVHJhY2luZ0NvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBzdHlsZXMgewogICAgICAgICAgICBlbGVtZW50ICJTb2Z0d2FyZSBTeXN0ZW0iIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzExNjhiZAogICAgICAgICAgICAgICAgY29sb3IgI2ZmZmZmZgogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkNvbnRhaW5lciIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjNDM4ZGQ1CiAgICAgICAgICAgICAgICBjb2xvciAjZmZmZmZmCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiTW9kdWxlIiB7CiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICM4NWJiZjAKICAgICAgICAgICAgICAgIGNvbG9yICMwMDAwMDAKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJDb21wb25lbnQiIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgI2E1YzlmNQogICAgICAgICAgICAgICAgY29sb3IgIzAwMDAwMAogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIlBlcnNvbiIgewogICAgICAgICAgICAgICAgc2hhcGUgcGVyc29uCiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICMwODQyN2IKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgdGhlbWUgaHR0cHM6Ly9zdGF0aWMuc3RydWN0dXJpenIuY29tL3RoZW1lcy9nb29nbGUtY2xvdWQtcGxhdGZvcm0tdjEuNS90aGVtZS5qc29uCgogICAgfQoKfQ==" + }, + "views" : { + "componentViews" : [ { + "containerId" : "4", + "dimensions" : { + "height" : 2738, + "width" : 3068 + }, + "elements" : [ { + "id" : "5", + "x" : 239, + "y" : 200 + }, { + "id" : "6", + "x" : 239, + "y" : 2000 + }, { + "id" : "7", + "x" : 614, + "y" : 800 + }, { + "id" : "8", + "x" : 989, + "y" : 1400 + }, { + "id" : "9", + "x" : 239, + "y" : 1400 + }, { + "id" : "10", + "x" : 989, + "y" : 200 + }, { + "id" : "11", + "x" : 2397, + "y" : 2000 + }, { + "id" : "15", + "x" : 2068, + "y" : 1400 + }, { + "id" : "25", + "x" : 1647, + "y" : 2000 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "MainComponents", + "name" : "Component View: Org Bot - Main", + "order" : 3, + "relationships" : [ { + "id" : "54" + }, { + "id" : "55" + }, { + "id" : "57" + }, { + "id" : "59" + }, { + "id" : "61", + "vertices" : [ { + "x" : 1456, + "y" : 1700 + }, { + "x" : 2247, + "y" : 2000 + } ] + }, { + "id" : "66" + }, { + "id" : "68" + }, { + "id" : "69" + } ] + }, { + "containerId" : "11", + "dimensions" : { + "height" : 2118, + "width" : 2410 + }, + "elements" : [ { + "id" : "4", + "x" : 990, + "y" : 180 + }, { + "id" : "12", + "x" : 990, + "y" : 825 + }, { + "id" : "13", + "x" : 250, + "y" : 820 + }, { + "id" : "14", + "x" : 990, + "y" : 1380 + }, { + "id" : "15", + "x" : 1675, + "y" : 1380 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "ConfigComponents", + "name" : "Component View: Org Bot - Config", + "order" : 4, + "relationships" : [ { + "id" : "41", + "vertices" : [ { + "x" : 1900, + "y" : 335 + } ] + }, { + "id" : "62" + }, { + "id" : "64" + }, { + "id" : "70" + }, { + "id" : "71" + }, { + "id" : "74" + } ] + }, { + "containerId" : "15", + "dimensions" : { + "height" : 2008, + "width" : 2885 + }, + "elements" : [ { + "id" : "4", + "x" : 1490, + "y" : 180 + }, { + "id" : "11", + "x" : 2220, + "y" : 1255 + }, { + "id" : "16", + "x" : 1005, + "y" : 1265 + }, { + "id" : "17", + "x" : 1479, + "y" : 935 + }, { + "id" : "18", + "x" : 239, + "y" : 1266 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "AuthComponents", + "name" : "Component View: Org Bot - Auth", + "order" : 5, + "relationships" : [ { + "id" : "39", + "vertices" : [ { + "x" : 2420, + "y" : 600 + } ] + }, { + "id" : "40", + "vertices" : [ { + "x" : 2255, + "y" : 665 + }, { + "x" : 2260, + "y" : 1195 + } ] + }, { + "id" : "58", + "vertices" : [ { + "x" : 1225, + "y" : 610 + } ] + }, { + "id" : "73" + }, { + "id" : "75" + }, { + "id" : "77" + } ] + }, { + "containerId" : "19", + "dimensions" : { + "height" : 2718, + "width" : 1834 + }, + "elements" : [ { + "id" : "20", + "x" : 989, + "y" : 1379 + }, { + "id" : "21", + "x" : 1143, + "y" : 1979 + }, { + "id" : "22", + "x" : 989, + "y" : 779 + }, { + "id" : "23", + "x" : 239, + "y" : 1379 + }, { + "id" : "24", + "x" : 239, + "y" : 779 + }, { + "id" : "29", + "x" : 614, + "y" : 179 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "OrgApiComponents", + "name" : "Component View: Org Bot - Org API", + "order" : 6, + "relationships" : [ { + "id" : "78" + }, { + "id" : "79" + }, { + "id" : "80", + "vertices" : [ { + "x" : 1589, + "y" : 1379 + }, { + "x" : 1589, + "y" : 1679 + } ] + }, { + "id" : "81" + }, { + "id" : "89" + }, { + "id" : "91" + } ] + }, { + "containerId" : "25", + "dimensions" : { + "height" : 1518, + "width" : 979 + }, + "elements" : [ { + "id" : "4", + "x" : 264, + "y" : 179 + }, { + "id" : "26", + "x" : 264, + "y" : 779 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "UtilsComponents", + "name" : "Component View: Org Bot - Utils", + "order" : 7, + "relationships" : [ { + "id" : "67" + } ] + }, { + "containerId" : "27", + "dimensions" : { + "height" : 1518, + "width" : 979 + }, + "elements" : [ { + "id" : "28", + "x" : 264, + "y" : 779 + }, { + "id" : "29", + "x" : 264, + "y" : 179 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "BaseCommandComponents", + "name" : "Component View: Org Bot - Base Command", + "order" : 8, + "relationships" : [ { + "id" : "83" + } ] + }, { + "containerId" : "29", + "dimensions" : { + "height" : 1439, + "width" : 3180 + }, + "elements" : [ { + "id" : "19", + "x" : 2485, + "y" : 835 + }, { + "id" : "27", + "x" : 890, + "y" : 895 + }, { + "id" : "30", + "x" : 239, + "y" : 200 + }, { + "id" : "31", + "x" : 1739, + "y" : 200 + }, { + "id" : "32", + "x" : 989, + "y" : 200 + }, { + "id" : "33", + "x" : 2489, + "y" : 200 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "CommandsComponents", + "name" : "Component View: Org Bot - Commands", + "order" : 9, + "relationships" : [ { + "id" : "49" + }, { + "id" : "50" + }, { + "id" : "51" + }, { + "id" : "52", + "vertices" : [ { + "x" : 2339, + "y" : 604 + }, { + "x" : 1589, + "y" : 800 + } ] + }, { + "id" : "88" + } ] + }, { + "containerId" : "34", + "dimensions" : { + "height" : 1518, + "width" : 979 + }, + "elements" : [ { + "id" : "4", + "x" : 0, + "y" : 0 + }, { + "id" : "35", + "x" : 264, + "y" : 779 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "TracingComponents", + "name" : "Component View: Org Bot - Tracing", + "order" : 10, + "relationships" : [ { + "id" : "44" + } ] + } ], + "configuration" : { + "branding" : { }, + "lastSavedView" : "CommandsComponents", + "metadataSymbols" : "SquareBrackets", + "styles" : { + "elements" : [ { + "background" : "#a5c9f5", + "color" : "#000000", + "tag" : "Component" + }, { + "background" : "#438dd5", + "color" : "#ffffff", + "tag" : "Container" + }, { + "background" : "#85bbf0", + "color" : "#000000", + "tag" : "Module" + }, { + "background" : "#08427b", + "color" : "#ffffff", + "shape" : "Person", + "tag" : "Person" + }, { + "background" : "#1168bd", + "color" : "#ffffff", + "tag" : "Software System" + } ] + }, + "terminology" : { }, + "themes" : [ "https://static.structurizr.com/themes/google-cloud-platform-v1.5/theme.json" ] + }, + "containerViews" : [ { + "dimensions" : { + "height" : 2019, + "width" : 3951 + }, + "elements" : [ { + "id" : "4", + "x" : 1398, + "y" : 179 + }, { + "id" : "11", + "x" : 1385, + "y" : 1515 + }, { + "id" : "15", + "x" : 315, + "y" : 1520 + }, { + "id" : "19", + "x" : 2105, + "y" : 1055 + }, { + "id" : "25", + "x" : 945, + "y" : 995 + }, { + "id" : "27", + "x" : 2680, + "y" : 220 + }, { + "id" : "29", + "x" : 2680, + "y" : 595 + }, { + "id" : "34", + "x" : 2685, + "y" : 1705 + } ], + "externalSoftwareSystemBoundariesVisible" : false, + "key" : "Containers", + "name" : "Container View: Org Bot", + "order" : 2, + "relationships" : [ { + "id" : "38" + }, { + "id" : "39" + }, { + "id" : "40" + }, { + "id" : "41", + "vertices" : [ { + "x" : 585, + "y" : 635 + } ] + }, { + "id" : "42" + }, { + "id" : "43" + }, { + "id" : "45" + }, { + "id" : "46" + }, { + "id" : "47" + }, { + "id" : "48" + }, { + "id" : "53" + }, { + "id" : "54" + } ], + "softwareSystemId" : "3" + } ], + "systemContextViews" : [ { + "dimensions" : { + "height" : 1401, + "width" : 1626 + }, + "elements" : [ { + "id" : "1", + "x" : 225, + "y" : 159 + }, { + "id" : "2", + "x" : 976, + "y" : 860 + }, { + "id" : "3", + "x" : 200, + "y" : 859 + } ], + "enterpriseBoundaryVisible" : true, + "key" : "SystemContext", + "name" : "System Context View: Org Bot", + "order" : 1, + "relationships" : [ { + "id" : "36" + }, { + "id" : "37" + } ], + "softwareSystemId" : "3" + } ] + } +} \ No newline at end of file diff --git a/taskfile.yml b/taskfile.yml index 425be45..817451f 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -1,17 +1,18 @@ # https://taskfile.dev -version: '3' - +version: "3" env: ENV: production -dotenv: ['config/{{.ENV}}/secrets.env'] +dotenv: ["config/{{.ENV}}/secrets.env"] + +vars: + TOOLS_DIRECTORY: ".bin" tasks: default: cmds: - - echo "env is activated using pyenv" silent: true @@ -94,3 +95,10 @@ tasks: cmds: - uv run pytest-watch tests/ -v silent: false + + structurizr: + desc: "Run Structurizr Lite server for C4 diagrams" + dir: "docs/c4" + cmds: + - docker compose down + - docker compose up -d From d30f1494c16a77b7896175dd1151c975a68a53c9 Mon Sep 17 00:00:00 2001 From: George Green Date: Mon, 29 Dec 2025 12:55:30 +0100 Subject: [PATCH 07/13] diagrams --- docs/c4/workspace.dsl | 12 ++ docs/c4/workspace.json | 264 +++++++++++++++++++++-------------------- 2 files changed, 150 insertions(+), 126 deletions(-) diff --git a/docs/c4/workspace.dsl b/docs/c4/workspace.dsl index 571fddd..af19587 100644 --- a/docs/c4/workspace.dsl +++ b/docs/c4/workspace.dsl @@ -122,9 +122,21 @@ workspace "Org Bot" "Architecture model for Org Bot system" { tags "Component" } + } + + actions = container "Actions" "Journal, todo, and reply actions module" "Python" { + tags "Module" postToJournal = component "Post to Journal" "Handles posting entries to journal" "Python" { tags "Component" } + + postToTodo = component "Post to Todo" "Handles posting entries to todo list" "Python" { + tags "Component" + } + + postReply = component "Post Reply" "Handles posting replies to entries" "Python" { + tags "Component" + } } tracing = container "Tracing" "Logging and tracing module" "Python" { diff --git a/docs/c4/workspace.json b/docs/c4/workspace.json index e077386..fc79d54 100644 --- a/docs/c4/workspace.json +++ b/docs/c4/workspace.json @@ -3,8 +3,7 @@ "description" : "Architecture model for Org Bot system", "documentation" : { }, "id" : 1, - "lastModifiedAgent" : "structurizr-ui", - "lastModifiedDate" : "2025-12-29T11:50:53Z", + "lastModifiedDate" : "2025-12-29T11:53:32Z", "model" : { "people" : [ { "description" : "Telegram user interacting with the bot", @@ -16,7 +15,7 @@ "relationships" : [ { "description" : "Interacts with", "destinationId" : "3", - "id" : "36", + "id" : "37", "sourceId" : "1", "tags" : "Relationship" } ], @@ -44,7 +43,7 @@ "relationships" : [ { "description" : "Delegates to", "destinationId" : "7", - "id" : "55", + "id" : "56", "sourceId" : "5", "tags" : "Relationship" } ], @@ -71,25 +70,25 @@ "relationships" : [ { "description" : "Checks authorization", "destinationId" : "16", - "id" : "56", + "id" : "57", "sourceId" : "7", "tags" : "Relationship" }, { "description" : "Checks authorization", "destinationId" : "15", - "id" : "57", - "linkedRelationshipId" : "56", + "id" : "58", + "linkedRelationshipId" : "57", "sourceId" : "7" }, { "description" : "Processes message", "destinationId" : "8", - "id" : "59", + "id" : "60", "sourceId" : "7", "tags" : "Relationship" }, { "description" : "Sends response", "destinationId" : "9", - "id" : "68", + "id" : "69", "sourceId" : "7", "tags" : "Relationship" } ], @@ -106,32 +105,32 @@ "relationships" : [ { "description" : "Gets commands", "destinationId" : "12", - "id" : "60", + "id" : "61", "sourceId" : "8", "tags" : "Relationship" }, { "description" : "Gets commands", "destinationId" : "11", - "id" : "61", - "linkedRelationshipId" : "60", + "id" : "62", + "linkedRelationshipId" : "61", "sourceId" : "8" }, { "description" : "Gets actions", "destinationId" : "13", - "id" : "63", + "id" : "64", "sourceId" : "8", "tags" : "Relationship" }, { "description" : "Extracts text", "destinationId" : "26", - "id" : "65", + "id" : "66", "sourceId" : "8", "tags" : "Relationship" }, { "description" : "Extracts text", "destinationId" : "25", - "id" : "66", - "linkedRelationshipId" : "65", + "id" : "67", + "linkedRelationshipId" : "66", "sourceId" : "8" } ], "tags" : "Element,Component", @@ -147,7 +146,7 @@ "relationships" : [ { "description" : "Gets bot instance", "destinationId" : "6", - "id" : "69", + "id" : "70", "sourceId" : "9", "tags" : "Relationship" } ], @@ -173,71 +172,71 @@ }, "relationships" : [ { "destinationId" : "29", - "id" : "38", + "id" : "39", "sourceId" : "4", "tags" : "Relationship" }, { "description" : "Get / commands", "destinationId" : "11", - "id" : "39", + "id" : "40", "sourceId" : "4", "tags" : "Relationship" }, { "description" : "Get actions", "destinationId" : "11", - "id" : "40", + "id" : "41", "sourceId" : "4", "tags" : "Relationship" }, { "destinationId" : "15", - "id" : "41", + "id" : "42", "sourceId" : "4", "tags" : "Relationship" }, { "destinationId" : "19", - "id" : "42", + "id" : "43", "sourceId" : "4", "tags" : "Relationship" }, { "destinationId" : "25", - "id" : "43", + "id" : "44", "sourceId" : "4", "tags" : "Relationship" }, { "description" : "Configure GCP structured logging", - "destinationId" : "35", - "id" : "44", + "destinationId" : "36", + "id" : "45", "sourceId" : "4", "tags" : "Relationship" }, { "description" : "Configure GCP structured logging", - "destinationId" : "34", - "id" : "45", - "linkedRelationshipId" : "44", + "destinationId" : "35", + "id" : "46", + "linkedRelationshipId" : "45", "sourceId" : "4" }, { "description" : "Checks authorization", "destinationId" : "16", - "id" : "58", - "linkedRelationshipId" : "56", + "id" : "59", + "linkedRelationshipId" : "57", "sourceId" : "4" }, { "description" : "Gets commands", "destinationId" : "12", - "id" : "62", - "linkedRelationshipId" : "60", + "id" : "63", + "linkedRelationshipId" : "61", "sourceId" : "4" }, { "description" : "Gets actions", "destinationId" : "13", - "id" : "64", - "linkedRelationshipId" : "63", + "id" : "65", + "linkedRelationshipId" : "64", "sourceId" : "4" }, { "description" : "Extracts text", "destinationId" : "26", - "id" : "67", - "linkedRelationshipId" : "65", + "id" : "68", + "linkedRelationshipId" : "66", "sourceId" : "4" } ], "tags" : "Element,Container", @@ -253,7 +252,7 @@ }, "relationships" : [ { "destinationId" : "14", - "id" : "70", + "id" : "71", "sourceId" : "12", "tags" : "Relationship" } ], @@ -269,7 +268,7 @@ }, "relationships" : [ { "destinationId" : "14", - "id" : "71", + "id" : "72", "sourceId" : "13", "tags" : "Relationship" } ], @@ -306,18 +305,18 @@ }, "relationships" : [ { "destinationId" : "14", - "id" : "72", + "id" : "73", "sourceId" : "16", "tags" : "Relationship" }, { "destinationId" : "11", - "id" : "73", - "linkedRelationshipId" : "72", + "id" : "74", + "linkedRelationshipId" : "73", "sourceId" : "16" }, { "description" : "Forwards unauthorized", "destinationId" : "18", - "id" : "75", + "id" : "76", "sourceId" : "16", "tags" : "Relationship" } ], @@ -333,13 +332,13 @@ }, "relationships" : [ { "destinationId" : "14", - "id" : "76", + "id" : "77", "sourceId" : "17", "tags" : "Relationship" }, { "destinationId" : "11", - "id" : "77", - "linkedRelationshipId" : "76", + "id" : "78", + "linkedRelationshipId" : "77", "sourceId" : "17" } ], "tags" : "Element,Component", @@ -364,13 +363,13 @@ }, "relationships" : [ { "destinationId" : "11", - "id" : "54", + "id" : "55", "sourceId" : "15", "tags" : "Relationship" }, { "destinationId" : "14", - "id" : "74", - "linkedRelationshipId" : "72", + "id" : "75", + "linkedRelationshipId" : "73", "sourceId" : "15" } ], "tags" : "Element,Container", @@ -387,7 +386,7 @@ "relationships" : [ { "description" : "Finds parent", "destinationId" : "21", - "id" : "78", + "id" : "79", "sourceId" : "20", "tags" : "Relationship" } ], @@ -413,12 +412,12 @@ }, "relationships" : [ { "destinationId" : "20", - "id" : "79", + "id" : "80", "sourceId" : "22", "tags" : "Relationship" }, { "destinationId" : "21", - "id" : "80", + "id" : "81", "sourceId" : "22", "tags" : "Relationship" } ], @@ -445,7 +444,7 @@ "relationships" : [ { "description" : "May create file", "destinationId" : "23", - "id" : "81", + "id" : "82", "sourceId" : "24", "tags" : "Relationship" } ], @@ -461,7 +460,7 @@ }, "relationships" : [ { "destinationId" : "11", - "id" : "53", + "id" : "54", "sourceId" : "19", "tags" : "Relationship" } ], @@ -521,13 +520,13 @@ "relationships" : [ { "description" : "Extends", "destinationId" : "27", - "id" : "49", + "id" : "50", "sourceId" : "30", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "82", + "id" : "83", "sourceId" : "30", "tags" : "Relationship" } ], @@ -544,13 +543,13 @@ "relationships" : [ { "description" : "Extends", "destinationId" : "27", - "id" : "50", + "id" : "51", "sourceId" : "31", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "84", + "id" : "85", "sourceId" : "31", "tags" : "Relationship" } ], @@ -567,13 +566,13 @@ "relationships" : [ { "description" : "Extends", "destinationId" : "27", - "id" : "51", + "id" : "52", "sourceId" : "32", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "85", + "id" : "86", "sourceId" : "32", "tags" : "Relationship" } ], @@ -590,28 +589,28 @@ "relationships" : [ { "description" : "Extends", "destinationId" : "27", - "id" : "52", + "id" : "53", "sourceId" : "33", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "86", + "id" : "87", "sourceId" : "33", "tags" : "Relationship" }, { "destinationId" : "24", - "id" : "87", + "id" : "88", "sourceId" : "33", "tags" : "Relationship" }, { "destinationId" : "19", - "id" : "88", - "linkedRelationshipId" : "87", + "id" : "89", + "linkedRelationshipId" : "88", "sourceId" : "33" }, { "destinationId" : "22", - "id" : "90", + "id" : "91", "sourceId" : "33", "tags" : "Relationship" } ], @@ -628,44 +627,54 @@ "relationships" : [ { "description" : "Extends", "destinationId" : "27", - "id" : "46", + "id" : "47", "sourceId" : "29", "tags" : "Relationship" }, { "description" : "Uses for logging", - "destinationId" : "34", - "id" : "47", + "destinationId" : "35", + "id" : "48", "sourceId" : "29", "tags" : "Relationship" }, { "destinationId" : "19", - "id" : "48", + "id" : "49", "sourceId" : "29", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "83", - "linkedRelationshipId" : "82", + "id" : "84", + "linkedRelationshipId" : "83", "sourceId" : "29" }, { "destinationId" : "24", - "id" : "89", - "linkedRelationshipId" : "87", + "id" : "90", + "linkedRelationshipId" : "88", "sourceId" : "29" }, { "destinationId" : "22", - "id" : "91", - "linkedRelationshipId" : "90", + "id" : "92", + "linkedRelationshipId" : "91", "sourceId" : "29" } ], "tags" : "Element,Container,Module", "technology" : "Python" + }, { + "description" : "Journal, todo, and reply actions module", + "documentation" : { }, + "id" : "34", + "name" : "Actions", + "properties" : { + "structurizr.dsl.identifier" : "actions" + }, + "tags" : "Element,Container,Module", + "technology" : "Python" }, { "components" : [ { "description" : "Logging utilities", "documentation" : { }, - "id" : "35", + "id" : "36", "name" : "Log", "properties" : { "structurizr.dsl.identifier" : "gcp_log" @@ -675,7 +684,7 @@ } ], "description" : "Logging and tracing module", "documentation" : { }, - "id" : "34", + "id" : "35", "name" : "Tracing", "properties" : { "structurizr.dsl.identifier" : "tracing" @@ -693,7 +702,7 @@ "relationships" : [ { "description" : "Deployed using", "destinationId" : "2", - "id" : "37", + "id" : "38", "sourceId" : "3", "tags" : "Relationship" } ], @@ -702,11 +711,11 @@ }, "name" : "Org Bot", "properties" : { + "structurizr.inspection.error" : "82", + "structurizr.dsl" : "d29ya3NwYWNlICJPcmcgQm90IiAiQXJjaGl0ZWN0dXJlIG1vZGVsIGZvciBPcmcgQm90IHN5c3RlbSIgewoKICAgIG1vZGVsIHsKICAgICAgICB1c2VyID0gcGVyc29uICJCZWF0aWZ1bCB5b3UiICJUZWxlZ3JhbSB1c2VyIGludGVyYWN0aW5nIHdpdGggdGhlIGJvdCIKCiAgICAgICAgdGVycmFmb3JtID0gc29mdHdhcmVTeXN0ZW0gIlRlcnJhZm9ybSIgIklhQyBmb3IgcHJvdmlzaW9uaW5nIGFuZCBtYW5hZ2luZyBjbG91ZCByZXNvdXJjZXMiCgogICAgICAgIG9yZ0JvdCA9IHNvZnR3YXJlU3lzdGVtICJPcmcgQm90IiAiVGVsZWdyYW0gYm90IGZvciBtYW5hZ2luZyBvcmctbW9kZSBub3RlcyBhbmQgam91cm5hbCBlbnRyaWVzIiB7CgogICAgICAgICAgICBtYWluID0gY29udGFpbmVyICJNYWluIiAiRW50cnkgcG9pbnQgZm9yIHRoZSBib3QgYXBwbGljYXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBodHRwRW50cnlwb2ludCA9IGNvbXBvbmVudCAiSFRUUCBFbnRyeXBvaW50IiAiR0NQIENsb3VkIEZ1bmN0aW9uIGhhbmRsZXIgZm9yIGluY29taW5nIHdlYmhvb2tzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGJvdEluaXRpYWxpemVyID0gY29tcG9uZW50ICJCb3QgSW5pdGlhbGl6ZXIiICJDcmVhdGVzIGFuZCBtYW5hZ2VzIGJvdCBpbnN0YW5jZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZUhhbmRsZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgSGFuZGxlciIgIkhhbmRsZXMgaW5jb21pbmcgVGVsZWdyYW0gbWVzc2FnZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZVByb2Nlc3NvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBQcm9jZXNzb3IiICJQcm9jZXNzZXMgY29tbWFuZHMgYW5kIG5vbi1jb21tYW5kIG1lc3NhZ2VzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIG1lc3NhZ2VTZW5kZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgU2VuZGVyIiAiU2VuZHMgcmVzcG9uc2VzIGJhY2sgdG8gVGVsZWdyYW0iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgc2VudHJ5SW5pdCA9IGNvbXBvbmVudCAiU2VudHJ5IEluaXRpYWxpemF0aW9uIiAiSW5pdGlhbGl6ZXMgZXJyb3IgdHJhY2tpbmciICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbmZpZyA9IGNvbnRhaW5lciAiQ29uZmlnIiAiQ29uZmlndXJhdGlvbiBtYW5hZ2VtZW50IiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJDb250YWluZXIiCgogICAgICAgICAgICAgICAgY29tbWFuZEluaXQgPSBjb21wb25lbnQgIkNvbW1hbmQgSW5pdGlhbGl6YXRpb24iICJJbml0aWFsaXplcyBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgYWN0aW9uQ29uZmlnID0gY29tcG9uZW50ICJBY3Rpb24gQ29uZmlndXJhdGlvbiIgIkNvbmZpZ3VyZXMgam91cm5hbCwgdG9kbywgYW5kIHJlcGx5IGFjdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZW52Q29uZmlnID0gY29tcG9uZW50ICJFbnZpcm9ubWVudCBDb25maWd1cmF0aW9uIiAiTG9hZHMgZW52aXJvbm1lbnQgdmFyaWFibGVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBhdXRoID0gY29udGFpbmVyICJBdXRoIiAiQXV0aGVudGljYXRpb24gYW5kIGF1dGhvcml6YXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBhdXRoQ2hlY2sgPSBjb21wb25lbnQgIkF1dGhvcml6YXRpb24gQ2hlY2siICJWYWxpZGF0ZXMgaWYgbWVzc2FnZSBjb21lcyBmcm9tIGF1dGhvcml6ZWQgY2hhdCIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICBpZ25vcmVDaGVjayA9IGNvbXBvbmVudCAiSWdub3JlIENoZWNrIiAiQ2hlY2tzIGlmIG1lc3NhZ2UgY29tZXMgZnJvbSBpZ25vcmVkIGNoYXQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdW5hdXRob3JpemVkRm9yd2FyZGVyID0gY29tcG9uZW50ICJVbmF1dGhvcml6ZWQgRm9yd2FyZGVyIiAiRm9yd2FyZHMgdW5hdXRob3JpemVkIG1lc3NhZ2VzIHRvIGFkbWluIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBvcmdBcGkgPSBjb250YWluZXIgIk9yZyBBUEkiICJBUEkgZm9yIG9yZy1tb2RlIG9wZXJhdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBlbnRyeUZpbmRlciA9IGNvbXBvbmVudCAiRW50cnkgRmluZGVyIiAiRmluZHMgZW50cmllcyBpbiBvcmcgZmlsZXMgYnkgbWVzc2FnZSBsaW5rcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICB0b3BMZXZlbEZpbmRlciA9IGNvbXBvbmVudCAiVG9wIExldmVsIEZpbmRlciIgIkZpbmRzIHRvcC1sZXZlbCBub24tcmVwbHkgZW50cmllcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICByZXBseUluc2VydGVyID0gY29tcG9uZW50ICJSZXBseSBJbnNlcnRlciIgIkluc2VydHMgcmVwbGllcyBhdCBjb3JyZWN0IG9yZyBoaWVyYXJjaHkgcG9zaXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZmlsZUNyZWF0b3IgPSBjb21wb25lbnQgIkZpbGUgQ3JlYXRvciIgIkNyZWF0ZXMgbmV3IGZpbGVzIGluIHJlcG9zaXRvcnkiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdGV4dEFwcGVuZGVyID0gY29tcG9uZW50ICJUZXh0IEFwcGVuZGVyIiAiQXBwZW5kcyB0ZXh0IHRvIG9yZyBmaWxlcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQoKICAgICAgICAgICAgdXRpbHMgPSBjb250YWluZXIgIlV0aWxzIiAiVXRpbGl0eSBmdW5jdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBtZXNzYWdlVGV4dEV4dHJhY3RvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBUZXh0IEV4dHJhY3RvciIgIkV4dHJhY3RzIHRleHQgZnJvbSB2YXJpb3VzIG1lc3NhZ2UgdHlwZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGJhc2VDb21tYW5kID0gY29udGFpbmVyICJCYXNlIENvbW1hbmQiICJCYXNlIGNsYXNzIGZvciBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBjb21tYW5kQmFzZSA9IGNvbXBvbmVudCAiQ29tbWFuZCBCYXNlIiAiQWJzdHJhY3QgYmFzZSBjbGFzcyBmb3IgYWxsIGNvbW1hbmRzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBjb21tYW5kcyA9IGNvbnRhaW5lciAiQ29tbWFuZHMiICJCb3QgY29tbWFuZCBoYW5kbGVycyBtb2R1bGUiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIk1vZHVsZSIKCiAgICAgICAgICAgICAgICBzdGFydENvbW1hbmQgPSBjb21wb25lbnQgIlN0YXJ0IENvbW1hbmQiICJIYW5kbGVzIC9zdGFydCBjb21tYW5kIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGluZm9Db21tYW5kID0gY29tcG9uZW50ICJJbmZvIENvbW1hbmQiICJIYW5kbGVzIC9pbmZvIGNvbW1hbmQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgd2ViaG9va0NvbW1hbmQgPSBjb21wb25lbnQgIldlYmhvb2sgQ29tbWFuZCIgIkhhbmRsZXMgd2ViaG9vayBvcGVyYXRpb25zIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIHBvc3RUb0pvdXJuYWwgPSBjb21wb25lbnQgIlBvc3QgdG8gSm91cm5hbCIgIkhhbmRsZXMgcG9zdGluZyBlbnRyaWVzIHRvIGpvdXJuYWwiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGFjdGlvbnMgPSBjb250YWluZXIgIkFjdGlvbnMiICJKb3VybmFsLCB0b2RvLCBhbmQgcmVwbHkgYWN0aW9ucyBtb2R1bGUiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIk1vZHVsZSIKICAgICAgICAgICAgfQoKICAgICAgICAgICAgdHJhY2luZyA9IGNvbnRhaW5lciAiVHJhY2luZyIgIkxvZ2dpbmcgYW5kIHRyYWNpbmcgbW9kdWxlIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJNb2R1bGUiCgogICAgICAgICAgICAgICAgZ2NwX2xvZyA9IGNvbXBvbmVudCAiTG9nIiAiTG9nZ2luZyB1dGlsaXRpZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KICAgICAgICB9CgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIFN5c3RlbSBMZXZlbAogICAgICAgIHVzZXIgLT4gb3JnQm90ICJJbnRlcmFjdHMgd2l0aCIKICAgICAgICBvcmdCb3QgLT4gdGVycmFmb3JtICJEZXBsb3llZCB1c2luZyIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29udGFpbmVyIExldmVsCiAgICAgICAgbWFpbiAtPiBjb21tYW5kcwogICAgICAgIG1haW4gLT4gY29uZmlnICJHZXQgLyBjb21tYW5kcyIKICAgICAgICBtYWluIC0+IGNvbmZpZyAiR2V0IGFjdGlvbnMiCiAgICAgICAgbWFpbiAtPiBhdXRoCiAgICAgICAgbWFpbiAtPiBvcmdBcGkKICAgICAgICBtYWluIC0+IHV0aWxzCiAgICAgICAgbWFpbiAtPiBnY3BfbG9nICJDb25maWd1cmUgR0NQIHN0cnVjdHVyZWQgbG9nZ2luZyIKCiAgICAgICAgY29tbWFuZHMgLT4gYmFzZUNvbW1hbmQgIkV4dGVuZHMiCiAgICAgICAgY29tbWFuZHMgLT4gdHJhY2luZyAiVXNlcyBmb3IgbG9nZ2luZyIKICAgICAgICBjb21tYW5kcyAtPiBvcmdBcGkKCiAgICAgICAgc3RhcnRDb21tYW5kIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgogICAgICAgIGluZm9Db21tYW5kIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgogICAgICAgIHdlYmhvb2tDb21tYW5kIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgogICAgICAgIHBvc3RUb0pvdXJuYWwgLT4gYmFzZUNvbW1hbmQgIkV4dGVuZHMiCgoKICAgICAgICBvcmdBcGkgLT4gY29uZmlnCiAgICAgICAgYXV0aCAtPiBjb25maWcKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChNYWluKQogICAgICAgIGh0dHBFbnRyeXBvaW50IC0+IG1lc3NhZ2VIYW5kbGVyICJEZWxlZ2F0ZXMgdG8iCiAgICAgICAgbWVzc2FnZUhhbmRsZXIgLT4gYXV0aENoZWNrICJDaGVja3MgYXV0aG9yaXphdGlvbiIKICAgICAgICBtZXNzYWdlSGFuZGxlciAtPiBtZXNzYWdlUHJvY2Vzc29yICJQcm9jZXNzZXMgbWVzc2FnZSIKICAgICAgICBtZXNzYWdlUHJvY2Vzc29yIC0+IGNvbW1hbmRJbml0ICJHZXRzIGNvbW1hbmRzIgogICAgICAgIG1lc3NhZ2VQcm9jZXNzb3IgLT4gYWN0aW9uQ29uZmlnICJHZXRzIGFjdGlvbnMiCiAgICAgICAgbWVzc2FnZVByb2Nlc3NvciAtPiBtZXNzYWdlVGV4dEV4dHJhY3RvciAiRXh0cmFjdHMgdGV4dCIKICAgICAgICBtZXNzYWdlSGFuZGxlciAtPiBtZXNzYWdlU2VuZGVyICJTZW5kcyByZXNwb25zZSIKICAgICAgICBtZXNzYWdlU2VuZGVyIC0+IGJvdEluaXRpYWxpemVyICJHZXRzIGJvdCBpbnN0YW5jZSIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChDb25maWcpCiAgICAgICAgY29tbWFuZEluaXQgLT4gZW52Q29uZmlnCiAgICAgICAgYWN0aW9uQ29uZmlnIC0+IGVudkNvbmZpZwoKICAgICAgICAjIFJlbGF0aW9uc2hpcHMgLSBDb21wb25lbnQgTGV2ZWwgKEF1dGgpCiAgICAgICAgYXV0aENoZWNrIC0+IGVudkNvbmZpZwogICAgICAgIGF1dGhDaGVjayAtPiB1bmF1dGhvcml6ZWRGb3J3YXJkZXIgIkZvcndhcmRzIHVuYXV0aG9yaXplZCIKICAgICAgICBpZ25vcmVDaGVjayAtPiBlbnZDb25maWcKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChPcmcgQVBJKQogICAgICAgIGVudHJ5RmluZGVyIC0+IHRvcExldmVsRmluZGVyICJGaW5kcyBwYXJlbnQiCiAgICAgICAgcmVwbHlJbnNlcnRlciAtPiBlbnRyeUZpbmRlcgogICAgICAgIHJlcGx5SW5zZXJ0ZXIgLT4gdG9wTGV2ZWxGaW5kZXIKICAgICAgICB0ZXh0QXBwZW5kZXIgLT4gZmlsZUNyZWF0b3IgIk1heSBjcmVhdGUgZmlsZSIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChDb21tYW5kcykKICAgICAgICBzdGFydENvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgaW5mb0NvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgd2ViaG9va0NvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgcG9zdFRvSm91cm5hbCAtPiBjb21tYW5kQmFzZSAiRXh0ZW5kcyIKICAgICAgICBwb3N0VG9Kb3VybmFsIC0+IHRleHRBcHBlbmRlcgogICAgICAgIHBvc3RUb0pvdXJuYWwgLT4gcmVwbHlJbnNlcnRlcgogICAgfQoKICAgIHZpZXdzIHsKICAgICAgICBzeXN0ZW1Db250ZXh0IG9yZ0JvdCAiU3lzdGVtQ29udGV4dCIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbnRhaW5lciBvcmdCb3QgIkNvbnRhaW5lcnMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgbWFpbiAiTWFpbkNvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgY29uZmlnICJDb25maWdDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGF1dGggIkF1dGhDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IG9yZ0FwaSAiT3JnQXBpQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCB1dGlscyAiVXRpbHNDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGJhc2VDb21tYW5kICJCYXNlQ29tbWFuZENvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgY29tbWFuZHMgIkNvbW1hbmRzQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCB0cmFjaW5nICJUcmFjaW5nQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIHN0eWxlcyB7CiAgICAgICAgICAgIGVsZW1lbnQgIlNvZnR3YXJlIFN5c3RlbSIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjMTE2OGJkCiAgICAgICAgICAgICAgICBjb2xvciAjZmZmZmZmCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiQ29udGFpbmVyIiB7CiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICM0MzhkZDUKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJNb2R1bGUiIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzg1YmJmMAogICAgICAgICAgICAgICAgY29sb3IgIzAwMDAwMAogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkNvbXBvbmVudCIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjYTVjOWY1CiAgICAgICAgICAgICAgICBjb2xvciAjMDAwMDAwCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiUGVyc29uIiB7CiAgICAgICAgICAgICAgICBzaGFwZSBwZXJzb24KICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzA4NDI3YgogICAgICAgICAgICAgICAgY29sb3IgI2ZmZmZmZgogICAgICAgICAgICB9CiAgICAgICAgfQoKICAgICAgICB0aGVtZSBodHRwczovL3N0YXRpYy5zdHJ1Y3R1cml6ci5jb20vdGhlbWVzL2dvb2dsZS1jbG91ZC1wbGF0Zm9ybS12MS41L3RoZW1lLmpzb24KCiAgICB9Cgp9", "structurizr.inspection.info" : "0", "structurizr.inspection.ignore" : "0", - "structurizr.inspection.error" : "81", - "structurizr.inspection.warning" : "0", - "structurizr.dsl" : "d29ya3NwYWNlICJPcmcgQm90IiAiQXJjaGl0ZWN0dXJlIG1vZGVsIGZvciBPcmcgQm90IHN5c3RlbSIgewoKICAgIG1vZGVsIHsKICAgICAgICB1c2VyID0gcGVyc29uICJCZWF0aWZ1bCB5b3UiICJUZWxlZ3JhbSB1c2VyIGludGVyYWN0aW5nIHdpdGggdGhlIGJvdCIKCiAgICAgICAgdGVycmFmb3JtID0gc29mdHdhcmVTeXN0ZW0gIlRlcnJhZm9ybSIgIklhQyBmb3IgcHJvdmlzaW9uaW5nIGFuZCBtYW5hZ2luZyBjbG91ZCByZXNvdXJjZXMiCgogICAgICAgIG9yZ0JvdCA9IHNvZnR3YXJlU3lzdGVtICJPcmcgQm90IiAiVGVsZWdyYW0gYm90IGZvciBtYW5hZ2luZyBvcmctbW9kZSBub3RlcyBhbmQgam91cm5hbCBlbnRyaWVzIiB7CgogICAgICAgICAgICBtYWluID0gY29udGFpbmVyICJNYWluIiAiRW50cnkgcG9pbnQgZm9yIHRoZSBib3QgYXBwbGljYXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBodHRwRW50cnlwb2ludCA9IGNvbXBvbmVudCAiSFRUUCBFbnRyeXBvaW50IiAiR0NQIENsb3VkIEZ1bmN0aW9uIGhhbmRsZXIgZm9yIGluY29taW5nIHdlYmhvb2tzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGJvdEluaXRpYWxpemVyID0gY29tcG9uZW50ICJCb3QgSW5pdGlhbGl6ZXIiICJDcmVhdGVzIGFuZCBtYW5hZ2VzIGJvdCBpbnN0YW5jZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZUhhbmRsZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgSGFuZGxlciIgIkhhbmRsZXMgaW5jb21pbmcgVGVsZWdyYW0gbWVzc2FnZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZVByb2Nlc3NvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBQcm9jZXNzb3IiICJQcm9jZXNzZXMgY29tbWFuZHMgYW5kIG5vbi1jb21tYW5kIG1lc3NhZ2VzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIG1lc3NhZ2VTZW5kZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgU2VuZGVyIiAiU2VuZHMgcmVzcG9uc2VzIGJhY2sgdG8gVGVsZWdyYW0iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgc2VudHJ5SW5pdCA9IGNvbXBvbmVudCAiU2VudHJ5IEluaXRpYWxpemF0aW9uIiAiSW5pdGlhbGl6ZXMgZXJyb3IgdHJhY2tpbmciICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbmZpZyA9IGNvbnRhaW5lciAiQ29uZmlnIiAiQ29uZmlndXJhdGlvbiBtYW5hZ2VtZW50IiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJDb250YWluZXIiCgogICAgICAgICAgICAgICAgY29tbWFuZEluaXQgPSBjb21wb25lbnQgIkNvbW1hbmQgSW5pdGlhbGl6YXRpb24iICJJbml0aWFsaXplcyBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgYWN0aW9uQ29uZmlnID0gY29tcG9uZW50ICJBY3Rpb24gQ29uZmlndXJhdGlvbiIgIkNvbmZpZ3VyZXMgam91cm5hbCwgdG9kbywgYW5kIHJlcGx5IGFjdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZW52Q29uZmlnID0gY29tcG9uZW50ICJFbnZpcm9ubWVudCBDb25maWd1cmF0aW9uIiAiTG9hZHMgZW52aXJvbm1lbnQgdmFyaWFibGVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBhdXRoID0gY29udGFpbmVyICJBdXRoIiAiQXV0aGVudGljYXRpb24gYW5kIGF1dGhvcml6YXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBhdXRoQ2hlY2sgPSBjb21wb25lbnQgIkF1dGhvcml6YXRpb24gQ2hlY2siICJWYWxpZGF0ZXMgaWYgbWVzc2FnZSBjb21lcyBmcm9tIGF1dGhvcml6ZWQgY2hhdCIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICBpZ25vcmVDaGVjayA9IGNvbXBvbmVudCAiSWdub3JlIENoZWNrIiAiQ2hlY2tzIGlmIG1lc3NhZ2UgY29tZXMgZnJvbSBpZ25vcmVkIGNoYXQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdW5hdXRob3JpemVkRm9yd2FyZGVyID0gY29tcG9uZW50ICJVbmF1dGhvcml6ZWQgRm9yd2FyZGVyIiAiRm9yd2FyZHMgdW5hdXRob3JpemVkIG1lc3NhZ2VzIHRvIGFkbWluIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBvcmdBcGkgPSBjb250YWluZXIgIk9yZyBBUEkiICJBUEkgZm9yIG9yZy1tb2RlIG9wZXJhdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBlbnRyeUZpbmRlciA9IGNvbXBvbmVudCAiRW50cnkgRmluZGVyIiAiRmluZHMgZW50cmllcyBpbiBvcmcgZmlsZXMgYnkgbWVzc2FnZSBsaW5rcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICB0b3BMZXZlbEZpbmRlciA9IGNvbXBvbmVudCAiVG9wIExldmVsIEZpbmRlciIgIkZpbmRzIHRvcC1sZXZlbCBub24tcmVwbHkgZW50cmllcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICByZXBseUluc2VydGVyID0gY29tcG9uZW50ICJSZXBseSBJbnNlcnRlciIgIkluc2VydHMgcmVwbGllcyBhdCBjb3JyZWN0IG9yZyBoaWVyYXJjaHkgcG9zaXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZmlsZUNyZWF0b3IgPSBjb21wb25lbnQgIkZpbGUgQ3JlYXRvciIgIkNyZWF0ZXMgbmV3IGZpbGVzIGluIHJlcG9zaXRvcnkiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdGV4dEFwcGVuZGVyID0gY29tcG9uZW50ICJUZXh0IEFwcGVuZGVyIiAiQXBwZW5kcyB0ZXh0IHRvIG9yZyBmaWxlcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQoKICAgICAgICAgICAgdXRpbHMgPSBjb250YWluZXIgIlV0aWxzIiAiVXRpbGl0eSBmdW5jdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBtZXNzYWdlVGV4dEV4dHJhY3RvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBUZXh0IEV4dHJhY3RvciIgIkV4dHJhY3RzIHRleHQgZnJvbSB2YXJpb3VzIG1lc3NhZ2UgdHlwZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGJhc2VDb21tYW5kID0gY29udGFpbmVyICJCYXNlIENvbW1hbmQiICJCYXNlIGNsYXNzIGZvciBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBjb21tYW5kQmFzZSA9IGNvbXBvbmVudCAiQ29tbWFuZCBCYXNlIiAiQWJzdHJhY3QgYmFzZSBjbGFzcyBmb3IgYWxsIGNvbW1hbmRzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBjb21tYW5kcyA9IGNvbnRhaW5lciAiQ29tbWFuZHMiICJCb3QgY29tbWFuZCBoYW5kbGVycyBtb2R1bGUiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIk1vZHVsZSIKCiAgICAgICAgICAgICAgICBzdGFydENvbW1hbmQgPSBjb21wb25lbnQgIlN0YXJ0IENvbW1hbmQiICJIYW5kbGVzIC9zdGFydCBjb21tYW5kIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGluZm9Db21tYW5kID0gY29tcG9uZW50ICJJbmZvIENvbW1hbmQiICJIYW5kbGVzIC9pbmZvIGNvbW1hbmQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgd2ViaG9va0NvbW1hbmQgPSBjb21wb25lbnQgIldlYmhvb2sgQ29tbWFuZCIgIkhhbmRsZXMgd2ViaG9vayBvcGVyYXRpb25zIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIHBvc3RUb0pvdXJuYWwgPSBjb21wb25lbnQgIlBvc3QgdG8gSm91cm5hbCIgIkhhbmRsZXMgcG9zdGluZyBlbnRyaWVzIHRvIGpvdXJuYWwiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIHRyYWNpbmcgPSBjb250YWluZXIgIlRyYWNpbmciICJMb2dnaW5nIGFuZCB0cmFjaW5nIG1vZHVsZSIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgdGFncyAiTW9kdWxlIgoKICAgICAgICAgICAgICAgIGdjcF9sb2cgPSBjb21wb25lbnQgIkxvZyIgIkxvZ2dpbmcgdXRpbGl0aWVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CiAgICAgICAgfQoKICAgICAgICAjIFJlbGF0aW9uc2hpcHMgLSBTeXN0ZW0gTGV2ZWwKICAgICAgICB1c2VyIC0+IG9yZ0JvdCAiSW50ZXJhY3RzIHdpdGgiCiAgICAgICAgb3JnQm90IC0+IHRlcnJhZm9ybSAiRGVwbG95ZWQgdXNpbmciCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbnRhaW5lciBMZXZlbAogICAgICAgIG1haW4gLT4gY29tbWFuZHMKICAgICAgICBtYWluIC0+IGNvbmZpZyAiR2V0IC8gY29tbWFuZHMiCiAgICAgICAgbWFpbiAtPiBjb25maWcgIkdldCBhY3Rpb25zIgogICAgICAgIG1haW4gLT4gYXV0aAogICAgICAgIG1haW4gLT4gb3JnQXBpCiAgICAgICAgbWFpbiAtPiB1dGlscwogICAgICAgIG1haW4gLT4gZ2NwX2xvZyAiQ29uZmlndXJlIEdDUCBzdHJ1Y3R1cmVkIGxvZ2dpbmciCgogICAgICAgIGNvbW1hbmRzIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgogICAgICAgIGNvbW1hbmRzIC0+IHRyYWNpbmcgIlVzZXMgZm9yIGxvZ2dpbmciCiAgICAgICAgY29tbWFuZHMgLT4gb3JnQXBpCgogICAgICAgIHN0YXJ0Q29tbWFuZCAtPiBiYXNlQ29tbWFuZCAiRXh0ZW5kcyIKICAgICAgICBpbmZvQ29tbWFuZCAtPiBiYXNlQ29tbWFuZCAiRXh0ZW5kcyIKICAgICAgICB3ZWJob29rQ29tbWFuZCAtPiBiYXNlQ29tbWFuZCAiRXh0ZW5kcyIKICAgICAgICBwb3N0VG9Kb3VybmFsIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgoKCiAgICAgICAgb3JnQXBpIC0+IGNvbmZpZwogICAgICAgIGF1dGggLT4gY29uZmlnCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoTWFpbikKICAgICAgICBodHRwRW50cnlwb2ludCAtPiBtZXNzYWdlSGFuZGxlciAiRGVsZWdhdGVzIHRvIgogICAgICAgIG1lc3NhZ2VIYW5kbGVyIC0+IGF1dGhDaGVjayAiQ2hlY2tzIGF1dGhvcml6YXRpb24iCiAgICAgICAgbWVzc2FnZUhhbmRsZXIgLT4gbWVzc2FnZVByb2Nlc3NvciAiUHJvY2Vzc2VzIG1lc3NhZ2UiCiAgICAgICAgbWVzc2FnZVByb2Nlc3NvciAtPiBjb21tYW5kSW5pdCAiR2V0cyBjb21tYW5kcyIKICAgICAgICBtZXNzYWdlUHJvY2Vzc29yIC0+IGFjdGlvbkNvbmZpZyAiR2V0cyBhY3Rpb25zIgogICAgICAgIG1lc3NhZ2VQcm9jZXNzb3IgLT4gbWVzc2FnZVRleHRFeHRyYWN0b3IgIkV4dHJhY3RzIHRleHQiCiAgICAgICAgbWVzc2FnZUhhbmRsZXIgLT4gbWVzc2FnZVNlbmRlciAiU2VuZHMgcmVzcG9uc2UiCiAgICAgICAgbWVzc2FnZVNlbmRlciAtPiBib3RJbml0aWFsaXplciAiR2V0cyBib3QgaW5zdGFuY2UiCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoQ29uZmlnKQogICAgICAgIGNvbW1hbmRJbml0IC0+IGVudkNvbmZpZwogICAgICAgIGFjdGlvbkNvbmZpZyAtPiBlbnZDb25maWcKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChBdXRoKQogICAgICAgIGF1dGhDaGVjayAtPiBlbnZDb25maWcKICAgICAgICBhdXRoQ2hlY2sgLT4gdW5hdXRob3JpemVkRm9yd2FyZGVyICJGb3J3YXJkcyB1bmF1dGhvcml6ZWQiCiAgICAgICAgaWdub3JlQ2hlY2sgLT4gZW52Q29uZmlnCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoT3JnIEFQSSkKICAgICAgICBlbnRyeUZpbmRlciAtPiB0b3BMZXZlbEZpbmRlciAiRmluZHMgcGFyZW50IgogICAgICAgIHJlcGx5SW5zZXJ0ZXIgLT4gZW50cnlGaW5kZXIKICAgICAgICByZXBseUluc2VydGVyIC0+IHRvcExldmVsRmluZGVyCiAgICAgICAgdGV4dEFwcGVuZGVyIC0+IGZpbGVDcmVhdG9yICJNYXkgY3JlYXRlIGZpbGUiCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoQ29tbWFuZHMpCiAgICAgICAgc3RhcnRDb21tYW5kIC0+IGNvbW1hbmRCYXNlICJFeHRlbmRzIgogICAgICAgIGluZm9Db21tYW5kIC0+IGNvbW1hbmRCYXNlICJFeHRlbmRzIgogICAgICAgIHdlYmhvb2tDb21tYW5kIC0+IGNvbW1hbmRCYXNlICJFeHRlbmRzIgogICAgICAgIHBvc3RUb0pvdXJuYWwgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgcG9zdFRvSm91cm5hbCAtPiB0ZXh0QXBwZW5kZXIKICAgICAgICBwb3N0VG9Kb3VybmFsIC0+IHJlcGx5SW5zZXJ0ZXIKICAgIH0KCiAgICB2aWV3cyB7CiAgICAgICAgc3lzdGVtQ29udGV4dCBvcmdCb3QgIlN5c3RlbUNvbnRleHQiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb250YWluZXIgb3JnQm90ICJDb250YWluZXJzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IG1haW4gIk1haW5Db21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGNvbmZpZyAiQ29uZmlnQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBhdXRoICJBdXRoQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBvcmdBcGkgIk9yZ0FwaUNvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgdXRpbHMgIlV0aWxzQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBiYXNlQ29tbWFuZCAiQmFzZUNvbW1hbmRDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGNvbW1hbmRzICJDb21tYW5kc0NvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgdHJhY2luZyAiVHJhY2luZ0NvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBzdHlsZXMgewogICAgICAgICAgICBlbGVtZW50ICJTb2Z0d2FyZSBTeXN0ZW0iIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzExNjhiZAogICAgICAgICAgICAgICAgY29sb3IgI2ZmZmZmZgogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkNvbnRhaW5lciIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjNDM4ZGQ1CiAgICAgICAgICAgICAgICBjb2xvciAjZmZmZmZmCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiTW9kdWxlIiB7CiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICM4NWJiZjAKICAgICAgICAgICAgICAgIGNvbG9yICMwMDAwMDAKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJDb21wb25lbnQiIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgI2E1YzlmNQogICAgICAgICAgICAgICAgY29sb3IgIzAwMDAwMAogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIlBlcnNvbiIgewogICAgICAgICAgICAgICAgc2hhcGUgcGVyc29uCiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICMwODQyN2IKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgdGhlbWUgaHR0cHM6Ly9zdGF0aWMuc3RydWN0dXJpenIuY29tL3RoZW1lcy9nb29nbGUtY2xvdWQtcGxhdGZvcm0tdjEuNS90aGVtZS5qc29uCgogICAgfQoKfQ==" + "structurizr.inspection.warning" : "0" }, "views" : { "componentViews" : [ { @@ -757,15 +766,15 @@ "name" : "Component View: Org Bot - Main", "order" : 3, "relationships" : [ { - "id" : "54" - }, { "id" : "55" }, { - "id" : "57" + "id" : "56" + }, { + "id" : "58" }, { - "id" : "59" + "id" : "60" }, { - "id" : "61", + "id" : "62", "vertices" : [ { "x" : 1456, "y" : 1700 @@ -774,11 +783,11 @@ "y" : 2000 } ] }, { - "id" : "66" - }, { - "id" : "68" + "id" : "67" }, { "id" : "69" + }, { + "id" : "70" } ] }, { "containerId" : "11", @@ -812,21 +821,21 @@ "name" : "Component View: Org Bot - Config", "order" : 4, "relationships" : [ { - "id" : "41", + "id" : "42", "vertices" : [ { "x" : 1900, "y" : 335 } ] }, { - "id" : "62" + "id" : "63" }, { - "id" : "64" - }, { - "id" : "70" + "id" : "65" }, { "id" : "71" }, { - "id" : "74" + "id" : "72" + }, { + "id" : "75" } ] }, { "containerId" : "15", @@ -860,13 +869,13 @@ "name" : "Component View: Org Bot - Auth", "order" : 5, "relationships" : [ { - "id" : "39", + "id" : "40", "vertices" : [ { "x" : 2420, "y" : 600 } ] }, { - "id" : "40", + "id" : "41", "vertices" : [ { "x" : 2255, "y" : 665 @@ -875,17 +884,17 @@ "y" : 1195 } ] }, { - "id" : "58", + "id" : "59", "vertices" : [ { "x" : 1225, "y" : 610 } ] }, { - "id" : "73" + "id" : "74" }, { - "id" : "75" + "id" : "76" }, { - "id" : "77" + "id" : "78" } ] }, { "containerId" : "19", @@ -923,11 +932,11 @@ "name" : "Component View: Org Bot - Org API", "order" : 6, "relationships" : [ { - "id" : "78" - }, { "id" : "79" }, { - "id" : "80", + "id" : "80" + }, { + "id" : "81", "vertices" : [ { "x" : 1589, "y" : 1379 @@ -936,11 +945,11 @@ "y" : 1679 } ] }, { - "id" : "81" + "id" : "82" }, { - "id" : "89" + "id" : "90" }, { - "id" : "91" + "id" : "92" } ] }, { "containerId" : "25", @@ -962,7 +971,7 @@ "name" : "Component View: Org Bot - Utils", "order" : 7, "relationships" : [ { - "id" : "67" + "id" : "68" } ] }, { "containerId" : "27", @@ -984,7 +993,7 @@ "name" : "Component View: Org Bot - Base Command", "order" : 8, "relationships" : [ { - "id" : "83" + "id" : "84" } ] }, { "containerId" : "29", @@ -1022,13 +1031,13 @@ "name" : "Component View: Org Bot - Commands", "order" : 9, "relationships" : [ { - "id" : "49" - }, { "id" : "50" }, { "id" : "51" }, { - "id" : "52", + "id" : "52" + }, { + "id" : "53", "vertices" : [ { "x" : 2339, "y" : 604 @@ -1037,10 +1046,10 @@ "y" : 800 } ] }, { - "id" : "88" + "id" : "89" } ] }, { - "containerId" : "34", + "containerId" : "35", "dimensions" : { "height" : 1518, "width" : 979 @@ -1050,7 +1059,7 @@ "x" : 0, "y" : 0 }, { - "id" : "35", + "id" : "36", "x" : 264, "y" : 779 } ], @@ -1059,13 +1068,12 @@ "name" : "Component View: Org Bot - Tracing", "order" : 10, "relationships" : [ { - "id" : "44" + "id" : "45" } ] } ], "configuration" : { "branding" : { }, "lastSavedView" : "CommandsComponents", - "metadataSymbols" : "SquareBrackets", "styles" : { "elements" : [ { "background" : "#a5c9f5", @@ -1130,29 +1138,31 @@ "id" : "34", "x" : 2685, "y" : 1705 + }, { + "id" : "35", + "x" : 2685, + "y" : 1705 } ], "externalSoftwareSystemBoundariesVisible" : false, "key" : "Containers", "name" : "Container View: Org Bot", "order" : 2, "relationships" : [ { - "id" : "38" - }, { "id" : "39" }, { "id" : "40" }, { - "id" : "41", + "id" : "41" + }, { + "id" : "42", "vertices" : [ { "x" : 585, "y" : 635 } ] - }, { - "id" : "42" }, { "id" : "43" }, { - "id" : "45" + "id" : "44" }, { "id" : "46" }, { @@ -1160,9 +1170,11 @@ }, { "id" : "48" }, { - "id" : "53" + "id" : "49" }, { "id" : "54" + }, { + "id" : "55" } ], "softwareSystemId" : "3" } ], @@ -1189,9 +1201,9 @@ "name" : "System Context View: Org Bot", "order" : 1, "relationships" : [ { - "id" : "36" - }, { "id" : "37" + }, { + "id" : "38" } ], "softwareSystemId" : "3" } ] From 5721db705dda5cb156c0512cc53828b9c07b150e Mon Sep 17 00:00:00 2001 From: George Green Date: Mon, 29 Dec 2025 13:03:29 +0100 Subject: [PATCH 08/13] formatting and pre-commit --- .gitignore | 2 + .pre-commit-config.yaml | 10 ++ src/actions/base_post_to_org_file.py | 3 - src/actions/post_reply.py | 3 - src/actions/post_to_journal.py | 4 - src/actions/post_to_todo.py | 3 - src/auth.py | 95 +++++++++--------- src/base_command.py | 6 +- src/commands/__init__.py | 6 +- src/commands/webhook.py | 6 +- src/org_api.py | 21 ++-- src/tracing/log.py | 6 +- tests/conftest.py | 34 +++++-- tests/test_journal_posting.py | 63 ++++++++---- tests/test_message_sequence_integration.py | 78 ++++++++------- tests/test_org_api.py | 79 +++++++++------ tests/test_reply_posting.py | 107 +++++++++++---------- tests/test_todo_posting.py | 66 +++++++++---- 18 files changed, 338 insertions(+), 254 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.gitignore b/.gitignore index e059bb4..35f37a4 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,5 @@ src/requirements.txt .structurizr/ +docs/c4/index/ +docs/c4/logs/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..609710c --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,10 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.14.10 + hooks: + # Run the linter. + - id: ruff-check + args: [--fix] + # Run the formatter. + - id: ruff-format diff --git a/src/actions/base_post_to_org_file.py b/src/actions/base_post_to_org_file.py index de5ab36..0d473ed 100644 --- a/src/actions/base_post_to_org_file.py +++ b/src/actions/base_post_to_org_file.py @@ -4,12 +4,9 @@ """ import logging -import re -from datetime import datetime from github import Github, Auth from telegram import Message -from ..utils import get_text_from_message from ..org_api import OrgApi logger = logging.getLogger(__name__) diff --git a/src/actions/post_reply.py b/src/actions/post_reply.py index a26f7ca..fe4c169 100644 --- a/src/actions/post_reply.py +++ b/src/actions/post_reply.py @@ -4,13 +4,10 @@ """ import logging -import re from datetime import datetime -from github import Github, Auth from telegram import Message from ..utils import get_text_from_message -from ..org_api import OrgApi from .base_post_to_org_file import BasePostToGitJournal from .post_to_journal import PostToGitJournal diff --git a/src/actions/post_to_journal.py b/src/actions/post_to_journal.py index 3a00231..f201464 100644 --- a/src/actions/post_to_journal.py +++ b/src/actions/post_to_journal.py @@ -4,13 +4,10 @@ """ import logging -import re from datetime import datetime -from github import Github, Auth from telegram import Message from ..utils import get_text_from_message -from ..org_api import OrgApi from .base_post_to_org_file import BasePostToGitJournal @@ -18,7 +15,6 @@ class PostToGitJournal(BasePostToGitJournal): - @staticmethod def _get_org_item(message: Message) -> str: """ diff --git a/src/actions/post_to_todo.py b/src/actions/post_to_todo.py index 5c4bd61..5fa31bb 100644 --- a/src/actions/post_to_todo.py +++ b/src/actions/post_to_todo.py @@ -4,13 +4,10 @@ """ import logging -import re from datetime import datetime -from github import Github, Auth from telegram import Message from ..utils import get_text_from_message -from ..org_api import OrgApi from .base_post_to_org_file import BasePostToGitJournal diff --git a/src/auth.py b/src/auth.py index 517ad8e..33aacc2 100644 --- a/src/auth.py +++ b/src/auth.py @@ -28,56 +28,66 @@ async def auth_check(message: Message, bot_getter=None): """ Check if message comes from an authorized chat. Logs comprehensive information about unauthorized access attempts. - + :param message: incoming telegram message :return: True if authorized, False otherwise """ logger.debug(f"All authorized chats: {authorized_chats}") if message.chat_id in authorized_chats: return True - + # Capture comprehensive unauthorized access information chat = message.chat user = message.from_user - + chat_info = { "chat_id": chat.id, "chat_type": chat.type, - "title": getattr(chat, 'title', None), - "username": getattr(chat, 'username', None), - "first_name": getattr(chat, 'first_name', None), - "last_name": getattr(chat, 'last_name', None), - "description": getattr(chat, 'description', None), - "invite_link": getattr(chat, 'invite_link', None), - "pinned_message_id": getattr(chat.pinned_message, 'message_id', None) if hasattr(chat, 'pinned_message') and chat.pinned_message else None, + "title": getattr(chat, "title", None), + "username": getattr(chat, "username", None), + "first_name": getattr(chat, "first_name", None), + "last_name": getattr(chat, "last_name", None), + "description": getattr(chat, "description", None), + "invite_link": getattr(chat, "invite_link", None), + "pinned_message_id": getattr(chat.pinned_message, "message_id", None) + if hasattr(chat, "pinned_message") and chat.pinned_message + else None, "permissions": { - "can_send_messages": getattr(chat.permissions, 'can_send_messages', None), - "can_send_media_messages": getattr(chat.permissions, 'can_send_media_messages', None), - "can_send_polls": getattr(chat.permissions, 'can_send_polls', None), - "can_send_other_messages": getattr(chat.permissions, 'can_send_other_messages', None), - "can_add_web_page_previews": getattr(chat.permissions, 'can_add_web_page_previews', None), - "can_change_info": getattr(chat.permissions, 'can_change_info', None), - "can_invite_users": getattr(chat.permissions, 'can_invite_users', None), - "can_pin_messages": getattr(chat.permissions, 'can_pin_messages', None), - } if hasattr(chat, 'permissions') and chat.permissions else None, - "member_count": getattr(chat, 'member_count', None), - "is_forum": getattr(chat, 'is_forum', None), + "can_send_messages": getattr(chat.permissions, "can_send_messages", None), + "can_send_media_messages": getattr( + chat.permissions, "can_send_media_messages", None + ), + "can_send_polls": getattr(chat.permissions, "can_send_polls", None), + "can_send_other_messages": getattr( + chat.permissions, "can_send_other_messages", None + ), + "can_add_web_page_previews": getattr( + chat.permissions, "can_add_web_page_previews", None + ), + "can_change_info": getattr(chat.permissions, "can_change_info", None), + "can_invite_users": getattr(chat.permissions, "can_invite_users", None), + "can_pin_messages": getattr(chat.permissions, "can_pin_messages", None), + } + if hasattr(chat, "permissions") and chat.permissions + else None, + "member_count": getattr(chat, "member_count", None), + "is_forum": getattr(chat, "is_forum", None), } - + user_info = { "user_id": user.id if user else None, "username": user.username if user else None, "first_name": user.first_name if user else None, "last_name": user.last_name if user else None, "is_bot": user.is_bot if user else None, - "is_premium": getattr(user, 'is_premium', None) if user else None, - "language_code": getattr(user, 'language_code', None) if user else None, + "is_premium": getattr(user, "is_premium", None) if user else None, + "language_code": getattr(user, "language_code", None) if user else None, } - + # Log comprehensive unauthorized access warning warning_msg = f"UNAUTHORIZED ACCESS ATTEMPT - Chat: {chat_info} | User: {user_info} | Message ID: {message.message_id} | Date: {message.date}" logger.warning(warning_msg) - + # Send to Sentry with additional context with sentry_sdk.push_scope() as scope: scope.set_extra("chat_info", chat_info) @@ -87,50 +97,47 @@ async def auth_check(message: Message, bot_getter=None): scope.set_extra("message_text", message.text[:100] if message.text else None) sentry_sdk.capture_message( f"Unauthorized chat access attempt from chat_id: {message.chat_id}", - level="warning" + level="warning", ) - + # Forward unauthorized message if configured if forward_unauthorized_to and bot_getter: try: bot = bot_getter() - + # Create summary message with context - summary_text = f"🚨 UNAUTHORIZED ACCESS\n\n" + summary_text = "🚨 UNAUTHORIZED ACCESS\n\n" summary_text += f"Chat: {chat_info.get('title') or chat_info.get('first_name') or 'Unknown'} ({chat_info['chat_type']})\n" summary_text += f"Chat ID: {chat_info['chat_id']}\n" - if user_info.get('username'): + if user_info.get("username"): summary_text += f"User: @{user_info['username']}\n" - if user_info.get('first_name'): + if user_info.get("first_name"): summary_text += f"Name: {user_info['first_name']} {user_info.get('last_name', '')}\n" summary_text += f"User ID: {user_info['user_id']}\n" summary_text += f"Date: {message.date}\n" - + # Send summary first - await bot.send_message( - chat_id=forward_unauthorized_to, - text=summary_text - ) - + await bot.send_message(chat_id=forward_unauthorized_to, text=summary_text) + # Then forward the original message await bot.forward_message( chat_id=forward_unauthorized_to, from_chat_id=message.chat_id, - message_id=message.message_id + message_id=message.message_id, ) - + logger.info(f"Forwarded unauthorized message to {forward_unauthorized_to}") - + except Exception as e: logger.error(f"Failed to forward unauthorized message: {e}") - + return False def ignore_check(message: Message): """ Check if message comes from an ignored chat. - + :param message: incoming telegram message :return: True if chat should be ignored, False otherwise """ @@ -138,4 +145,4 @@ def ignore_check(message: Message): if message.chat_id in ignored_chats: logger.info(f"Message from ignored chat: {message.chat_id}") return True - return False \ No newline at end of file + return False diff --git a/src/base_command.py b/src/base_command.py index 989f22f..a05c439 100644 --- a/src/base_command.py +++ b/src/base_command.py @@ -5,15 +5,15 @@ class BaseCommand(ABC): """Base class for all bot commands with dependency injection.""" - + def __init__(self, get_bot: Callable[[], Bot]): self._get_bot = get_bot - + @property def bot(self) -> Bot: """Get a fresh bot instance for each request.""" return self._get_bot() - + @abstractmethod async def execute(self, message: Message) -> str: """Execute the command and return response text.""" diff --git a/src/commands/__init__.py b/src/commands/__init__.py index ff9f884..e3c371b 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -1,3 +1,3 @@ -from .info import InfoCommand -from .start import StartCommand -from .webhook import WebhookCommand +from .info import InfoCommand # noqa F401 +from .start import StartCommand # noqa F401 +from .webhook import WebhookCommand # noqa F401 diff --git a/src/commands/webhook.py b/src/commands/webhook.py index 5fce276..22080c0 100644 --- a/src/commands/webhook.py +++ b/src/commands/webhook.py @@ -11,10 +11,10 @@ async def execute(self, message: Message) -> str: try: # Get webhook info webhook_info = await self.bot.get_webhook_info() - + # Get basic bot info bot_info = await self.bot.get_me() - + # Prepare response with webhook info, bot info, and chat ID response_data = { "webhook_info": webhook_info.to_dict(), @@ -24,7 +24,7 @@ async def execute(self, message: Message) -> str: "first_name": bot_info.first_name, }, "chat_id": message.chat.id, - "chat_type": message.chat.type + "chat_type": message.chat.type, } response = json.dumps(response_data, indent=1).replace("\\", "\\\\") return f"""Webhook data diff --git a/src/org_api.py b/src/org_api.py index 2e22513..d1edd35 100644 --- a/src/org_api.py +++ b/src/org_api.py @@ -44,7 +44,7 @@ def find_original_entry( for i, line in enumerate(lines): if original_message_link in line: # Determine the org-mode level (count asterisks at the start) - match = re.match(r'^(\*+)\s', line) + match = re.match(r"^(\*+)\s", line) if match: org_level = len(match.group(1)) logger.info( @@ -83,7 +83,7 @@ def find_top_level_entry( # Search backwards for the first non-reply entry with lower level for i in range(start_line - 1, -1, -1): - match = re.match(r'^(\*+)\s', lines[i]) + match = re.match(r"^(\*+)\s", lines[i]) if match: line_level = len(match.group(1)) # Found an entry with lower level @@ -104,7 +104,7 @@ def insert_reply_after_entry( line_number: int, org_level: int, reply_text: str, - commit_message: str + commit_message: str, ): """ Inserts a reply as a subheader after the original entry. @@ -124,7 +124,7 @@ def insert_reply_after_entry( # Look for the next line that starts with asterisks of equal or lesser count for i in range(line_number + 1, len(lines)): - match = re.match(r'^(\*+)\s', lines[i]) + match = re.match(r"^(\*+)\s", lines[i]) if match and len(match.group(1)) <= org_level: insert_position = i break @@ -150,16 +150,11 @@ def insert_reply_after_entry( "action": "insert_reply", "file": file_path, "line": insert_position, - "org_level": org_level + 1 - } + "org_level": org_level + 1, + }, ) - def create_file( - self, - file_path: str, - content: bytes, - commit_message: str - ): + def create_file(self, file_path: str, content: bytes, commit_message: str): """ Creates a new file in the repository. @@ -188,7 +183,7 @@ def append_text_to_file( file_path: str, new_text: str, commit_message: str, - image_filename: Optional[str] = None + image_filename: Optional[str] = None, ): """ Appends text to an org file in the repository. diff --git a/src/tracing/log.py b/src/tracing/log.py index 377eacf..e40638d 100644 --- a/src/tracing/log.py +++ b/src/tracing/log.py @@ -13,9 +13,9 @@ def __init__(self, name, project=None, request=None): trace_header = request.headers.get("X-Cloud-Trace-Context") if trace_header and project: trace = trace_header.split("/") - self.global_log_fields[ - "logging.googleapis.com/trace" - ] = f"projects/{project}/traces/{trace[0]}" + self.global_log_fields["logging.googleapis.com/trace"] = ( + f"projects/{project}/traces/{trace[0]}" + ) def _log_structured(self, severity, msg, args, kwargs): if args: diff --git a/tests/conftest.py b/tests/conftest.py index 3065166..1c4016b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ import sys from pathlib import Path from typing import Any, Dict -from unittest.mock import MagicMock, Mock +from unittest.mock import Mock import pytest @@ -37,14 +37,14 @@ def configure_verbose_logging() -> None: # Very detailed formatter formatter = logging.Formatter( - fmt='%(asctime)s.%(msecs)03d [%(levelname)-8s] [%(name)-30s] %(funcName)-25s:%(lineno)-4d - %(message)s', - datefmt='%Y-%m-%d_%H:%M:%S' + fmt="%(asctime)s.%(msecs)03d [%(levelname)-8s] [%(name)-30s] %(funcName)-25s:%(lineno)-4d - %(message)s", + datefmt="%Y-%m-%d_%H:%M:%S", ) console_handler.setFormatter(formatter) root_logger.addHandler(console_handler) # Also configure specific loggers - for logger_name in ['src', 'telegram', 'github', 'urllib3']: + for logger_name in ["src", "telegram", "github", "urllib3"]: logger = logging.getLogger(logger_name) logger.setLevel(logging.DEBUG) @@ -146,7 +146,9 @@ def mock_telegram_message_text() -> Mock: chat.id = 1234567890 message.chat = chat - logging.debug(f"Mock message created - ID: {message.message_id}, Chat ID: {chat.id}") + logging.debug( + f"Mock message created - ID: {message.message_id}, Chat ID: {chat.id}" + ) logging.debug(f"Message text: {message.text}") return message @@ -185,7 +187,9 @@ def mock_telegram_message_photo() -> Mock: chat.id = 1234567890 message.chat = chat - logging.debug(f"Mock photo message created - ID: {message.message_id}, Chat ID: {chat.id}") + logging.debug( + f"Mock photo message created - ID: {message.message_id}, Chat ID: {chat.id}" + ) logging.debug(f"Message caption: {message.caption}") logging.debug(f"Photo file_id: {photo.file_id}") @@ -224,9 +228,13 @@ def mock_telegram_message_document() -> Mock: chat.id = 1234567890 message.chat = chat - logging.debug(f"Mock document message created - ID: {message.message_id}, Chat ID: {chat.id}") + logging.debug( + f"Mock document message created - ID: {message.message_id}, Chat ID: {chat.id}" + ) logging.debug(f"Message caption: {message.caption}") - logging.debug(f"Document file_id: {document.file_id}, filename: {document.file_name}") + logging.debug( + f"Document file_id: {document.file_id}, filename: {document.file_name}" + ) return message @@ -235,7 +243,11 @@ def mock_telegram_message_document() -> Mock: def mock_github_token() -> str: """Return a mock GitHub token or get from environment.""" token = os.getenv("GITHUB_TOKEN") or "mock_github_token_for_testing" - logging.debug(f"Using GitHub token: {token[:10]}..." if len(token) > 10 else "Using mock token") + logging.debug( + f"Using GitHub token: {token[:10]}..." + if len(token) > 10 + else "Using mock token" + ) return token @@ -260,7 +272,9 @@ def test_config() -> Dict[str, Any]: logging.debug("Test configuration:") for key, value in config.items(): if "token" in key.lower(): - logging.debug(f" {key}: {value[:10]}..." if len(str(value)) > 10 else f" {key}: ***") + logging.debug( + f" {key}: {value[:10]}..." if len(str(value)) > 10 else f" {key}: ***" + ) else: logging.debug(f" {key}: {value}") diff --git a/tests/test_journal_posting.py b/tests/test_journal_posting.py index 17e6f15..832af6b 100644 --- a/tests/test_journal_posting.py +++ b/tests/test_journal_posting.py @@ -10,7 +10,7 @@ import logging import os from pathlib import Path -from unittest.mock import Mock, MagicMock, patch, call +from unittest.mock import Mock, MagicMock, patch from typing import Any, Dict import pytest @@ -37,7 +37,9 @@ def mock_github_client(self) -> MagicMock: # Mock file contents mock_contents = MagicMock() - mock_contents.decoded_content = b"* Existing journal entry\nSome existing content" + mock_contents.decoded_content = ( + b"* Existing journal entry\nSome existing content" + ) mock_contents.sha = "mock_sha_123" mock_contents.path = "test_journal.org" mock_repo.get_contents.return_value = mock_contents @@ -56,7 +58,9 @@ def journal_instance( """Create a PostToGitJournal instance with mocked GitHub client.""" logger.info("Creating PostToGitJournal instance for testing") - with patch('src.actions.base_post_to_org_file.Github', return_value=mock_github_client): + with patch( + "src.actions.base_post_to_org_file.Github", return_value=mock_github_client + ): instance = PostToGitJournal( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -102,7 +106,9 @@ def test_post_text_message_to_journal( # Should have called get_contents to fetch current file journal_instance.repo.get_contents.assert_called_once() - logger.debug(f"get_contents called with: {journal_instance.repo.get_contents.call_args}") + logger.debug( + f"get_contents called with: {journal_instance.repo.get_contents.call_args}" + ) # Should have called update_file to append new content journal_instance.repo.update_file.assert_called_once() @@ -116,7 +122,9 @@ def test_post_text_message_to_journal( logger.debug(f"Updated content:\n{updated_content}") # Verify the message text appears in the updated content - assert message.text in updated_content, "Message text should be in updated content" + assert message.text in updated_content, ( + "Message text should be in updated content" + ) # Verify org-mode entry format assert "* Entry:" in updated_content, "Should contain org-mode entry header" @@ -155,13 +163,13 @@ def test_post_photo_message_to_journal( # Create a small PNG file (1x1 pixel) png_data = ( - b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01' - b'\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89' - b'\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01' - b'\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82' + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01" + b"\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) - with open(temp_image_path, 'wb') as f: + with open(temp_image_path, "wb") as f: f.write(png_data) logger.info("Temporary image created successfully") @@ -173,7 +181,9 @@ def test_post_photo_message_to_journal( # Verify result logger.info(f"Result: {result}") - assert result is True, "Expected run() to return True for successful posting" + assert result is True, ( + "Expected run() to return True for successful posting" + ) # Verify GitHub interactions logger.info("Verifying GitHub API interactions") @@ -194,11 +204,15 @@ def test_post_photo_message_to_journal( logger.debug(f"Updated content:\n{updated_content}") # Verify the caption appears in the content - assert message.caption in updated_content, "Message caption should be in updated content" + assert message.caption in updated_content, ( + "Message caption should be in updated content" + ) # Verify org-mode image reference format assert "[[file:" in updated_content, "Should contain org-mode file link" - assert "#+attr_html:" in updated_content, "Should contain HTML attributes for image" + assert "#+attr_html:" in updated_content, ( + "Should contain HTML attributes for image" + ) logger.info("Photo message test PASSED") @@ -238,9 +252,9 @@ def test_post_file_message_to_journal( logger.info(f"Creating temporary test PDF at: {temp_pdf_path}") # Minimal PDF content - pdf_data = b'%PDF-1.4\n1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000056 00000 n\n0000000115 00000 n\ntrailer<>\nstartxref\n210\n%%EOF' + pdf_data = b"%PDF-1.4\n1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000056 00000 n\n0000000115 00000 n\ntrailer<>\nstartxref\n210\n%%EOF" - with open(temp_pdf_path, 'wb') as f: + with open(temp_pdf_path, "wb") as f: f.write(pdf_data) logger.info("Temporary PDF created successfully") @@ -252,7 +266,9 @@ def test_post_file_message_to_journal( # Verify result logger.info(f"Result: {result}") - assert result is True, "Expected run() to return True for successful posting" + assert result is True, ( + "Expected run() to return True for successful posting" + ) # Verify GitHub interactions logger.info("Verifying GitHub API interactions") @@ -265,7 +281,9 @@ def test_post_file_message_to_journal( # Verify file was uploaded to correct path uploaded_path = create_call_args[1]["path"] logger.info(f"File uploaded to path: {uploaded_path}") - assert uploaded_path.startswith("pics/telegram/"), "File should be uploaded to pics/telegram/" + assert uploaded_path.startswith("pics/telegram/"), ( + "File should be uploaded to pics/telegram/" + ) # Should have called update_file to append journal entry journal_instance.repo.update_file.assert_called_once() @@ -278,7 +296,9 @@ def test_post_file_message_to_journal( logger.debug(f"Updated content:\n{updated_content}") # Verify the caption appears in the content - assert message.caption in updated_content, "Message caption should be in updated content" + assert message.caption in updated_content, ( + "Message caption should be in updated content" + ) # Verify org-mode file reference format assert "[[file:" in updated_content, "Should contain org-mode file link" @@ -317,7 +337,10 @@ def test_get_org_item_format( # Verify timestamp format (YYYY-MM-DD HH:MM) import re - timestamp_pattern = r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}' - assert re.search(timestamp_pattern, org_item), "Should contain timestamp in correct format" + + timestamp_pattern = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}" + assert re.search(timestamp_pattern, org_item), ( + "Should contain timestamp in correct format" + ) logger.info("Org-mode formatting test PASSED") diff --git a/tests/test_message_sequence_integration.py b/tests/test_message_sequence_integration.py index ea86c1f..4a2ad72 100644 --- a/tests/test_message_sequence_integration.py +++ b/tests/test_message_sequence_integration.py @@ -10,7 +10,6 @@ """ import os -import sys import logging from unittest.mock import Mock, MagicMock, patch from typing import Any, Dict, List @@ -43,7 +42,8 @@ def _create_dummy_github_client(): # Patch Github before any imports with patch( - "src.actions.base_post_to_org_file.Github", return_value=_create_dummy_github_client() + "src.actions.base_post_to_org_file.Github", + return_value=_create_dummy_github_client(), ): from src.main import process_non_command @@ -196,7 +196,9 @@ def test_message_sequence_full_flow( previous_messages = [] responses = [] - with patch("src.actions.base_post_to_org_file.Github", return_value=mock_client): + with patch( + "src.actions.base_post_to_org_file.Github", return_value=mock_client + ): # Import here to ensure patch is applied from src.actions.post_to_journal import PostToGitJournal from src.actions.post_to_todo import PostToTodo @@ -228,7 +230,7 @@ def test_message_sequence_full_flow( # Process each message in sequence for i, msg_data in enumerate(message_sequence): logger.info( - f"\n--- Processing message {i+1}/{len(message_sequence)}: {msg_data['name']} ---" + f"\n--- Processing message {i + 1}/{len(message_sequence)}: {msg_data['name']} ---" ) # Create mock message @@ -244,9 +246,9 @@ def test_message_sequence_full_flow( # Verify response expected_response = msg_data["expected_response"] - assert ( - response == expected_response - ), f"Message {msg_data['name']}: expected '{expected_response}', got '{response}'" + assert response == expected_response, ( + f"Message {msg_data['name']}: expected '{expected_response}', got '{response}'" + ) logger.info(f"✓ Response matches expected: {response}") @@ -256,41 +258,41 @@ def test_message_sequence_full_flow( logger.info(f"Final content:\n{final_content}") # Verify all messages are in the file - assert ( - "https://t.me/c/1234567890/100" in final_content - ), "Original entry should be in file" - assert ( - "This is my original journal entry" in final_content - ), "Original text should be in file" - - assert ( - "https://t.me/c/1234567890/200" in final_content - ), "First reply should be in file" - assert ( - "This is a reply to the original entry" in final_content - ), "First reply text should be in file" - - assert ( - "https://t.me/c/1234567890/300" in final_content - ), "Second reply should be in file" - assert ( - "This is a reply to the first reply" in final_content - ), "Second reply text should be in file" + assert "https://t.me/c/1234567890/100" in final_content, ( + "Original entry should be in file" + ) + assert "This is my original journal entry" in final_content, ( + "Original text should be in file" + ) + + assert "https://t.me/c/1234567890/200" in final_content, ( + "First reply should be in file" + ) + assert "This is a reply to the original entry" in final_content, ( + "First reply text should be in file" + ) + + assert "https://t.me/c/1234567890/300" in final_content, ( + "Second reply should be in file" + ) + assert "This is a reply to the first reply" in final_content, ( + "Second reply text should be in file" + ) # Verify proper nesting - all replies should be at ** level reply_count = final_content.count("** Reply:") logger.info(f"Number of ** Reply: entries: {reply_count}") - assert ( - reply_count == 2 - ), f"Should have 2 replies at ** level, found {reply_count}" + assert reply_count == 2, ( + f"Should have 2 replies at ** level, found {reply_count}" + ) # Should NOT have *** level replies assert "*** Reply:" not in final_content, "Should not have *** level replies" # Verify all responses were generated - assert len(responses) == len( - message_sequence - ), "Should have response for each message" + assert len(responses) == len(message_sequence), ( + "Should have response for each message" + ) assert all(r is not None for r in responses), "All responses should be non-None" logger.info("\n✓ All messages processed correctly") @@ -316,7 +318,9 @@ def test_reply_response_not_none( mock_client = MagicMock() mock_client.get_repo.return_value = mock_github_repo_with_state - with patch("src.actions.base_post_to_org_file.Github", return_value=mock_client): + with patch( + "src.actions.base_post_to_org_file.Github", return_value=mock_client + ): from src.actions.post_reply import PostReplyToEntry reply_instance = PostReplyToEntry( @@ -371,8 +375,8 @@ def test_reply_response_not_none( # Verify response is not None assert response is not None, "Reply should generate a response" - assert ( - response == "Added reply to entry!" - ), f"Expected 'Added reply to entry!', got '{response}'" + assert response == "Added reply to entry!", ( + f"Expected 'Added reply to entry!', got '{response}'" + ) logger.info("✓ Reply generated correct response") diff --git a/tests/test_org_api.py b/tests/test_org_api.py index 54ba89c..a128455 100644 --- a/tests/test_org_api.py +++ b/tests/test_org_api.py @@ -9,8 +9,7 @@ """ import logging -from unittest.mock import MagicMock, Mock -from typing import Any, Dict +from unittest.mock import MagicMock import pytest @@ -88,7 +87,7 @@ def test_find_original_entry_found( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.decoded_content = simple_org_content.encode("utf-8") mock_repo.get_contents.return_value = mock_contents # Execute @@ -127,7 +126,7 @@ def test_find_original_entry_not_found( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.decoded_content = simple_org_content.encode("utf-8") mock_repo.get_contents.return_value = mock_contents # Execute @@ -160,7 +159,7 @@ def test_find_original_entry_with_todo_level( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = todo_org_content.encode('utf-8') + mock_contents.decoded_content = todo_org_content.encode("utf-8") mock_repo.get_contents.return_value = mock_contents # Execute @@ -229,7 +228,7 @@ def test_find_top_level_entry_non_reply( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.decoded_content = simple_org_content.encode("utf-8") mock_repo.get_contents.return_value = mock_contents # Execute - line 1 is "* Entry:" (not a reply) @@ -264,7 +263,7 @@ def test_find_top_level_entry_from_reply( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = nested_org_content.encode('utf-8') + mock_contents.decoded_content = nested_org_content.encode("utf-8") mock_repo.get_contents.return_value = mock_contents # Execute - line 3 is "** Reply:" (first reply) @@ -274,7 +273,9 @@ def test_find_top_level_entry_from_reply( logger.info(f"Result: {result}") line_number, org_level = result - logger.info(f"Found top-level entry at line {line_number} with level {org_level}") + logger.info( + f"Found top-level entry at line {line_number} with level {org_level}" + ) assert line_number == 1, "Should find parent entry at line 1" assert org_level == 1, "Parent entry should have level 1" @@ -300,7 +301,7 @@ def test_find_top_level_entry_from_second_reply( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = nested_org_content.encode('utf-8') + mock_contents.decoded_content = nested_org_content.encode("utf-8") mock_repo.get_contents.return_value = mock_contents # Execute - line 5 is the second "** Reply:" @@ -337,7 +338,7 @@ def test_insert_reply_after_entry_simple( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.decoded_content = simple_org_content.encode("utf-8") mock_contents.sha = "mock_sha_123" mock_contents.path = "test.org" mock_repo.get_contents.return_value = mock_contents @@ -350,7 +351,7 @@ def test_insert_reply_after_entry_simple( 1, # line_number 1, # org_level reply_text, - "Test commit message" + "Test commit message", ) # Verify update_file was called @@ -418,20 +419,22 @@ def test_insert_reply_after_entry_with_content( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = content_with_lines.encode('utf-8') + mock_contents.decoded_content = content_with_lines.encode("utf-8") mock_contents.sha = "mock_sha_456" mock_contents.path = "test.org" mock_repo.get_contents.return_value = mock_contents mock_repo.update_file.return_value = {"commit": {"sha": "new_sha"}} # Execute - reply_text = "** Reply: [[https://t.me/c/1234567890/200][2025-12-17 11:00]]\nReply text." + reply_text = ( + "** Reply: [[https://t.me/c/1234567890/200][2025-12-17 11:00]]\nReply text." + ) org_api.insert_reply_after_entry( "test.org", 1, # line_number (first entry) 1, # org_level reply_text, - "Insert reply commit" + "Insert reply commit", ) # Verify @@ -494,7 +497,7 @@ def test_insert_reply_at_end_of_file( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = single_entry_content.encode('utf-8') + mock_contents.decoded_content = single_entry_content.encode("utf-8") mock_contents.sha = "mock_sha_789" mock_contents.path = "test.org" mock_repo.get_contents.return_value = mock_contents @@ -507,7 +510,7 @@ def test_insert_reply_at_end_of_file( 1, # line_number 1, # org_level reply_text, - "Reply at end commit" + "Reply at end commit", ) # Verify @@ -555,7 +558,7 @@ def test_insert_reply_maintains_structure( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = nested_org_content.encode('utf-8') + mock_contents.decoded_content = nested_org_content.encode("utf-8") mock_contents.sha = "mock_sha_structure" mock_contents.path = "test.org" mock_repo.get_contents.return_value = mock_contents @@ -568,7 +571,7 @@ def test_insert_reply_maintains_structure( 1, # line_number (first entry) 1, # org_level reply_text, - "Third reply commit" + "Third reply commit", ) # Verify @@ -582,10 +585,18 @@ def test_insert_reply_maintains_structure( assert reply_count == 3, "Should have 3 replies now" # Verify all original content is preserved - assert "https://t.me/c/1234567890/100" in updated_content, "Original entry preserved" - assert "https://t.me/c/1234567890/200" in updated_content, "First reply preserved" - assert "https://t.me/c/1234567890/300" in updated_content, "Second reply preserved" - assert "https://t.me/c/1234567890/101" in updated_content, "Second entry preserved" + assert "https://t.me/c/1234567890/100" in updated_content, ( + "Original entry preserved" + ) + assert "https://t.me/c/1234567890/200" in updated_content, ( + "First reply preserved" + ) + assert "https://t.me/c/1234567890/300" in updated_content, ( + "Second reply preserved" + ) + assert "https://t.me/c/1234567890/101" in updated_content, ( + "Second entry preserved" + ) # Verify new reply is present assert "https://t.me/c/1234567890/400" in updated_content, "New reply added" @@ -618,7 +629,7 @@ def test_create_file( org_api.create_file( file_path="pics/telegram/test.png", content=file_content, - commit_message="Test file upload" + commit_message="Test file upload", ) # Verify @@ -658,7 +669,7 @@ def test_append_text_to_file_without_image( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.decoded_content = simple_org_content.encode("utf-8") mock_contents.sha = "mock_sha_append" mock_contents.path = "test.org" mock_repo.get_contents.return_value = mock_contents @@ -667,9 +678,7 @@ def test_append_text_to_file_without_image( # Execute new_text = "* New Entry: This is a new entry" org_api.append_text_to_file( - file_path="test.org", - new_text=new_text, - commit_message="Append new entry" + file_path="test.org", new_text=new_text, commit_message="Append new entry" ) # Verify @@ -684,7 +693,9 @@ def test_append_text_to_file_without_image( logger.info(f"Updated content:\n{updated_content}") assert new_text in updated_content, "New text should be in updated content" - assert simple_org_content in updated_content, "Original content should be preserved" + assert simple_org_content in updated_content, ( + "Original content should be preserved" + ) logger.info("Test PASSED") @@ -710,7 +721,7 @@ def test_append_text_to_file_with_image( # Setup mock mock_contents = MagicMock() - mock_contents.decoded_content = simple_org_content.encode('utf-8') + mock_contents.decoded_content = simple_org_content.encode("utf-8") mock_contents.sha = "mock_sha_append_img" mock_contents.path = "test.org" mock_repo.get_contents.return_value = mock_contents @@ -723,7 +734,7 @@ def test_append_text_to_file_with_image( file_path="test.org", new_text=new_text, commit_message="Append entry with image", - image_filename=image_filename + image_filename=image_filename, ) # Verify @@ -736,7 +747,11 @@ def test_append_text_to_file_with_image( # Verify text and image reference are present assert new_text in updated_content, "New text should be in updated content" - assert f"[[file:{image_filename}]]" in updated_content, "Image link should be in updated content" - assert "#+attr_html: :width 600px" in updated_content, "Image attributes should be in updated content" + assert f"[[file:{image_filename}]]" in updated_content, ( + "Image link should be in updated content" + ) + assert "#+attr_html: :width 600px" in updated_content, ( + "Image attributes should be in updated content" + ) logger.info("Test PASSED") diff --git a/tests/test_reply_posting.py b/tests/test_reply_posting.py index 213ced8..2d2c62b 100644 --- a/tests/test_reply_posting.py +++ b/tests/test_reply_posting.py @@ -17,7 +17,6 @@ import pytest from src.actions.post_reply import PostReplyToEntry -from src.actions.post_to_journal import PostToGitJournal logger = logging.getLogger(__name__) @@ -234,17 +233,17 @@ def test_reply_to_journal_entry( # Verify result logger.info(f"Result: {result}") - assert ( - result is True - ), "Expected run() to return True for successful reply posting" + assert result is True, ( + "Expected run() to return True for successful reply posting" + ) # Verify GitHub interactions logger.info("Verifying GitHub API interactions") # Should have called get_contents to search for original entry - assert ( - reply_instance.repo.get_contents.called - ), "Should call get_contents to search" + assert reply_instance.repo.get_contents.called, ( + "Should call get_contents to search" + ) # Should have called update_file to insert the reply reply_instance.repo.update_file.assert_called_once() @@ -258,26 +257,26 @@ def test_reply_to_journal_entry( logger.debug(f"Updated content:\n{updated_content}") # Verify the reply text appears - assert ( - message.text in updated_content - ), "Reply text should be in updated content" + assert message.text in updated_content, ( + "Reply text should be in updated content" + ) # Verify it's a nested entry (** Reply:) - assert ( - "** Reply:" in updated_content - ), "Should contain nested reply header (** level)" + assert "** Reply:" in updated_content, ( + "Should contain nested reply header (** level)" + ) # Verify it contains the reply message link - assert ( - "https://t.me/c/1234567890/200" in updated_content - ), "Should contain reply message link" + assert "https://t.me/c/1234567890/200" in updated_content, ( + "Should contain reply message link" + ) # Verify the commit message commit_message = update_call_args[1]["message"] logger.info(f"Commit message: {commit_message}") - assert ( - "Reply to message 100" in commit_message - ), "Commit should reference original message" + assert "Reply to message 100" in commit_message, ( + "Commit should reference original message" + ) logger.info("Reply to journal entry test PASSED") @@ -305,7 +304,7 @@ def test_reply_to_todo_entry( with patch( "src.actions.base_post_to_org_file.Github", return_value=mock_github_client_with_todo_entry, - ) as mock_github: + ): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -324,9 +323,9 @@ def test_reply_to_todo_entry( # Verify result logger.info(f"Result: {result}") - assert ( - result is True - ), "Expected run() to return True for successful reply posting" + assert result is True, ( + "Expected run() to return True for successful reply posting" + ) # Verify update was called assert reply_instance.repo.update_file.called, "Should update file" @@ -337,14 +336,14 @@ def test_reply_to_todo_entry( logger.debug(f"Updated content:\n{updated_content}") # Verify the reply is nested correctly (*** level for TODO which is **) - assert ( - "*** Reply:" in updated_content - ), "Should contain nested reply header (*** level)" + assert "*** Reply:" in updated_content, ( + "Should contain nested reply header (*** level)" + ) # Verify reply text - assert ( - message.text in updated_content - ), "Reply text should be in updated content" + assert message.text in updated_content, ( + "Reply text should be in updated content" + ) logger.info("Reply to TODO entry test PASSED") @@ -390,18 +389,18 @@ def test_reply_original_not_found_fallback( assert result is True, "Expected run() to return True (fallback to journal)" # Verify it called update_file (for the fallback journal entry) - assert ( - reply_instance.repo.update_file.called - ), "Should update file with fallback entry" + assert reply_instance.repo.update_file.called, ( + "Should update file with fallback entry" + ) update_call_args = reply_instance.repo.update_file.call_args updated_content = update_call_args[1]["content"] logger.debug(f"Updated content:\n{updated_content}") # Verify it's a regular journal entry, not a nested reply - assert ( - "* Entry:" in updated_content - ), "Should create regular journal entry (fallback)" + assert "* Entry:" in updated_content, ( + "Should create regular journal entry (fallback)" + ) # Should NOT have the nested Reply format assert "** Reply:" not in updated_content, "Should not be a nested reply" @@ -561,17 +560,17 @@ def test_reply_to_reply_stays_at_same_level( assert reply_count == 2, "Should have 2 replies at ** level" # Should NOT have *** Reply: (no deeper nesting) - assert ( - "*** Reply:" not in updated_content - ), "Should NOT have *** level replies" + assert "*** Reply:" not in updated_content, ( + "Should NOT have *** level replies" + ) # Verify both replies are present - assert ( - "https://t.me/c/1234567890/200" in updated_content - ), "First reply link should be present" - assert ( - "https://t.me/c/1234567890/300" in updated_content - ), "Second reply link should be present" + assert "https://t.me/c/1234567890/200" in updated_content, ( + "First reply link should be present" + ) + assert "https://t.me/c/1234567890/300" in updated_content, ( + "Second reply link should be present" + ) logger.info("Reply to reply test PASSED - all replies stay at same level") @@ -623,7 +622,9 @@ def test_find_top_level_entry_method( # Test 1: Non-reply entry should return itself logger.info("Test 1: Non-reply entry") line_num, level = reply_instance.org_api.find_top_level_entry( - test_config["journal_file"], 1, 1 # Line 1 is "* Entry:" (the original) + test_config["journal_file"], + 1, + 1, # Line 1 is "* Entry:" (the original) ) logger.info(f"Result: line {line_num}, level {level}") assert line_num == 1, "Should return same line for non-reply entry" @@ -632,7 +633,9 @@ def test_find_top_level_entry_method( # Test 2: Reply entry should find its parent logger.info("Test 2: Reply entry") line_num, level = reply_instance.org_api.find_top_level_entry( - test_config["journal_file"], 3, 2 # Line 3 is "** Reply:" + test_config["journal_file"], + 3, + 2, # Line 3 is "** Reply:" ) logger.info(f"Result: line {line_num}, level {level}") assert line_num == 1, "Should return parent entry line" @@ -760,9 +763,9 @@ def test_insert_position_calculation( assert original_entry_index is not None, "Should find original entry" logger.info(f"Original entry at line {original_entry_index}") - assert ( - reply_line_index > original_entry_index - ), "Reply should come after original entry" + assert reply_line_index > original_entry_index, ( + "Reply should come after original entry" + ) # Verify it comes before the next top-level entry next_entry_index = None @@ -777,8 +780,8 @@ def test_insert_position_calculation( if next_entry_index: logger.info(f"Next entry at line {next_entry_index}") - assert ( - reply_line_index < next_entry_index - ), "Reply should come before next entry" + assert reply_line_index < next_entry_index, ( + "Reply should come before next entry" + ) logger.info("Insertion position test PASSED") diff --git a/tests/test_todo_posting.py b/tests/test_todo_posting.py index 1009231..aee960a 100644 --- a/tests/test_todo_posting.py +++ b/tests/test_todo_posting.py @@ -9,7 +9,6 @@ import logging import os -from pathlib import Path from unittest.mock import Mock, MagicMock, patch from typing import Any, Dict @@ -56,7 +55,9 @@ def todo_instance( """Create a PostToTodo instance with mocked GitHub client.""" logger.info("Creating PostToTodo instance for testing") - with patch('src.actions.base_post_to_org_file.Github', return_value=mock_github_client): + with patch( + "src.actions.base_post_to_org_file.Github", return_value=mock_github_client + ): instance = PostToTodo( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -83,7 +84,9 @@ def mock_todo_message_text(self) -> Mock: chat.id = 9876543210 message.chat = chat - logger.debug(f"Mock TODO message created - ID: {message.message_id}, Chat ID: {chat.id}") + logger.debug( + f"Mock TODO message created - ID: {message.message_id}, Chat ID: {chat.id}" + ) logger.debug(f"Message text: {message.text}") return message @@ -183,7 +186,9 @@ def test_post_text_message_to_todo( # Should have called get_contents to fetch current file todo_instance.repo.get_contents.assert_called_once() - logger.debug(f"get_contents called with: {todo_instance.repo.get_contents.call_args}") + logger.debug( + f"get_contents called with: {todo_instance.repo.get_contents.call_args}" + ) # Should have called update_file to append new TODO todo_instance.repo.update_file.assert_called_once() @@ -198,7 +203,9 @@ def test_post_text_message_to_todo( # Verify TODO format - the method strips "TODO " prefix and adds it back assert "** TODO" in updated_content, "Should contain org-mode TODO header" - assert "Review the pull request and merge it" in updated_content, "Should contain TODO text without prefix" + assert "Review the pull request and merge it" in updated_content, ( + "Should contain TODO text without prefix" + ) assert "Created at:" in updated_content, "Should contain creation timestamp" assert "https://t.me/" in updated_content, "Should contain Telegram link" @@ -234,13 +241,13 @@ def test_post_photo_message_to_todo( # Create a small PNG file (1x1 pixel) png_data = ( - b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01' - b'\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89' - b'\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01' - b'\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82' + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01" + b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01" + b"\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82" ) - with open(temp_image_path, 'wb') as f: + with open(temp_image_path, "wb") as f: f.write(png_data) logger.info("Temporary image created successfully") @@ -252,7 +259,9 @@ def test_post_photo_message_to_todo( # Verify result logger.info(f"Result: {result}") - assert result is True, "Expected run() to return True for successful posting" + assert result is True, ( + "Expected run() to return True for successful posting" + ) # Verify GitHub interactions logger.info("Verifying GitHub API interactions") @@ -274,9 +283,13 @@ def test_post_photo_message_to_todo( # Verify TODO format with image assert "** TODO" in updated_content, "Should contain org-mode TODO header" - assert "Check this screenshot for bugs" in updated_content, "Should contain TODO text" + assert "Check this screenshot for bugs" in updated_content, ( + "Should contain TODO text" + ) assert "[[file:" in updated_content, "Should contain org-mode file link" - assert "#+attr_html:" in updated_content, "Should contain HTML attributes for image" + assert "#+attr_html:" in updated_content, ( + "Should contain HTML attributes for image" + ) logger.info("TODO photo message test PASSED") @@ -316,9 +329,9 @@ def test_post_file_message_to_todo( logger.info(f"Creating temporary test PDF at: {temp_pdf_path}") # Minimal PDF content - pdf_data = b'%PDF-1.4\n1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000056 00000 n\n0000000115 00000 n\ntrailer<>\nstartxref\n210\n%%EOF' + pdf_data = b"%PDF-1.4\n1 0 obj<>endobj 2 0 obj<>endobj 3 0 obj<>>>endobj\nxref\n0 4\n0000000000 65535 f\n0000000009 00000 n\n0000000056 00000 n\n0000000115 00000 n\ntrailer<>\nstartxref\n210\n%%EOF" - with open(temp_pdf_path, 'wb') as f: + with open(temp_pdf_path, "wb") as f: f.write(pdf_data) logger.info("Temporary PDF created successfully") @@ -330,7 +343,9 @@ def test_post_file_message_to_todo( # Verify result logger.info(f"Result: {result}") - assert result is True, "Expected run() to return True for successful posting" + assert result is True, ( + "Expected run() to return True for successful posting" + ) # Verify GitHub interactions logger.info("Verifying GitHub API interactions") @@ -343,7 +358,9 @@ def test_post_file_message_to_todo( # Verify file was uploaded to correct path uploaded_path = create_call_args[1]["path"] logger.info(f"File uploaded to path: {uploaded_path}") - assert uploaded_path.startswith("pics/telegram/"), "File should be uploaded to pics/telegram/" + assert uploaded_path.startswith("pics/telegram/"), ( + "File should be uploaded to pics/telegram/" + ) # Should have called update_file to append TODO entry todo_instance.repo.update_file.assert_called_once() @@ -357,7 +374,9 @@ def test_post_file_message_to_todo( # Verify TODO format with file assert "** TODO" in updated_content, "Should contain org-mode TODO header" - assert "Process this invoice document" in updated_content, "Should contain TODO text" + assert "Process this invoice document" in updated_content, ( + "Should contain TODO text" + ) assert "[[file:" in updated_content, "Should contain org-mode file link" logger.info("TODO file message test PASSED") @@ -393,11 +412,16 @@ def test_get_org_item_format_todo( assert "https://t.me/" in org_item, "Should contain Telegram link" # The TODO prefix should be stripped from the original message - assert "Review the pull request and merge it" in org_item, "Should contain task text without TODO prefix" + assert "Review the pull request and merge it" in org_item, ( + "Should contain task text without TODO prefix" + ) # Verify timestamp format (YYYY-MM-DD HH:MM) import re - timestamp_pattern = r'\d{4}-\d{2}-\d{2} \d{2}:\d{2}' - assert re.search(timestamp_pattern, org_item), "Should contain timestamp in correct format" + + timestamp_pattern = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}" + assert re.search(timestamp_pattern, org_item), ( + "Should contain timestamp in correct format" + ) logger.info("TODO org-mode formatting test PASSED") From 3591261eede64a9b3fb991d979d24c141d83cbe0 Mon Sep 17 00:00:00 2001 From: George Green Date: Mon, 29 Dec 2025 13:29:45 +0100 Subject: [PATCH 09/13] diagrams --- docs/c4/workspace.dsl | 32 +- docs/c4/workspace.json | 687 ++++++++++++++++++++++++++--------------- taskfile.yml | 2 + 3 files changed, 466 insertions(+), 255 deletions(-) diff --git a/docs/c4/workspace.dsl b/docs/c4/workspace.dsl index af19587..9d1a9d5 100644 --- a/docs/c4/workspace.dsl +++ b/docs/c4/workspace.dsl @@ -70,6 +70,10 @@ workspace "Org Bot" "Architecture model for Org Bot system" { orgApi = container "Org API" "API for org-mode operations" "Python" { tags "Container" + initializer = component "Initializer" "Initializes org API client" "Python" { + tags "Component" + } + entryFinder = component "Entry Finder" "Finds entries in org files by message links" "Python" { tags "Component" } @@ -161,15 +165,9 @@ workspace "Org Bot" "Architecture model for Org Bot system" { main -> utils main -> gcp_log "Configure GCP structured logging" - commands -> baseCommand "Extends" commands -> tracing "Uses for logging" - commands -> orgApi - - startCommand -> baseCommand "Extends" - infoCommand -> baseCommand "Extends" - webhookCommand -> baseCommand "Extends" - postToJournal -> baseCommand "Extends" - + + actions -> tracing "Uses for logging" orgApi -> config auth -> config @@ -199,13 +197,23 @@ workspace "Org Bot" "Architecture model for Org Bot system" { replyInserter -> topLevelFinder textAppender -> fileCreator "May create file" + # Relationships - Component Level (Base Command) + commandBase -> initializer + # Relationships - Component Level (Commands) startCommand -> commandBase "Extends" infoCommand -> commandBase "Extends" webhookCommand -> commandBase "Extends" + + # Relationships - Component Level (Actions) postToJournal -> commandBase "Extends" - postToJournal -> textAppender - postToJournal -> replyInserter + postToJournal -> textAppender "Appends text to journal.org" + + postToTodo -> commandBase "Extends" + postToTodo -> textAppender "Appends text to todo.org" + + postReply -> commandBase "Extends" + postReply -> replyInserter "Inserts reply" } views { @@ -244,6 +252,10 @@ workspace "Org Bot" "Architecture model for Org Bot system" { component commands "CommandsComponents" { include * } + + component actions "ActionsComponents" { + include * + } component tracing "TracingComponents" { include * diff --git a/docs/c4/workspace.json b/docs/c4/workspace.json index fc79d54..cc6942a 100644 --- a/docs/c4/workspace.json +++ b/docs/c4/workspace.json @@ -3,7 +3,8 @@ "description" : "Architecture model for Org Bot system", "documentation" : { }, "id" : 1, - "lastModifiedDate" : "2025-12-29T11:53:32Z", + "lastModifiedAgent" : "structurizr-ui", + "lastModifiedDate" : "2025-12-29T12:26:41Z", "model" : { "people" : [ { "description" : "Telegram user interacting with the bot", @@ -15,7 +16,7 @@ "relationships" : [ { "description" : "Interacts with", "destinationId" : "3", - "id" : "37", + "id" : "40", "sourceId" : "1", "tags" : "Relationship" } ], @@ -43,7 +44,7 @@ "relationships" : [ { "description" : "Delegates to", "destinationId" : "7", - "id" : "56", + "id" : "54", "sourceId" : "5", "tags" : "Relationship" } ], @@ -70,25 +71,25 @@ "relationships" : [ { "description" : "Checks authorization", "destinationId" : "16", - "id" : "57", + "id" : "55", "sourceId" : "7", "tags" : "Relationship" }, { "description" : "Checks authorization", "destinationId" : "15", - "id" : "58", - "linkedRelationshipId" : "57", + "id" : "56", + "linkedRelationshipId" : "55", "sourceId" : "7" }, { "description" : "Processes message", "destinationId" : "8", - "id" : "60", + "id" : "58", "sourceId" : "7", "tags" : "Relationship" }, { "description" : "Sends response", "destinationId" : "9", - "id" : "69", + "id" : "67", "sourceId" : "7", "tags" : "Relationship" } ], @@ -105,32 +106,32 @@ "relationships" : [ { "description" : "Gets commands", "destinationId" : "12", - "id" : "61", + "id" : "59", "sourceId" : "8", "tags" : "Relationship" }, { "description" : "Gets commands", "destinationId" : "11", - "id" : "62", - "linkedRelationshipId" : "61", + "id" : "60", + "linkedRelationshipId" : "59", "sourceId" : "8" }, { "description" : "Gets actions", "destinationId" : "13", - "id" : "64", + "id" : "62", "sourceId" : "8", "tags" : "Relationship" }, { "description" : "Extracts text", - "destinationId" : "26", - "id" : "66", + "destinationId" : "27", + "id" : "64", "sourceId" : "8", "tags" : "Relationship" }, { "description" : "Extracts text", - "destinationId" : "25", - "id" : "67", - "linkedRelationshipId" : "66", + "destinationId" : "26", + "id" : "65", + "linkedRelationshipId" : "64", "sourceId" : "8" } ], "tags" : "Element,Component", @@ -146,7 +147,7 @@ "relationships" : [ { "description" : "Gets bot instance", "destinationId" : "6", - "id" : "70", + "id" : "68", "sourceId" : "9", "tags" : "Relationship" } ], @@ -171,72 +172,72 @@ "structurizr.dsl.identifier" : "main" }, "relationships" : [ { - "destinationId" : "29", - "id" : "39", + "destinationId" : "30", + "id" : "42", "sourceId" : "4", "tags" : "Relationship" }, { "description" : "Get / commands", "destinationId" : "11", - "id" : "40", + "id" : "43", "sourceId" : "4", "tags" : "Relationship" }, { "description" : "Get actions", "destinationId" : "11", - "id" : "41", + "id" : "44", "sourceId" : "4", "tags" : "Relationship" }, { "destinationId" : "15", - "id" : "42", + "id" : "45", "sourceId" : "4", "tags" : "Relationship" }, { "destinationId" : "19", - "id" : "43", + "id" : "46", "sourceId" : "4", "tags" : "Relationship" }, { - "destinationId" : "25", - "id" : "44", + "destinationId" : "26", + "id" : "47", "sourceId" : "4", "tags" : "Relationship" }, { "description" : "Configure GCP structured logging", - "destinationId" : "36", - "id" : "45", + "destinationId" : "39", + "id" : "48", "sourceId" : "4", "tags" : "Relationship" }, { "description" : "Configure GCP structured logging", - "destinationId" : "35", - "id" : "46", - "linkedRelationshipId" : "45", + "destinationId" : "38", + "id" : "49", + "linkedRelationshipId" : "48", "sourceId" : "4" }, { "description" : "Checks authorization", "destinationId" : "16", - "id" : "59", - "linkedRelationshipId" : "57", + "id" : "57", + "linkedRelationshipId" : "55", "sourceId" : "4" }, { "description" : "Gets commands", "destinationId" : "12", - "id" : "63", - "linkedRelationshipId" : "61", + "id" : "61", + "linkedRelationshipId" : "59", "sourceId" : "4" }, { "description" : "Gets actions", "destinationId" : "13", - "id" : "65", - "linkedRelationshipId" : "64", + "id" : "63", + "linkedRelationshipId" : "62", "sourceId" : "4" }, { "description" : "Extracts text", - "destinationId" : "26", - "id" : "68", - "linkedRelationshipId" : "66", + "destinationId" : "27", + "id" : "66", + "linkedRelationshipId" : "64", "sourceId" : "4" } ], "tags" : "Element,Container", @@ -252,7 +253,7 @@ }, "relationships" : [ { "destinationId" : "14", - "id" : "71", + "id" : "69", "sourceId" : "12", "tags" : "Relationship" } ], @@ -268,7 +269,7 @@ }, "relationships" : [ { "destinationId" : "14", - "id" : "72", + "id" : "70", "sourceId" : "13", "tags" : "Relationship" } ], @@ -305,18 +306,18 @@ }, "relationships" : [ { "destinationId" : "14", - "id" : "73", + "id" : "71", "sourceId" : "16", "tags" : "Relationship" }, { "destinationId" : "11", - "id" : "74", - "linkedRelationshipId" : "73", + "id" : "72", + "linkedRelationshipId" : "71", "sourceId" : "16" }, { "description" : "Forwards unauthorized", "destinationId" : "18", - "id" : "76", + "id" : "74", "sourceId" : "16", "tags" : "Relationship" } ], @@ -332,13 +333,13 @@ }, "relationships" : [ { "destinationId" : "14", - "id" : "77", + "id" : "75", "sourceId" : "17", "tags" : "Relationship" }, { "destinationId" : "11", - "id" : "78", - "linkedRelationshipId" : "77", + "id" : "76", + "linkedRelationshipId" : "75", "sourceId" : "17" } ], "tags" : "Element,Component", @@ -363,31 +364,41 @@ }, "relationships" : [ { "destinationId" : "11", - "id" : "55", + "id" : "53", "sourceId" : "15", "tags" : "Relationship" }, { "destinationId" : "14", - "id" : "75", - "linkedRelationshipId" : "73", + "id" : "73", + "linkedRelationshipId" : "71", "sourceId" : "15" } ], "tags" : "Element,Container", "technology" : "Python" }, { "components" : [ { - "description" : "Finds entries in org files by message links", + "description" : "Initializes org API client", "documentation" : { }, "id" : "20", + "name" : "Initializer", + "properties" : { + "structurizr.dsl.identifier" : "initializer" + }, + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Finds entries in org files by message links", + "documentation" : { }, + "id" : "21", "name" : "Entry Finder", "properties" : { "structurizr.dsl.identifier" : "entryFinder" }, "relationships" : [ { "description" : "Finds parent", - "destinationId" : "21", - "id" : "79", - "sourceId" : "20", + "destinationId" : "22", + "id" : "77", + "sourceId" : "21", "tags" : "Relationship" } ], "tags" : "Element,Component", @@ -395,7 +406,7 @@ }, { "description" : "Finds top-level non-reply entries", "documentation" : { }, - "id" : "21", + "id" : "22", "name" : "Top Level Finder", "properties" : { "structurizr.dsl.identifier" : "topLevelFinder" @@ -405,20 +416,20 @@ }, { "description" : "Inserts replies at correct org hierarchy position", "documentation" : { }, - "id" : "22", + "id" : "23", "name" : "Reply Inserter", "properties" : { "structurizr.dsl.identifier" : "replyInserter" }, "relationships" : [ { - "destinationId" : "20", - "id" : "80", - "sourceId" : "22", + "destinationId" : "21", + "id" : "78", + "sourceId" : "23", "tags" : "Relationship" }, { - "destinationId" : "21", - "id" : "81", - "sourceId" : "22", + "destinationId" : "22", + "id" : "79", + "sourceId" : "23", "tags" : "Relationship" } ], "tags" : "Element,Component", @@ -426,7 +437,7 @@ }, { "description" : "Creates new files in repository", "documentation" : { }, - "id" : "23", + "id" : "24", "name" : "File Creator", "properties" : { "structurizr.dsl.identifier" : "fileCreator" @@ -436,16 +447,16 @@ }, { "description" : "Appends text to org files", "documentation" : { }, - "id" : "24", + "id" : "25", "name" : "Text Appender", "properties" : { "structurizr.dsl.identifier" : "textAppender" }, "relationships" : [ { "description" : "May create file", - "destinationId" : "23", - "id" : "82", - "sourceId" : "24", + "destinationId" : "24", + "id" : "80", + "sourceId" : "25", "tags" : "Relationship" } ], "tags" : "Element,Component", @@ -460,7 +471,7 @@ }, "relationships" : [ { "destinationId" : "11", - "id" : "54", + "id" : "52", "sourceId" : "19", "tags" : "Relationship" } ], @@ -470,7 +481,7 @@ "components" : [ { "description" : "Extracts text from various message types", "documentation" : { }, - "id" : "26", + "id" : "27", "name" : "Message Text Extractor", "properties" : { "structurizr.dsl.identifier" : "messageTextExtractor" @@ -480,7 +491,7 @@ } ], "description" : "Utility functions", "documentation" : { }, - "id" : "25", + "id" : "26", "name" : "Utils", "properties" : { "structurizr.dsl.identifier" : "utils" @@ -491,190 +502,302 @@ "components" : [ { "description" : "Abstract base class for all commands", "documentation" : { }, - "id" : "28", + "id" : "29", "name" : "Command Base", "properties" : { "structurizr.dsl.identifier" : "commandBase" }, + "relationships" : [ { + "destinationId" : "20", + "id" : "81", + "sourceId" : "29", + "tags" : "Relationship" + }, { + "destinationId" : "19", + "id" : "82", + "linkedRelationshipId" : "81", + "sourceId" : "29" + } ], "tags" : "Element,Component", "technology" : "Python" } ], "description" : "Base class for bot commands", "documentation" : { }, - "id" : "27", + "id" : "28", "name" : "Base Command", "properties" : { "structurizr.dsl.identifier" : "baseCommand" }, + "relationships" : [ { + "destinationId" : "20", + "id" : "83", + "linkedRelationshipId" : "81", + "sourceId" : "28" + }, { + "destinationId" : "19", + "id" : "84", + "linkedRelationshipId" : "81", + "sourceId" : "28" + } ], "tags" : "Element,Container", "technology" : "Python" }, { "components" : [ { "description" : "Handles /start command", "documentation" : { }, - "id" : "30", + "id" : "31", "name" : "Start Command", "properties" : { "structurizr.dsl.identifier" : "startCommand" }, "relationships" : [ { "description" : "Extends", - "destinationId" : "27", - "id" : "50", - "sourceId" : "30", + "destinationId" : "29", + "id" : "85", + "sourceId" : "31", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "83", - "sourceId" : "30", - "tags" : "Relationship" + "id" : "86", + "linkedRelationshipId" : "85", + "sourceId" : "31" } ], "tags" : "Element,Component", "technology" : "Python" }, { "description" : "Handles /info command", "documentation" : { }, - "id" : "31", + "id" : "32", "name" : "Info Command", "properties" : { "structurizr.dsl.identifier" : "infoCommand" }, "relationships" : [ { "description" : "Extends", - "destinationId" : "27", - "id" : "51", - "sourceId" : "31", + "destinationId" : "29", + "id" : "89", + "sourceId" : "32", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "85", - "sourceId" : "31", - "tags" : "Relationship" + "id" : "90", + "linkedRelationshipId" : "89", + "sourceId" : "32" } ], "tags" : "Element,Component", "technology" : "Python" }, { "description" : "Handles webhook operations", "documentation" : { }, - "id" : "32", + "id" : "33", "name" : "Webhook Command", "properties" : { "structurizr.dsl.identifier" : "webhookCommand" }, "relationships" : [ { "description" : "Extends", - "destinationId" : "27", - "id" : "52", - "sourceId" : "32", + "destinationId" : "29", + "id" : "91", + "sourceId" : "33", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "86", - "sourceId" : "32", - "tags" : "Relationship" + "id" : "92", + "linkedRelationshipId" : "91", + "sourceId" : "33" } ], "tags" : "Element,Component", "technology" : "Python" + } ], + "description" : "Bot command handlers module", + "documentation" : { }, + "id" : "30", + "name" : "Commands", + "properties" : { + "structurizr.dsl.identifier" : "commands" + }, + "relationships" : [ { + "description" : "Uses for logging", + "destinationId" : "38", + "id" : "50", + "sourceId" : "30", + "tags" : "Relationship" }, { + "description" : "Extends", + "destinationId" : "29", + "id" : "87", + "linkedRelationshipId" : "85", + "sourceId" : "30" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "88", + "linkedRelationshipId" : "85", + "sourceId" : "30" + } ], + "tags" : "Element,Container,Module", + "technology" : "Python" + }, { + "components" : [ { "description" : "Handles posting entries to journal", "documentation" : { }, - "id" : "33", + "id" : "35", "name" : "Post to Journal", "properties" : { "structurizr.dsl.identifier" : "postToJournal" }, "relationships" : [ { "description" : "Extends", - "destinationId" : "27", - "id" : "53", - "sourceId" : "33", + "destinationId" : "29", + "id" : "93", + "sourceId" : "35", "tags" : "Relationship" }, { "description" : "Extends", "destinationId" : "28", - "id" : "87", - "sourceId" : "33", + "id" : "94", + "linkedRelationshipId" : "93", + "sourceId" : "35" + }, { + "description" : "Appends text to journal.org", + "destinationId" : "25", + "id" : "97", + "sourceId" : "35", "tags" : "Relationship" }, { - "destinationId" : "24", - "id" : "88", - "sourceId" : "33", + "description" : "Appends text to journal.org", + "destinationId" : "19", + "id" : "98", + "linkedRelationshipId" : "97", + "sourceId" : "35" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Handles posting entries to todo list", + "documentation" : { }, + "id" : "36", + "name" : "Post to Todo", + "properties" : { + "structurizr.dsl.identifier" : "postToTodo" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "29", + "id" : "101", + "sourceId" : "36", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "102", + "linkedRelationshipId" : "101", + "sourceId" : "36" + }, { + "description" : "Appends text to todo.org", + "destinationId" : "25", + "id" : "103", + "sourceId" : "36", "tags" : "Relationship" }, { + "description" : "Appends text to todo.org", "destinationId" : "19", - "id" : "89", - "linkedRelationshipId" : "88", - "sourceId" : "33" + "id" : "104", + "linkedRelationshipId" : "103", + "sourceId" : "36" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Handles posting replies to entries", + "documentation" : { }, + "id" : "37", + "name" : "Post Reply", + "properties" : { + "structurizr.dsl.identifier" : "postReply" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "29", + "id" : "105", + "sourceId" : "37", + "tags" : "Relationship" }, { - "destinationId" : "22", - "id" : "91", - "sourceId" : "33", + "description" : "Extends", + "destinationId" : "28", + "id" : "106", + "linkedRelationshipId" : "105", + "sourceId" : "37" + }, { + "description" : "Inserts reply", + "destinationId" : "23", + "id" : "107", + "sourceId" : "37", "tags" : "Relationship" + }, { + "description" : "Inserts reply", + "destinationId" : "19", + "id" : "108", + "linkedRelationshipId" : "107", + "sourceId" : "37" } ], "tags" : "Element,Component", "technology" : "Python" } ], - "description" : "Bot command handlers module", + "description" : "Journal, todo, and reply actions module", "documentation" : { }, - "id" : "29", - "name" : "Commands", + "id" : "34", + "name" : "Actions", "properties" : { - "structurizr.dsl.identifier" : "commands" + "structurizr.dsl.identifier" : "actions" }, "relationships" : [ { - "description" : "Extends", - "destinationId" : "27", - "id" : "47", - "sourceId" : "29", - "tags" : "Relationship" - }, { "description" : "Uses for logging", - "destinationId" : "35", - "id" : "48", - "sourceId" : "29", + "destinationId" : "38", + "id" : "51", + "sourceId" : "34", "tags" : "Relationship" }, { - "destinationId" : "19", - "id" : "49", - "sourceId" : "29", - "tags" : "Relationship" + "description" : "Extends", + "destinationId" : "29", + "id" : "95", + "linkedRelationshipId" : "93", + "sourceId" : "34" }, { "description" : "Extends", "destinationId" : "28", - "id" : "84", - "linkedRelationshipId" : "83", - "sourceId" : "29" + "id" : "96", + "linkedRelationshipId" : "93", + "sourceId" : "34" }, { - "destinationId" : "24", - "id" : "90", - "linkedRelationshipId" : "88", - "sourceId" : "29" + "description" : "Appends text to journal.org", + "destinationId" : "25", + "id" : "99", + "linkedRelationshipId" : "97", + "sourceId" : "34" + }, { + "description" : "Appends text to journal.org", + "destinationId" : "19", + "id" : "100", + "linkedRelationshipId" : "97", + "sourceId" : "34" }, { - "destinationId" : "22", - "id" : "92", - "linkedRelationshipId" : "91", - "sourceId" : "29" + "description" : "Inserts reply", + "destinationId" : "23", + "id" : "109", + "linkedRelationshipId" : "107", + "sourceId" : "34" } ], "tags" : "Element,Container,Module", "technology" : "Python" - }, { - "description" : "Journal, todo, and reply actions module", - "documentation" : { }, - "id" : "34", - "name" : "Actions", - "properties" : { - "structurizr.dsl.identifier" : "actions" - }, - "tags" : "Element,Container,Module", - "technology" : "Python" }, { "components" : [ { "description" : "Logging utilities", "documentation" : { }, - "id" : "36", + "id" : "39", "name" : "Log", "properties" : { "structurizr.dsl.identifier" : "gcp_log" @@ -684,7 +807,7 @@ } ], "description" : "Logging and tracing module", "documentation" : { }, - "id" : "35", + "id" : "38", "name" : "Tracing", "properties" : { "structurizr.dsl.identifier" : "tracing" @@ -702,7 +825,7 @@ "relationships" : [ { "description" : "Deployed using", "destinationId" : "2", - "id" : "38", + "id" : "41", "sourceId" : "3", "tags" : "Relationship" } ], @@ -711,11 +834,11 @@ }, "name" : "Org Bot", "properties" : { - "structurizr.inspection.error" : "82", - "structurizr.dsl" : "d29ya3NwYWNlICJPcmcgQm90IiAiQXJjaGl0ZWN0dXJlIG1vZGVsIGZvciBPcmcgQm90IHN5c3RlbSIgewoKICAgIG1vZGVsIHsKICAgICAgICB1c2VyID0gcGVyc29uICJCZWF0aWZ1bCB5b3UiICJUZWxlZ3JhbSB1c2VyIGludGVyYWN0aW5nIHdpdGggdGhlIGJvdCIKCiAgICAgICAgdGVycmFmb3JtID0gc29mdHdhcmVTeXN0ZW0gIlRlcnJhZm9ybSIgIklhQyBmb3IgcHJvdmlzaW9uaW5nIGFuZCBtYW5hZ2luZyBjbG91ZCByZXNvdXJjZXMiCgogICAgICAgIG9yZ0JvdCA9IHNvZnR3YXJlU3lzdGVtICJPcmcgQm90IiAiVGVsZWdyYW0gYm90IGZvciBtYW5hZ2luZyBvcmctbW9kZSBub3RlcyBhbmQgam91cm5hbCBlbnRyaWVzIiB7CgogICAgICAgICAgICBtYWluID0gY29udGFpbmVyICJNYWluIiAiRW50cnkgcG9pbnQgZm9yIHRoZSBib3QgYXBwbGljYXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBodHRwRW50cnlwb2ludCA9IGNvbXBvbmVudCAiSFRUUCBFbnRyeXBvaW50IiAiR0NQIENsb3VkIEZ1bmN0aW9uIGhhbmRsZXIgZm9yIGluY29taW5nIHdlYmhvb2tzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGJvdEluaXRpYWxpemVyID0gY29tcG9uZW50ICJCb3QgSW5pdGlhbGl6ZXIiICJDcmVhdGVzIGFuZCBtYW5hZ2VzIGJvdCBpbnN0YW5jZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZUhhbmRsZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgSGFuZGxlciIgIkhhbmRsZXMgaW5jb21pbmcgVGVsZWdyYW0gbWVzc2FnZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZVByb2Nlc3NvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBQcm9jZXNzb3IiICJQcm9jZXNzZXMgY29tbWFuZHMgYW5kIG5vbi1jb21tYW5kIG1lc3NhZ2VzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIG1lc3NhZ2VTZW5kZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgU2VuZGVyIiAiU2VuZHMgcmVzcG9uc2VzIGJhY2sgdG8gVGVsZWdyYW0iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgc2VudHJ5SW5pdCA9IGNvbXBvbmVudCAiU2VudHJ5IEluaXRpYWxpemF0aW9uIiAiSW5pdGlhbGl6ZXMgZXJyb3IgdHJhY2tpbmciICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbmZpZyA9IGNvbnRhaW5lciAiQ29uZmlnIiAiQ29uZmlndXJhdGlvbiBtYW5hZ2VtZW50IiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJDb250YWluZXIiCgogICAgICAgICAgICAgICAgY29tbWFuZEluaXQgPSBjb21wb25lbnQgIkNvbW1hbmQgSW5pdGlhbGl6YXRpb24iICJJbml0aWFsaXplcyBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgYWN0aW9uQ29uZmlnID0gY29tcG9uZW50ICJBY3Rpb24gQ29uZmlndXJhdGlvbiIgIkNvbmZpZ3VyZXMgam91cm5hbCwgdG9kbywgYW5kIHJlcGx5IGFjdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZW52Q29uZmlnID0gY29tcG9uZW50ICJFbnZpcm9ubWVudCBDb25maWd1cmF0aW9uIiAiTG9hZHMgZW52aXJvbm1lbnQgdmFyaWFibGVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBhdXRoID0gY29udGFpbmVyICJBdXRoIiAiQXV0aGVudGljYXRpb24gYW5kIGF1dGhvcml6YXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBhdXRoQ2hlY2sgPSBjb21wb25lbnQgIkF1dGhvcml6YXRpb24gQ2hlY2siICJWYWxpZGF0ZXMgaWYgbWVzc2FnZSBjb21lcyBmcm9tIGF1dGhvcml6ZWQgY2hhdCIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICBpZ25vcmVDaGVjayA9IGNvbXBvbmVudCAiSWdub3JlIENoZWNrIiAiQ2hlY2tzIGlmIG1lc3NhZ2UgY29tZXMgZnJvbSBpZ25vcmVkIGNoYXQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdW5hdXRob3JpemVkRm9yd2FyZGVyID0gY29tcG9uZW50ICJVbmF1dGhvcml6ZWQgRm9yd2FyZGVyIiAiRm9yd2FyZHMgdW5hdXRob3JpemVkIG1lc3NhZ2VzIHRvIGFkbWluIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBvcmdBcGkgPSBjb250YWluZXIgIk9yZyBBUEkiICJBUEkgZm9yIG9yZy1tb2RlIG9wZXJhdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBlbnRyeUZpbmRlciA9IGNvbXBvbmVudCAiRW50cnkgRmluZGVyIiAiRmluZHMgZW50cmllcyBpbiBvcmcgZmlsZXMgYnkgbWVzc2FnZSBsaW5rcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICB0b3BMZXZlbEZpbmRlciA9IGNvbXBvbmVudCAiVG9wIExldmVsIEZpbmRlciIgIkZpbmRzIHRvcC1sZXZlbCBub24tcmVwbHkgZW50cmllcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICByZXBseUluc2VydGVyID0gY29tcG9uZW50ICJSZXBseSBJbnNlcnRlciIgIkluc2VydHMgcmVwbGllcyBhdCBjb3JyZWN0IG9yZyBoaWVyYXJjaHkgcG9zaXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZmlsZUNyZWF0b3IgPSBjb21wb25lbnQgIkZpbGUgQ3JlYXRvciIgIkNyZWF0ZXMgbmV3IGZpbGVzIGluIHJlcG9zaXRvcnkiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdGV4dEFwcGVuZGVyID0gY29tcG9uZW50ICJUZXh0IEFwcGVuZGVyIiAiQXBwZW5kcyB0ZXh0IHRvIG9yZyBmaWxlcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQoKICAgICAgICAgICAgdXRpbHMgPSBjb250YWluZXIgIlV0aWxzIiAiVXRpbGl0eSBmdW5jdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBtZXNzYWdlVGV4dEV4dHJhY3RvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBUZXh0IEV4dHJhY3RvciIgIkV4dHJhY3RzIHRleHQgZnJvbSB2YXJpb3VzIG1lc3NhZ2UgdHlwZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGJhc2VDb21tYW5kID0gY29udGFpbmVyICJCYXNlIENvbW1hbmQiICJCYXNlIGNsYXNzIGZvciBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBjb21tYW5kQmFzZSA9IGNvbXBvbmVudCAiQ29tbWFuZCBCYXNlIiAiQWJzdHJhY3QgYmFzZSBjbGFzcyBmb3IgYWxsIGNvbW1hbmRzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBjb21tYW5kcyA9IGNvbnRhaW5lciAiQ29tbWFuZHMiICJCb3QgY29tbWFuZCBoYW5kbGVycyBtb2R1bGUiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIk1vZHVsZSIKCiAgICAgICAgICAgICAgICBzdGFydENvbW1hbmQgPSBjb21wb25lbnQgIlN0YXJ0IENvbW1hbmQiICJIYW5kbGVzIC9zdGFydCBjb21tYW5kIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGluZm9Db21tYW5kID0gY29tcG9uZW50ICJJbmZvIENvbW1hbmQiICJIYW5kbGVzIC9pbmZvIGNvbW1hbmQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgd2ViaG9va0NvbW1hbmQgPSBjb21wb25lbnQgIldlYmhvb2sgQ29tbWFuZCIgIkhhbmRsZXMgd2ViaG9vayBvcGVyYXRpb25zIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIHBvc3RUb0pvdXJuYWwgPSBjb21wb25lbnQgIlBvc3QgdG8gSm91cm5hbCIgIkhhbmRsZXMgcG9zdGluZyBlbnRyaWVzIHRvIGpvdXJuYWwiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGFjdGlvbnMgPSBjb250YWluZXIgIkFjdGlvbnMiICJKb3VybmFsLCB0b2RvLCBhbmQgcmVwbHkgYWN0aW9ucyBtb2R1bGUiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIk1vZHVsZSIKICAgICAgICAgICAgfQoKICAgICAgICAgICAgdHJhY2luZyA9IGNvbnRhaW5lciAiVHJhY2luZyIgIkxvZ2dpbmcgYW5kIHRyYWNpbmcgbW9kdWxlIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJNb2R1bGUiCgogICAgICAgICAgICAgICAgZ2NwX2xvZyA9IGNvbXBvbmVudCAiTG9nIiAiTG9nZ2luZyB1dGlsaXRpZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KICAgICAgICB9CgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIFN5c3RlbSBMZXZlbAogICAgICAgIHVzZXIgLT4gb3JnQm90ICJJbnRlcmFjdHMgd2l0aCIKICAgICAgICBvcmdCb3QgLT4gdGVycmFmb3JtICJEZXBsb3llZCB1c2luZyIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29udGFpbmVyIExldmVsCiAgICAgICAgbWFpbiAtPiBjb21tYW5kcwogICAgICAgIG1haW4gLT4gY29uZmlnICJHZXQgLyBjb21tYW5kcyIKICAgICAgICBtYWluIC0+IGNvbmZpZyAiR2V0IGFjdGlvbnMiCiAgICAgICAgbWFpbiAtPiBhdXRoCiAgICAgICAgbWFpbiAtPiBvcmdBcGkKICAgICAgICBtYWluIC0+IHV0aWxzCiAgICAgICAgbWFpbiAtPiBnY3BfbG9nICJDb25maWd1cmUgR0NQIHN0cnVjdHVyZWQgbG9nZ2luZyIKCiAgICAgICAgY29tbWFuZHMgLT4gYmFzZUNvbW1hbmQgIkV4dGVuZHMiCiAgICAgICAgY29tbWFuZHMgLT4gdHJhY2luZyAiVXNlcyBmb3IgbG9nZ2luZyIKICAgICAgICBjb21tYW5kcyAtPiBvcmdBcGkKCiAgICAgICAgc3RhcnRDb21tYW5kIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgogICAgICAgIGluZm9Db21tYW5kIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgogICAgICAgIHdlYmhvb2tDb21tYW5kIC0+IGJhc2VDb21tYW5kICJFeHRlbmRzIgogICAgICAgIHBvc3RUb0pvdXJuYWwgLT4gYmFzZUNvbW1hbmQgIkV4dGVuZHMiCgoKICAgICAgICBvcmdBcGkgLT4gY29uZmlnCiAgICAgICAgYXV0aCAtPiBjb25maWcKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChNYWluKQogICAgICAgIGh0dHBFbnRyeXBvaW50IC0+IG1lc3NhZ2VIYW5kbGVyICJEZWxlZ2F0ZXMgdG8iCiAgICAgICAgbWVzc2FnZUhhbmRsZXIgLT4gYXV0aENoZWNrICJDaGVja3MgYXV0aG9yaXphdGlvbiIKICAgICAgICBtZXNzYWdlSGFuZGxlciAtPiBtZXNzYWdlUHJvY2Vzc29yICJQcm9jZXNzZXMgbWVzc2FnZSIKICAgICAgICBtZXNzYWdlUHJvY2Vzc29yIC0+IGNvbW1hbmRJbml0ICJHZXRzIGNvbW1hbmRzIgogICAgICAgIG1lc3NhZ2VQcm9jZXNzb3IgLT4gYWN0aW9uQ29uZmlnICJHZXRzIGFjdGlvbnMiCiAgICAgICAgbWVzc2FnZVByb2Nlc3NvciAtPiBtZXNzYWdlVGV4dEV4dHJhY3RvciAiRXh0cmFjdHMgdGV4dCIKICAgICAgICBtZXNzYWdlSGFuZGxlciAtPiBtZXNzYWdlU2VuZGVyICJTZW5kcyByZXNwb25zZSIKICAgICAgICBtZXNzYWdlU2VuZGVyIC0+IGJvdEluaXRpYWxpemVyICJHZXRzIGJvdCBpbnN0YW5jZSIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChDb25maWcpCiAgICAgICAgY29tbWFuZEluaXQgLT4gZW52Q29uZmlnCiAgICAgICAgYWN0aW9uQ29uZmlnIC0+IGVudkNvbmZpZwoKICAgICAgICAjIFJlbGF0aW9uc2hpcHMgLSBDb21wb25lbnQgTGV2ZWwgKEF1dGgpCiAgICAgICAgYXV0aENoZWNrIC0+IGVudkNvbmZpZwogICAgICAgIGF1dGhDaGVjayAtPiB1bmF1dGhvcml6ZWRGb3J3YXJkZXIgIkZvcndhcmRzIHVuYXV0aG9yaXplZCIKICAgICAgICBpZ25vcmVDaGVjayAtPiBlbnZDb25maWcKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChPcmcgQVBJKQogICAgICAgIGVudHJ5RmluZGVyIC0+IHRvcExldmVsRmluZGVyICJGaW5kcyBwYXJlbnQiCiAgICAgICAgcmVwbHlJbnNlcnRlciAtPiBlbnRyeUZpbmRlcgogICAgICAgIHJlcGx5SW5zZXJ0ZXIgLT4gdG9wTGV2ZWxGaW5kZXIKICAgICAgICB0ZXh0QXBwZW5kZXIgLT4gZmlsZUNyZWF0b3IgIk1heSBjcmVhdGUgZmlsZSIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChDb21tYW5kcykKICAgICAgICBzdGFydENvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgaW5mb0NvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgd2ViaG9va0NvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgcG9zdFRvSm91cm5hbCAtPiBjb21tYW5kQmFzZSAiRXh0ZW5kcyIKICAgICAgICBwb3N0VG9Kb3VybmFsIC0+IHRleHRBcHBlbmRlcgogICAgICAgIHBvc3RUb0pvdXJuYWwgLT4gcmVwbHlJbnNlcnRlcgogICAgfQoKICAgIHZpZXdzIHsKICAgICAgICBzeXN0ZW1Db250ZXh0IG9yZ0JvdCAiU3lzdGVtQ29udGV4dCIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbnRhaW5lciBvcmdCb3QgIkNvbnRhaW5lcnMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgbWFpbiAiTWFpbkNvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgY29uZmlnICJDb25maWdDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGF1dGggIkF1dGhDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IG9yZ0FwaSAiT3JnQXBpQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCB1dGlscyAiVXRpbHNDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IGJhc2VDb21tYW5kICJCYXNlQ29tbWFuZENvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgY29tbWFuZHMgIkNvbW1hbmRzQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCB0cmFjaW5nICJUcmFjaW5nQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIHN0eWxlcyB7CiAgICAgICAgICAgIGVsZW1lbnQgIlNvZnR3YXJlIFN5c3RlbSIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjMTE2OGJkCiAgICAgICAgICAgICAgICBjb2xvciAjZmZmZmZmCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiQ29udGFpbmVyIiB7CiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICM0MzhkZDUKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJNb2R1bGUiIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzg1YmJmMAogICAgICAgICAgICAgICAgY29sb3IgIzAwMDAwMAogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkNvbXBvbmVudCIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjYTVjOWY1CiAgICAgICAgICAgICAgICBjb2xvciAjMDAwMDAwCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiUGVyc29uIiB7CiAgICAgICAgICAgICAgICBzaGFwZSBwZXJzb24KICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzA4NDI3YgogICAgICAgICAgICAgICAgY29sb3IgI2ZmZmZmZgogICAgICAgICAgICB9CiAgICAgICAgfQoKICAgICAgICB0aGVtZSBodHRwczovL3N0YXRpYy5zdHJ1Y3R1cml6ci5jb20vdGhlbWVzL2dvb2dsZS1jbG91ZC1wbGF0Zm9ybS12MS41L3RoZW1lLmpzb24KCiAgICB9Cgp9", "structurizr.inspection.info" : "0", "structurizr.inspection.ignore" : "0", - "structurizr.inspection.warning" : "0" + "structurizr.inspection.error" : "93", + "structurizr.inspection.warning" : "0", + "structurizr.dsl" : "d29ya3NwYWNlICJPcmcgQm90IiAiQXJjaGl0ZWN0dXJlIG1vZGVsIGZvciBPcmcgQm90IHN5c3RlbSIgewoKICAgIG1vZGVsIHsKICAgICAgICB1c2VyID0gcGVyc29uICJCZWF0aWZ1bCB5b3UiICJUZWxlZ3JhbSB1c2VyIGludGVyYWN0aW5nIHdpdGggdGhlIGJvdCIKCiAgICAgICAgdGVycmFmb3JtID0gc29mdHdhcmVTeXN0ZW0gIlRlcnJhZm9ybSIgIklhQyBmb3IgcHJvdmlzaW9uaW5nIGFuZCBtYW5hZ2luZyBjbG91ZCByZXNvdXJjZXMiCgogICAgICAgIG9yZ0JvdCA9IHNvZnR3YXJlU3lzdGVtICJPcmcgQm90IiAiVGVsZWdyYW0gYm90IGZvciBtYW5hZ2luZyBvcmctbW9kZSBub3RlcyBhbmQgam91cm5hbCBlbnRyaWVzIiB7CgogICAgICAgICAgICBtYWluID0gY29udGFpbmVyICJNYWluIiAiRW50cnkgcG9pbnQgZm9yIHRoZSBib3QgYXBwbGljYXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBodHRwRW50cnlwb2ludCA9IGNvbXBvbmVudCAiSFRUUCBFbnRyeXBvaW50IiAiR0NQIENsb3VkIEZ1bmN0aW9uIGhhbmRsZXIgZm9yIGluY29taW5nIHdlYmhvb2tzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGJvdEluaXRpYWxpemVyID0gY29tcG9uZW50ICJCb3QgSW5pdGlhbGl6ZXIiICJDcmVhdGVzIGFuZCBtYW5hZ2VzIGJvdCBpbnN0YW5jZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZUhhbmRsZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgSGFuZGxlciIgIkhhbmRsZXMgaW5jb21pbmcgVGVsZWdyYW0gbWVzc2FnZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgbWVzc2FnZVByb2Nlc3NvciA9IGNvbXBvbmVudCAiTWVzc2FnZSBQcm9jZXNzb3IiICJQcm9jZXNzZXMgY29tbWFuZHMgYW5kIG5vbi1jb21tYW5kIG1lc3NhZ2VzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIG1lc3NhZ2VTZW5kZXIgPSBjb21wb25lbnQgIk1lc3NhZ2UgU2VuZGVyIiAiU2VuZHMgcmVzcG9uc2VzIGJhY2sgdG8gVGVsZWdyYW0iICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgc2VudHJ5SW5pdCA9IGNvbXBvbmVudCAiU2VudHJ5IEluaXRpYWxpemF0aW9uIiAiSW5pdGlhbGl6ZXMgZXJyb3IgdHJhY2tpbmciICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGNvbmZpZyA9IGNvbnRhaW5lciAiQ29uZmlnIiAiQ29uZmlndXJhdGlvbiBtYW5hZ2VtZW50IiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJDb250YWluZXIiCgogICAgICAgICAgICAgICAgY29tbWFuZEluaXQgPSBjb21wb25lbnQgIkNvbW1hbmQgSW5pdGlhbGl6YXRpb24iICJJbml0aWFsaXplcyBib3QgY29tbWFuZHMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgYWN0aW9uQ29uZmlnID0gY29tcG9uZW50ICJBY3Rpb24gQ29uZmlndXJhdGlvbiIgIkNvbmZpZ3VyZXMgam91cm5hbCwgdG9kbywgYW5kIHJlcGx5IGFjdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgZW52Q29uZmlnID0gY29tcG9uZW50ICJFbnZpcm9ubWVudCBDb25maWd1cmF0aW9uIiAiTG9hZHMgZW52aXJvbm1lbnQgdmFyaWFibGVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBhdXRoID0gY29udGFpbmVyICJBdXRoIiAiQXV0aGVudGljYXRpb24gYW5kIGF1dGhvcml6YXRpb24iICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBhdXRoQ2hlY2sgPSBjb21wb25lbnQgIkF1dGhvcml6YXRpb24gQ2hlY2siICJWYWxpZGF0ZXMgaWYgbWVzc2FnZSBjb21lcyBmcm9tIGF1dGhvcml6ZWQgY2hhdCIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICBpZ25vcmVDaGVjayA9IGNvbXBvbmVudCAiSWdub3JlIENoZWNrIiAiQ2hlY2tzIGlmIG1lc3NhZ2UgY29tZXMgZnJvbSBpZ25vcmVkIGNoYXQiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdW5hdXRob3JpemVkRm9yd2FyZGVyID0gY29tcG9uZW50ICJVbmF1dGhvcml6ZWQgRm9yd2FyZGVyIiAiRm9yd2FyZHMgdW5hdXRob3JpemVkIG1lc3NhZ2VzIHRvIGFkbWluIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBvcmdBcGkgPSBjb250YWluZXIgIk9yZyBBUEkiICJBUEkgZm9yIG9yZy1tb2RlIG9wZXJhdGlvbnMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIkNvbnRhaW5lciIKCiAgICAgICAgICAgICAgICBpbml0aWFsaXplciA9IGNvbXBvbmVudCAiSW5pdGlhbGl6ZXIiICJJbml0aWFsaXplcyBvcmcgQVBJIGNsaWVudCIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgZW50cnlGaW5kZXIgPSBjb21wb25lbnQgIkVudHJ5IEZpbmRlciIgIkZpbmRzIGVudHJpZXMgaW4gb3JnIGZpbGVzIGJ5IG1lc3NhZ2UgbGlua3MiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgdG9wTGV2ZWxGaW5kZXIgPSBjb21wb25lbnQgIlRvcCBMZXZlbCBGaW5kZXIiICJGaW5kcyB0b3AtbGV2ZWwgbm9uLXJlcGx5IGVudHJpZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgcmVwbHlJbnNlcnRlciA9IGNvbXBvbmVudCAiUmVwbHkgSW5zZXJ0ZXIiICJJbnNlcnRzIHJlcGxpZXMgYXQgY29ycmVjdCBvcmcgaGllcmFyY2h5IHBvc2l0aW9uIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIGZpbGVDcmVhdG9yID0gY29tcG9uZW50ICJGaWxlIENyZWF0b3IiICJDcmVhdGVzIG5ldyBmaWxlcyBpbiByZXBvc2l0b3J5IiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIHRleHRBcHBlbmRlciA9IGNvbXBvbmVudCAiVGV4dCBBcHBlbmRlciIgIkFwcGVuZHMgdGV4dCB0byBvcmcgZmlsZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIHV0aWxzID0gY29udGFpbmVyICJVdGlscyIgIlV0aWxpdHkgZnVuY3Rpb25zIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJDb250YWluZXIiCgogICAgICAgICAgICAgICAgbWVzc2FnZVRleHRFeHRyYWN0b3IgPSBjb21wb25lbnQgIk1lc3NhZ2UgVGV4dCBFeHRyYWN0b3IiICJFeHRyYWN0cyB0ZXh0IGZyb20gdmFyaW91cyBtZXNzYWdlIHR5cGVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CgogICAgICAgICAgICBiYXNlQ29tbWFuZCA9IGNvbnRhaW5lciAiQmFzZSBDb21tYW5kIiAiQmFzZSBjbGFzcyBmb3IgYm90IGNvbW1hbmRzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJDb250YWluZXIiCgogICAgICAgICAgICAgICAgY29tbWFuZEJhc2UgPSBjb21wb25lbnQgIkNvbW1hbmQgQmFzZSIgIkFic3RyYWN0IGJhc2UgY2xhc3MgZm9yIGFsbCBjb21tYW5kcyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KICAgICAgICAgICAgfQoKICAgICAgICAgICAgY29tbWFuZHMgPSBjb250YWluZXIgIkNvbW1hbmRzIiAiQm90IGNvbW1hbmQgaGFuZGxlcnMgbW9kdWxlIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICB0YWdzICJNb2R1bGUiCgogICAgICAgICAgICAgICAgc3RhcnRDb21tYW5kID0gY29tcG9uZW50ICJTdGFydCBDb21tYW5kIiAiSGFuZGxlcyAvc3RhcnQgY29tbWFuZCIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgICAgICBpbmZvQ29tbWFuZCA9IGNvbXBvbmVudCAiSW5mbyBDb21tYW5kIiAiSGFuZGxlcyAvaW5mbyBjb21tYW5kIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIHdlYmhvb2tDb21tYW5kID0gY29tcG9uZW50ICJXZWJob29rIENvbW1hbmQiICJIYW5kbGVzIHdlYmhvb2sgb3BlcmF0aW9ucyIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgICAgIHRhZ3MgIkNvbXBvbmVudCIKICAgICAgICAgICAgICAgIH0KCiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIGFjdGlvbnMgPSBjb250YWluZXIgIkFjdGlvbnMiICJKb3VybmFsLCB0b2RvLCBhbmQgcmVwbHkgYWN0aW9ucyBtb2R1bGUiICJQeXRob24iIHsKICAgICAgICAgICAgICAgIHRhZ3MgIk1vZHVsZSIKICAgICAgICAgICAgICAgIHBvc3RUb0pvdXJuYWwgPSBjb21wb25lbnQgIlBvc3QgdG8gSm91cm5hbCIgIkhhbmRsZXMgcG9zdGluZyBlbnRyaWVzIHRvIGpvdXJuYWwiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CgogICAgICAgICAgICAgICAgcG9zdFRvVG9kbyA9IGNvbXBvbmVudCAiUG9zdCB0byBUb2RvIiAiSGFuZGxlcyBwb3N0aW5nIGVudHJpZXMgdG8gdG9kbyBsaXN0IiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQoKICAgICAgICAgICAgICAgIHBvc3RSZXBseSA9IGNvbXBvbmVudCAiUG9zdCBSZXBseSIgIkhhbmRsZXMgcG9zdGluZyByZXBsaWVzIHRvIGVudHJpZXMiICJQeXRob24iIHsKICAgICAgICAgICAgICAgICAgICB0YWdzICJDb21wb25lbnQiCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgIH0KCiAgICAgICAgICAgIHRyYWNpbmcgPSBjb250YWluZXIgIlRyYWNpbmciICJMb2dnaW5nIGFuZCB0cmFjaW5nIG1vZHVsZSIgIlB5dGhvbiIgewogICAgICAgICAgICAgICAgdGFncyAiTW9kdWxlIgoKICAgICAgICAgICAgICAgIGdjcF9sb2cgPSBjb21wb25lbnQgIkxvZyIgIkxvZ2dpbmcgdXRpbGl0aWVzIiAiUHl0aG9uIiB7CiAgICAgICAgICAgICAgICAgICAgdGFncyAiQ29tcG9uZW50IgogICAgICAgICAgICAgICAgfQogICAgICAgICAgICB9CiAgICAgICAgfQoKICAgICAgICAjIFJlbGF0aW9uc2hpcHMgLSBTeXN0ZW0gTGV2ZWwKICAgICAgICB1c2VyIC0+IG9yZ0JvdCAiSW50ZXJhY3RzIHdpdGgiCiAgICAgICAgb3JnQm90IC0+IHRlcnJhZm9ybSAiRGVwbG95ZWQgdXNpbmciCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbnRhaW5lciBMZXZlbAogICAgICAgIG1haW4gLT4gY29tbWFuZHMKICAgICAgICBtYWluIC0+IGNvbmZpZyAiR2V0IC8gY29tbWFuZHMiCiAgICAgICAgbWFpbiAtPiBjb25maWcgIkdldCBhY3Rpb25zIgogICAgICAgIG1haW4gLT4gYXV0aAogICAgICAgIG1haW4gLT4gb3JnQXBpCiAgICAgICAgbWFpbiAtPiB1dGlscwogICAgICAgIG1haW4gLT4gZ2NwX2xvZyAiQ29uZmlndXJlIEdDUCBzdHJ1Y3R1cmVkIGxvZ2dpbmciCgogICAgICAgIGNvbW1hbmRzIC0+IHRyYWNpbmcgIlVzZXMgZm9yIGxvZ2dpbmciCiAgICAgICAgCiAgICAgICAgYWN0aW9ucyAtPiB0cmFjaW5nICJVc2VzIGZvciBsb2dnaW5nIgoKICAgICAgICBvcmdBcGkgLT4gY29uZmlnCiAgICAgICAgYXV0aCAtPiBjb25maWcKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChNYWluKQogICAgICAgIGh0dHBFbnRyeXBvaW50IC0+IG1lc3NhZ2VIYW5kbGVyICJEZWxlZ2F0ZXMgdG8iCiAgICAgICAgbWVzc2FnZUhhbmRsZXIgLT4gYXV0aENoZWNrICJDaGVja3MgYXV0aG9yaXphdGlvbiIKICAgICAgICBtZXNzYWdlSGFuZGxlciAtPiBtZXNzYWdlUHJvY2Vzc29yICJQcm9jZXNzZXMgbWVzc2FnZSIKICAgICAgICBtZXNzYWdlUHJvY2Vzc29yIC0+IGNvbW1hbmRJbml0ICJHZXRzIGNvbW1hbmRzIgogICAgICAgIG1lc3NhZ2VQcm9jZXNzb3IgLT4gYWN0aW9uQ29uZmlnICJHZXRzIGFjdGlvbnMiCiAgICAgICAgbWVzc2FnZVByb2Nlc3NvciAtPiBtZXNzYWdlVGV4dEV4dHJhY3RvciAiRXh0cmFjdHMgdGV4dCIKICAgICAgICBtZXNzYWdlSGFuZGxlciAtPiBtZXNzYWdlU2VuZGVyICJTZW5kcyByZXNwb25zZSIKICAgICAgICBtZXNzYWdlU2VuZGVyIC0+IGJvdEluaXRpYWxpemVyICJHZXRzIGJvdCBpbnN0YW5jZSIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChDb25maWcpCiAgICAgICAgY29tbWFuZEluaXQgLT4gZW52Q29uZmlnCiAgICAgICAgYWN0aW9uQ29uZmlnIC0+IGVudkNvbmZpZwoKICAgICAgICAjIFJlbGF0aW9uc2hpcHMgLSBDb21wb25lbnQgTGV2ZWwgKEF1dGgpCiAgICAgICAgYXV0aENoZWNrIC0+IGVudkNvbmZpZwogICAgICAgIGF1dGhDaGVjayAtPiB1bmF1dGhvcml6ZWRGb3J3YXJkZXIgIkZvcndhcmRzIHVuYXV0aG9yaXplZCIKICAgICAgICBpZ25vcmVDaGVjayAtPiBlbnZDb25maWcKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChPcmcgQVBJKQogICAgICAgIGVudHJ5RmluZGVyIC0+IHRvcExldmVsRmluZGVyICJGaW5kcyBwYXJlbnQiCiAgICAgICAgcmVwbHlJbnNlcnRlciAtPiBlbnRyeUZpbmRlcgogICAgICAgIHJlcGx5SW5zZXJ0ZXIgLT4gdG9wTGV2ZWxGaW5kZXIKICAgICAgICB0ZXh0QXBwZW5kZXIgLT4gZmlsZUNyZWF0b3IgIk1heSBjcmVhdGUgZmlsZSIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChCYXNlIENvbW1hbmQpCiAgICAgICAgY29tbWFuZEJhc2UgLT4gaW5pdGlhbGl6ZXIKCiAgICAgICAgIyBSZWxhdGlvbnNoaXBzIC0gQ29tcG9uZW50IExldmVsIChDb21tYW5kcykKICAgICAgICBzdGFydENvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgaW5mb0NvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgd2ViaG9va0NvbW1hbmQgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCgogICAgICAgICMgUmVsYXRpb25zaGlwcyAtIENvbXBvbmVudCBMZXZlbCAoQWN0aW9ucykKICAgICAgICBwb3N0VG9Kb3VybmFsIC0+IGNvbW1hbmRCYXNlICJFeHRlbmRzIgogICAgICAgIHBvc3RUb0pvdXJuYWwgLT4gdGV4dEFwcGVuZGVyICJBcHBlbmRzIHRleHQgdG8gam91cm5hbC5vcmciCiAgICAgICAgCiAgICAgICAgcG9zdFRvVG9kbyAtPiBjb21tYW5kQmFzZSAiRXh0ZW5kcyIKICAgICAgICBwb3N0VG9Ub2RvIC0+IHRleHRBcHBlbmRlciAiQXBwZW5kcyB0ZXh0IHRvIHRvZG8ub3JnIgoKICAgICAgICBwb3N0UmVwbHkgLT4gY29tbWFuZEJhc2UgIkV4dGVuZHMiCiAgICAgICAgcG9zdFJlcGx5IC0+IHJlcGx5SW5zZXJ0ZXIgIkluc2VydHMgcmVwbHkiCiAgICB9CgogICAgdmlld3MgewogICAgICAgIHN5c3RlbUNvbnRleHQgb3JnQm90ICJTeXN0ZW1Db250ZXh0IiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29udGFpbmVyIG9yZ0JvdCAiQ29udGFpbmVycyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBtYWluICJNYWluQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBjb25maWcgIkNvbmZpZ0NvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgYXV0aCAiQXV0aENvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgb3JnQXBpICJPcmdBcGlDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KCiAgICAgICAgY29tcG9uZW50IHV0aWxzICJVdGlsc0NvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgYmFzZUNvbW1hbmQgIkJhc2VDb21tYW5kQ29tcG9uZW50cyIgewogICAgICAgICAgICBpbmNsdWRlICoKICAgICAgICB9CgogICAgICAgIGNvbXBvbmVudCBjb21tYW5kcyAiQ29tbWFuZHNDb21wb25lbnRzIiB7CiAgICAgICAgICAgIGluY2x1ZGUgKgogICAgICAgIH0KICAgICAgICAKICAgICAgICBjb21wb25lbnQgYWN0aW9ucyAiQWN0aW9uc0NvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBjb21wb25lbnQgdHJhY2luZyAiVHJhY2luZ0NvbXBvbmVudHMiIHsKICAgICAgICAgICAgaW5jbHVkZSAqCiAgICAgICAgfQoKICAgICAgICBzdHlsZXMgewogICAgICAgICAgICBlbGVtZW50ICJTb2Z0d2FyZSBTeXN0ZW0iIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgIzExNjhiZAogICAgICAgICAgICAgICAgY29sb3IgI2ZmZmZmZgogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIkNvbnRhaW5lciIgewogICAgICAgICAgICAgICAgYmFja2dyb3VuZCAjNDM4ZGQ1CiAgICAgICAgICAgICAgICBjb2xvciAjZmZmZmZmCiAgICAgICAgICAgIH0KICAgICAgICAgICAgZWxlbWVudCAiTW9kdWxlIiB7CiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICM4NWJiZjAKICAgICAgICAgICAgICAgIGNvbG9yICMwMDAwMDAKICAgICAgICAgICAgfQogICAgICAgICAgICBlbGVtZW50ICJDb21wb25lbnQiIHsKICAgICAgICAgICAgICAgIGJhY2tncm91bmQgI2E1YzlmNQogICAgICAgICAgICAgICAgY29sb3IgIzAwMDAwMAogICAgICAgICAgICB9CiAgICAgICAgICAgIGVsZW1lbnQgIlBlcnNvbiIgewogICAgICAgICAgICAgICAgc2hhcGUgcGVyc29uCiAgICAgICAgICAgICAgICBiYWNrZ3JvdW5kICMwODQyN2IKICAgICAgICAgICAgICAgIGNvbG9yICNmZmZmZmYKICAgICAgICAgICAgfQogICAgICAgIH0KCiAgICAgICAgdGhlbWUgaHR0cHM6Ly9zdGF0aWMuc3RydWN0dXJpenIuY29tL3RoZW1lcy9nb29nbGUtY2xvdWQtcGxhdGZvcm0tdjEuNS90aGVtZS5qc29uCgogICAgfQoKfQ==" }, "views" : { "componentViews" : [ { @@ -757,7 +880,7 @@ "x" : 2068, "y" : 1400 }, { - "id" : "25", + "id" : "26", "x" : 1647, "y" : 2000 } ], @@ -766,15 +889,15 @@ "name" : "Component View: Org Bot - Main", "order" : 3, "relationships" : [ { - "id" : "55" + "id" : "53" + }, { + "id" : "54" }, { "id" : "56" }, { "id" : "58" }, { - "id" : "60" - }, { - "id" : "62", + "id" : "60", "vertices" : [ { "x" : 1456, "y" : 1700 @@ -783,11 +906,11 @@ "y" : 2000 } ] }, { - "id" : "67" + "id" : "65" }, { - "id" : "69" + "id" : "67" }, { - "id" : "70" + "id" : "68" } ] }, { "containerId" : "11", @@ -821,21 +944,21 @@ "name" : "Component View: Org Bot - Config", "order" : 4, "relationships" : [ { - "id" : "42", + "id" : "45", "vertices" : [ { "x" : 1900, "y" : 335 } ] }, { - "id" : "63" + "id" : "61" }, { - "id" : "65" + "id" : "63" }, { - "id" : "71" + "id" : "69" }, { - "id" : "72" + "id" : "70" }, { - "id" : "75" + "id" : "73" } ] }, { "containerId" : "15", @@ -869,13 +992,13 @@ "name" : "Component View: Org Bot - Auth", "order" : 5, "relationships" : [ { - "id" : "40", + "id" : "43", "vertices" : [ { "x" : 2420, "y" : 600 } ] }, { - "id" : "41", + "id" : "44", "vertices" : [ { "x" : 2255, "y" : 665 @@ -884,17 +1007,17 @@ "y" : 1195 } ] }, { - "id" : "59", + "id" : "57", "vertices" : [ { "x" : 1225, "y" : 610 } ] + }, { + "id" : "72" }, { "id" : "74" }, { "id" : "76" - }, { - "id" : "78" } ] }, { "containerId" : "19", @@ -908,35 +1031,45 @@ "y" : 1379 }, { "id" : "21", + "x" : 989, + "y" : 1379 + }, { + "id" : "22", "x" : 1143, "y" : 1979 }, { - "id" : "22", + "id" : "23", "x" : 989, "y" : 779 }, { - "id" : "23", + "id" : "24", "x" : 239, "y" : 1379 }, { - "id" : "24", + "id" : "25", "x" : 239, "y" : 779 }, { - "id" : "29", - "x" : 614, - "y" : 179 + "id" : "28", + "x" : 0, + "y" : 0 + }, { + "id" : "34", + "x" : 0, + "y" : 0 } ], "externalContainerBoundariesVisible" : false, "key" : "OrgApiComponents", "name" : "Component View: Org Bot - Org API", "order" : 6, "relationships" : [ { - "id" : "79" + "id" : "109" }, { - "id" : "80" + "id" : "77" }, { - "id" : "81", + "id" : "78" + }, { + "id" : "79", "vertices" : [ { "x" : 1589, "y" : 1379 @@ -945,14 +1078,16 @@ "y" : 1679 } ] }, { - "id" : "82" + "id" : "80" }, { - "id" : "90" + "id" : "83" }, { - "id" : "92" + "id" : "96" + }, { + "id" : "99" } ] }, { - "containerId" : "25", + "containerId" : "26", "dimensions" : { "height" : 1518, "width" : 979 @@ -962,7 +1097,7 @@ "x" : 264, "y" : 179 }, { - "id" : "26", + "id" : "27", "x" : 264, "y" : 779 } ], @@ -971,59 +1106,70 @@ "name" : "Component View: Org Bot - Utils", "order" : 7, "relationships" : [ { - "id" : "68" + "id" : "66" } ] }, { - "containerId" : "27", + "containerId" : "28", "dimensions" : { - "height" : 1518, - "width" : 979 + "height" : 2158, + "width" : 1858 }, "elements" : [ { - "id" : "28", - "x" : 264, - "y" : 779 + "id" : "19", + "x" : 455, + "y" : 1480 }, { "id" : "29", - "x" : 264, - "y" : 179 + "x" : 858, + "y" : 929 + }, { + "id" : "30", + "x" : 1079, + "y" : 329 + }, { + "id" : "34", + "x" : 329, + "y" : 329 } ], "externalContainerBoundariesVisible" : false, "key" : "BaseCommandComponents", "name" : "Component View: Org Bot - Base Command", "order" : 8, + "paperSize" : "A4_Portrait", "relationships" : [ { - "id" : "84" + "id" : "100", + "vertices" : [ { + "x" : 679, + "y" : 1333 + } ] + }, { + "id" : "82" + }, { + "id" : "87" + }, { + "id" : "95" } ] }, { - "containerId" : "29", + "containerId" : "30", "dimensions" : { "height" : 1439, "width" : 3180 }, "elements" : [ { - "id" : "19", - "x" : 2485, - "y" : 835 - }, { - "id" : "27", + "id" : "28", "x" : 890, "y" : 895 - }, { - "id" : "30", - "x" : 239, - "y" : 200 }, { "id" : "31", - "x" : 1739, + "x" : 239, "y" : 200 }, { "id" : "32", - "x" : 989, + "x" : 1739, "y" : 200 }, { "id" : "33", - "x" : 2489, + "x" : 989, "y" : 200 } ], "externalContainerBoundariesVisible" : false, @@ -1031,25 +1177,61 @@ "name" : "Component View: Org Bot - Commands", "order" : 9, "relationships" : [ { - "id" : "50" + "id" : "86" }, { - "id" : "51" + "id" : "90" }, { - "id" : "52" + "id" : "92" + } ] + }, { + "containerId" : "34", + "dimensions" : { + "height" : 1662, + "width" : 2816 + }, + "elements" : [ { + "id" : "19", + "x" : 1190, + "y" : 1120 }, { - "id" : "53", - "vertices" : [ { - "x" : 2339, - "y" : 604 - }, { - "x" : 1589, - "y" : 800 - } ] + "id" : "28", + "x" : 1180, + "y" : 0 + }, { + "id" : "35", + "x" : 433, + "y" : 433 }, { - "id" : "89" + "id" : "36", + "x" : 1183, + "y" : 433 + }, { + "id" : "37", + "x" : 1933, + "y" : 433 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "ActionsComponents", + "name" : "Component View: Org Bot - Actions", + "order" : 10, + "paperSize" : "A4_Landscape", + "relationships" : [ { + "id" : "102" + }, { + "id" : "104" + }, { + "id" : "106" + }, { + "id" : "108" + }, { + "id" : "84" + }, { + "id" : "94" + }, { + "id" : "98" } ] }, { - "containerId" : "35", + "containerId" : "38", "dimensions" : { "height" : 1518, "width" : 979 @@ -1059,21 +1241,22 @@ "x" : 0, "y" : 0 }, { - "id" : "36", + "id" : "39", "x" : 264, "y" : 779 } ], "externalContainerBoundariesVisible" : false, "key" : "TracingComponents", "name" : "Component View: Org Bot - Tracing", - "order" : 10, + "order" : 11, "relationships" : [ { - "id" : "45" + "id" : "48" } ] } ], "configuration" : { "branding" : { }, - "lastSavedView" : "CommandsComponents", + "lastSavedView" : "BaseCommandComponents", + "metadataSymbols" : "SquareBrackets", "styles" : { "elements" : [ { "background" : "#a5c9f5", @@ -1123,15 +1306,15 @@ "x" : 2105, "y" : 1055 }, { - "id" : "25", + "id" : "26", "x" : 945, "y" : 995 }, { - "id" : "27", + "id" : "28", "x" : 2680, "y" : 220 }, { - "id" : "29", + "id" : "30", "x" : 2680, "y" : 595 }, { @@ -1139,7 +1322,7 @@ "x" : 2685, "y" : 1705 }, { - "id" : "35", + "id" : "38", "x" : 2685, "y" : 1705 } ], @@ -1148,33 +1331,47 @@ "name" : "Container View: Org Bot", "order" : 2, "relationships" : [ { - "id" : "39" + "id" : "100" }, { - "id" : "40" + "id" : "42" }, { - "id" : "41" + "id" : "43", + "vertices" : [ { + "x" : 1466, + "y" : 995 + } ] + }, { + "id" : "44", + "vertices" : [ { + "x" : 1766, + "y" : 998 + } ] }, { - "id" : "42", + "id" : "45", "vertices" : [ { "x" : 585, "y" : 635 } ] - }, { - "id" : "43" - }, { - "id" : "44" }, { "id" : "46" }, { "id" : "47" - }, { - "id" : "48" }, { "id" : "49" }, { - "id" : "54" + "id" : "50" + }, { + "id" : "51" + }, { + "id" : "52" }, { - "id" : "55" + "id" : "53" + }, { + "id" : "84" + }, { + "id" : "88" + }, { + "id" : "96" } ], "softwareSystemId" : "3" } ], @@ -1201,9 +1398,9 @@ "name" : "System Context View: Org Bot", "order" : 1, "relationships" : [ { - "id" : "37" + "id" : "40" }, { - "id" : "38" + "id" : "41" } ], "softwareSystemId" : "3" } ] diff --git a/taskfile.yml b/taskfile.yml index 817451f..488c8d1 100644 --- a/taskfile.yml +++ b/taskfile.yml @@ -62,12 +62,14 @@ tasks: deploy_gcp: deps: + - switch_gcp_project - generate_requirements cmds: - sh deploy.sh deploy_terraform: deps: + - switch_gcp_project - generate_requirements cmds: - sh deploy_terraform.sh From 89fde0eba595e4dc8d978a4e9c9bbf8de544c66c Mon Sep 17 00:00:00 2001 From: George Green Date: Mon, 29 Dec 2025 15:01:50 +0100 Subject: [PATCH 10/13] refactorings --- pyproject.toml | 2 + pytest.ini | 5 + src/bot.py | 309 ++++++++++++++++++ src/config.py | 135 ++++++-- src/main.py | 227 +++---------- tests/test_bot.py | 363 +++++++++++++++++++++ tests/test_message_sequence_integration.py | 88 ++--- uv.lock | 17 + 8 files changed, 893 insertions(+), 253 deletions(-) create mode 100644 src/bot.py create mode 100644 tests/test_bot.py diff --git a/pyproject.toml b/pyproject.toml index e56339e..29b68d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ dev = [ "pytest", "pytest-cov", + "pytest-asyncio", "black", "flake8", "mypy", @@ -36,6 +37,7 @@ packages = ["src"] dev-dependencies = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", + "pytest-asyncio>=0.21.0", "black>=23.0.0", "flake8>=6.0.0", "mypy>=1.0.0", diff --git a/pytest.ini b/pytest.ini index f1cd256..b06b6f0 100644 --- a/pytest.ini +++ b/pytest.ini @@ -15,6 +15,10 @@ addopts = -p no:cacheprovider -s +# Async test configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function + # Test paths testpaths = tests @@ -41,3 +45,4 @@ markers = picture: Tests for picture message handling file: Tests for file message handling orgapi: Tests for OrgApi class functionality + asyncio: Async tests using pytest-asyncio diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..7321769 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,309 @@ +""" +OrgBot main application class. + +This module contains the core OrgBot class that orchestrates: +- Command and action routing +- Authentication and authorization +- Message processing pipeline +- Error handling +- Response generation +""" + +import logging +import asyncio +from typing import Dict, Optional +from telegram import Bot, Message +from telegram.request import HTTPXRequest +from telegram.error import TimedOut, NetworkError + +from .config import ( + BotConfig, + GitHubConfig, + ActionConfig, + create_commands, + create_actions, +) +from .auth import auth_check, ignore_check +from .utils import get_text_from_message + + +logger = logging.getLogger(__name__) + + +class OrgBot: + """ + Main bot application class. + + Responsibilities: + - Initialize commands and actions from configuration + - Route incoming messages to appropriate handlers + - Manage authentication and authorization + - Handle errors and provide appropriate responses + """ + + def __init__( + self, + bot_config: Optional[BotConfig] = None, + github_config: Optional[GitHubConfig] = None, + ): + """ + Initialize the OrgBot with configurations. + + Args: + bot_config: Bot configuration (loads from env if not provided) + github_config: GitHub configuration (loads from env if not provided) + """ + # Load configurations + self.bot_config = bot_config or BotConfig.from_env() + self.github_config = github_config or GitHubConfig.from_env() + + # Configure HTTP client for bot + self.request = HTTPXRequest( + pool_timeout=30, + connection_pool_size=10, + read_timeout=30, + write_timeout=30, + ) + + # Initialize commands and actions + self.commands = create_commands(self._get_bot) + self.actions = create_actions(self.github_config) + self.default_action_key = "journal" + + logger.info( + f"OrgBot initialized with {len(self.commands)} commands " + f"and {len(self.actions)} actions" + ) + + def _get_bot(self) -> Bot: + """Create a fresh bot instance for each request.""" + return Bot(token=self.bot_config.bot_token, request=self.request) + + async def handle_update(self, message: Message) -> None: + """ + Main entry point for processing a Telegram message update. + + Args: + message: Incoming Telegram message + """ + # Check authorization + if not await auth_check(message, self._get_bot): + await self._send_unauthorized_response(message) + return + + # Process the message + response = await self._process_message(message) + + # Send response + if response: + await self._send_response(message, response) + + async def _process_message(self, message: Message) -> Optional[str]: + """ + Process a message and return response text. + + Args: + message: Telegram message to process + + Returns: + Response text or None + """ + # Handle photos - save to temp file + temp_file_path = None + if message.photo: + temp_file_path = await self._save_photo(message) + + message_text = get_text_from_message(message) + + # Route to command or action + if message_text.startswith("/"): + return await self._handle_command(message, message_text) + else: + return await self._handle_action(message, message_text, temp_file_path) + + async def _handle_command(self, message: Message, message_text: str) -> str: + """ + Route message to appropriate command handler. + + Args: + message: Telegram message + message_text: Text content of the message + + Returns: + Response text + """ + # Extract command (remove bot name if present) + command_text = message_text.split("@")[0] + + # Find and execute command + command = self.commands.get(command_text) + if command: + return await command.execute(message) + else: + return "Unrecognized command" + + async def _handle_action( + self, + message: Message, + message_text: str, + file_path: Optional[str] = None, + ) -> Optional[str]: + """ + Route message to appropriate action handler. + + Args: + message: Telegram message + message_text: Text content of the message + file_path: Optional path to attached file + + Returns: + Response text or None if chat is ignored + """ + # Check if chat should be ignored for non-command messages + if ignore_check(message): + logger.info(f"Ignoring message from chat {message.chat_id}") + return None + + # Determine action based on message context + action_key = self._determine_action(message, message_text) + + # Execute action + try: + action_config = self.actions.get(action_key) + if action_config: + action_config.function(message, file_path=file_path) + return action_config.response_message + else: + logger.error(f"Action not found: {action_key}") + return "Failed to process message." + except Exception as e: + logger.error(f"Action execution failed: {e}", exc_info=True) + return "Failed to add to journal." + + def _determine_action(self, message: Message, message_text: str) -> str: + """ + Determine which action to use based on message context. + + Args: + message: Telegram message + message_text: Text content of the message + + Returns: + Action key (journal/todo/reply) + """ + # Check if this is a reply to another message + if message.reply_to_message: + logger.info( + f"Detected reply to message {message.reply_to_message.message_id}", + extra={"original_message_id": message.reply_to_message.message_id}, + ) + return "reply" + + # Check if message starts with "todo" + if message_text.lower().startswith("todo "): + return "todo" + + # Default to journal + return self.default_action_key + + async def _save_photo(self, message: Message) -> str: + """ + Save a photo from message to temporary file. + + Args: + message: Message containing photo + + Returns: + Path to saved file + """ + photo_file_id = message.photo[-1].file_id + temp_file_path = f"/tmp/{photo_file_id}.jpg" + + with open(temp_file_path, "wb") as file: + bot = self._get_bot() + file_obj = await bot.get_file(photo_file_id) + file.write(await file_obj.download_as_bytearray()) + + logger.info(f"Photo saved to {temp_file_path}") + return temp_file_path + + async def _send_response(self, message: Message, text: str) -> None: + """ + Send a response message with retry logic. + + Args: + message: Original message to reply to + text: Response text to send + """ + # Escape text for MarkdownV2 + escaped_text = self._escape_markdown_v2(text) + + # Retry logic for network issues + for attempt in range(3): + try: + bot = self._get_bot() + await bot.send_message( + chat_id=message.chat_id, + text=escaped_text, + reply_to_message_id=message.message_id, + parse_mode="MarkdownV2", + ) + return + except TimedOut: + if attempt < 2: + logger.warning(f"Timeout retry {attempt + 1}/3") + await asyncio.sleep(attempt + 1) + continue + logger.error("Failed after 3 timeout attempts") + raise + except NetworkError as e: + if "Event loop is closed" in str(e): + logger.info("Event loop closed, request likely completed") + return + if attempt < 2: + logger.warning(f"Network retry {attempt + 1}/3: {type(e).__name__}") + await asyncio.sleep(attempt + 1) + continue + logger.error(f"Failed after 3 network attempts: {type(e).__name__}") + raise + + async def _send_unauthorized_response(self, message: Message) -> None: + """Send response to unauthorized user.""" + await self._send_response( + message, + "It's not for you! If you have any questions ask @iamkarlson", + ) + + @staticmethod + def _escape_markdown_v2(text: str) -> str: + """ + Escape special characters for Telegram MarkdownV2. + + Args: + text: Text to escape + + Returns: + Escaped text + """ + escape_chars = [ + "_", + "*", + "[", + "]", + "(", + ")", + "~", + ">", + "#", + "+", + "-", + "=", + "|", + "{", + "}", + ".", + "!", + ] + for char in escape_chars: + text = text.replace(char, f"\\{char}") + return text diff --git a/src/config.py b/src/config.py index ce581ab..a69ecdf 100644 --- a/src/config.py +++ b/src/config.py @@ -1,5 +1,6 @@ import os -from typing import Callable +from dataclasses import dataclass +from typing import Callable, Dict, Any from telegram import Bot from .commands import StartCommand, WebhookCommand, InfoCommand @@ -10,8 +11,76 @@ ) -def init_commands(get_bot: Callable[[], Bot]): - """Initialize command instances with bot getter dependency.""" +@dataclass +class BotConfig: + """Core bot configuration from environment.""" + + bot_token: str + authorized_chat_ids: list[int] + ignored_chat_ids: list[int] + forward_unauthorized_to: int | None + sentry_dsn: str + + @classmethod + def from_env(cls) -> "BotConfig": + """Load configuration from environment variables.""" + authorized_ids = [ + int(id) for id in os.environ["AUTHORIZED_CHAT_IDS"].split(",") + ] + ignored_ids = [ + int(id) + for id in os.environ.get("IGNORED_CHAT_IDS", "").split(",") + if id + ] + forward_to = os.environ.get("FORWARD_UNAUTHORIZED_TO") + + return cls( + bot_token=os.environ["BOT_TOKEN"], + authorized_chat_ids=authorized_ids, + ignored_chat_ids=ignored_ids, + forward_unauthorized_to=int(forward_to) if forward_to else None, + sentry_dsn=os.environ.get("SENTRY_DSN", ""), + ) + + +@dataclass +class GitHubConfig: + """GitHub integration configuration.""" + + token: str + repo_name: str + journal_file: str + todo_file: str + + @classmethod + def from_env(cls) -> "GitHubConfig": + """Load GitHub configuration from environment variables.""" + return cls( + token=os.environ.get("GITHUB_TOKEN", ""), + repo_name=os.environ.get("GITHUB_REPO", ""), + journal_file=os.environ.get("JOURNAL_FILE", "journal.md"), + todo_file="todo.org", + ) + + +@dataclass +class ActionConfig: + """Configuration for a single action.""" + + function: Callable + response_message: str + + +def create_commands(get_bot: Callable[[], Bot]) -> Dict[str, Any]: + """ + Factory function to create command instances. + + Args: + get_bot: Callable that returns a Bot instance + + Returns: + Dictionary mapping command names to command instances + """ return { "/start": StartCommand(get_bot), "/webhook": WebhookCommand(get_bot), @@ -19,29 +88,47 @@ def init_commands(get_bot: Callable[[], Bot]): } -# Configuration is coming from "JOURNAL_FILE" env variable. -github_token = os.getenv("GITHUB_TOKEN", None) -repo_name = os.getenv("GITHUB_REPO", None) -file_path = os.getenv("JOURNAL_FILE", "journal.md") +def create_actions(github_config: GitHubConfig) -> Dict[str, ActionConfig]: + """ + Factory function to create action instances. -journal = PostToGitJournal( - github_token=github_token, repo_name=repo_name, file_path=file_path -) + Args: + github_config: GitHub configuration dataclass -todo = PostToTodo(github_token=github_token, repo_name=repo_name, file_path="todo.org") + Returns: + Dictionary mapping action keywords to ActionConfig instances + """ + # Initialize action instances + journal = PostToGitJournal( + github_token=github_config.token, + repo_name=github_config.repo_name, + file_path=github_config.journal_file, + ) -reply = PostReplyToEntry( - github_token=github_token, - repo_name=repo_name, - file_path=file_path, - todo_file_path="todo.org", -) + todo = PostToTodo( + github_token=github_config.token, + repo_name=github_config.repo_name, + file_path=github_config.todo_file, + ) -# Default action is to post to journal -default_action = journal.run + reply = PostReplyToEntry( + github_token=github_config.token, + repo_name=github_config.repo_name, + file_path=github_config.journal_file, + todo_file_path=github_config.todo_file, + ) -actions = { - "journal": {"function": journal.run, "response": "Added to journal!"}, - "todo": {"function": todo.run, "response": "Added to todo list!"}, - "reply": {"function": reply.run, "response": "Added reply to entry!"}, -} + return { + "journal": ActionConfig( + function=journal.run, + response_message="Added to journal!", + ), + "todo": ActionConfig( + function=todo.run, + response_message="Added to todo list!", + ), + "reply": ActionConfig( + function=reply.run, + response_message="Added reply to entry!", + ), + } diff --git a/src/main.py b/src/main.py index 93d16ca..8b83453 100644 --- a/src/main.py +++ b/src/main.py @@ -1,228 +1,79 @@ +""" +GCP Cloud Functions entry point for org-bot. + +This module provides the HTTP endpoint for Google Cloud Functions, +delegating all bot logic to the OrgBot class. +""" + import logging -import os import asyncio import functions_framework from flask import Request, abort -from telegram import Bot, Update, Message -from telegram.request import HTTPXRequest -from telegram.error import TimedOut, NetworkError +from telegram import Update import sentry_sdk from sentry_sdk.integrations.gcp import GcpIntegration -from .config import init_commands, actions +from .bot import OrgBot +from .config import BotConfig from .tracing.log import GCPLogger -from .utils import get_text_from_message -from .auth import auth_check, ignore_check - - -BOT_TOKEN = os.environ["BOT_TOKEN"] - -# Configure HTTP client with larger pool and longer timeout to prevent pool exhaustion -request = HTTPXRequest( - pool_timeout=30, # Wait up to 30 seconds for connection - connection_pool_size=10, # Allow up to 10 concurrent connections - read_timeout=30, # Timeout for reading response - write_timeout=30, # Timeout for writing request -) - - -def get_bot() -> Bot: - """Safe bot getter that creates a fresh instance for each request.""" - return Bot(token=BOT_TOKEN, request=request) -# Set the new logger class +# Configure logging logging.setLoggerClass(GCPLogger) - -logger = logging.getLogger(__name__) - logger = logging.getLogger(__name__) -SENTRY_DSN = os.environ.get("SENTRY_DSN", "") - +# Initialize Sentry +bot_config = BotConfig.from_env() sentry_sdk.init( - dsn=SENTRY_DSN, + dsn=bot_config.sentry_dsn, integrations=[GcpIntegration()], - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for performance monitoring. traces_sample_rate=1.0, - # Set profiles_sample_rate to 1.0 to profile 100% - # of sampled transactions. - # We recommend adjusting this value in production. profiles_sample_rate=1.0, ) - -async def send_back(message: Message, text: str): - markdownv2_escape_chars = [ - "_", - "*", - "[", - "]", - "(", - ")", - "~", - # "`", # Perhaps this shouldn't be escaped - ">", - "#", - "+", - "-", - "=", - "|", - "{", - "}", - ".", - "!", - ] - for char in markdownv2_escape_chars: - text = text.replace(char, f"\\{char}") - - for attempt in range(3): - try: - bot = get_bot() - await bot.send_message( - chat_id=message.chat_id, - text=text, - reply_to_message_id=message.message_id, - parse_mode="MarkdownV2", - ) - return - except TimedOut: - if attempt < 2: - logger.warning(f"Timeout retry {attempt + 1}/3") - await asyncio.sleep(attempt + 1) - continue - logger.error("Failed after 3 timeout attempts") - raise - except NetworkError as e: - if "Event loop is closed" in str(e): - logger.info("Event loop closed, request likely completed") - return - if attempt < 2: - logger.warning(f"Network retry {attempt + 1}/3: {type(e).__name__}") - await asyncio.sleep(attempt + 1) - continue - logger.error(f"Failed after 3 network attempts: {type(e).__name__}") - raise - - -async def handle_message(message: Message): - """ - Handles incoming telegram message. - :param message: incoming telegram message - :return: - """ - response = await process_message(message) - if response: - await send_back(message, response) - else: - await send_back(message, "I don't understand") +# Create singleton OrgBot instance +org_bot = OrgBot() -async def process_message(message: Message): - """ - Command handler for telegram bot. +@functions_framework.http +def http_entrypoint(request: Request): """ - # Generate temp file path upfront - temp_file_path = None - if message.photo: - # we got a picture. - # let's save it to a random file in /tmp - # and then pass it command to insert it into the journal - temp_file_path = f"/tmp/{message.photo[-1].file_id}.jpg" - with open(temp_file_path, "wb") as file: - bot = get_bot() - file_obj = await bot.get_file(message.photo[-1].file_id) - file.write(await file_obj.download_as_bytearray()) - logger.info("Photo received") - - message_text = get_text_from_message(message) - commands = init_commands(get_bot) # Initialize commands with bot getter - - # Check if the message is a command - if message_text.startswith("/"): - # Commands are always processed, even from ignored chats - command_text = (message_text or "").split("@")[ - 0 - ] # Split command and bot's name - command = commands.get(command_text) - if command: - return await command.execute(message) - else: - return "Unrecognized command" - else: - # For non-command messages, check if chat should be ignored - if ignore_check(message): - return None # Don't respond to ignored chats for regular messages - else: - return process_non_command(message, file_path=temp_file_path) - - -def process_non_command(message: Message, file_path=None): - # Your code here to process non-command messages - logger.info("Processing non-command message") - logger.debug(message.to_json()) - - # Check if this is a reply to another message - if message.reply_to_message: - keyword = "reply" - logger.info( - f"Detected reply to message {message.reply_to_message.message_id}", - extra={"original_message_id": message.reply_to_message.message_id}, - ) - else: - # Not a reply, check if it's a todo or journal entry - message_text = get_text_from_message(message) - if message_text.lower().startswith("todo "): - keyword = "todo" - else: - keyword = "journal" + HTTP webhook handler for Telegram updates. - try: - if action_config := actions.get(keyword): - action_config["function"](message, file_path=file_path) - return action_config["response"] - except Exception as e: - logger.error(e) - return "Failed to add to journal." + This function is called by Google Cloud Functions when a webhook + is received from Telegram. + Args: + request: Flask request object containing the webhook data -async def handle_telegram_update(message: Message): - """ - Async handler for telegram updates. - """ - if await auth_check(message, get_bot): - await handle_message(message) - else: - await send_back( - message, "It's not for you! If you have any questions ask @iamkarlson" - ) - - -@functions_framework.http -def http_entrypoint(request: Request): - """ - Incoming telegram webhook handler for a GCP Cloud Function. + Returns: + HTTP response with status code """ try: + # Health check endpoint if request.method == "GET": return {"statusCode": 200} + # Process webhook if request.method == "POST": incoming_data = request.get_json() - logger.debug(f"incoming data: {incoming_data}") - update_message = Update.de_json(incoming_data, get_bot()) - message = update_message.message or update_message.edited_message + logger.debug(f"Incoming data: {incoming_data}") + # Parse Telegram update + update = Update.de_json(incoming_data, org_bot._get_bot()) + message = update.message or update.edited_message + + # Process message if present if message: - # Run async function with proper lifecycle management - asyncio.run(handle_telegram_update(message)) + asyncio.run(org_bot.handle_update(message)) + return {"statusCode": 200} + except Exception as e: sentry_sdk.capture_exception(e) - logger.exception("Error occurred but message wasn't processed") - return {"statusCode": 200} + logger.exception("Error processing webhook") + return {"statusCode": 200} # Always return 200 to Telegram - # Unprocessable entity + # Invalid request abort(422) diff --git a/tests/test_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..b798743 --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,363 @@ +""" +Unit tests for the OrgBot class. + +Tests cover: +- Initialization with custom configs +- Action determination logic (reply vs todo vs journal) +- MarkdownV2 escaping +- Command routing +- Unauthorized access handling +""" + +import os +import pytest +from unittest.mock import Mock, MagicMock, patch, AsyncMock +from telegram import Message + +# Set environment variables before importing src modules +os.environ.setdefault("GITHUB_TOKEN", "test_token") +os.environ.setdefault("GITHUB_REPO", "test/repo") +os.environ.setdefault("JOURNAL_FILE", "journal.org") +os.environ.setdefault("AUTHORIZED_CHAT_IDS", "1234567890") +os.environ.setdefault("BOT_TOKEN", "test_bot_token") +os.environ.setdefault("SENTRY_DSN", "") + +from src.bot import OrgBot +from src.config import BotConfig, GitHubConfig + + +@pytest.fixture +def test_bot_config(): + """Create a test bot configuration.""" + return BotConfig( + bot_token="test_bot_token", + authorized_chat_ids=[1234567890], + ignored_chat_ids=[9999], + forward_unauthorized_to=None, + sentry_dsn="", + ) + + +@pytest.fixture +def test_github_config(): + """Create a test GitHub configuration.""" + return GitHubConfig( + token="test_token", + repo_name="test/repo", + journal_file="journal.org", + todo_file="todo.org", + ) + + +@pytest.fixture +def mock_github(): + """Mock GitHub client to avoid real API calls during initialization.""" + mock_client = MagicMock() + mock_repo = MagicMock() + mock_contents = MagicMock() + mock_contents.decoded_content = b"# Test content" + mock_contents.sha = "test_sha" + mock_repo.get_contents.return_value = mock_contents + mock_client.get_repo.return_value = mock_repo + return mock_client + + +@pytest.fixture +def org_bot(test_bot_config, test_github_config, mock_github): + """Create an OrgBot instance with test configurations.""" + with patch("src.actions.base_post_to_org_file.Github", return_value=mock_github): + bot = OrgBot(bot_config=test_bot_config, github_config=test_github_config) + return bot + + +class TestOrgBotInitialization: + """Test OrgBot initialization.""" + + def test_init_with_configs(self, test_bot_config, test_github_config, mock_github): + """Test initialization with provided configs.""" + with patch( + "src.actions.base_post_to_org_file.Github", return_value=mock_github + ): + bot = OrgBot(bot_config=test_bot_config, github_config=test_github_config) + + assert bot.bot_config == test_bot_config + assert bot.github_config == test_github_config + assert len(bot.commands) == 3 # /start, /webhook, /info + assert len(bot.actions) == 3 # journal, todo, reply + assert bot.default_action_key == "journal" + + def test_init_from_env(self, mock_github): + """Test initialization loading configs from environment.""" + with patch( + "src.actions.base_post_to_org_file.Github", return_value=mock_github + ): + # Config will be loaded from environment variables set in conftest or test setup + bot = OrgBot() + + assert bot.bot_config is not None + assert bot.github_config is not None + + +class TestActionDetermination: + """Test the _determine_action method.""" + + def test_determine_action_reply(self, org_bot): + """Test that reply messages are identified correctly.""" + message = Mock() + message.reply_to_message = Mock() # Has a reply + message.text = "This is a reply" + + action_key = org_bot._determine_action(message, "This is a reply") + assert action_key == "reply" + + def test_determine_action_todo(self, org_bot): + """Test that TODO messages are identified correctly.""" + message = Mock() + message.reply_to_message = None # Not a reply + + action_key = org_bot._determine_action(message, "todo write tests") + assert action_key == "todo" + + action_key = org_bot._determine_action(message, "TODO write tests") + assert action_key == "todo" + + action_key = org_bot._determine_action(message, "ToDo write tests") + assert action_key == "todo" + + def test_determine_action_journal_default(self, org_bot): + """Test that non-reply, non-todo messages default to journal.""" + message = Mock() + message.reply_to_message = None + + action_key = org_bot._determine_action(message, "Regular journal entry") + assert action_key == "journal" + + action_key = org_bot._determine_action(message, "Something with todo in middle") + assert action_key == "journal" # Not starting with "todo " + + +class TestMarkdownEscaping: + """Test the _escape_markdown_v2 static method.""" + + def test_escape_basic_chars(self, org_bot): + """Test escaping of basic special characters.""" + text = "Hello_World*Test" + escaped = org_bot._escape_markdown_v2(text) + assert escaped == r"Hello\_World\*Test" + + def test_escape_all_special_chars(self, org_bot): + """Test escaping of all MarkdownV2 special characters.""" + text = "_*[]()~>#+-=|{}.!" + escaped = org_bot._escape_markdown_v2(text) + assert escaped == r"\_\*\[\]\(\)\~\>\#\+\-\=\|\{\}\.\!" + + def test_escape_response_message(self, org_bot): + """Test escaping of typical response message.""" + text = "Added to journal!" + escaped = org_bot._escape_markdown_v2(text) + assert escaped == r"Added to journal\!" + + +class TestCommandRouting: + """Test command routing logic.""" + + @pytest.mark.asyncio + async def test_handle_command_recognized(self, org_bot): + """Test handling of recognized commands.""" + message = Mock() + message.message_id = 123 + message.chat_id = 1234567890 + + response = await org_bot._handle_command(message, "/start") + assert response == "Hello brain!" + + @pytest.mark.asyncio + async def test_handle_command_unrecognized(self, org_bot): + """Test handling of unrecognized commands.""" + message = Mock() + response = await org_bot._handle_command(message, "/unknown") + assert response == "Unrecognized command" + + @pytest.mark.asyncio + async def test_handle_command_with_bot_mention(self, org_bot): + """Test command with bot username mention.""" + message = Mock() + message.message_id = 123 + message.chat_id = 1234567890 + + # Command with @botname should still work + response = await org_bot._handle_command(message, "/start@testbot") + assert response == "Hello brain!" + + +class TestActionRouting: + """Test action routing logic.""" + + @pytest.mark.asyncio + async def test_handle_action_ignored_chat(self, org_bot): + """Test that ignored chats return None.""" + message = Mock() + message.chat_id = 9999 # In ignored_chat_ids + + # Patch the module-level ignored_chats in auth.py + with patch("src.auth.ignored_chats", [9999]): + response = await org_bot._handle_action(message, "Test message", None) + + assert response is None + + @pytest.mark.asyncio + async def test_handle_action_journal(self, org_bot): + """Test journal action execution.""" + message = Mock() + message.chat_id = 1234567890 + message.reply_to_message = None + message.text = "Regular journal entry" + message.caption = None + message.photo = None + message.document = None + + # Mock the action function to avoid GitHub API calls + with patch.object( + org_bot.actions["journal"], "function", return_value=None + ) as mock_func: + response = await org_bot._handle_action(message, "Regular entry", None) + + assert response == "Added to journal!" + mock_func.assert_called_once_with(message, file_path=None) + + @pytest.mark.asyncio + async def test_handle_action_todo(self, org_bot): + """Test todo action execution.""" + message = Mock() + message.chat_id = 1234567890 + message.reply_to_message = None + message.text = "todo write tests" + message.caption = None + message.photo = None + message.document = None + + with patch.object( + org_bot.actions["todo"], "function", return_value=None + ) as mock_func: + response = await org_bot._handle_action(message, "todo write tests", None) + + assert response == "Added to todo list!" + mock_func.assert_called_once_with(message, file_path=None) + + @pytest.mark.asyncio + async def test_handle_action_reply(self, org_bot): + """Test reply action execution.""" + message = Mock() + message.chat_id = 1234567890 + message.reply_to_message = Mock() + message.text = "This is a reply" + message.caption = None + message.photo = None + message.document = None + + with patch.object( + org_bot.actions["reply"], "function", return_value=None + ) as mock_func: + response = await org_bot._handle_action(message, "This is a reply", None) + + assert response == "Added reply to entry!" + mock_func.assert_called_once_with(message, file_path=None) + + @pytest.mark.asyncio + async def test_handle_action_with_file(self, org_bot): + """Test action with file attachment.""" + message = Mock() + message.chat_id = 1234567890 + message.reply_to_message = None + + with patch.object( + org_bot.actions["journal"], "function", return_value=None + ) as mock_func: + response = await org_bot._handle_action( + message, "Entry with photo", file_path="/tmp/test.jpg" + ) + + assert response == "Added to journal!" + mock_func.assert_called_once_with(message, file_path="/tmp/test.jpg") + + @pytest.mark.asyncio + async def test_handle_action_error(self, org_bot): + """Test error handling in action execution.""" + message = Mock() + message.chat_id = 1234567890 + message.reply_to_message = None + + # Mock action to raise an exception + with patch.object( + org_bot.actions["journal"], "function", side_effect=Exception("Test error") + ): + response = await org_bot._handle_action(message, "Test message", None) + + assert response == "Failed to add to journal." + + +class TestPhotoHandling: + """Test photo saving functionality.""" + + @pytest.mark.asyncio + async def test_save_photo(self, org_bot): + """Test photo saving to temp file.""" + message = Mock() + photo = Mock() + photo.file_id = "test_photo_123" + message.photo = [photo] + + # Mock bot and file operations + mock_file = Mock() + mock_file.download_as_bytearray = AsyncMock(return_value=b"fake image data") + + with patch.object(org_bot, "_get_bot") as mock_get_bot: + mock_bot = Mock() + mock_bot.get_file = AsyncMock(return_value=mock_file) + mock_get_bot.return_value = mock_bot + + with patch("builtins.open", create=True) as mock_open: + file_path = await org_bot._save_photo(message) + + assert file_path == "/tmp/test_photo_123.jpg" + mock_bot.get_file.assert_called_once_with("test_photo_123") + + +class TestResponseSending: + """Test response sending with retry logic.""" + + @pytest.mark.asyncio + async def test_send_response_success(self, org_bot): + """Test successful response sending.""" + message = Mock() + message.chat_id = 1234567890 + message.message_id = 123 + + with patch.object(org_bot, "_get_bot") as mock_get_bot: + mock_bot = Mock() + mock_bot.send_message = AsyncMock() + mock_get_bot.return_value = mock_bot + + await org_bot._send_response(message, "Test response!") + + mock_bot.send_message.assert_called_once() + call_args = mock_bot.send_message.call_args + assert call_args.kwargs["chat_id"] == 1234567890 + assert call_args.kwargs["reply_to_message_id"] == 123 + assert call_args.kwargs["parse_mode"] == "MarkdownV2" + # Text should be escaped + assert r"Test response\!" in call_args.kwargs["text"] + + @pytest.mark.asyncio + async def test_send_unauthorized_response(self, org_bot): + """Test unauthorized response message.""" + message = Mock() + message.chat_id = 1234567890 + message.message_id = 123 + + with patch.object(org_bot, "_send_response") as mock_send: + await org_bot._send_unauthorized_response(message) + + mock_send.assert_called_once() + call_args = mock_send.call_args + assert "not for you" in call_args[0][1] diff --git a/tests/test_message_sequence_integration.py b/tests/test_message_sequence_integration.py index 4a2ad72..1ea84c7 100644 --- a/tests/test_message_sequence_integration.py +++ b/tests/test_message_sequence_integration.py @@ -40,12 +40,7 @@ def _create_dummy_github_client(): return mock_client -# Patch Github before any imports -with patch( - "src.actions.base_post_to_org_file.Github", - return_value=_create_dummy_github_client(), -): - from src.main import process_non_command +# No imports needed here - we'll import inside test functions after patching class TestMessageSequenceIntegration: @@ -172,7 +167,8 @@ def _create_mock_message( @pytest.mark.integration @pytest.mark.sequence - def test_message_sequence_full_flow( + @pytest.mark.asyncio + async def test_message_sequence_full_flow( self, mock_github_repo_with_state: MagicMock, message_sequence: List[Dict[str, Any]], @@ -200,32 +196,26 @@ def test_message_sequence_full_flow( "src.actions.base_post_to_org_file.Github", return_value=mock_client ): # Import here to ensure patch is applied - from src.actions.post_to_journal import PostToGitJournal - from src.actions.post_to_todo import PostToTodo - from src.actions.post_reply import PostReplyToEntry - - # Recreate the action instances with mocked GitHub - journal = PostToGitJournal( - github_token="test_token", - repo_name="test/repo", - file_path="journal.org", - ) - todo = PostToTodo( - github_token="test_token", repo_name="test/repo", file_path="todo.org" + from src.bot import OrgBot + from src.config import BotConfig, GitHubConfig + + # Create test configs + bot_config = BotConfig( + bot_token="test_bot_token", + authorized_chat_ids=[1234567890], + ignored_chat_ids=[], + forward_unauthorized_to=None, + sentry_dsn="", ) - reply = PostReplyToEntry( - github_token="test_token", + github_config = GitHubConfig( + token="test_token", repo_name="test/repo", - file_path="journal.org", - todo_file_path="todo.org", + journal_file="journal.org", + todo_file="todo.org", ) - # Mock the actions dict - test_actions = { - "journal": {"function": journal.run, "response": "Added to journal!"}, - "todo": {"function": todo.run, "response": "Added to todo list!"}, - "reply": {"function": reply.run, "response": "Added reply to entry!"}, - } + # Create OrgBot instance with test configs + org_bot = OrgBot(bot_config=bot_config, github_config=github_config) # Process each message in sequence for i, msg_data in enumerate(message_sequence): @@ -237,9 +227,10 @@ def test_message_sequence_full_flow( message = self._create_mock_message(msg_data, previous_messages) previous_messages.append(message) - # Process the message with mocked actions - with patch("src.main.actions", test_actions): - response = process_non_command(message, file_path=None) + # Process the message using OrgBot's internal method + response = await org_bot._handle_action( + message, message.text, file_path=None + ) logger.info(f"Response: {response}") responses.append(response) @@ -302,7 +293,8 @@ def test_message_sequence_full_flow( @pytest.mark.integration @pytest.mark.sequence - def test_reply_response_not_none( + @pytest.mark.asyncio + async def test_reply_response_not_none( self, mock_github_repo_with_state: MagicMock, test_config: Dict[str, Any], @@ -360,16 +352,30 @@ def test_reply_response_not_none( reply_chat.id = 1234567890 reply_message.chat = reply_chat - test_actions = { - "reply": { - "function": reply_instance.run, - "response": "Added reply to entry!", - }, - } + # Create OrgBot instance + from src.bot import OrgBot + from src.config import BotConfig, GitHubConfig + + bot_config = BotConfig( + bot_token="test_bot_token", + authorized_chat_ids=[1234567890], + ignored_chat_ids=[], + forward_unauthorized_to=None, + sentry_dsn="", + ) + github_config = GitHubConfig( + token="test_token", + repo_name="test/repo", + journal_file="journal.org", + todo_file="todo.org", + ) + + org_bot = OrgBot(bot_config=bot_config, github_config=github_config) # Process the reply - with patch("src.main.actions", test_actions): - response = process_non_command(reply_message, file_path=None) + response = await org_bot._handle_action( + reply_message, reply_message.text, file_path=None + ) logger.info(f"Response from reply: {response}") diff --git a/uv.lock b/uv.lock index a2a19b4..ec86b12 100644 --- a/uv.lock +++ b/uv.lock @@ -613,6 +613,7 @@ dev = [ { name = "flake8" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, ] @@ -622,6 +623,7 @@ dev = [ { name = "flake8" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, ] @@ -635,6 +637,7 @@ requires-dist = [ { name = "mypy", marker = "extra == 'dev'" }, { name = "pygithub", specifier = "==1.59.1" }, { name = "pytest", marker = "extra == 'dev'" }, + { name = "pytest-asyncio", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "python-telegram-bot", specifier = "==22.3" }, { name = "sentry-sdk", specifier = "==2.8.0" }, @@ -647,6 +650,7 @@ dev = [ { name = "flake8", specifier = ">=6.0.0" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "pytest", specifier = ">=7.0.0" }, + { name = "pytest-asyncio", specifier = ">=0.21.0" }, { name = "pytest-cov", specifier = ">=4.0.0" }, ] @@ -799,6 +803,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/29/16/c8a903f4c4dffe7a12843191437d7cd8e32751d5de349d45d3fe69544e87/pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7", size = 365474, upload-time = "2025-06-18T05:48:03.955Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://gitlab.com/api/v4/projects/24061829/packages/pypi/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "pytest-cov" version = "6.2.1" From 250085854cd3cebab2d5fe77fd689465c5d1a92b Mon Sep 17 00:00:00 2001 From: George Green Date: Mon, 29 Dec 2025 19:29:05 +0100 Subject: [PATCH 11/13] config example for sentry --- config/production/config.yaml.example | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config/production/config.yaml.example b/config/production/config.yaml.example index e0d077f..b4a3c87 100644 --- a/config/production/config.yaml.example +++ b/config/production/config.yaml.example @@ -12,3 +12,5 @@ JOURNAL_FILE: journal.org AUTHORIZED_CHAT_IDS: "888888888" IGNORED_CHAT_IDS: "1234567890" FORWARD_UNAUTHORIZED_TO: "999999999" + +SENTRY_DSN: "https://xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@xxxxxxxxxxxxxxxxx.ingest.de.sentry.io/7777777777777777" From a4ff4752060baaaf5ba37e79b5a48d6101a8c73e Mon Sep 17 00:00:00 2001 From: George Green Date: Tue, 30 Dec 2025 14:23:57 +0100 Subject: [PATCH 12/13] use pydantic settings --- config/production/secrets.env.example | 8 ++ dev.env | 10 +- pyproject.toml | 2 + src/bot.py | 14 +- src/config.py | 134 ----------------- src/config/__init__.py | 100 +++++++++++++ src/config/action_config.py | 12 ++ src/config/bot_config.py | 36 +++++ src/config/github_settings.py | 15 ++ src/config/org_settings.py | 15 ++ tests/test_bot.py | 41 ++++-- tests/test_message_sequence_integration.py | 28 ++-- uv.lock | 160 +++++++++++++++++++++ 13 files changed, 416 insertions(+), 159 deletions(-) delete mode 100644 src/config.py create mode 100644 src/config/__init__.py create mode 100644 src/config/action_config.py create mode 100644 src/config/bot_config.py create mode 100644 src/config/github_settings.py create mode 100644 src/config/org_settings.py diff --git a/config/production/secrets.env.example b/config/production/secrets.env.example index 8ee6460..ad38e86 100644 --- a/config/production/secrets.env.example +++ b/config/production/secrets.env.example @@ -1,2 +1,10 @@ GCP_ACCOUNT=example@gmail.com GCP_PROJECT_NAME=bot-org + +# GitHub Settings +GITHUB_TOKEN=github_pat_your_token_here +GITHUB_REPO=username/repo-name + +# Org Settings (optional, defaults shown) +# ORG_JOURNAL_FILE=journal.md +# ORG_TODO_FILE=todo.org diff --git a/dev.env b/dev.env index e12dd81..adff66e 100644 --- a/dev.env +++ b/dev.env @@ -1,2 +1,10 @@ +# Bot Settings BOT_TOKEN=9999999999:AAAAAAAAAAA999aaaaaaaaaaaaaaaaaaaaa -GITHUB_TOKEN=github_pat_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX \ No newline at end of file + +# GitHub Settings +GITHUB_TOKEN=github_pat_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +GITHUB_REPO=iamkarlson/my-notes + +# Org Settings (optional, defaults shown) +ORG_JOURNAL_FILE=journal.md +ORG_TODO_FILE=todo.org \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 29b68d5..9426983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,8 @@ dependencies = [ "PyGithub==1.59.1", "defopt", "sentry-sdk==2.8.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", ] [project.optional-dependencies] diff --git a/src/bot.py b/src/bot.py index 7321769..d7549cb 100644 --- a/src/bot.py +++ b/src/bot.py @@ -18,7 +18,8 @@ from .config import ( BotConfig, - GitHubConfig, + GitHubSettings, + OrgSettings, ActionConfig, create_commands, create_actions, @@ -44,18 +45,21 @@ class OrgBot: def __init__( self, bot_config: Optional[BotConfig] = None, - github_config: Optional[GitHubConfig] = None, + github_settings: Optional[GitHubSettings] = None, + org_settings: Optional[OrgSettings] = None, ): """ Initialize the OrgBot with configurations. Args: bot_config: Bot configuration (loads from env if not provided) - github_config: GitHub configuration (loads from env if not provided) + github_settings: GitHub settings (loads from env if not provided) + org_settings: Org-mode settings (loads from env if not provided) """ # Load configurations self.bot_config = bot_config or BotConfig.from_env() - self.github_config = github_config or GitHubConfig.from_env() + self.github_settings = github_settings or GitHubSettings() + self.org_settings = org_settings or OrgSettings() # Configure HTTP client for bot self.request = HTTPXRequest( @@ -67,7 +71,7 @@ def __init__( # Initialize commands and actions self.commands = create_commands(self._get_bot) - self.actions = create_actions(self.github_config) + self.actions = create_actions(self.github_settings, self.org_settings) self.default_action_key = "journal" logger.info( diff --git a/src/config.py b/src/config.py deleted file mode 100644 index a69ecdf..0000000 --- a/src/config.py +++ /dev/null @@ -1,134 +0,0 @@ -import os -from dataclasses import dataclass -from typing import Callable, Dict, Any -from telegram import Bot - -from .commands import StartCommand, WebhookCommand, InfoCommand -from .actions import ( - PostToGitJournal, - PostToTodo, - PostReplyToEntry, -) - - -@dataclass -class BotConfig: - """Core bot configuration from environment.""" - - bot_token: str - authorized_chat_ids: list[int] - ignored_chat_ids: list[int] - forward_unauthorized_to: int | None - sentry_dsn: str - - @classmethod - def from_env(cls) -> "BotConfig": - """Load configuration from environment variables.""" - authorized_ids = [ - int(id) for id in os.environ["AUTHORIZED_CHAT_IDS"].split(",") - ] - ignored_ids = [ - int(id) - for id in os.environ.get("IGNORED_CHAT_IDS", "").split(",") - if id - ] - forward_to = os.environ.get("FORWARD_UNAUTHORIZED_TO") - - return cls( - bot_token=os.environ["BOT_TOKEN"], - authorized_chat_ids=authorized_ids, - ignored_chat_ids=ignored_ids, - forward_unauthorized_to=int(forward_to) if forward_to else None, - sentry_dsn=os.environ.get("SENTRY_DSN", ""), - ) - - -@dataclass -class GitHubConfig: - """GitHub integration configuration.""" - - token: str - repo_name: str - journal_file: str - todo_file: str - - @classmethod - def from_env(cls) -> "GitHubConfig": - """Load GitHub configuration from environment variables.""" - return cls( - token=os.environ.get("GITHUB_TOKEN", ""), - repo_name=os.environ.get("GITHUB_REPO", ""), - journal_file=os.environ.get("JOURNAL_FILE", "journal.md"), - todo_file="todo.org", - ) - - -@dataclass -class ActionConfig: - """Configuration for a single action.""" - - function: Callable - response_message: str - - -def create_commands(get_bot: Callable[[], Bot]) -> Dict[str, Any]: - """ - Factory function to create command instances. - - Args: - get_bot: Callable that returns a Bot instance - - Returns: - Dictionary mapping command names to command instances - """ - return { - "/start": StartCommand(get_bot), - "/webhook": WebhookCommand(get_bot), - "/info": InfoCommand(get_bot), - } - - -def create_actions(github_config: GitHubConfig) -> Dict[str, ActionConfig]: - """ - Factory function to create action instances. - - Args: - github_config: GitHub configuration dataclass - - Returns: - Dictionary mapping action keywords to ActionConfig instances - """ - # Initialize action instances - journal = PostToGitJournal( - github_token=github_config.token, - repo_name=github_config.repo_name, - file_path=github_config.journal_file, - ) - - todo = PostToTodo( - github_token=github_config.token, - repo_name=github_config.repo_name, - file_path=github_config.todo_file, - ) - - reply = PostReplyToEntry( - github_token=github_config.token, - repo_name=github_config.repo_name, - file_path=github_config.journal_file, - todo_file_path=github_config.todo_file, - ) - - return { - "journal": ActionConfig( - function=journal.run, - response_message="Added to journal!", - ), - "todo": ActionConfig( - function=todo.run, - response_message="Added to todo list!", - ), - "reply": ActionConfig( - function=reply.run, - response_message="Added reply to entry!", - ), - } diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..b959299 --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,100 @@ +""" +Configuration module for org-bot. + +This module provides: +1. Configuration dataclasses (BotConfig, GitHubConfig, ActionConfig) +2. Factory functions to create configured instances +""" + +from typing import Callable, Dict, Any +from telegram import Bot + +from .bot_config import BotConfig +from .github_settings import GitHubSettings +from .org_settings import OrgSettings +from .action_config import ActionConfig + +# Import commands and actions for factory functions +from ..commands import StartCommand, WebhookCommand, InfoCommand +from ..actions import ( + PostToGitJournal, + PostToTodo, + PostReplyToEntry, +) + + +def create_commands(get_bot: Callable[[], Bot]) -> Dict[str, Any]: + """ + Factory function to create command instances. + + Args: + get_bot: Callable that returns a Bot instance + + Returns: + Dictionary mapping command names to command instances + """ + return { + "/start": StartCommand(get_bot), + "/webhook": WebhookCommand(get_bot), + "/info": InfoCommand(get_bot), + } + + +def create_actions( + github_settings: GitHubSettings, + org_settings: OrgSettings, +) -> Dict[str, ActionConfig]: + """ + Factory function to create action instances. + + Args: + github_settings: GitHub configuration + org_settings: Org-mode repository configuration + + Returns: + Dictionary mapping action keywords to ActionConfig instances + """ + # Initialize action instances + journal = PostToGitJournal( + github_token=github_settings.token, + repo_name=github_settings.repo, + file_path=org_settings.journal_file, + ) + + todo = PostToTodo( + github_token=github_settings.token, + repo_name=github_settings.repo, + file_path=org_settings.todo_file, + ) + + reply = PostReplyToEntry( + github_token=github_settings.token, + repo_name=github_settings.repo, + file_path=org_settings.journal_file, + todo_file_path=org_settings.todo_file, + ) + + return { + "journal": ActionConfig( + function=journal.run, + response_message="Added to journal!", + ), + "todo": ActionConfig( + function=todo.run, + response_message="Added to todo list!", + ), + "reply": ActionConfig( + function=reply.run, + response_message="Added reply to entry!", + ), + } + + +__all__ = [ + "BotConfig", + "GitHubSettings", + "OrgSettings", + "ActionConfig", + "create_commands", + "create_actions", +] diff --git a/src/config/action_config.py b/src/config/action_config.py new file mode 100644 index 0000000..2e0a0b2 --- /dev/null +++ b/src/config/action_config.py @@ -0,0 +1,12 @@ +"""Action configuration dataclass.""" + +from dataclasses import dataclass +from typing import Callable + + +@dataclass +class ActionConfig: + """Configuration for a single action.""" + + function: Callable + response_message: str diff --git a/src/config/bot_config.py b/src/config/bot_config.py new file mode 100644 index 0000000..7e53f68 --- /dev/null +++ b/src/config/bot_config.py @@ -0,0 +1,36 @@ +"""Bot configuration dataclass.""" + +import os +from dataclasses import dataclass + + +@dataclass +class BotConfig: + """Core bot configuration from environment.""" + + bot_token: str + authorized_chat_ids: list[int] + ignored_chat_ids: list[int] + forward_unauthorized_to: int | None + sentry_dsn: str + + @classmethod + def from_env(cls) -> "BotConfig": + """Load configuration from environment variables.""" + authorized_ids = [ + int(id) for id in os.environ["AUTHORIZED_CHAT_IDS"].split(",") + ] + ignored_ids = [ + int(id) + for id in os.environ.get("IGNORED_CHAT_IDS", "").split(",") + if id + ] + forward_to = os.environ.get("FORWARD_UNAUTHORIZED_TO") + + return cls( + bot_token=os.environ["BOT_TOKEN"], + authorized_chat_ids=authorized_ids, + ignored_chat_ids=ignored_ids, + forward_unauthorized_to=int(forward_to) if forward_to else None, + sentry_dsn=os.environ.get("SENTRY_DSN", ""), + ) diff --git a/src/config/github_settings.py b/src/config/github_settings.py new file mode 100644 index 0000000..906d993 --- /dev/null +++ b/src/config/github_settings.py @@ -0,0 +1,15 @@ +"""GitHub integration settings.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class GitHubSettings(BaseSettings): + """GitHub integration settings.""" + + model_config = SettingsConfigDict( + env_prefix="GITHUB_", + case_sensitive=False, + ) + + token: str = "" + repo: str = "" diff --git a/src/config/org_settings.py b/src/config/org_settings.py new file mode 100644 index 0000000..20ea0b4 --- /dev/null +++ b/src/config/org_settings.py @@ -0,0 +1,15 @@ +"""Org-mode repository settings.""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class OrgSettings(BaseSettings): + """Org-mode repository settings.""" + + model_config = SettingsConfigDict( + env_prefix="ORG_", + case_sensitive=False, + ) + + journal_file: str = "journal.md" + todo_file: str = "todo.org" diff --git a/tests/test_bot.py b/tests/test_bot.py index b798743..0a00bde 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -23,7 +23,7 @@ os.environ.setdefault("SENTRY_DSN", "") from src.bot import OrgBot -from src.config import BotConfig, GitHubConfig +from src.config import BotConfig, GitHubSettings, OrgSettings @pytest.fixture @@ -39,11 +39,18 @@ def test_bot_config(): @pytest.fixture -def test_github_config(): - """Create a test GitHub configuration.""" - return GitHubConfig( +def test_github_settings(): + """Create a test GitHub settings.""" + return GitHubSettings( token="test_token", - repo_name="test/repo", + repo="test/repo", + ) + + +@pytest.fixture +def test_org_settings(): + """Create a test Org settings.""" + return OrgSettings( journal_file="journal.org", todo_file="todo.org", ) @@ -63,25 +70,36 @@ def mock_github(): @pytest.fixture -def org_bot(test_bot_config, test_github_config, mock_github): +def org_bot(test_bot_config, test_github_settings, test_org_settings, mock_github): """Create an OrgBot instance with test configurations.""" with patch("src.actions.base_post_to_org_file.Github", return_value=mock_github): - bot = OrgBot(bot_config=test_bot_config, github_config=test_github_config) + bot = OrgBot( + bot_config=test_bot_config, + github_settings=test_github_settings, + org_settings=test_org_settings, + ) return bot class TestOrgBotInitialization: """Test OrgBot initialization.""" - def test_init_with_configs(self, test_bot_config, test_github_config, mock_github): + def test_init_with_configs( + self, test_bot_config, test_github_settings, test_org_settings, mock_github + ): """Test initialization with provided configs.""" with patch( "src.actions.base_post_to_org_file.Github", return_value=mock_github ): - bot = OrgBot(bot_config=test_bot_config, github_config=test_github_config) + bot = OrgBot( + bot_config=test_bot_config, + github_settings=test_github_settings, + org_settings=test_org_settings, + ) assert bot.bot_config == test_bot_config - assert bot.github_config == test_github_config + assert bot.github_settings == test_github_settings + assert bot.org_settings == test_org_settings assert len(bot.commands) == 3 # /start, /webhook, /info assert len(bot.actions) == 3 # journal, todo, reply assert bot.default_action_key == "journal" @@ -95,7 +113,8 @@ def test_init_from_env(self, mock_github): bot = OrgBot() assert bot.bot_config is not None - assert bot.github_config is not None + assert bot.github_settings is not None + assert bot.org_settings is not None class TestActionDetermination: diff --git a/tests/test_message_sequence_integration.py b/tests/test_message_sequence_integration.py index 1ea84c7..96ca03e 100644 --- a/tests/test_message_sequence_integration.py +++ b/tests/test_message_sequence_integration.py @@ -197,7 +197,7 @@ async def test_message_sequence_full_flow( ): # Import here to ensure patch is applied from src.bot import OrgBot - from src.config import BotConfig, GitHubConfig + from src.config import BotConfig, GitHubSettings, OrgSettings # Create test configs bot_config = BotConfig( @@ -207,15 +207,21 @@ async def test_message_sequence_full_flow( forward_unauthorized_to=None, sentry_dsn="", ) - github_config = GitHubConfig( + github_settings = GitHubSettings( token="test_token", - repo_name="test/repo", + repo="test/repo", + ) + org_settings = OrgSettings( journal_file="journal.org", todo_file="todo.org", ) # Create OrgBot instance with test configs - org_bot = OrgBot(bot_config=bot_config, github_config=github_config) + org_bot = OrgBot( + bot_config=bot_config, + github_settings=github_settings, + org_settings=org_settings, + ) # Process each message in sequence for i, msg_data in enumerate(message_sequence): @@ -354,7 +360,7 @@ async def test_reply_response_not_none( # Create OrgBot instance from src.bot import OrgBot - from src.config import BotConfig, GitHubConfig + from src.config import BotConfig, GitHubSettings, OrgSettings bot_config = BotConfig( bot_token="test_bot_token", @@ -363,14 +369,20 @@ async def test_reply_response_not_none( forward_unauthorized_to=None, sentry_dsn="", ) - github_config = GitHubConfig( + github_settings = GitHubSettings( token="test_token", - repo_name="test/repo", + repo="test/repo", + ) + org_settings = OrgSettings( journal_file="journal.org", todo_file="todo.org", ) - org_bot = OrgBot(bot_config=bot_config, github_config=github_config) + org_bot = OrgBot( + bot_config=bot_config, + github_settings=github_settings, + org_settings=org_settings, + ) # Process the reply response = await org_bot._handle_action( diff --git a/uv.lock b/uv.lock index ec86b12..6675a08 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://gitlab.com/api/v4/projects/24061829/packages/pypi/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.10.0" @@ -602,6 +611,8 @@ dependencies = [ { name = "defopt" }, { name = "flask" }, { name = "functions-framework" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, { name = "pygithub" }, { name = "python-telegram-bot" }, { name = "sentry-sdk" }, @@ -635,6 +646,8 @@ requires-dist = [ { name = "flask", specifier = "~=2.3.2" }, { name = "functions-framework", specifier = "==3.4.0" }, { name = "mypy", marker = "extra == 'dev'" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pygithub", specifier = "==1.59.1" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-asyncio", marker = "extra == 'dev'" }, @@ -720,6 +733,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://gitlab.com/api/v4/projects/24061829/packages/pypi/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://gitlab.com/api/v4/projects/24061829/packages/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://gitlab.com/api/v4/projects/24061829/packages/pypi/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pyflakes" version = "3.4.0" @@ -830,6 +969,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://gitlab.com/api/v4/projects/24061829/packages/pypi/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + [[package]] name = "python-telegram-bot" version = "22.3" @@ -958,6 +1106,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://gitlab.com/api/v4/projects/24061829/packages/pypi/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.5.0" From 4777dbcf959098a249004746e7552e51c6a76c92 Mon Sep 17 00:00:00 2001 From: George Green Date: Wed, 7 Jan 2026 20:31:37 +0100 Subject: [PATCH 13/13] tests and formatting --- src/actions/__init__.py | 5 ++++- src/actions/base_post_to_org_file.py | 2 +- src/actions/post_to_journal.py | 5 +++-- src/actions/post_to_todo.py | 2 +- src/bot.py | 3 +-- src/commands/__init__.py | 10 +++++++--- src/config/bot_config.py | 4 +--- tests/test_bot.py | 3 +-- tests/test_journal_posting.py | 6 +++--- tests/test_message_sequence_integration.py | 4 ++++ tests/test_todo_posting.py | 6 +++--- 11 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/actions/__init__.py b/src/actions/__init__.py index 43d8ebb..0e41fdb 100644 --- a/src/actions/__init__.py +++ b/src/actions/__init__.py @@ -1,4 +1,7 @@ -# Expose all actions from python classes in this package +""" +This module provides diffrent message "shortcuts" for posting different kind of notes. +For example, posting to a journal, posting a todo, replying to an entry, and, perhaps, add to a shopping list. +""" from .post_to_journal import PostToGitJournal from .post_to_todo import PostToTodo diff --git a/src/actions/base_post_to_org_file.py b/src/actions/base_post_to_org_file.py index 0d473ed..c3cf43c 100644 --- a/src/actions/base_post_to_org_file.py +++ b/src/actions/base_post_to_org_file.py @@ -56,7 +56,7 @@ def run(self, message: Message, file_path=None): message_id = message.message_id chat_id = message.chat.id commit_message = f"Message {message_id} from chat {chat_id}" - new_text = self._get_org_item(message) + new_text = self._get_new_org_item(message) self.org_api.append_text_to_file( self.file_path, new_text, commit_message, image_filename=filename ) diff --git a/src/actions/post_to_journal.py b/src/actions/post_to_journal.py index f201464..ac4902a 100644 --- a/src/actions/post_to_journal.py +++ b/src/actions/post_to_journal.py @@ -1,5 +1,6 @@ """ -in this task, I take the message from telegram command, and post it to my journal on github. +in this action, I take the message from telegram command, +and post it to my journal in the repo. I will use the github api to do this. """ @@ -16,7 +17,7 @@ class PostToGitJournal(BasePostToGitJournal): @staticmethod - def _get_org_item(message: Message) -> str: + def _get_new_org_item(message: Message) -> str: """ In this method, I'm making an message for my org-mode journal. It includes title "log entry" and link to the message. diff --git a/src/actions/post_to_todo.py b/src/actions/post_to_todo.py index 5fa31bb..1e07984 100644 --- a/src/actions/post_to_todo.py +++ b/src/actions/post_to_todo.py @@ -20,7 +20,7 @@ class PostToTodo(BasePostToGitJournal): """ @staticmethod - def _get_org_item(message: Message) -> str: + def _get_new_org_item(message: Message) -> str: """ In this method, I'm making an message for my org-mode journal. It includes title "log entry" and link to the message. diff --git a/src/bot.py b/src/bot.py index d7549cb..91c2f8d 100644 --- a/src/bot.py +++ b/src/bot.py @@ -11,7 +11,7 @@ import logging import asyncio -from typing import Dict, Optional +from typing import Optional from telegram import Bot, Message from telegram.request import HTTPXRequest from telegram.error import TimedOut, NetworkError @@ -20,7 +20,6 @@ BotConfig, GitHubSettings, OrgSettings, - ActionConfig, create_commands, create_actions, ) diff --git a/src/commands/__init__.py b/src/commands/__init__.py index e3c371b..f8cd319 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -1,3 +1,7 @@ -from .info import InfoCommand # noqa F401 -from .start import StartCommand # noqa F401 -from .webhook import WebhookCommand # noqa F401 +from .info import InfoCommand # noqa: F401 +from .start import StartCommand # noqa: F401 +from .webhook import WebhookCommand # noqa: F401 + +""" +This module contains telegram commands that start with "/" +""" diff --git a/src/config/bot_config.py b/src/config/bot_config.py index 7e53f68..a3f4d05 100644 --- a/src/config/bot_config.py +++ b/src/config/bot_config.py @@ -21,9 +21,7 @@ def from_env(cls) -> "BotConfig": int(id) for id in os.environ["AUTHORIZED_CHAT_IDS"].split(",") ] ignored_ids = [ - int(id) - for id in os.environ.get("IGNORED_CHAT_IDS", "").split(",") - if id + int(id) for id in os.environ.get("IGNORED_CHAT_IDS", "").split(",") if id ] forward_to = os.environ.get("FORWARD_UNAUTHORIZED_TO") diff --git a/tests/test_bot.py b/tests/test_bot.py index 0a00bde..e93b0da 100644 --- a/tests/test_bot.py +++ b/tests/test_bot.py @@ -12,7 +12,6 @@ import os import pytest from unittest.mock import Mock, MagicMock, patch, AsyncMock -from telegram import Message # Set environment variables before importing src modules os.environ.setdefault("GITHUB_TOKEN", "test_token") @@ -335,7 +334,7 @@ async def test_save_photo(self, org_bot): mock_bot.get_file = AsyncMock(return_value=mock_file) mock_get_bot.return_value = mock_bot - with patch("builtins.open", create=True) as mock_open: + with patch("builtins.open", create=True): file_path = await org_bot._save_photo(message) assert file_path == "/tmp/test_photo_123.jpg" diff --git a/tests/test_journal_posting.py b/tests/test_journal_posting.py index 832af6b..e75d74e 100644 --- a/tests/test_journal_posting.py +++ b/tests/test_journal_posting.py @@ -313,20 +313,20 @@ def test_post_file_message_to_journal( @pytest.mark.unit @pytest.mark.journal - def test_get_org_item_format( + def test_get_new_org_item_format( self, journal_instance: PostToGitJournal, mock_telegram_message_text: Mock, ) -> None: """ - Test the _get_org_item method returns properly formatted org-mode entry. + Test the _get_new_org_item method returns properly formatted org-mode entry. """ logger.info("=" * 80) logger.info("TEST: Verify org-mode item formatting") logger.info("=" * 80) message = mock_telegram_message_text - org_item = journal_instance._get_org_item(message) + org_item = journal_instance._get_new_org_item(message) logger.info(f"Generated org item:\n{org_item}") diff --git a/tests/test_message_sequence_integration.py b/tests/test_message_sequence_integration.py index 96ca03e..007e02e 100644 --- a/tests/test_message_sequence_integration.py +++ b/tests/test_message_sequence_integration.py @@ -328,6 +328,10 @@ async def test_reply_response_not_none( todo_file_path="todo.org", ) + assert reply_instance is not None, ( + "PostReplyToEntry instance should be created" + ) + # First, add an original entry original_message = Mock() original_message.message_id = 100 diff --git a/tests/test_todo_posting.py b/tests/test_todo_posting.py index aee960a..0eeb47b 100644 --- a/tests/test_todo_posting.py +++ b/tests/test_todo_posting.py @@ -389,20 +389,20 @@ def test_post_file_message_to_todo( @pytest.mark.unit @pytest.mark.todo - def test_get_org_item_format_todo( + def test_get_new_org_item_format_todo( self, todo_instance: PostToTodo, mock_todo_message_text: Mock, ) -> None: """ - Test the _get_org_item method returns properly formatted TODO entry. + Test the _get_new_org_item method returns properly formatted TODO entry. """ logger.info("=" * 80) logger.info("TEST: Verify TODO org-mode item formatting") logger.info("=" * 80) message = mock_todo_message_text - org_item = todo_instance._get_org_item(message) + org_item = todo_instance._get_new_org_item(message) logger.info(f"Generated TODO org item:\n{org_item}")