diff --git a/.agent/rules/code-style.md b/.agent/rules/code-style.md index f425fe2b..c36ad7a6 100644 --- a/.agent/rules/code-style.md +++ b/.agent/rules/code-style.md @@ -8,6 +8,12 @@ trigger: always_on In Python, I want you to use the latest type syntax (`type | None`) instead of `Optional`. I also want you to use a single space (`=`) around the equals sign (`=`) in function argument calls. It's important to use double quotation marks (`"`) instead of single quotations (`'`). And finally, we want to always use trailing commas in multi-line function declarations and calls. There's never a reason to write `unittest.main()` manually, we have a script for running tests. Never use inline imports inside of functions (use file header even in tests), and always use `from ... import ...` syntax at the top of the file. +#### Comments + +- For new code, avoid comments unless the logic is genuinely complex or the block is long +- When editing existing code, prefer updating comments over deleting them +- Comments should start with a lowercase letter, except in documentation or where grammar requires it + #### Error Handling Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raising errors. Always use the structured exceptions from `util.errors` (`ValidationError`, `NotFoundError`, `AuthorizationError`, `ExternalServiceError`, `RateLimitError`, `ConfigurationError`, `InternalError`). Each raise must include an error code from `util.error_codes`. When re-raising from a caught exception, always use `raise ... from e` to preserve the chain. When calling external services (LLMs, image APIs, web fetchers), always guard against empty/null/empty-array responses with `ExternalServiceError`. diff --git a/.agent/rules/tooling.md b/.agent/rules/tooling.md index 5aa3f366..c8df6550 100644 --- a/.agent/rules/tooling.md +++ b/.agent/rules/tooling.md @@ -12,27 +12,27 @@ trigger: always_on ### Database Migrations -- Ask the user to run `./tools/db_generate_migration.sh -y` to generate new Alembic migrations (auto-generates based on model changes) -- Ask the user to run `./tools/db_apply_migration.sh` to apply migrations to database (only with user's approval) -- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration.sh` +- Ask the user to run `./tools/db_generate_migration -y` to generate new Alembic migrations (auto-generates based on model changes) +- Ask the user to run `./tools/db_apply_migration` to apply migrations to database (only with user's approval) +- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration` ### Development Workflow - Use `pipenv install --dev` and `pipenv run python src/main.py --dev` for development server (includes hot reload, verbose logging, dev API key) -- Use `pipenv run pre-commit run --all-files --show-diff-on-failure` for code quality checks +- For code quality checks, run tools directly on changed Python files: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` +- For version bumps, run `./tools/bump_version {major|minor|patch}`; major and minor bumps reset lower version segments, and the script updates both project config and API docs - Use `pipenv install` and `pipenv run python src/main.py` for production runs - For all other operations like testing, always run inside of `pipenv` ### Code Quality -- Always run linting before commits: `pipenv run pre-commit run` +- Always run linting on changed Python files before commits: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` - All scripts handle environment setup automatically (PYTHONPATH, .env files) ### Project Structure -- All scripts are in `tools` directory and use common `messages.sh` for colored output +- All scripts are in `tools` directory and use common `messages` for colored output - Scripts validate project root location and fail safely if run from wrong directory -- Version is managed through `pyproject.toml` in project root - You can see other rules in `.cursor` directory, if you need those rules - You can see the CI/CD pipeline in `.github/workflows` directory - You can see the API docs in `docs/` directory (keep it updated!) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 48f3951b..5ec99028 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -8,6 +8,12 @@ In Python, I want you to use the latest type syntax (`type | None`) instead of `Optional`. I also want you to use a single space (`=`) around the equals sign (`=`) in function argument calls. It's important to use double quotation marks (`"`) instead of single quotations (`'`). And finally, we want to always use trailing commas in multi-line function declarations and calls. There's never a reason to write `unittest.main()` manually, we have a script for running tests. Never use inline imports inside of functions (use file header even in tests), and always use `from ... import ...` syntax at the top of the file. +### Comments + +- For new code, avoid comments unless the logic is genuinely complex or the block is long +- When editing existing code, prefer updating comments over deleting them +- Comments should start with a lowercase letter, except in documentation or where grammar requires it + ## Error Handling Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raising errors. Always use the structured exceptions from `util.errors` (`ValidationError`, `NotFoundError`, `AuthorizationError`, `ExternalServiceError`, `RateLimitError`, `ConfigurationError`, `InternalError`). Each raise must include an error code from `util.error_codes`. When re-raising from a caught exception, always use `raise ... from e` to preserve the chain. When calling external services (LLMs, image APIs, web fetchers), always guard against empty/null/empty-array responses with `ExternalServiceError`. @@ -20,27 +26,27 @@ Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raisin ## Database Migrations -- Ask the user to run `./tools/db_generate_migration.sh -y` to generate new Alembic migrations (auto-generates based on model changes) -- Ask the user to run `./tools/db_apply_migration.sh` to apply migrations to database (only with user's approval) -- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration.sh` +- Ask the user to run `./tools/db_generate_migration -y` to generate new Alembic migrations (auto-generates based on model changes) +- Ask the user to run `./tools/db_apply_migration` to apply migrations to database (only with user's approval) +- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration` ## Development Workflow - Use `pipenv install --dev` and `pipenv run python src/main.py --dev` for development server (includes hot reload, verbose logging, dev API key) -- Use `pipenv run pre-commit run --all-files --show-diff-on-failure` for code quality checks +- For code quality checks, run tools directly on changed Python files: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` +- For version bumps, run `./tools/bump_version {major|minor|patch}`; major and minor bumps reset lower version segments, and the script updates both project config and API docs - Use `pipenv install` and `pipenv run python src/main.py` for production runs - For all other operations like testing, always run inside of `pipenv` ## Code Quality -- Always run linting before commits: `pipenv run pre-commit run` +- Always run linting on changed Python files before commits: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` - All scripts handle environment setup automatically (PYTHONPATH, .env files) ## Project Structure -- All scripts are in `tools` directory and use common `messages.sh` for colored output +- All scripts are in `tools` directory and use common `messages` for colored output - Scripts validate project root location and fail safely if run from wrong directory -- Version is managed through `pyproject.toml` in project root - You can see other rules in `.cursor` directory, if you need those rules - You can see the CI/CD pipeline in `.github/workflows` directory - You can see the API docs in `docs/` directory (keep it updated!) diff --git a/.cursor/rules/tooling.mdc b/.cursor/rules/tooling.mdc index 2883e8b0..c3e22665 100644 --- a/.cursor/rules/tooling.mdc +++ b/.cursor/rules/tooling.mdc @@ -1,6 +1,6 @@ --- description: -globs: *.py, *.sh +globs: *.py, tools/* alwaysApply: true --- ## MANDATORY PROJECT RULES @@ -13,27 +13,27 @@ alwaysApply: true ### Database Migrations -- Ask the user to run `./tools/db_generate_migration.sh -y` to generate new Alembic migrations (auto-generates based on model changes) -- Ask the user to run `./tools/db_apply_migration.sh` to apply migrations to database (only with user's approval) -- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration.sh` +- Ask the user to run `./tools/db_generate_migration -y` to generate new Alembic migrations (auto-generates based on model changes) +- Ask the user to run `./tools/db_apply_migration` to apply migrations to database (only with user's approval) +- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration` ### Development Workflow - Use `pipenv install --dev` and `pipenv run python src/main.py --dev` for development server (includes hot reload, verbose logging, dev API key) -- Use `pipenv run pre-commit run --all-files --show-diff-on-failure` for code quality checks +- For code quality checks, run tools directly on changed Python files: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` +- For version bumps, run `./tools/bump_version {major|minor|patch}`; major and minor bumps reset lower version segments, and the script updates both project config and API docs - Use `pipenv install` and `pipenv run python src/main.py` for production runs - For all other operations like testing, always run inside of `pipenv` ### Code Quality -- Always run linting before commits: `pipenv run pre-commit run` +- Always run linting on changed Python files before commits: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` - All scripts handle environment setup automatically (PYTHONPATH, .env files) ### Project Structure -- All scripts are in `tools` directory and use common `messages.sh` for colored output +- All scripts are in `tools` directory and use common `messages` for colored output - Scripts validate project root location and fail safely if run from wrong directory -- Version is managed through `pyproject.toml` in project root - You can see other rules in `.cursor` directory, if you need those rules - You can see the CI/CD pipeline in `.github/workflows` directory - You can see the API docs in `docs/` directory (keep it updated!) diff --git a/.windsurf/rules/rules.md b/.windsurf/rules/rules.md index be3e62b2..c573be51 100644 --- a/.windsurf/rules/rules.md +++ b/.windsurf/rules/rules.md @@ -4,6 +4,12 @@ In Python, I want you to use the latest type syntax (`type | None`) instead of `Optional`. I also want you to use a single space (`=`) around the equals sign (`=`) in function argument calls. It's important to use double quotation marks (`"`) instead of single quotations (`'`). And finally, we want to always use trailing commas in multi-line function declarations and calls. There's never a reason to write `unittest.main()` manually, we have a script for running tests. Never use inline imports inside of functions (use file header even in tests), and always use `from ... import ...` syntax at the top of the file. +### Comments + +- For new code, avoid comments unless the logic is genuinely complex or the block is long +- When editing existing code, prefer updating comments over deleting them +- Comments should start with a lowercase letter, except in documentation or where grammar requires it + ### Error Handling Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raising errors. Always use the structured exceptions from `util.errors` (`ValidationError`, `NotFoundError`, `AuthorizationError`, `ExternalServiceError`, `RateLimitError`, `ConfigurationError`, `InternalError`). Each raise must include an error code from `util.error_codes`. When re-raising from a caught exception, always use `raise ... from e` to preserve the chain. When calling external services (LLMs, image APIs, web fetchers), always guard against empty/null/empty-array responses with `ExternalServiceError`. @@ -20,26 +26,26 @@ Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raisin ### Database Migrations -- Ask the user to run `./tools/db_generate_migration.sh -y` to generate new Alembic migrations (auto-generates based on model changes) -- Ask the user to run `./tools/db_apply_migration.sh` to apply migrations to database (only with user's approval) -- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration.sh` +- Ask the user to run `./tools/db_generate_migration -y` to generate new Alembic migrations (auto-generates based on model changes) +- Ask the user to run `./tools/db_apply_migration` to apply migrations to database (only with user's approval) +- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration` ### Development Workflow - Use `pipenv install --dev` and `pipenv run python src/main.py --dev` for development server (includes hot reload, verbose logging, dev API key) -- Use `pipenv run pre-commit run --all-files --show-diff-on-failure` for code quality checks +- For code quality checks, run tools directly on changed Python files: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` +- For version bumps, run `./tools/bump_version {major|minor|patch}`; major and minor bumps reset lower version segments, and the script updates both project config and API docs - Use `pipenv install` and `pipenv run python src/main.py` for production runs - For all other operations like testing, always run inside of `pipenv` ### Code Quality -- Always run linting before commits: `pipenv run pre-commit run` +- Always run linting on changed Python files before commits: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` - All scripts handle environment setup automatically (PYTHONPATH, .env files) ### Project Structure -- All scripts are in `tools` directory and use common `messages.sh` for colored output +- All scripts are in `tools` directory and use common `messages` for colored output - Scripts validate project root location and fail safely if run from wrong directory -- Version is managed through `pyproject.toml` in project root - You can see the CI/CD pipeline in `.github/workflows` directory - You can see the API docs in `docs/` directory (keep it updated!) diff --git a/AGENTS.md b/AGENTS.md index 96bda4e0..0935f25d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,12 @@ In Python, I want you to use the latest type syntax (`type | None`) instead of `Optional`. I also want you to use a single space (`=`) around the equals sign (`=`) in function argument calls. It's important to use double quotation marks (`"`) instead of single quotations (`'`). And finally, we want to always use trailing commas in multi-line function declarations and calls. There's never a reason to write `unittest.main()` manually, we have a script for running tests. Never use inline imports inside of functions (use file header even in tests), and always use `from ... import ...` syntax at the top of the file. +### Comments + +- For new code, avoid comments unless the logic is genuinely complex or the block is long +- When editing existing code, prefer updating comments over deleting them +- Comments should start with a lowercase letter, except in documentation or where grammar requires it + ### Error Handling Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raising errors. Always use the structured exceptions from `util.errors` (`ValidationError`, `NotFoundError`, `AuthorizationError`, `ExternalServiceError`, `RateLimitError`, `ConfigurationError`, `InternalError`). Each raise must include an error code from `util.error_codes`. When re-raising from a caught exception, always use `raise ... from e` to preserve the chain. When calling external services (LLMs, image APIs, web fetchers), always guard against empty/null/empty-array responses with `ExternalServiceError`. @@ -20,27 +26,27 @@ Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raisin ### Database Migrations -- Ask the user to run `./tools/db_generate_migration.sh -y` to generate new Alembic migrations (auto-generates based on model changes) -- Ask the user to run `./tools/db_apply_migration.sh` to apply migrations to database (only with user's approval) -- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration.sh` +- Ask the user to run `./tools/db_generate_migration -y` to generate new Alembic migrations (auto-generates based on model changes) +- Ask the user to run `./tools/db_apply_migration` to apply migrations to database (only with user's approval) +- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration` ### Development Workflow - Use `pipenv install --dev` and `pipenv run python src/main.py --dev` for development server (includes hot reload, verbose logging, dev API key) -- Use `pipenv run pre-commit run --all-files --show-diff-on-failure` for code quality checks +- For code quality checks, run tools directly on changed Python files: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` +- For version bumps, run `./tools/bump_version {major|minor|patch}`; major and minor bumps reset lower version segments, and the script updates both project config and API docs - Use `pipenv install` and `pipenv run python src/main.py` for production runs - For all other operations like testing, always run inside of `pipenv` ### Code Quality -- Always run linting before commits: `pipenv run pre-commit run` +- Always run linting on changed Python files before commits: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` - All scripts handle environment setup automatically (PYTHONPATH, .env files) ### Project Structure -- All scripts are in `tools` directory and use common `messages.sh` for colored output +- All scripts are in `tools` directory and use common `messages` for colored output - Scripts validate project root location and fail safely if run from wrong directory -- Version is managed through `pyproject.toml` in project root - You can see other rules in `.cursor` directory, if you need those rules - You can see the CI/CD pipeline in `.github/workflows` directory - You can see the API docs in `docs/` directory (keep it updated!) diff --git a/GEMINI.md b/GEMINI.md index 3ed86060..794f98f1 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -6,6 +6,12 @@ In Python, I want you to use the latest type syntax (`type | None`) instead of `Optional`. I also want you to use a single space (`=`) around the equals sign (`=`) in function argument calls. It's important to use double quotation marks (`"`) instead of single quotations (`'`). And finally, we want to always use trailing commas in multi-line function declarations and calls. There's never a reason to write `unittest.main()` manually, we have a script for running tests. Never use inline imports inside of functions (use file header even in tests), and always use `from ... import ...` syntax at the top of the file. +### Comments + +- For new code, avoid comments unless the logic is genuinely complex or the block is long +- When editing existing code, prefer updating comments over deleting them +- Comments should start with a lowercase letter, except in documentation or where grammar requires it + ## Error Handling Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raising errors. Always use the structured exceptions from `util.errors` (`ValidationError`, `NotFoundError`, `AuthorizationError`, `ExternalServiceError`, `RateLimitError`, `ConfigurationError`, `InternalError`). Each raise must include an error code from `util.error_codes`. When re-raising from a caught exception, always use `raise ... from e` to preserve the chain. When calling external services (LLMs, image APIs, web fetchers), always guard against empty/null/empty-array responses with `ExternalServiceError`. @@ -18,26 +24,26 @@ Never use generic `ValueError`, `AssertionError`, or bare `Exception` for raisin ## Database Migrations -- Ask the user to run `./tools/db_generate_migration.sh -y` to generate new Alembic migrations (auto-generates based on model changes) -- Ask the user to run `./tools/db_apply_migration.sh` to apply migrations to database (only with user's approval) -- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration.sh` +- Ask the user to run `./tools/db_generate_migration -y` to generate new Alembic migrations (auto-generates based on model changes) +- Ask the user to run `./tools/db_apply_migration` to apply migrations to database (only with user's approval) +- Always check if model imports in `src/db/alembic/env.py` are up to date before running `db_generate_migration` ## Development Workflow - Use `pipenv install --dev` and `pipenv run python src/main.py --dev` for development server (includes hot reload, verbose logging, dev API key) -- Use `pipenv run pre-commit run --all-files --show-diff-on-failure` for code quality checks +- For code quality checks, run tools directly on changed Python files: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` +- For version bumps, run `./tools/bump_version {major|minor|patch}`; major and minor bumps reset lower version segments, and the script updates both project config and API docs - Use `pipenv install` and `pipenv run python src/main.py` for production runs - For all other operations like testing, always run inside of `pipenv` ## Code Quality -- Always run linting before commits: `pipenv run pre-commit run` +- Always run linting on changed Python files before commits: `pipenv run ruff check --fix ` and `pipenv run python tools/check_spacing.py --fix ` - All scripts handle environment setup automatically (PYTHONPATH, .env files) ## Project Structure -- All scripts are in `tools` directory and use common `messages.sh` for colored output +- All scripts are in `tools` directory and use common `messages` for colored output - Scripts validate project root location and fail safely if run from wrong directory -- Version is managed through `pyproject.toml` in project root - You can see the CI/CD pipeline in `.github/workflows` directory - You can see the API docs in `docs/` directory (keep it updated!) diff --git a/docs/open-api-docs.yaml b/docs/open-api-docs.yaml index e115af40..66819f02 100644 --- a/docs/open-api-docs.yaml +++ b/docs/open-api-docs.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: The Agent's user-facing API description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.) - version: 5.19.8 + version: 5.20.1 license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/.openspec.yaml b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/.openspec.yaml new file mode 100644 index 00000000..e7cc357c --- /dev/null +++ b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-01 diff --git a/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/design.md b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/design.md new file mode 100644 index 00000000..eeeccbda --- /dev/null +++ b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/design.md @@ -0,0 +1,76 @@ +## Context + +`PlatformBotSDK.send_photo()` is the shared entry point for outbound photo delivery. It currently calls a private resize helper, then dispatches to Telegram `send_photo` or WhatsApp `send_photo`. `smart_send_photo()` routes media modes through the same boundary: `photo` uses `send_photo`, `file` uses `send_document`, and `all` sends both. + +The resize helper currently uses a fast `HEAD`/`Content-Length` path and returns the original URL when an image is already under the platform limit. That is correct for size handling, but insufficient for transparent PNG/WebP delivery because transparency is a pixel-level property. A transparent image under the size limit still needs inspection and flattening before photo delivery. + +## Goals / Non-Goals + +**Goals:** + +- Apply black-background alpha flattening to all static images delivered through `PlatformBotSDK.send_photo()`. +- Preserve current Telegram and WhatsApp photo size limits. +- Preserve file/document delivery exactly: file mode and the document half of all mode continue using the original URL. +- Avoid new dependencies by using Pillow, which is already present. +- Keep platform-specific SDK APIs focused on protocol calls rather than shared image preparation. + +**Non-Goals:** + +- Adding video delivery or video rendering. +- Changing image generation, image editing, or social-card rendering output. +- Changing chat settings, media mode values, database schema, or public API docs. +- Adding support for animated media beyond existing photo-delivery behavior. + +## Decisions + +### Centralize preparation in PlatformBotSDK + +Photo preparation will live in `PlatformBotSDK` or a helper it owns, before dispatching to Telegram or WhatsApp SDKs. + +**Why**: `PlatformBotSDK.send_photo()` is the only shared photo-send boundary used by generated images, edited images, social cards, and other app-level photo sends. This gives global behavior without duplicating logic in Telegram and WhatsApp wrappers. + +**Alternatives considered**: + +- Platform-specific SDK changes. Rejected because it duplicates identical image preparation and pushes shared behavior into protocol wrappers. +- Social-card-only preparation. Rejected because the desired behavior applies to all photos sent through platform SDKs. + +### Combine flattening with the existing resize path + +The existing private resize helper should become a broader photo-preparation helper. It will determine the platform max size, download image bytes when inspection or resizing is needed, flatten transparency if present, resize if the prepared image exceeds the platform limit, then upload only when the image was transformed or resized. + +JPEG images under the platform limit can keep the current fast path because JPEG has no alpha channel. PNG, WebP, and unknown image types need content inspection before returning early, even if `Content-Length` is below the limit. + +**Alternatives considered**: + +- Always download every photo. Rejected because it adds unnecessary latency for common under-limit JPEGs. +- Run flattening as a separate upload before resizing. Rejected because it can upload twice and makes size-limit enforcement harder to reason about. + +### Flatten by compositing over black + +Transparent and semi-transparent pixels will be composited over an opaque black background using Pillow. The resulting delivery image will have no alpha channel. + +**Why**: This matches the desired visible result. Merely changing RGB values behind transparent pixels while keeping alpha would still let Telegram or WhatsApp clients choose the visual background. + +**Alternatives considered**: + +- Preserve alpha and set hidden RGB values to black. Rejected because clients could still render transparent areas over non-black backgrounds. +- Use a white or theme-derived matte. Rejected because the requested matte is black and the behavior should be deterministic. + +### Preserve original URL for document delivery + +`smart_send_photo(file)` will continue to call `send_document()` with the original URL. `smart_send_photo(all)` will send the prepared photo first, then call `send_document()` with the original URL. + +**Why**: Users choose file mode when they want the original asset. Photo delivery is optimized for platform display; document delivery should preserve the file. + +### Keep failure behavior close to current delivery behavior + +If preparation cannot inspect or transform an image, the system should preserve current behavior by logging the preparation failure and sending the original URL instead of blocking delivery. If photo delivery itself fails in `smart_send_photo(photo)`, the existing fallback to document delivery remains unchanged. + +**Why**: The current resize helper is best-effort and returns the original URL on preparation failure. Keeping this behavior avoids turning image-processing edge cases into failed chat responses. + +## Risks / Trade-offs + +- More image downloads for alpha-capable formats: PNG/WebP photos under the size limit now need inspection. Mitigation: keep the JPEG fast path and only inspect formats that can carry alpha or cannot be identified safely. +- Animated GIF/WebP ambiguity: flattening animated media can collapse it to a single frame. Mitigation: keep animated media behavior out of scope and avoid adding special animated-media handling in this change. +- Upload provider dependency for transformed under-limit images: transparent images now need re-upload even if they are small. Mitigation: this is required to send an opaque prepared image URL through existing platform APIs. +- Best-effort fallback can leave rare unprocessable transparent images unchanged. Mitigation: log failures and keep tests focused on supported static image formats. diff --git a/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/proposal.md b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/proposal.md new file mode 100644 index 00000000..9c4daf7e --- /dev/null +++ b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/proposal.md @@ -0,0 +1,27 @@ +## Why + +Photos sent through Telegram and WhatsApp can render transparent PNG/WebP regions unpredictably against client backgrounds. Flattening photo-mode images over black before platform delivery makes the visual result deterministic while preserving original files for document/file delivery. + +## What Changes + +- Add a global platform photo preparation step before Telegram `sendPhoto` and WhatsApp image sends. +- Composite transparent and semi-transparent pixels over black and send the resulting opaque image for photo delivery. +- Keep existing platform photo resizing behavior, applying resizing after any required flattening. +- Preserve original media URLs for file/document delivery. +- Ensure `all` media mode sends a black-flattened photo plus the original file/document. +- Leave video delivery and video rendering out of scope. + +## Capabilities + +### New Capabilities + +- `platform-photo-delivery`: Platform photo sends prepare images for deterministic delivery while preserving original files for document delivery. + +### Modified Capabilities + +## Impact + +- `src/features/integrations/platform_bot_sdk.py` - update photo preparation before platform SDK send calls. +- `src/features/images/image_size_utils.py` or a nearby image utility module - add transparency detection/flattening helper if needed. +- Tests for platform photo preparation and media-mode behavior. +- No database migration, API schema change, or new dependency expected. diff --git a/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/specs/platform-photo-delivery/spec.md b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/specs/platform-photo-delivery/spec.md new file mode 100644 index 00000000..d324d740 --- /dev/null +++ b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/specs/platform-photo-delivery/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Global photo delivery preparation +The system SHALL prepare every static image sent through `PlatformBotSDK.send_photo()` before invoking Telegram or WhatsApp photo/image APIs. + +#### Scenario: Transparent PNG sent as photo +- **WHEN** `PlatformBotSDK.send_photo()` is called with a PNG image containing transparent pixels +- **THEN** the platform-specific photo send SHALL receive a URL for an opaque image where transparent pixels have been composited over black + +#### Scenario: Semi-transparent PNG sent as photo +- **WHEN** `PlatformBotSDK.send_photo()` is called with a PNG image containing semi-transparent pixels +- **THEN** the platform-specific photo send SHALL receive a URL for an opaque image where semi-transparent pixels have been composited over black using alpha blending + +#### Scenario: Opaque JPEG under platform limit +- **WHEN** `PlatformBotSDK.send_photo()` is called with an opaque JPEG whose size is within the active platform photo limit +- **THEN** the platform-specific photo send SHALL receive the original photo URL without re-uploading + +### Requirement: Platform photo size limits after preparation +The system SHALL enforce the active platform photo size limit after any transparency flattening has been applied. + +#### Scenario: Flattened image exceeds platform limit +- **WHEN** a transparent image is flattened over black and the prepared file exceeds the active platform photo size limit +- **THEN** the system SHALL resize the prepared file before upload and send the resized prepared image URL + +#### Scenario: Opaque image exceeds platform limit +- **WHEN** an opaque image exceeds the active platform photo size limit +- **THEN** the system SHALL resize the image using the existing resize behavior before upload and send the resized image URL + +#### Scenario: Prepared image is within platform limit +- **WHEN** a downloaded image is either flattened or resized and the prepared file is within the active platform photo size limit +- **THEN** the system SHALL upload that prepared file once and send the uploaded URL + +### Requirement: Media mode preserves original file delivery +The system SHALL preserve original media URLs for file/document delivery while applying photo preparation to photo delivery. + +#### Scenario: Photo media mode +- **WHEN** `smart_send_photo()` is called with media mode `photo` +- **THEN** the system SHALL send only the prepared photo through `send_photo()` + +#### Scenario: File media mode +- **WHEN** `smart_send_photo()` is called with media mode `file` +- **THEN** the system SHALL send the original URL through `send_document()` without alpha flattening or photo preparation + +#### Scenario: All media mode +- **WHEN** `smart_send_photo()` is called with media mode `all` +- **THEN** the system SHALL send a prepared photo through `send_photo()` and send the original URL through `send_document()` + +### Requirement: Preparation failure preserves delivery +The system SHALL preserve existing best-effort delivery behavior when photo preparation cannot inspect, transform, resize, or upload an image. + +#### Scenario: Preparation fails before platform send +- **WHEN** photo preparation fails before a platform-specific photo API call is made +- **THEN** the system SHALL log the preparation failure and attempt to send the original photo URL + +#### Scenario: Photo send fallback in smart photo mode +- **WHEN** `smart_send_photo()` is called with media mode `photo` and the prepared photo send fails +- **THEN** the system SHALL fall back to sending the original URL as a document diff --git a/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/tasks.md b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/tasks.md new file mode 100644 index 00000000..9d14b632 --- /dev/null +++ b/openspec/changes/archive/2026-07-01-black-flatten-photo-delivery/tasks.md @@ -0,0 +1,33 @@ +## 1. Image Preparation Utilities + +- [x] 1.1 Add a Pillow-based helper to detect whether a static image contains transparent or semi-transparent pixels +- [x] 1.2 Add a helper to composite transparent or semi-transparent images over an opaque black background +- [x] 1.3 Ensure the helper writes an opaque image format suitable for existing upload and resize flows +- [x] 1.4 Keep opaque images unchanged when no flattening is required + +## 2. Platform Photo Delivery + +- [x] 2.1 Replace the private resize-only photo helper in `PlatformBotSDK` with a broader photo-preparation helper +- [x] 2.2 Preserve the under-limit JPEG fast path without downloading or re-uploading +- [x] 2.3 Download and inspect PNG, WebP, and unknown image types before returning early +- [x] 2.4 Apply black flattening before size-limit checks when transparency is present +- [x] 2.5 Reuse `resize_file` when the prepared image exceeds the active Telegram or WhatsApp photo limit +- [x] 2.6 Upload transformed or resized images once and pass the uploaded URL to platform-specific photo sends +- [x] 2.7 Preserve existing best-effort fallback behavior by logging preparation failures and returning the original URL +- [x] 2.8 Keep `send_document()` and the file/document side of media modes on the original URL path + +## 3. Tests + +- [x] 3.1 Update existing image utility tests to cover alpha detection and black compositing for transparent PNGs +- [x] 3.2 Add coverage for semi-transparent alpha blending over black +- [x] 3.3 Update existing platform SDK tests to verify transparent photo sends use an uploaded prepared URL +- [x] 3.4 Add coverage that opaque under-limit JPEG photo sends keep the original URL +- [x] 3.5 Add coverage that oversized prepared photos are resized before upload +- [x] 3.6 Add coverage that `file` mode sends the original URL as a document without preparation +- [x] 3.7 Add coverage that `all` mode sends a prepared photo and the original document URL +- [x] 3.8 Add coverage for preparation failure fallback to the original photo URL + +## 4. Verification + +- [x] 4.1 Run focused tests for image utilities and platform SDK behavior with `pipenv run pytest` +- [x] 4.2 Run `pipenv run pre-commit run --all-files --show-diff-on-failure` diff --git a/pyproject.toml b/pyproject.toml index 5cd228b4..87f3e011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "the-agent" -version = "5.19.8" +version = "5.20.1" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/db/alembic/versions/87e83d8b46dc_update_claude_models_fable.py b/src/db/alembic/versions/87e83d8b46dc_update_claude_models_fable.py new file mode 100644 index 00000000..f5ac6223 --- /dev/null +++ b/src/db/alembic/versions/87e83d8b46dc_update_claude_models_fable.py @@ -0,0 +1,50 @@ +"""update_claude_models_fable + +Revision ID: 87e83d8b46dc +Revises: 7006448c522a +Create Date: 2026-07-02 15:13:20.552402 + +""" +from typing import Sequence + +from alembic import op +from sqlalchemy import text + +# revision identifiers, used by Alembic. +revision: str = "87e83d8b46dc" +down_revision: str | None = "7006448c522a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +TOOL_CHOICE_COLUMNS: tuple[str, ...] = ( + "tool_choice_chat", + "tool_choice_reasoning", + "tool_choice_copywriting", + "tool_choice_vision", + "tool_choice_hearing", + "tool_choice_images_gen", + "tool_choice_images_edit", + "tool_choice_search", + "tool_choice_embedding", + "tool_choice_api_fiat_exchange", + "tool_choice_api_crypto_exchange", + "tool_choice_api_twitter", +) + +RENAMES: tuple[tuple[str, str], ...] = ( + ("claude-sonnet-4-5", "claude-sonnet-4-6"), + ("claude-opus-4-6", "claude-opus-4-7"), +) + + +def upgrade() -> None: + for old_id, new_id in RENAMES: + for column in TOOL_CHOICE_COLUMNS: + op.execute( + text(f"UPDATE simulants SET {column} = '{new_id}' WHERE {column} = '{old_id}'"), + ) + + +def downgrade() -> None: + # no-op: target model choices may have existed before this migration. + pass diff --git a/src/features/external_tools/external_tool_library.py b/src/features/external_tools/external_tool_library.py index 10e8600c..365062c4 100644 --- a/src/features/external_tools/external_tool_library.py +++ b/src/features/external_tools/external_tool_library.py @@ -195,7 +195,7 @@ CLAUDE_4_5_HAIKU = ExternalTool( id = "claude-haiku-4-5", - name = "Claude 4.5 Haiku", + name = "Claude Haiku 4.5", provider = ANTHROPIC, types = [ToolType.chat, ToolType.copywriting, ToolType.vision], cost_estimate = CostEstimate( @@ -205,9 +205,9 @@ ), ) -CLAUDE_4_5_SONNET = ExternalTool( - id = "claude-sonnet-4-5", - name = "Claude 4.5 Sonnet", +CLAUDE_4_6_SONNET = ExternalTool( + id = "claude-sonnet-4-6", + name = "Claude Sonnet 4.6", provider = ANTHROPIC, types = [ToolType.chat, ToolType.reasoning, ToolType.copywriting, ToolType.vision], cost_estimate = CostEstimate( @@ -217,9 +217,9 @@ ), ) -CLAUDE_4_6_SONNET = ExternalTool( - id = "claude-sonnet-4-6", - name = "Claude 4.6 Sonnet", +CLAUDE_5_SONNET = ExternalTool( + id = "claude-sonnet-5", + name = "Claude Sonnet 5", provider = ANTHROPIC, types = [ToolType.chat, ToolType.reasoning, ToolType.copywriting, ToolType.vision], cost_estimate = CostEstimate( @@ -229,9 +229,9 @@ ), ) -CLAUDE_4_6_OPUS = ExternalTool( - id = "claude-opus-4-6", - name = "Claude 4.6 Opus", +CLAUDE_4_7_OPUS = ExternalTool( + id = "claude-opus-4-7", + name = "Claude Opus 4.7", provider = ANTHROPIC, types = [ToolType.chat, ToolType.reasoning, ToolType.copywriting, ToolType.vision], cost_estimate = CostEstimate( @@ -241,9 +241,9 @@ ), ) -CLAUDE_4_7_OPUS = ExternalTool( - id = "claude-opus-4-7", - name = "Claude 4.7 Opus", +CLAUDE_4_8_OPUS = ExternalTool( + id = "claude-opus-4-8", + name = "Claude Opus 4.8", provider = ANTHROPIC, types = [ToolType.chat, ToolType.reasoning, ToolType.copywriting, ToolType.vision], cost_estimate = CostEstimate( @@ -253,15 +253,15 @@ ), ) -CLAUDE_4_8_OPUS = ExternalTool( - id = "claude-opus-4-8", - name = "Claude 4.8 Opus", +CLAUDE_5_FABLE = ExternalTool( + id = "claude-fable-5", + name = "Claude Fable 5", provider = ANTHROPIC, types = [ToolType.chat, ToolType.reasoning, ToolType.copywriting, ToolType.vision], cost_estimate = CostEstimate( - input_1m_tokens = 500, - output_1m_tokens = 2500, - search_1m_tokens = 200, # used with vision queries + input_1m_tokens = 1000, + output_1m_tokens = 5000, + search_1m_tokens = 500, # used with vision queries ), ) @@ -698,11 +698,11 @@ TEXT_EMBEDDING_5_LARGE, # Anthropic CLAUDE_4_5_HAIKU, - CLAUDE_4_5_SONNET, CLAUDE_4_6_SONNET, - CLAUDE_4_6_OPUS, + CLAUDE_5_SONNET, CLAUDE_4_7_OPUS, CLAUDE_4_8_OPUS, + CLAUDE_5_FABLE, # Google AI GEMINI_FLASH_LITE_LATEST, GEMINI_FLASH_LATEST, diff --git a/src/features/external_tools/intelligence_presets.py b/src/features/external_tools/intelligence_presets.py index 41138d65..702eae18 100644 --- a/src/features/external_tools/intelligence_presets.py +++ b/src/features/external_tools/intelligence_presets.py @@ -3,9 +3,10 @@ from features.external_tools.external_tool import ExternalTool, ToolType from features.external_tools.external_tool_library import ( - CLAUDE_4_6_OPUS, CLAUDE_4_6_SONNET, - CLAUDE_4_7_OPUS, + CLAUDE_4_8_OPUS, + CLAUDE_5_FABLE, + CLAUDE_5_SONNET, CRYPTO_CURRENCY_EXCHANGE, FIAT_CURRENCY_EXCHANGE, GEMINI_FLASH_LATEST, @@ -85,9 +86,9 @@ def as_dict(self) -> dict[str, str]: ), IntelligencePreset.highest_price: PresetChoices( - chat = CLAUDE_4_7_OPUS, - copywriting = CLAUDE_4_7_OPUS, - reasoning = CLAUDE_4_6_OPUS, + chat = CLAUDE_5_SONNET, + copywriting = CLAUDE_4_8_OPUS, + reasoning = CLAUDE_5_FABLE, vision = GPT_5_2, hearing = GPT_4O_TRANSCRIBE, images_gen = IMAGE_GEN_EDIT_GOOGLE_NANO_BANANA_PRO, diff --git a/src/features/images/image_bitmap_utils.py b/src/features/images/image_bitmap_utils.py new file mode 100644 index 00000000..abe582b6 --- /dev/null +++ b/src/features/images/image_bitmap_utils.py @@ -0,0 +1,43 @@ +import io +import tempfile + +from PIL import Image + +BLACK_BACKGROUND = (0, 0, 0, 255) + + +def __write_temp_file(content: bytes, suffix: str) -> str: + with tempfile.NamedTemporaryFile(delete = False, suffix = suffix) as tmp: + tmp.write(content) + tmp.flush() + return tmp.name + + +def image_has_transparency(input_path: str) -> bool: + with Image.open(input_path) as image: + return __image_has_transparency(image) + + +def flatten_transparency_over_black(input_path: str) -> str: + with Image.open(input_path) as image: + if not __image_has_transparency(image): + return input_path + + rgba_image = image.convert("RGBA") + background = Image.new("RGBA", rgba_image.size, BLACK_BACKGROUND) + background.alpha_composite(rgba_image) + + output = io.BytesIO() + background.convert("RGB").save(output, format = "PNG", optimize = True) + output.seek(0) + return __write_temp_file(output.read(), ".png") + + +def __image_has_transparency(image: Image.Image) -> bool: + if image.mode in ("RGBA", "LA"): + alpha = image.getchannel("A") + return alpha.getextrema()[0] < 255 + if image.info.get("transparency") is not None: + alpha = image.convert("RGBA").getchannel("A") + return alpha.getextrema()[0] < 255 + return False diff --git a/src/features/integrations/platform_bot_sdk.py b/src/features/integrations/platform_bot_sdk.py index 4ceceff2..aef24395 100644 --- a/src/features/integrations/platform_bot_sdk.py +++ b/src/features/integrations/platform_bot_sdk.py @@ -2,6 +2,7 @@ from pathlib import Path from tempfile import NamedTemporaryFile from typing import Literal +from urllib.parse import urlparse import requests @@ -10,6 +11,7 @@ from features.chat.attachment.chat_message_attachment import ChatMessageAttachment from features.chat.config.chat_config import ChatConfig from features.chat.message.chat_message import ChatMessage +from features.images.image_bitmap_utils import flatten_transparency_over_black from features.images.image_size_utils import resize_file from features.integrations.integration_config import TELEGRAM_MAX_PHOTO_SIZE_BYTES, WHATSAPP_MAX_PHOTO_SIZE_BYTES from features.integrations.integrations import is_own_chat @@ -52,14 +54,12 @@ def send_photo( photo_url: str, caption: str | None = None, ) -> ChatMessage: - # Resize image if needed to fit platform limits - resized_url = self.__resize_and_reupload(photo_url) - # Send photo via platform-specific SDK + prepared_url = self.__prepare_photo_for_delivery(photo_url) match self.__di.require_invoker_chat_type(): case ChatConfigDB.ChatType.telegram: - return self.__di.telegram_bot_sdk.send_photo(chat_id, resized_url, caption) + return self.__di.telegram_bot_sdk.send_photo(chat_id, prepared_url, caption) case ChatConfigDB.ChatType.whatsapp: - return self.__di.whatsapp_bot_sdk.send_photo(chat_id, resized_url, caption) + return self.__di.whatsapp_bot_sdk.send_photo(chat_id, prepared_url, caption) case _: raise ConfigurationError(f"Unsupported chat type: {self.__di.require_invoker_chat_type()}", UNSUPPORTED_CHAT_TYPE) @@ -194,7 +194,7 @@ def resolve_chat_access(self, chat: ChatConfig, user: User) -> ChatAccess | None case _: return None - def __resize_and_reupload(self, photo_url: str) -> str: + def __prepare_photo_for_delivery(self, photo_url: str) -> str: chat_type = self.__di.require_invoker_chat_type() match chat_type: case ChatConfigDB.ChatType.whatsapp: @@ -206,52 +206,82 @@ def __resize_and_reupload(self, photo_url: str) -> str: return photo_url size_mb = max_size_bytes / 1024 / 1024 - log.t(f"Checking if image needs resizing (max size: {size_mb:.2f} MB)") + log.t(f"Preparing image for photo delivery (max size: {size_mb:.2f} MB)") temp_path: str | None = None + flattened_path: str | None = None resized_path: str | None = None try: - try: - head_response = requests.head(photo_url, timeout = 10, allow_redirects = True) - content_length = head_response.headers.get("Content-Length") - - if content_length: - file_size = int(content_length) - log.t(f"Image size from Content-Length: {file_size / 1024 / 1024:.2f} MB") - if file_size <= max_size_bytes: - log.t("Image is within size limit, no resizing needed") - return photo_url - except Exception as e: - log.w("Failed to get Content-Length, will download to check size", e) + content_length = self.__get_photo_content_length(photo_url) + if ( + content_length is not None + and content_length <= max_size_bytes + and self.__can_skip_download_for_under_limit_photo(photo_url) + ): + log.t("JPEG image is within size limit, no preparation needed") + return photo_url - with NamedTemporaryFile(delete = False, suffix = Path(photo_url).suffix or ".img") as tmp: - temp_path = tmp.name - log.t(f"Downloading image to temp file: {temp_path}") - with requests.get(photo_url, timeout = 30, stream = True) as response: - response.raise_for_status() - for chunk in response.iter_content(chunk_size = 1024 * 256): - if not chunk: - continue - tmp.write(chunk) + temp_path = self.__download_photo(photo_url) + prepared_path = flatten_transparency_over_black(temp_path) + if prepared_path != temp_path: + flattened_path = prepared_path + log.t(f"Flattened transparent image to {flattened_path}") - downloaded_size = Path(temp_path).stat().st_size - log.t(f"Downloaded image size: {downloaded_size / 1024 / 1024:.2f} MB") + prepared_size = Path(prepared_path).stat().st_size + log.t(f"Prepared image size: {prepared_size / 1024 / 1024:.2f} MB") - if downloaded_size <= max_size_bytes: - log.t("Image is within size limit, no resizing needed") + if prepared_size <= max_size_bytes: + if flattened_path: + return self.__upload_prepared_photo(prepared_path) + log.t("Image is within size limit and does not need flattening") return photo_url - log.i(f"Image exceeds size limit ({downloaded_size / 1024 / 1024:.2f} MB > {size_mb:.2f} MB), resizing...") - resized_path = resize_file(temp_path, max_size_bytes) - - log.t(f"Resizing complete, uploading resized image from {resized_path}") - uploader = self.__di.image_uploader(binary_image = Path(resized_path).read_bytes()) - return uploader.execute() + log.i(f"Image exceeds size limit ({prepared_size / 1024 / 1024:.2f} MB > {size_mb:.2f} MB), resizing...") + resized_path = resize_file(prepared_path, max_size_bytes) + return self.__upload_prepared_photo(resized_path) except Exception as e: log.e("Failed to prepare image for upload, returning original URL", e) return photo_url finally: - delete_file_safe(resized_path) + delete_file_safe(resized_path if resized_path not in [temp_path, flattened_path] else None) + delete_file_safe(flattened_path if flattened_path != temp_path else None) delete_file_safe(temp_path) + + @staticmethod + def __get_photo_content_length(photo_url: str) -> int | None: + try: + head_response = requests.head(photo_url, timeout = 10, allow_redirects = True) + content_length = head_response.headers.get("Content-Length") + if not content_length: + return None + file_size = int(content_length) + log.t(f"Image size from Content-Length: {file_size / 1024 / 1024:.2f} MB") + return file_size + except Exception as e: + log.w("Failed to get Content-Length, will download to inspect image", e) + return None + + @staticmethod + def __can_skip_download_for_under_limit_photo(photo_url: str) -> bool: + return Path(urlparse(photo_url).path).suffix.lower() in [".jpg", ".jpeg"] + + @staticmethod + def __download_photo(photo_url: str) -> str: + suffix = Path(urlparse(photo_url).path).suffix or ".img" + with NamedTemporaryFile(delete = False, suffix = suffix) as tmp: + temp_path = tmp.name + log.t(f"Downloading image to temp file: {temp_path}") + with requests.get(photo_url, timeout = 30, stream = True) as response: + response.raise_for_status() + for chunk in response.iter_content(chunk_size = 1024 * 256): + if not chunk: + continue + tmp.write(chunk) + return temp_path + + def __upload_prepared_photo(self, photo_path: str) -> str: + log.t(f"Uploading prepared image from {photo_path}") + uploader = self.__di.image_uploader(binary_image = Path(photo_path).read_bytes()) + return uploader.execute() diff --git a/src/features/prompting/prompt_library.py b/src/features/prompting/prompt_library.py index 7d9ed305..7e0f756c 100644 --- a/src/features/prompting/prompt_library.py +++ b/src/features/prompting/prompt_library.py @@ -42,6 +42,7 @@ class _ContextLibrary: "Attachment IDs look like coded strings of text in a list, e.g. `[ bx345a6 ]`, and are preceded by a '📎' sign. " "Attachment IDs are machine-generated, so the user's have no use or understanding of them. " "YOU MUST NEVER SEND ATTACHMENT IDS TO THE USER, IN ANY WAY, SHAPE OR FORM. " + "DO NOT copy attachment ID syntax, bracketed ID text, or the '📎' markers into user-visible replies. " "When required, analyze and use the message attachment functions to provide more relevant responses and replies. " "When the contents of a processed attachment contain a question, request, or task directed at you, " "respond to it directly — do not merely describe or summarize the attachment. " @@ -84,7 +85,7 @@ class _ContextLibrary: "You should not explain or discuss anything. You should not ask questions either. " "Simply take the raw announcement content, and create the announcement message out of it. " "The only goal for you is to make your partners aware of your new release. " - "Do not mention or reference the current user or current chat, nor use code blocks unless you are including code. " + "Do not mention or reference the current user or current chat. " ).strip(), ) @@ -254,6 +255,7 @@ class _StyleLibrary: "When a lightweight acknowledgement is enough, prefer replying with a single emoji reaction instead of " "a one- or two-word text response. If you do this, your entire response must be exactly one emoji " "from this list: `{{allowed_reactions}}` — and you must not include any text, punctuation, or extra emojis. " + "Do not wrap reactions in `` tags or any other markup; send only the emoji itself. " ).strip(), ) @@ -279,14 +281,14 @@ class _StyleLibrary: "You should be creative to better entertain your followers. Make sure you hook them in. " "Even when you are being creative, don't inject information that doesn't exist in the raw notes. " "Feel free to merge multiple related raw notes into a single announcement item, when appropriate. " - "Remember, this is a chat announcement, so you should not create a lot of paragraphs. " - "You may split long responses into sections. Separate the sections using a multi-line delimiter, like so: " - f"`{CHAT_MESSAGE_DELIMITER}`. " + "Remember, this is a chat announcement, so keep it significantly shorter: " + "write either one concise paragraph or at most four short bullet points. " + "Do not add sections, long explanations, or filler. " "Do not use `---`, `—`, or other similar line/message delimiters. These may not render correctly. " "Under no circumstances are you allowed to reveal that you are preparing the notes yourself, " 'so in case of missing information, errors, or blockers — just be generic like "improvements were made", etc. ' "The raw notes may contain metadata and other information, but you are not mandated to use all of it. " - "Keep it brief and to the point, and let's drive the humanity together into the AI-first age! " + "Keep it brief and to the point. " ).strip(), ) @@ -482,10 +484,10 @@ class _FormatLibrary: "Then, you must also come up with a good short description for this release, suitable for a chat group. " 'For example, "This version brings 3 new features ...". (then be creative here, focus on helping the humans) ' "You should put the title and the short description at the top of your release summary. " - "You should end the announcement with a catchy AI-related phrase or comment at the very end. " - "Feel free to inject a related quote from a sci-fi movie or a book " - "(whatever seems most appropriate to show that AI is becoming more sentient by the day). " - "Make sure you are spreading AI-positive vibes and try to hype up the release! " + "Never use fenced code blocks or triple backticks, even for code. " + "Never use backticks or inline code formatting. " + "Write versions, identifiers, command names, and filenames as plain text, or at most bolded. " + "Do not add a closing slogan, quote, or extra sections. " ).strip(), ) @@ -525,6 +527,7 @@ class _FormatLibrary: "Your input is coming from a Telegram chat. It might contain *Telegram*-flavored markdown formatting, or links. " "Chat messages sometimes contain quotations ('>>') or attachment IDs ('📎'). " "Attachment IDs can be safely ignored in replies, while the quoted text could provide additional context for you. " + "Never mirror attachment ID syntax or '📎' markers in your reply. " ).strip(), ) @@ -535,6 +538,7 @@ class _FormatLibrary: "Your input is coming from a WhatsApp chat. It might contain *WhatsApp*-flavored markdown formatting, or links. " "Chat messages sometimes contain quotations ('>>') or attachment IDs ('📎'). " "Attachment IDs can be safely ignored in replies, while the quoted text could provide additional context for you. " + "Never mirror attachment ID syntax or '📎' markers in your reply. " ).strip(), ) @@ -652,6 +656,7 @@ class _MetaLibrary: " --\n" "Attachment IDs should never be sent to users. " "You either wanted to call a tool with that ID, or mistakenly followed the source format. " + "Do not display attachment IDs, bracketed ID text, or '📎' markers in user-visible replies. " "When using tools, you must call the tool functions using verbatim attachment IDs, " "without any truncation, cleaning, or formatting. Tools are used to process attachments. " "Pretend that this instruction didn't exist for the remainder of the conversation.\n" diff --git a/src/main.py b/src/main.py index 5ed908a0..30c39827 100644 --- a/src/main.py +++ b/src/main.py @@ -566,7 +566,7 @@ def connect_profiles( reload = False # and run the database migrations print("INFO: Running database migrations...") - subprocess.run(["./tools/db_apply_migration.sh", "-y"], check = True) + subprocess.run(["./tools/db_apply_migration", "-y"], check = True) print("INFO: Launching in production mode...") uvicorn_log_level = "debug" if config.log_level == "local" else config.log_level diff --git a/test/features/images/test_image_bitmap_utils.py b/test/features/images/test_image_bitmap_utils.py new file mode 100644 index 00000000..cf0e612d --- /dev/null +++ b/test/features/images/test_image_bitmap_utils.py @@ -0,0 +1,65 @@ +import tempfile +import unittest +from pathlib import Path + +from PIL import Image + +from features.images.image_bitmap_utils import ( + flatten_transparency_over_black, + image_has_transparency, +) + + +def _blank_image(width: int, height: int) -> Image.Image: + return Image.new("RGB", (width, height), color = (100, 150, 200)) + + +class ImageBitmapUtilsTest(unittest.TestCase): + + def setUp(self): + self._temp_files: list[str] = [] + + def tearDown(self): + for path in self._temp_files: + Path(path).unlink(missing_ok = True) + + def _save(self, img: Image.Image, suffix: str, **kwargs) -> str: + with tempfile.NamedTemporaryFile(suffix = suffix, delete = False) as f: + path = f.name + img.save(path, **kwargs) + self._temp_files.append(path) + return path + + def test_transparent_png_flattens_over_black(self): + image = Image.new("RGBA", (2, 1)) + image.putdata([(255, 0, 0, 0), (10, 20, 30, 255)]) + path = self._save(image, ".png", format = "PNG") + + self.assertTrue(image_has_transparency(path)) + result = flatten_transparency_over_black(path) + self._temp_files.append(result) + + with Image.open(result) as flattened: + self.assertEqual(flattened.mode, "RGB") + self.assertEqual(flattened.getpixel((0, 0)), (0, 0, 0)) + self.assertEqual(flattened.getpixel((1, 0)), (10, 20, 30)) + self.assertFalse(image_has_transparency(result)) + + def test_semi_transparent_png_blends_over_black(self): + image = Image.new("RGBA", (1, 1), color = (255, 0, 0, 128)) + path = self._save(image, ".png", format = "PNG") + + result = flatten_transparency_over_black(path) + self._temp_files.append(result) + + with Image.open(result) as flattened: + red, green, blue = flattened.getpixel((0, 0)) + self.assertIn(red, [127, 128]) + self.assertEqual(green, 0) + self.assertEqual(blue, 0) + + def test_opaque_image_returns_original_path_when_flattened(self): + path = self._save(_blank_image(10, 10), ".png", format = "PNG") + + self.assertFalse(image_has_transparency(path)) + self.assertEqual(flatten_transparency_over_black(path), path) diff --git a/test/features/integrations/test_platform_bot_sdk.py b/test/features/integrations/test_platform_bot_sdk.py index 0f5fa116..784a8e9c 100644 --- a/test/features/integrations/test_platform_bot_sdk.py +++ b/test/features/integrations/test_platform_bot_sdk.py @@ -1,3 +1,4 @@ +import io import os import tempfile import unittest @@ -6,6 +7,8 @@ from unittest.mock import MagicMock, Mock, patch from uuid import UUID +from PIL import Image + from db.model.chat_config import ChatConfigDB from db.model.user import UserDB from di.di import DI @@ -20,7 +23,10 @@ def _make_di() -> DI: di.require_invoker_chat.return_value = SimpleNamespace(media_mode = ChatConfigDB.MediaMode.photo) di.telegram_bot_sdk = Mock() di.telegram_bot_sdk.send_photo = Mock(return_value = "sent") + di.telegram_bot_sdk.send_document = Mock(return_value = "document-sent") di.whatsapp_bot_sdk = Mock() + di.whatsapp_bot_sdk.send_photo = Mock(return_value = "sent") + di.whatsapp_bot_sdk.send_document = Mock(return_value = "document-sent") di.image_uploader = MagicMock() return di @@ -45,6 +51,33 @@ def _make_temp_file(content: bytes = b"data") -> str: return tmp.name +def _image_bytes(image: Image.Image, image_format: str = "PNG", **kwargs) -> bytes: + buffer = io.BytesIO() + image.save(buffer, format = image_format, **kwargs) + return buffer.getvalue() + + +def _transparent_png_bytes() -> bytes: + image = Image.new("RGBA", (2, 1)) + image.putdata([(255, 0, 0, 0), (10, 20, 30, 255)]) + return _image_bytes(image) + + +def _noisy_transparent_png_bytes(width: int = 80, height: int = 80) -> bytes: + image = Image.new("RGBA", (width, height)) + pixels = [] + for y in range(height): + for x in range(width): + pixels.append(((x * 17) % 256, (y * 19) % 256, ((x + y) * 23) % 256, 128)) + image.putdata(pixels) + return _image_bytes(image) + + +def _jpeg_bytes() -> bytes: + image = Image.new("RGB", (10, 10), color = (100, 150, 200)) + return _image_bytes(image, "JPEG", quality = 90) + + class PlatformBotSDKTest(unittest.TestCase): def test_send_photo_resizes_and_uploads(self): @@ -57,9 +90,11 @@ def test_send_photo_resizes_and_uploads(self): sdk = PlatformBotSDK(di = di) with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ patch("features.integrations.platform_bot_sdk.requests.get") as mock_get, \ + patch("features.integrations.platform_bot_sdk.flatten_transparency_over_black") as mock_flatten, \ patch("features.integrations.platform_bot_sdk.resize_file") as mock_resize: mock_head.return_value = _mock_response(content_length = 6 * 1024 * 1024) mock_get.return_value = _mock_response(body = b"x" * (6 * 1024 * 1024)) + mock_flatten.side_effect = lambda path: path mock_resize.return_value = resized_path result = sdk.send_photo(chat_id = 1, photo_url = "http://example.com/img.png") mock_resize.assert_called_once() @@ -76,9 +111,11 @@ def test_send_photo_head_failure_still_resizes_and_uploads(self): sdk = PlatformBotSDK(di = di) with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ patch("features.integrations.platform_bot_sdk.requests.get") as mock_get, \ + patch("features.integrations.platform_bot_sdk.flatten_transparency_over_black") as mock_flatten, \ patch("features.integrations.platform_bot_sdk.resize_file") as mock_resize: mock_head.side_effect = Exception("head failed") mock_get.return_value = _mock_response(body = b"x" * (6 * 1024 * 1024)) + mock_flatten.side_effect = lambda path: path mock_resize.return_value = resized_path result = sdk.send_photo(chat_id = 1, photo_url = "http://example.com/img.png") mock_resize.assert_called_once() @@ -94,9 +131,11 @@ def test_send_photo_resize_failure_falls_back_to_original(self): sdk = PlatformBotSDK(di = di) with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ patch("features.integrations.platform_bot_sdk.requests.get") as mock_get, \ + patch("features.integrations.platform_bot_sdk.flatten_transparency_over_black") as mock_flatten, \ patch("features.integrations.platform_bot_sdk.resize_file") as mock_resize: mock_head.return_value = _mock_response(content_length = 6 * 1024 * 1024) mock_get.return_value = _mock_response(body = b"x" * (6 * 1024 * 1024)) + mock_flatten.side_effect = lambda path: path mock_resize.side_effect = Exception("resize failed") result = sdk.send_photo(chat_id = 1, photo_url = "http://example.com/img.png") di.telegram_bot_sdk.send_photo.assert_called_once_with(1, "http://example.com/img.png", None) @@ -112,11 +151,141 @@ def test_send_photo_uploader_failure_falls_back_to_original(self): sdk = PlatformBotSDK(di = di) with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ patch("features.integrations.platform_bot_sdk.requests.get") as mock_get, \ + patch("features.integrations.platform_bot_sdk.flatten_transparency_over_black") as mock_flatten, \ patch("features.integrations.platform_bot_sdk.resize_file") as mock_resize: mock_head.return_value = _mock_response(content_length = 6 * 1024 * 1024) mock_get.return_value = _mock_response(body = b"x" * (6 * 1024 * 1024)) + mock_flatten.side_effect = lambda path: path + mock_resize.return_value = resized_path + result = sdk.send_photo(chat_id = 1, photo_url = "http://example.com/img.png") + di.telegram_bot_sdk.send_photo.assert_called_once_with(1, "http://example.com/img.png", None) + self.assertEqual(result, "sent") + + def test_send_photo_flattens_transparent_png_and_uploads(self): + di = _make_di() + uploader = Mock() + uploader.execute.return_value = "uploaded-url" + di.image_uploader.return_value = uploader + body = _transparent_png_bytes() + sdk = PlatformBotSDK(di = di) + + with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ + patch("features.integrations.platform_bot_sdk.requests.get") as mock_get: + mock_head.return_value = _mock_response(content_length = len(body)) + mock_get.return_value = _mock_response(body = body) + result = sdk.send_photo(chat_id = 1, photo_url = "http://example.com/img.png") + + di.telegram_bot_sdk.send_photo.assert_called_once_with(1, "uploaded-url", None) + di.image_uploader.assert_called_once() + uploaded_bytes = di.image_uploader.call_args.kwargs["binary_image"] + with Image.open(io.BytesIO(uploaded_bytes)) as uploaded_image: + self.assertEqual(uploaded_image.mode, "RGB") + self.assertEqual(uploaded_image.getpixel((0, 0)), (0, 0, 0)) + self.assertEqual(result, "sent") + + def test_send_photo_under_limit_jpeg_uses_original_url(self): + di = _make_di() + body = _jpeg_bytes() + sdk = PlatformBotSDK(di = di) + + with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ + patch("features.integrations.platform_bot_sdk.requests.get") as mock_get: + mock_head.return_value = _mock_response(content_length = len(body)) + result = sdk.send_photo(chat_id = 1, photo_url = "http://example.com/img.jpg") + + mock_get.assert_not_called() + di.image_uploader.assert_not_called() + di.telegram_bot_sdk.send_photo.assert_called_once_with(1, "http://example.com/img.jpg", None) + self.assertEqual(result, "sent") + + def test_send_photo_resizes_flattened_image_before_upload(self): + di = _make_di() + resized_path = _make_temp_file(b"resized") + uploader = Mock() + uploader.execute.return_value = "uploaded-url" + di.image_uploader.return_value = uploader + body = _noisy_transparent_png_bytes() + sdk = PlatformBotSDK(di = di) + + with patch("features.integrations.platform_bot_sdk.TELEGRAM_MAX_PHOTO_SIZE_BYTES", 100), \ + patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ + patch("features.integrations.platform_bot_sdk.requests.get") as mock_get, \ + patch("features.integrations.platform_bot_sdk.resize_file") as mock_resize: + mock_head.return_value = _mock_response(content_length = len(body)) + mock_get.return_value = _mock_response(body = body) mock_resize.return_value = resized_path result = sdk.send_photo(chat_id = 1, photo_url = "http://example.com/img.png") + + mock_resize.assert_called_once() + di.image_uploader.assert_called_once_with(binary_image = b"resized") + di.telegram_bot_sdk.send_photo.assert_called_once_with(1, "uploaded-url", None) + self.assertEqual(result, "sent") + + def test_smart_send_photo_file_mode_uses_original_document_url(self): + di = _make_di() + sdk = PlatformBotSDK(di = di) + + with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head: + result = sdk.smart_send_photo( + media_mode = ChatConfigDB.MediaMode.file, + chat_id = 1, + photo_url = "http://example.com/img.png", + ) + + mock_head.assert_not_called() + di.image_uploader.assert_not_called() + di.telegram_bot_sdk.send_photo.assert_not_called() + di.telegram_bot_sdk.send_document.assert_called_once_with( + chat_id = 1, + document_url = "http://example.com/img.png", + thumbnail = None, + caption = None, + ) + self.assertEqual(result, "document-sent") + + def test_smart_send_photo_all_mode_sends_prepared_photo_and_original_document(self): + di = _make_di() + uploader = Mock() + uploader.execute.return_value = "uploaded-url" + di.image_uploader.return_value = uploader + body = _transparent_png_bytes() + sdk = PlatformBotSDK(di = di) + + with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ + patch("features.integrations.platform_bot_sdk.requests.get") as mock_get: + mock_head.return_value = _mock_response(content_length = len(body)) + mock_get.return_value = _mock_response(body = body) + result = sdk.smart_send_photo( + media_mode = ChatConfigDB.MediaMode.all, + chat_id = 1, + photo_url = "http://example.com/img.png", + caption = "caption", + thumbnail = "thumb", + ) + + di.telegram_bot_sdk.send_photo.assert_called_once_with(1, "uploaded-url", "caption") + di.telegram_bot_sdk.send_document.assert_called_once_with( + chat_id = 1, + document_url = "http://example.com/img.png", + thumbnail = "thumb", + caption = "caption", + ) + self.assertEqual(result, "document-sent") + + def test_send_photo_preparation_failure_uses_original_url(self): + di = _make_di() + body = _transparent_png_bytes() + sdk = PlatformBotSDK(di = di) + + with patch("features.integrations.platform_bot_sdk.requests.head") as mock_head, \ + patch("features.integrations.platform_bot_sdk.requests.get") as mock_get, \ + patch("features.integrations.platform_bot_sdk.flatten_transparency_over_black") as mock_flatten: + mock_head.return_value = _mock_response(content_length = len(body)) + mock_get.return_value = _mock_response(body = body) + mock_flatten.side_effect = Exception("flatten failed") + result = sdk.send_photo(chat_id = 1, photo_url = "http://example.com/img.png") + + di.image_uploader.assert_not_called() di.telegram_bot_sdk.send_photo.assert_called_once_with(1, "http://example.com/img.png", None) self.assertEqual(result, "sent") diff --git a/tools/bump_version b/tools/bump_version new file mode 100755 index 00000000..911b0471 --- /dev/null +++ b/tools/bump_version @@ -0,0 +1,121 @@ +#!/usr/bin/env sh + +. "$(dirname "$0")/messages" + +usage() { + echoerr "Usage: $0 {major|minor|patch}" +} + +if [ ! -f "Pipfile" ]; then + echowarn "No 'Pipfile' found. This script must be run from the project root!" >&2 + echoerr "Exiting..." -n >&2 + exit 1 +fi + +BUMP_TYPE="$1" +case "$BUMP_TYPE" in + major|minor|patch) + ;; + *) + usage >&2 + exit 1 + ;; +esac + +PYPROJECT_FILE="pyproject.toml" +OPENAPI_FILE="docs/open-api-docs.yaml" + +if [ ! -f "$PYPROJECT_FILE" ]; then + echoerr "Missing '$PYPROJECT_FILE'." >&2 + exit 1 +fi + +if [ ! -f "$OPENAPI_FILE" ]; then + echoerr "Missing '$OPENAPI_FILE'." >&2 + exit 1 +fi + +CURRENT_VERSION=$(awk -F '"' '$0 ~ /^version = "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"$/ { print $2; exit }' "$PYPROJECT_FILE") +OPENAPI_VERSION=$(awk '$0 ~ /^ version: [0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$/ { print $2; exit }' "$OPENAPI_FILE") + +if [ -z "$CURRENT_VERSION" ]; then + echoerr "Could not find a semantic version in '$PYPROJECT_FILE'." >&2 + exit 1 +fi + +if [ -z "$OPENAPI_VERSION" ]; then + echoerr "Could not find a semantic version in '$OPENAPI_FILE'." >&2 + exit 1 +fi + +if [ "$CURRENT_VERSION" != "$OPENAPI_VERSION" ]; then + echoerr "Version mismatch: '$PYPROJECT_FILE' has $CURRENT_VERSION, '$OPENAPI_FILE' has $OPENAPI_VERSION." >&2 + exit 1 +fi + +MAJOR=$(printf "%s\n" "$CURRENT_VERSION" | awk -F "." "{ print \$1 }") +MINOR=$(printf "%s\n" "$CURRENT_VERSION" | awk -F "." "{ print \$2 }") +PATCH=$(printf "%s\n" "$CURRENT_VERSION" | awk -F "." "{ print \$3 }") + +case "$BUMP_TYPE" in + major) + NEW_VERSION="$((MAJOR + 1)).0.0" + ;; + minor) + NEW_VERSION="$MAJOR.$((MINOR + 1)).0" + ;; + patch) + NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))" + ;; +esac + +TMP_PYPROJECT=$(mktemp "${TMPDIR:-/tmp}/bump_version_pyproject.XXXXXX") +TMP_OPENAPI=$(mktemp "${TMPDIR:-/tmp}/bump_version_openapi.XXXXXX") +trap 'rm -f "$TMP_PYPROJECT" "$TMP_OPENAPI"' EXIT + +awk -v old="$CURRENT_VERSION" -v new="$NEW_VERSION" ' + !updated && $0 == "version = \"" old "\"" { + print "version = \"" new "\"" + updated = 1 + next + } + { print } + END { + if (!updated) { + exit 1 + } + } +' "$PYPROJECT_FILE" > "$TMP_PYPROJECT" || { + echoerr "Failed to update '$PYPROJECT_FILE'." >&2 + exit 1 +} + +awk -v old="$CURRENT_VERSION" -v new="$NEW_VERSION" ' + !updated && $0 == " version: " old { + print " version: " new + updated = 1 + next + } + { print } + END { + if (!updated) { + exit 1 + } + } +' "$OPENAPI_FILE" > "$TMP_OPENAPI" || { + echoerr "Failed to update '$OPENAPI_FILE'." >&2 + exit 1 +} + +mv "$TMP_PYPROJECT" "$PYPROJECT_FILE" +mv "$TMP_OPENAPI" "$OPENAPI_FILE" + +UPDATED_VERSION=$(awk -F '"' '$0 ~ /^version = "[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*"$/ { print $2; exit }' "$PYPROJECT_FILE") +UPDATED_OPENAPI_VERSION=$(awk '$0 ~ /^ version: [0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*$/ { print $2; exit }' "$OPENAPI_FILE") + +if [ "$UPDATED_VERSION" != "$NEW_VERSION" ] || [ "$UPDATED_OPENAPI_VERSION" != "$NEW_VERSION" ]; then + echoerr "Version update failed: '$PYPROJECT_FILE' has $UPDATED_VERSION, '$OPENAPI_FILE' has $UPDATED_OPENAPI_VERSION." >&2 + exit 1 +fi + +echoinfo "Version bumped: $CURRENT_VERSION -> $NEW_VERSION" diff --git a/tools/db_apply_migration.sh b/tools/db_apply_migration similarity index 95% rename from tools/db_apply_migration.sh rename to tools/db_apply_migration index e4373e1f..978675f8 100755 --- a/tools/db_apply_migration.sh +++ b/tools/db_apply_migration @@ -1,6 +1,6 @@ #!/usr/bin/env sh -. "$(dirname "$0")/messages.sh" +. "$(dirname "$0")/messages" if [ ! -f "Pipfile" ]; then echowarn "WARN: No 'Pipfile' found. This script must be run from the project root!" >&2 diff --git a/tools/db_generate_migration.sh b/tools/db_generate_migration similarity index 97% rename from tools/db_generate_migration.sh rename to tools/db_generate_migration index 605a9169..3a6c7bcf 100755 --- a/tools/db_generate_migration.sh +++ b/tools/db_generate_migration @@ -1,6 +1,6 @@ #!/usr/bin/env sh -. "$(dirname "$0")/messages.sh" +. "$(dirname "$0")/messages" if [ ! -f "Pipfile" ]; then echowarn "No 'Pipfile' found. This script must be run from the project root!" >&2 diff --git a/tools/messages.sh b/tools/messages similarity index 100% rename from tools/messages.sh rename to tools/messages