Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}

- name: Install uv package manager
run: task install_uv
uses: astral-sh/setup-uv@v5

- name: Install dependencies
run: task sync_deps
run: task deps:sync

- name: Display environment information
run: |
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,4 @@ src/requirements.txt
.structurizr/
docs/c4/index/
docs/c4/logs/
.tmp/
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ authors = [
]
requires-python = ">=3.11"
dependencies = [
"functions-framework==3.4.0",
"functions-framework==3.10.0",
"Flask~=2.3.2",
"python-telegram-bot==22.3",
"PyGithub==1.59.1",
Expand Down
85 changes: 68 additions & 17 deletions src/actions/base_post_to_org_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,30 +34,81 @@ def __init__(self, github_token=None, repo_name=None, file_path=None, org_api=No
# Use provided org_api or create a new one
self.org_api = org_api if org_api is not None else OrgApi(self.repo)

def run(self, message: Message, file_path=None):
def run(self, message: Message, file_path=None, file_paths=None):
"""
Adds a message to a file on github. File should exists on the github.
:param message: incoming telegram message
:return: status of operation
Adds a message to a file on github. File should exist on github.

Args:
message: incoming telegram message
file_path: (deprecated) single file path for backward compatibility
file_paths: list of file paths for media groups

Returns:
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",
)
# Handle backward compatibility
if file_path and not file_paths:
file_paths = [file_path]

if not file_paths:
file_paths = []

# Build message metadata
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

# If no files, use old simple path for backward compatibility
if not file_paths:
logger.info(
"Creating single-file commit (text only)",
extra={"message_id": message_id, "chat_id": chat_id}
)
self.org_api.append_text_to_file(
self.file_path,
new_text,
commit_message,
image_filename=None
)
return True

# Have files - use atomic commit for all files + org entry
file_changes = []
image_filenames = []

# Add all photos to file_changes
for fp in file_paths:
with open(fp, "rb") as file:
file_bytes = file.read()
filename = "pics/telegram/" + fp.split("/")[-1]
file_changes.append((filename, file_bytes))
image_filenames.append(filename)

# Get current org file content
contents = self.repo.get_contents(self.file_path, ref="main")
decoded_content = contents.decoded_content.decode("utf-8")

# Add image links
image_text = "\n".join([
f"#+attr_html: :width 600px\n[[file:{fn}]]"
for fn in image_filenames
])
new_content = "\n".join([decoded_content, new_text, image_text])

# Add org file to changes
file_changes.append((self.file_path, new_content))

# Create atomic commit
logger.info(
f"Creating atomic commit for {len(file_changes)} files",
extra={
"message_id": message_id,
"chat_id": chat_id,
"photo_count": len(image_filenames)
}
)
self.org_api.create_atomic_commit(file_changes, commit_message)

return True
14 changes: 10 additions & 4 deletions src/actions/post_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,28 @@ def __init__(
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):
def run(self, message: Message, file_path=None, file_paths=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
:param file_path: Optional single attachment path (deprecated)
:param file_paths: Optional list of attachment paths
:return: Status of operation
"""
if file_path and not file_paths:
file_paths = [file_path]

attachment = file_paths[0] if file_paths else None

# 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
message, attachment
)

# Build the link to the original message
Expand Down Expand Up @@ -85,7 +91,7 @@ def run(self, message: Message, file_path=None):
"Original entry not found, falling back to regular journal entry"
)
return PostToGitJournal(self.token, self.repo_name, self.file_path).run(
message, file_path
message, attachment
)

line_number, org_level = entry_location
Expand Down
117 changes: 99 additions & 18 deletions src/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import logging
import asyncio
from typing import Optional
from typing import List, Optional
from telegram import Bot, Message
from telegram.request import HTTPXRequest
from telegram.error import TimedOut, NetworkError
Expand Down Expand Up @@ -60,14 +60,6 @@ def __init__(
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)
Expand All @@ -79,8 +71,19 @@ def __init__(
)

def _get_bot(self) -> Bot:
"""Create a fresh bot instance for each request."""
return Bot(token=self.bot_settings.token, request=self.request)
"""
Create a fresh bot instance with a new HTTPXRequest for each request.

This ensures the httpx client is tied to the current event loop,
preventing "Event loop is closed" errors in serverless environments.
"""
request = HTTPXRequest(
pool_timeout=30,
connection_pool_size=10,
read_timeout=30,
write_timeout=30,
)
return Bot(token=self.bot_settings.token, request=request)

async def handle_update(self, message: Message) -> None:
"""
Expand All @@ -101,6 +104,53 @@ async def handle_update(self, message: Message) -> None:
if response:
await self._send_response(message, response)

async def handle_media_group(self, messages: List[Message]) -> None:
"""
Handle a media group (album with multiple photos).

Args:
messages: List of messages from same media group
"""
if not messages:
logger.warning("Empty media group received")
return

# Use first message for auth and response
primary_message = messages[0]

# Check authorization
if not await auth_check(primary_message, self.bot_settings, self._get_bot):
await self._send_unauthorized_response(primary_message)
return

# Save all photos
file_paths = await self._save_photos(messages)

logger.info(
f"Media group: {len(messages)} messages, {len(file_paths)} photos",
extra={"media_group_id": primary_message.media_group_id}
)

# Get text from first message with caption
message_text = ""
text_message = primary_message
for msg in messages:
text = get_text_from_message(msg)
if text:
message_text = text
text_message = msg
break

# Process as action (media groups can't be commands)
response = await self._handle_action(
text_message,
message_text,
file_paths=file_paths
)

if response:
await self._send_response(text_message, response)

async def _process_message(self, message: Message) -> Optional[str]:
"""
Process a message and return response text.
Expand All @@ -122,7 +172,9 @@ async def _process_message(self, message: Message) -> Optional[str]:
if message_text.startswith("/"):
return await self._handle_command(message, message_text)
else:
return await self._handle_action(message, message_text, temp_file_path)
# Pass as list for consistency with media group handling
file_paths = [temp_file_path] if temp_file_path else None
return await self._handle_action(message, message_text, file_paths)

async def _handle_command(self, message: Message, message_text: str) -> str:
"""
Expand All @@ -149,15 +201,15 @@ async def _handle_action(
self,
message: Message,
message_text: str,
file_path: Optional[str] = None,
file_paths: Optional[List[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
file_paths: Optional list of paths to attached files

Returns:
Response text or None if chat is ignored
Expand All @@ -174,7 +226,8 @@ async def _handle_action(
try:
action_config = self.actions.get(action_key)
if action_config:
action_config.function(message, file_path=file_path)
# Call with file_paths (new signature)
action_config.function(message, file_paths=file_paths)
return action_config.response_message
else:
logger.error(f"Action not found: {action_key}")
Expand Down Expand Up @@ -230,6 +283,37 @@ async def _save_photo(self, message: Message) -> str:
logger.info(f"Photo saved to {temp_file_path}")
return temp_file_path

async def _save_photos(self, messages: List[Message]) -> List[str]:
"""
Save multiple photos from media group messages.

Args:
messages: List of messages, each may contain photo

Returns:
List of temp file paths
"""
file_paths = []

for message in messages:
if message.photo:
# Get highest resolution photo
photo_file_id = message.photo[-1].file_id
temp_file_path = f"/tmp/{photo_file_id}.jpg"

bot = self._get_bot()
file_obj = await bot.get_file(photo_file_id)
file_bytes = await file_obj.download_as_bytearray()

with open(temp_file_path, "wb") as file:
file.write(file_bytes)

file_paths.append(temp_file_path)
logger.debug(f"Saved photo to {temp_file_path}")

logger.info(f"Saved {len(file_paths)} photos from media group")
return file_paths

async def _send_response(self, message: Message, text: str) -> None:
"""
Send a response message with retry logic.
Expand Down Expand Up @@ -260,9 +344,6 @@ async def _send_response(self, message: Message, text: str) -> None:
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)
Expand Down
13 changes: 10 additions & 3 deletions src/commands/info.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import json
import logging
from telegram import Message
from ..base_command import BaseCommand

logger = logging.getLogger(__name__)


class InfoCommand(BaseCommand):
async def execute(self, message: Message) -> str:
response_data = await self.bot.get_me()
response = json.dumps(response_data, indent=1).replace("\\", "\\\\")
return f"""```
try:
response_data = await self.bot.get_me()
response = json.dumps(response_data.to_dict(), indent=1).replace("\\", "\\\\")
return f"""```
{response}
```"""
except Exception as e:
logger.error(f"Error getting info: {e}")
return f"Error retrieving bot information: {str(e)}"
4 changes: 2 additions & 2 deletions src/commands/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ async def execute(self, message: Message) -> str:
}
response = json.dumps(response_data, indent=1).replace("\\", "\\\\")
return f"""Webhook data

```

{response}

```"""
Expand Down
Loading
Loading