diff --git a/.gitignore b/.gitignore index ec13b3e..35f37a4 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,8 @@ terraform.tfstate terraform.tfstate.backup *.env 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/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" 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/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..9d1a9d5 --- /dev/null +++ b/docs/c4/workspace.dsl @@ -0,0 +1,292 @@ +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" + + 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" + } + + 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" + } + + } + + 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" { + 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 -> tracing "Uses for logging" + + actions -> tracing "Uses for logging" + + 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 (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 "Appends text to journal.org" + + postToTodo -> commandBase "Extends" + postToTodo -> textAppender "Appends text to todo.org" + + postReply -> commandBase "Extends" + postReply -> replyInserter "Inserts reply" + } + + 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 actions "ActionsComponents" { + 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..cc6942a --- /dev/null +++ b/docs/c4/workspace.json @@ -0,0 +1,1408 @@ +{ + "configuration" : { }, + "description" : "Architecture model for Org Bot system", + "documentation" : { }, + "id" : 1, + "lastModifiedAgent" : "structurizr-ui", + "lastModifiedDate" : "2025-12-29T12:26:41Z", + "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" : "40", + "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" : "54", + "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" : "55", + "sourceId" : "7", + "tags" : "Relationship" + }, { + "description" : "Checks authorization", + "destinationId" : "15", + "id" : "56", + "linkedRelationshipId" : "55", + "sourceId" : "7" + }, { + "description" : "Processes message", + "destinationId" : "8", + "id" : "58", + "sourceId" : "7", + "tags" : "Relationship" + }, { + "description" : "Sends response", + "destinationId" : "9", + "id" : "67", + "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" : "59", + "sourceId" : "8", + "tags" : "Relationship" + }, { + "description" : "Gets commands", + "destinationId" : "11", + "id" : "60", + "linkedRelationshipId" : "59", + "sourceId" : "8" + }, { + "description" : "Gets actions", + "destinationId" : "13", + "id" : "62", + "sourceId" : "8", + "tags" : "Relationship" + }, { + "description" : "Extracts text", + "destinationId" : "27", + "id" : "64", + "sourceId" : "8", + "tags" : "Relationship" + }, { + "description" : "Extracts text", + "destinationId" : "26", + "id" : "65", + "linkedRelationshipId" : "64", + "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" : "68", + "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" : "30", + "id" : "42", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "description" : "Get / commands", + "destinationId" : "11", + "id" : "43", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "description" : "Get actions", + "destinationId" : "11", + "id" : "44", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "destinationId" : "15", + "id" : "45", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "destinationId" : "19", + "id" : "46", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "destinationId" : "26", + "id" : "47", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "description" : "Configure GCP structured logging", + "destinationId" : "39", + "id" : "48", + "sourceId" : "4", + "tags" : "Relationship" + }, { + "description" : "Configure GCP structured logging", + "destinationId" : "38", + "id" : "49", + "linkedRelationshipId" : "48", + "sourceId" : "4" + }, { + "description" : "Checks authorization", + "destinationId" : "16", + "id" : "57", + "linkedRelationshipId" : "55", + "sourceId" : "4" + }, { + "description" : "Gets commands", + "destinationId" : "12", + "id" : "61", + "linkedRelationshipId" : "59", + "sourceId" : "4" + }, { + "description" : "Gets actions", + "destinationId" : "13", + "id" : "63", + "linkedRelationshipId" : "62", + "sourceId" : "4" + }, { + "description" : "Extracts text", + "destinationId" : "27", + "id" : "66", + "linkedRelationshipId" : "64", + "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" : "69", + "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" : "70", + "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" : "71", + "sourceId" : "16", + "tags" : "Relationship" + }, { + "destinationId" : "11", + "id" : "72", + "linkedRelationshipId" : "71", + "sourceId" : "16" + }, { + "description" : "Forwards unauthorized", + "destinationId" : "18", + "id" : "74", + "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" : "75", + "sourceId" : "17", + "tags" : "Relationship" + }, { + "destinationId" : "11", + "id" : "76", + "linkedRelationshipId" : "75", + "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" : "53", + "sourceId" : "15", + "tags" : "Relationship" + }, { + "destinationId" : "14", + "id" : "73", + "linkedRelationshipId" : "71", + "sourceId" : "15" + } ], + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "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" : "22", + "id" : "77", + "sourceId" : "21", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Finds top-level non-reply entries", + "documentation" : { }, + "id" : "22", + "name" : "Top Level Finder", + "properties" : { + "structurizr.dsl.identifier" : "topLevelFinder" + }, + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Inserts replies at correct org hierarchy position", + "documentation" : { }, + "id" : "23", + "name" : "Reply Inserter", + "properties" : { + "structurizr.dsl.identifier" : "replyInserter" + }, + "relationships" : [ { + "destinationId" : "21", + "id" : "78", + "sourceId" : "23", + "tags" : "Relationship" + }, { + "destinationId" : "22", + "id" : "79", + "sourceId" : "23", + "tags" : "Relationship" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Creates new files in repository", + "documentation" : { }, + "id" : "24", + "name" : "File Creator", + "properties" : { + "structurizr.dsl.identifier" : "fileCreator" + }, + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Appends text to org files", + "documentation" : { }, + "id" : "25", + "name" : "Text Appender", + "properties" : { + "structurizr.dsl.identifier" : "textAppender" + }, + "relationships" : [ { + "description" : "May create file", + "destinationId" : "24", + "id" : "80", + "sourceId" : "25", + "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" : "52", + "sourceId" : "19", + "tags" : "Relationship" + } ], + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Extracts text from various message types", + "documentation" : { }, + "id" : "27", + "name" : "Message Text Extractor", + "properties" : { + "structurizr.dsl.identifier" : "messageTextExtractor" + }, + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Utility functions", + "documentation" : { }, + "id" : "26", + "name" : "Utils", + "properties" : { + "structurizr.dsl.identifier" : "utils" + }, + "tags" : "Element,Container", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Abstract base class for all commands", + "documentation" : { }, + "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" : "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" : "31", + "name" : "Start Command", + "properties" : { + "structurizr.dsl.identifier" : "startCommand" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "29", + "id" : "85", + "sourceId" : "31", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "86", + "linkedRelationshipId" : "85", + "sourceId" : "31" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Handles /info command", + "documentation" : { }, + "id" : "32", + "name" : "Info Command", + "properties" : { + "structurizr.dsl.identifier" : "infoCommand" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "29", + "id" : "89", + "sourceId" : "32", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "90", + "linkedRelationshipId" : "89", + "sourceId" : "32" + } ], + "tags" : "Element,Component", + "technology" : "Python" + }, { + "description" : "Handles webhook operations", + "documentation" : { }, + "id" : "33", + "name" : "Webhook Command", + "properties" : { + "structurizr.dsl.identifier" : "webhookCommand" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "29", + "id" : "91", + "sourceId" : "33", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "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" : "35", + "name" : "Post to Journal", + "properties" : { + "structurizr.dsl.identifier" : "postToJournal" + }, + "relationships" : [ { + "description" : "Extends", + "destinationId" : "29", + "id" : "93", + "sourceId" : "35", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "94", + "linkedRelationshipId" : "93", + "sourceId" : "35" + }, { + "description" : "Appends text to journal.org", + "destinationId" : "25", + "id" : "97", + "sourceId" : "35", + "tags" : "Relationship" + }, { + "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" : "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" + }, { + "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" : "Journal, todo, and reply actions module", + "documentation" : { }, + "id" : "34", + "name" : "Actions", + "properties" : { + "structurizr.dsl.identifier" : "actions" + }, + "relationships" : [ { + "description" : "Uses for logging", + "destinationId" : "38", + "id" : "51", + "sourceId" : "34", + "tags" : "Relationship" + }, { + "description" : "Extends", + "destinationId" : "29", + "id" : "95", + "linkedRelationshipId" : "93", + "sourceId" : "34" + }, { + "description" : "Extends", + "destinationId" : "28", + "id" : "96", + "linkedRelationshipId" : "93", + "sourceId" : "34" + }, { + "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" + }, { + "description" : "Inserts reply", + "destinationId" : "23", + "id" : "109", + "linkedRelationshipId" : "107", + "sourceId" : "34" + } ], + "tags" : "Element,Container,Module", + "technology" : "Python" + }, { + "components" : [ { + "description" : "Logging utilities", + "documentation" : { }, + "id" : "39", + "name" : "Log", + "properties" : { + "structurizr.dsl.identifier" : "gcp_log" + }, + "tags" : "Element,Component", + "technology" : "Python" + } ], + "description" : "Logging and tracing module", + "documentation" : { }, + "id" : "38", + "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" : "41", + "sourceId" : "3", + "tags" : "Relationship" + } ], + "tags" : "Element,Software System" + } ] + }, + "name" : "Org Bot", + "properties" : { + "structurizr.inspection.info" : "0", + "structurizr.inspection.ignore" : "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" : [ { + "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" : "26", + "x" : 1647, + "y" : 2000 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "MainComponents", + "name" : "Component View: Org Bot - Main", + "order" : 3, + "relationships" : [ { + "id" : "53" + }, { + "id" : "54" + }, { + "id" : "56" + }, { + "id" : "58" + }, { + "id" : "60", + "vertices" : [ { + "x" : 1456, + "y" : 1700 + }, { + "x" : 2247, + "y" : 2000 + } ] + }, { + "id" : "65" + }, { + "id" : "67" + }, { + "id" : "68" + } ] + }, { + "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" : "45", + "vertices" : [ { + "x" : 1900, + "y" : 335 + } ] + }, { + "id" : "61" + }, { + "id" : "63" + }, { + "id" : "69" + }, { + "id" : "70" + }, { + "id" : "73" + } ] + }, { + "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" : "43", + "vertices" : [ { + "x" : 2420, + "y" : 600 + } ] + }, { + "id" : "44", + "vertices" : [ { + "x" : 2255, + "y" : 665 + }, { + "x" : 2260, + "y" : 1195 + } ] + }, { + "id" : "57", + "vertices" : [ { + "x" : 1225, + "y" : 610 + } ] + }, { + "id" : "72" + }, { + "id" : "74" + }, { + "id" : "76" + } ] + }, { + "containerId" : "19", + "dimensions" : { + "height" : 2718, + "width" : 1834 + }, + "elements" : [ { + "id" : "20", + "x" : 989, + "y" : 1379 + }, { + "id" : "21", + "x" : 989, + "y" : 1379 + }, { + "id" : "22", + "x" : 1143, + "y" : 1979 + }, { + "id" : "23", + "x" : 989, + "y" : 779 + }, { + "id" : "24", + "x" : 239, + "y" : 1379 + }, { + "id" : "25", + "x" : 239, + "y" : 779 + }, { + "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" : "109" + }, { + "id" : "77" + }, { + "id" : "78" + }, { + "id" : "79", + "vertices" : [ { + "x" : 1589, + "y" : 1379 + }, { + "x" : 1589, + "y" : 1679 + } ] + }, { + "id" : "80" + }, { + "id" : "83" + }, { + "id" : "96" + }, { + "id" : "99" + } ] + }, { + "containerId" : "26", + "dimensions" : { + "height" : 1518, + "width" : 979 + }, + "elements" : [ { + "id" : "4", + "x" : 264, + "y" : 179 + }, { + "id" : "27", + "x" : 264, + "y" : 779 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "UtilsComponents", + "name" : "Component View: Org Bot - Utils", + "order" : 7, + "relationships" : [ { + "id" : "66" + } ] + }, { + "containerId" : "28", + "dimensions" : { + "height" : 2158, + "width" : 1858 + }, + "elements" : [ { + "id" : "19", + "x" : 455, + "y" : 1480 + }, { + "id" : "29", + "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" : "100", + "vertices" : [ { + "x" : 679, + "y" : 1333 + } ] + }, { + "id" : "82" + }, { + "id" : "87" + }, { + "id" : "95" + } ] + }, { + "containerId" : "30", + "dimensions" : { + "height" : 1439, + "width" : 3180 + }, + "elements" : [ { + "id" : "28", + "x" : 890, + "y" : 895 + }, { + "id" : "31", + "x" : 239, + "y" : 200 + }, { + "id" : "32", + "x" : 1739, + "y" : 200 + }, { + "id" : "33", + "x" : 989, + "y" : 200 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "CommandsComponents", + "name" : "Component View: Org Bot - Commands", + "order" : 9, + "relationships" : [ { + "id" : "86" + }, { + "id" : "90" + }, { + "id" : "92" + } ] + }, { + "containerId" : "34", + "dimensions" : { + "height" : 1662, + "width" : 2816 + }, + "elements" : [ { + "id" : "19", + "x" : 1190, + "y" : 1120 + }, { + "id" : "28", + "x" : 1180, + "y" : 0 + }, { + "id" : "35", + "x" : 433, + "y" : 433 + }, { + "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" : "38", + "dimensions" : { + "height" : 1518, + "width" : 979 + }, + "elements" : [ { + "id" : "4", + "x" : 0, + "y" : 0 + }, { + "id" : "39", + "x" : 264, + "y" : 779 + } ], + "externalContainerBoundariesVisible" : false, + "key" : "TracingComponents", + "name" : "Component View: Org Bot - Tracing", + "order" : 11, + "relationships" : [ { + "id" : "48" + } ] + } ], + "configuration" : { + "branding" : { }, + "lastSavedView" : "BaseCommandComponents", + "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" : "26", + "x" : 945, + "y" : 995 + }, { + "id" : "28", + "x" : 2680, + "y" : 220 + }, { + "id" : "30", + "x" : 2680, + "y" : 595 + }, { + "id" : "34", + "x" : 2685, + "y" : 1705 + }, { + "id" : "38", + "x" : 2685, + "y" : 1705 + } ], + "externalSoftwareSystemBoundariesVisible" : false, + "key" : "Containers", + "name" : "Container View: Org Bot", + "order" : 2, + "relationships" : [ { + "id" : "100" + }, { + "id" : "42" + }, { + "id" : "43", + "vertices" : [ { + "x" : 1466, + "y" : 995 + } ] + }, { + "id" : "44", + "vertices" : [ { + "x" : 1766, + "y" : 998 + } ] + }, { + "id" : "45", + "vertices" : [ { + "x" : 585, + "y" : 635 + } ] + }, { + "id" : "46" + }, { + "id" : "47" + }, { + "id" : "49" + }, { + "id" : "50" + }, { + "id" : "51" + }, { + "id" : "52" + }, { + "id" : "53" + }, { + "id" : "84" + }, { + "id" : "88" + }, { + "id" : "96" + } ], + "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" : "40" + }, { + "id" : "41" + } ], + "softwareSystemId" : "3" + } ] + } +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index e56339e..9426983 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,12 +18,15 @@ dependencies = [ "PyGithub==1.59.1", "defopt", "sentry-sdk==2.8.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", ] [project.optional-dependencies] dev = [ "pytest", "pytest-cov", + "pytest-asyncio", "black", "flake8", "mypy", @@ -36,6 +39,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 407ad4a..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 @@ -40,3 +44,5 @@ 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 + asyncio: Async tests using pytest-asyncio 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 new file mode 100644 index 0000000..0e41fdb --- /dev/null +++ b/src/actions/__init__.py @@ -0,0 +1,14 @@ +""" +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 +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..c3cf43c --- /dev/null +++ b/src/actions/base_post_to_org_file.py @@ -0,0 +1,63 @@ +""" +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 + +from github import Github, Auth +from telegram import 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_new_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..fe4c169 --- /dev/null +++ b/src/actions/post_reply.py @@ -0,0 +1,119 @@ +""" +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 +from datetime import datetime + +from telegram import Message +from ..utils import get_text_from_message + +from .base_post_to_org_file import BasePostToGitJournal +from .post_to_journal import PostToGitJournal + +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..ac4902a --- /dev/null +++ b/src/actions/post_to_journal.py @@ -0,0 +1,31 @@ +""" +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. +""" + +import logging +from datetime import datetime + +from telegram import Message +from ..utils import get_text_from_message + +from .base_post_to_org_file import BasePostToGitJournal + +logger = logging.getLogger(__name__) + + +class PostToGitJournal(BasePostToGitJournal): + @staticmethod + 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. + 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..1e07984 --- /dev/null +++ b/src/actions/post_to_todo.py @@ -0,0 +1,36 @@ +""" +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 +from datetime import datetime + +from telegram import Message +from ..utils import get_text_from_message + +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_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. + 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/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/bot.py b/src/bot.py new file mode 100644 index 0000000..91c2f8d --- /dev/null +++ b/src/bot.py @@ -0,0 +1,312 @@ +""" +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 Optional +from telegram import Bot, Message +from telegram.request import HTTPXRequest +from telegram.error import TimedOut, NetworkError + +from .config import ( + BotConfig, + GitHubSettings, + OrgSettings, + 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_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_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_settings = github_settings or GitHubSettings() + self.org_settings = org_settings or OrgSettings() + + # 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_settings, self.org_settings) + 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/commands/__init__.py b/src/commands/__init__.py index 6d121ee..f8cd319 100644 --- a/src/commands/__init__.py +++ b/src/commands/__init__.py @@ -1,4 +1,7 @@ -from .info import InfoCommand -from .start import StartCommand -from .webhook import WebhookCommand -from .post_to_journal import PostToGitJournal, PostToTodo, PostReplyToEntry +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/commands/post_to_journal.py b/src/commands/post_to_journal.py deleted file mode 100644 index 7685b3f..0000000 --- a/src/commands/post_to_journal.py +++ /dev/null @@ -1,353 +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 - -logger = logging.getLogger(__name__) - - -class BasePostToGitJournal: - def __init__(self, github_token=None, repo_name=None, file_path=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) - - 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", - ) - - 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.repo.create_file( - path=filename, - message="Image from telegram", - content=file_bytes, - branch="main", - ) - - 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._append_text_to_file(new_text, commit_message, 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): - super().__init__(github_token, repo_name, file_path) - 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 - """ - 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 - } - ) - - 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._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._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._insert_reply_after_entry( - file_to_update, - top_line_number, - top_org_level, - reply_text, - commit_message - ) - - return True 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/config.py b/src/config.py deleted file mode 100644 index 4d8e6d6..0000000 --- a/src/config.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -from typing import Callable -from telegram import Bot - -from .commands import ( - StartCommand, - WebhookCommand, - InfoCommand, - PostToGitJournal, - PostToTodo, - PostReplyToEntry, -) - - -def init_commands(get_bot: Callable[[], Bot]): - """Initialize command instances with bot getter dependency.""" - return { - "/start": StartCommand(get_bot), - "/webhook": WebhookCommand(get_bot), - "/info": InfoCommand(get_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") - -journal = PostToGitJournal( - github_token=github_token, repo_name=repo_name, file_path=file_path -) - -todo = PostToTodo(github_token=github_token, repo_name=repo_name, file_path="todo.org") - -reply = PostReplyToEntry( - github_token=github_token, - repo_name=repo_name, - file_path=file_path, - todo_file_path="todo.org" -) - -# Default action is to post to journal -default_action = journal.run - -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!"}, -} 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..a3f4d05 --- /dev/null +++ b/src/config/bot_config.py @@ -0,0 +1,34 @@ +"""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/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/src/org_api.py b/src/org_api.py new file mode 100644 index 0000000..d1edd35 --- /dev/null +++ b/src/org_api.py @@ -0,0 +1,223 @@ +""" +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, + }, + ) + + 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/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/taskfile.yml b/taskfile.yml index 425be45..488c8d1 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 @@ -61,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 @@ -94,3 +97,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 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_bot.py b/tests/test_bot.py new file mode 100644 index 0000000..e93b0da --- /dev/null +++ b/tests/test_bot.py @@ -0,0 +1,381 @@ +""" +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 + +# 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, GitHubSettings, OrgSettings + + +@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_settings(): + """Create a test GitHub settings.""" + return GitHubSettings( + token="test_token", + repo="test/repo", + ) + + +@pytest.fixture +def test_org_settings(): + """Create a test Org settings.""" + return OrgSettings( + 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_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_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_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_settings=test_github_settings, + org_settings=test_org_settings, + ) + + assert bot.bot_config == test_bot_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" + + 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_settings is not None + assert bot.org_settings 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): + 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_journal_posting.py b/tests/test_journal_posting.py index 88097d0..e75d74e 100644 --- a/tests/test_journal_posting.py +++ b/tests/test_journal_posting.py @@ -10,12 +10,12 @@ 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 -from src.commands.post_to_journal import PostToGitJournal +from src.actions.post_to_journal import PostToGitJournal logger = logging.getLogger(__name__) @@ -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.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"], @@ -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" @@ -293,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}") @@ -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 742649a..007e02e 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 @@ -41,9 +40,7 @@ def _create_dummy_github_client(): return mock_client -# Patch Github before any imports -with patch('src.commands.post_to_journal.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: @@ -66,7 +63,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 +125,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 +149,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 @@ -164,7 +167,8 @@ def _create_mock_message(self, msg_data: Dict[str, Any], previous_messages: List @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]], @@ -188,54 +192,60 @@ 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 - - # Recreate the action instances with mocked GitHub - journal = PostToGitJournal( - github_token="test_token", - repo_name="test/repo", - file_path="journal.org" + from src.bot import OrgBot + from src.config import BotConfig, GitHubSettings, OrgSettings + + # Create test configs + bot_config = BotConfig( + bot_token="test_bot_token", + authorized_chat_ids=[1234567890], + ignored_chat_ids=[], + forward_unauthorized_to=None, + sentry_dsn="", ) - todo = PostToTodo( - github_token="test_token", - repo_name="test/repo", - file_path="todo.org" + github_settings = GitHubSettings( + token="test_token", + repo="test/repo", ) - reply = PostReplyToEntry( - github_token="test_token", - repo_name="test/repo", - file_path="journal.org", - todo_file_path="todo.org" + org_settings = OrgSettings( + 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_settings=github_settings, + org_settings=org_settings, + ) # 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): - 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) # Verify response expected_response = msg_data["expected_response"] - assert response == expected_response, \ + assert response == expected_response, ( f"Message {msg_data['name']}: expected '{expected_response}', got '{response}'" + ) logger.info(f"✓ Response matches expected: {response}") @@ -245,25 +255,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") @@ -273,7 +299,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], @@ -289,14 +316,20 @@ 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", + ) + + assert reply_instance is not None, ( + "PostReplyToEntry instance should be created" ) # First, add an original entry @@ -329,18 +362,43 @@ 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, GitHubSettings, OrgSettings + + bot_config = BotConfig( + bot_token="test_bot_token", + authorized_chat_ids=[1234567890], + ignored_chat_ids=[], + forward_unauthorized_to=None, + sentry_dsn="", + ) + github_settings = GitHubSettings( + token="test_token", + repo="test/repo", + ) + org_settings = OrgSettings( + journal_file="journal.org", + todo_file="todo.org", + ) + + org_bot = OrgBot( + bot_config=bot_config, + github_settings=github_settings, + org_settings=org_settings, + ) # 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}") # 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 new file mode 100644 index 0000000..a128455 --- /dev/null +++ b/tests/test_org_api.py @@ -0,0 +1,757 @@ +""" +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 + +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") + + @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") diff --git a/tests/test_reply_posting.py b/tests/test_reply_posting.py index 780c07c..2d2c62b 100644 --- a/tests/test_reply_posting.py +++ b/tests/test_reply_posting.py @@ -16,7 +16,7 @@ import pytest -from src.commands.post_to_journal import PostReplyToEntry, PostToGitJournal +from src.actions.post_reply import PostReplyToEntry logger = logging.getLogger(__name__) @@ -49,7 +49,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 +87,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 +123,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 +211,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 +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() @@ -250,18 +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") @@ -286,7 +301,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, + ): reply_instance = PostReplyToEntry( github_token=test_config["github_token"], repo_name=test_config["github_repo"], @@ -305,7 +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" @@ -316,10 +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") @@ -343,7 +367,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 +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" @@ -395,7 +426,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 +504,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 +530,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 +538,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 +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") @@ -539,14 +581,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 @@ -563,13 +605,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"], @@ -579,8 +621,10 @@ 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( - test_config["journal_file"], 1, 1 # Line 1 is "* Entry:" (the original) + 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}") assert line_num == 1, "Should return same line for non-reply entry" @@ -588,14 +632,16 @@ 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( - test_config["journal_file"], 3, 2 # Line 3 is "** Reply:" + 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 +651,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,10 +659,13 @@ 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): + 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 +675,9 @@ 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 +690,14 @@ 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 @@ -666,7 +719,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 +740,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 +763,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..0eeb47b 100644 --- a/tests/test_todo_posting.py +++ b/tests/test_todo_posting.py @@ -9,13 +9,12 @@ import logging import os -from pathlib import Path from unittest.mock import Mock, MagicMock, patch from typing import Any, Dict import pytest -from src.commands.post_to_journal import PostToTodo +from src.actions.post_to_todo import PostToTodo logger = logging.getLogger(__name__) @@ -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.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"], @@ -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") @@ -370,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}") @@ -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") diff --git a/uv.lock b/uv.lock index a2a19b4..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" }, @@ -613,6 +624,7 @@ dev = [ { name = "flake8" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, ] @@ -622,6 +634,7 @@ dev = [ { name = "flake8" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, ] @@ -633,8 +646,11 @@ 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'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "python-telegram-bot", specifier = "==22.3" }, { name = "sentry-sdk", specifier = "==2.8.0" }, @@ -647,6 +663,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" }, ] @@ -716,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" @@ -799,6 +942,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" @@ -813,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" @@ -941,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"