diff --git a/Pipfile.lock b/Pipfile.lock index 460f1619..4ac49d80 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -186,11 +186,11 @@ }, "anthropic": { "hashes": [ - "sha256:0e26b90841c2dced7cc6e98d21d5517d0be33f1876b8e779f478202e28bcaa07", - "sha256:e53ed5f6bf36fb1ecb9b25d8634cfd30e02fab9fb3374a0c2d5c585874757230" + "sha256:d0e4a7448e54c3942833cee5b3de5f1b31289fd49999bfbcc2ec0c0acaddf75f", + "sha256:f26e2645e31f66eff526b923f539b80b4b6eda1a918790cd77c0afe5e24a2203" ], "markers": "python_version >= '3.9'", - "version": "==0.105.2" + "version": "==0.106.0" }, "anyio": { "hashes": [ @@ -1111,12 +1111,12 @@ }, "langchain-core": { "hashes": [ - "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", - "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c" + "sha256:8234eb8cd3200f690e278159b7d7cee5976381ec90ece7b48db8d8e8850ab37d", + "sha256:e5dee06e70c123cb98cb0158e4416efac1e386ff47a484901ccf88555e28eec6" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.4.0" + "version": "==1.4.1" }, "langchain-google-genai": { "hashes": [ @@ -1138,12 +1138,12 @@ }, "langchain-perplexity": { "hashes": [ - "sha256:6bad128417c3841e5aa10458760247fa98f4f2bc98b1d247815aaf463ce9b53a", - "sha256:f9c0e2cf24a636d4bf90ac46e0c36cfe506b7f83f08d181c80a57194068f54e6" + "sha256:b806e62ccabc5dfc385c7afeee05a48b32c2553505227e92807053389e8f70c9", + "sha256:ba9424c0447906704a6d1a49003689e29480291c8716311934ab305990dd0fc3" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.3.1" + "version": "==1.3.2" }, "langchain-protocol": { "hashes": [ @@ -2415,12 +2415,12 @@ }, "python-multipart": { "hashes": [ - "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", - "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b" + "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", + "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==0.0.30" + "version": "==0.0.32" }, "pyuploadcare": { "hashes": [ @@ -2958,11 +2958,11 @@ }, "tqdm": { "hashes": [ - "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", - "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" + "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", + "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8" ], "markers": "python_version >= '3.7'", - "version": "==4.67.3" + "version": "==4.68.1" }, "typing-extensions": { "hashes": [ @@ -3968,28 +3968,28 @@ }, "ruff": { "hashes": [ - "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", - "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", - "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", - "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", - "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", - "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", - "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", - "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", - "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", - "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", - "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", - "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", - "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", - "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", - "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", - "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", - "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", - "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd" + "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", + "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", + "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", + "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", + "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", + "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", + "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", + "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", + "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", + "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", + "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", + "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", + "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", + "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", + "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", + "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", + "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", + "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.15.15" + "version": "==0.15.16" }, "the-agent": { "editable": true, diff --git a/docs/open-api-docs.yaml b/docs/open-api-docs.yaml index e41b8cb3..a6e2bd08 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.16.1 + version: 5.17.0 license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/openspec/changes/archive/2026-06-07-link-preview-renderer/.openspec.yaml b/openspec/changes/archive/2026-06-07-link-preview-renderer/.openspec.yaml new file mode 100644 index 00000000..c53ef21a --- /dev/null +++ b/openspec/changes/archive/2026-06-07-link-preview-renderer/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-07-link-preview-renderer/design.md b/openspec/changes/archive/2026-06-07-link-preview-renderer/design.md new file mode 100644 index 00000000..06e73dda --- /dev/null +++ b/openspec/changes/archive/2026-06-07-link-preview-renderer/design.md @@ -0,0 +1,58 @@ +## Context + +The social card system renders tweet screenshots as SVG (via `card_template.py`) then rasterizes with `resvg_py`. Tweet text currently includes raw `t.co` URLs that are either self-referencing media links (redundant) or external URLs (meaningful but ugly). The Twitter API v2 provides an `entities` field with URL expansion metadata including OG title, description, images, and unwound URLs — but we don't request it today. + +## Goals / Non-Goals + +**Goals:** +- Strip self-referencing t.co URLs (media links) from rendered tweet text +- Render external URLs as rich link preview cards within the social card +- Support two visual modes: with OG image (blur overlay) and without (favicon-based) +- Keep the link preview renderer as a standalone, testable component +- Maintain visual consistency with existing card elements (corner radius, padding, fonts) + +**Non-Goals:** +- Rendering video previews or animated content +- Caching OG metadata beyond what Twitter API provides +- Supporting non-Twitter social card sources +- Interactive elements (the output is a static PNG) + +## Decisions + +### 1. Link preview as SVG snippet generator + +The link preview renderer produces SVG fragment strings (defs + content) that `card_template.py` composes into the full SVG. This mirrors how photos are already handled — no new rendering pipeline. + +**Alternative**: Render link previews as separate images and embed as base64. Rejected because it adds complexity and a second render pass. + +### 2. Blur via feGaussianBlur on clipped image duplicate + +For the overlay panel, we duplicate the OG image region, clip it to the overlay bounds, apply `feGaussianBlur`, then overlay the semi-transparent rect and text. This is the standard SVG approach and `resvg` already supports `feGaussianBlur` (we use `feDropShadow` which wraps it). + +**Alternative**: CSS `backdrop-filter` — not supported in SVG/resvg. + +### 3. Color computation reuses existing theme infrastructure + +The overlay contrast color is computed the same way as `_accent_color` / `_contrast_text` in `card_template.py` — extract dominant color from OG image, compute contrasting overlay fill and text color. For the no-image case, contrast against `theme.gradient_start`. + +### 4. Favicon fetching with graceful fallback + +Fetch `https://{domain}/favicon.ico` with a short timeout. If it fails or returns non-image content, fall back to the 🌐 emoji rendered via the emoji font. Favicon is embedded as base64 in the SVG. + +**Alternative**: Parse HTML for `` tags. Rejected as too complex for marginal gain — most sites serve `/favicon.ico`. + +### 5. URL shortening for displayed external links + +External URLs are shortened using our existing URL shortener (same as the tweet card footer URL) with 365-day expiry. The shortened URL is displayed in the preview; the original expanded URL is what was shortened. + +### 6. Data flow through TweetData + +Add a new `TweetLinkPreview` dataclass to hold per-link metadata (title, description, OG image URL, expanded URL, domain). `TweetData` gets a new `link_previews: list[TweetLinkPreview]` field populated during `__parse_structured`. The orchestrator fetches OG images and favicons, then passes bytes to the renderer. + +## Risks / Trade-offs + +- **Favicon fetch latency** → Short timeout (2s), parallel with OG image downloads via existing `PhotoDownloader` +- **Missing OG metadata** → Graceful degradation: no title/description = skip preview entirely +- **Large OG images** → We already handle image downloads for tweet photos; same size constraints apply +- **feGaussianBlur performance** → Blur is applied to a small clipped region (~40% of a 3:2 box), not the full image. Negligible render cost. +- **Twitter API rate limits** → No additional API calls needed; `entities` is part of the same tweet fetch response diff --git a/openspec/changes/archive/2026-06-07-link-preview-renderer/proposal.md b/openspec/changes/archive/2026-06-07-link-preview-renderer/proposal.md new file mode 100644 index 00000000..48ced68a --- /dev/null +++ b/openspec/changes/archive/2026-06-07-link-preview-renderer/proposal.md @@ -0,0 +1,30 @@ +## Why + +Tweets containing external URLs currently display raw `t.co` shortened links in the social card text, which are ugly and meaningless to the viewer. Additionally, t.co links referencing the tweet's own media (photos) are redundant since we already render those as attached images. We need to strip self-referencing URLs and render external links as rich preview cards — similar to how Twitter/iMessage render link previews. + +## What Changes + +- Strip t.co URLs from tweet text when they reference the tweet's own media +- Request `entities` field from Twitter API to get URL expansion metadata +- Build a new link preview renderer that generates SVG snippets for external URLs +- Render link previews with OG image (blurred overlay panel) or without (favicon-based fallback) +- Integrate link preview boxes into the social card layout, positioned above post photos +- Fetch favicons from linked domains for display in the preview +- Shorten external URLs using our URL shortener before display + +## Capabilities + +### New Capabilities +- `link-preview-rendering`: Standalone SVG snippet generator for external URL preview boxes, supporting two layouts (with/without OG image), blur effects, favicon fetching, and color contrast computation +- `tweet-url-processing`: Logic to classify t.co URLs (media self-reference vs external), strip redundant ones from text, expand external URLs via entities metadata, and shorten them for display + +### Modified Capabilities + +## Impact + +- `src/features/social_cards/card_template.py` — new section for link previews above photos +- `src/features/web_browsing/twitter_status_fetcher.py` — add `entities` to API params, strip/classify URLs, pass metadata downstream +- `src/features/social_cards/` — new module(s) for link preview rendering +- `src/features/social_cards/card_layout.py` — new layout constants for link preview dimensions +- `TweetData` dataclass — new field for external link metadata +- External dependency: favicon fetching from arbitrary domains (HTTP GET) diff --git a/openspec/changes/archive/2026-06-07-link-preview-renderer/specs/link-preview-rendering/spec.md b/openspec/changes/archive/2026-06-07-link-preview-renderer/specs/link-preview-rendering/spec.md new file mode 100644 index 00000000..3d8f023e --- /dev/null +++ b/openspec/changes/archive/2026-06-07-link-preview-renderer/specs/link-preview-rendering/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Link preview with OG image renders blur overlay panel + +The system SHALL render a link preview box at full card inner width with a 3:2 aspect ratio OG image on top. The bottom ~40% SHALL contain an info overlay panel with a blurred copy of the image region underneath, covered by a semi-transparent rect (30% opacity) whose color contrasts to the OG image's average color. Text color SHALL contrast to the overlay color. + +#### Scenario: External link with OG image and metadata +- **WHEN** a tweet contains an external URL with OG image, title, and description available from Twitter entities +- **THEN** the renderer produces an SVG snippet with the OG image cropped to 3:2, a feGaussianBlur filter on the bottom image region, a 30% opacity overlay rect with bottom corners rounded, and text content (title bold max 2 lines, description max 3 lines, small favicon + domain) + +#### Scenario: OG image overlay corner radius +- **WHEN** the overlay panel is rendered on top of an OG image +- **THEN** only the bottom corners SHALL be rounded (matching photo corner radius), top corners SHALL be square since they overlap the image + +### Requirement: Link preview without OG image renders favicon-based fallback + +The system SHALL render a fully-rounded rect when no OG image is available. The rect SHALL be semi-transparent (30% opacity) with color contrasting to the social card's background gradient. A large favicon SHALL be placed on the left side, vertically centered against the text block. Text color SHALL contrast to the rect color. + +#### Scenario: External link without OG image +- **WHEN** a tweet contains an external URL with title and description but no OG image +- **THEN** the renderer produces an SVG snippet with a fully-rounded semi-transparent rect, a large favicon on the left vertically centered, and text on the right (title bold max 2 lines, description max 3 lines, small favicon + domain on bottom line) + +#### Scenario: Favicon unavailable +- **WHEN** the favicon cannot be fetched from the linked domain +- **THEN** the system SHALL use a 🌐 globe emoji as fallback, rendered via the emoji font + +### Requirement: Domain display is shortened + +The system SHALL display only the subdomain.domain.tld portion of the URL (e.g., "nasa.gov", "wikipedia.org"), not the full path. + +#### Scenario: URL with path and query params +- **WHEN** the expanded URL is "https://www.nasa.gov/news-release/crew-13/?utm_source=twitter" +- **THEN** the displayed domain SHALL be "nasa.gov" + +#### Scenario: URL with subdomain +- **WHEN** the expanded URL is "https://docs.python.org/3/library/re.html" +- **THEN** the displayed domain SHALL be "docs.python.org" + +### Requirement: Text truncation with ellipsis + +Title SHALL be bold, maximum 2 lines with ellipsis truncation. Description SHALL be regular weight, maximum 3 lines with ellipsis truncation. + +#### Scenario: Title exceeds 2 lines +- **WHEN** the link title text would wrap beyond 2 lines at the rendered font size +- **THEN** the title SHALL be truncated at 2 lines with trailing ellipsis character + +#### Scenario: Description exceeds 3 lines +- **WHEN** the link description text would wrap beyond 3 lines at the rendered font size +- **THEN** the description SHALL be truncated at 3 lines with trailing ellipsis character + +### Requirement: Multiple link previews stack vertically + +When multiple external links exist in a tweet, their preview boxes SHALL be stacked vertically with the same gap used between photos. + +#### Scenario: Tweet with two external links +- **WHEN** a tweet text contains two external URLs with metadata +- **THEN** two link preview boxes SHALL be rendered stacked vertically with PHOTO_GAP spacing between them + +### Requirement: Link previews are positioned above post photos + +Link preview boxes SHALL appear in the card layout after the tweet body text and before any attached post photos. + +#### Scenario: Tweet with external link and photos +- **WHEN** a tweet has both an external URL and attached photos +- **THEN** the link preview box SHALL render between the body text section and the photos section + +### Requirement: Missing metadata omits preview + +If no title and no description are available for an external URL, the link preview SHALL be omitted entirely. + +#### Scenario: External URL with no OG metadata +- **WHEN** the Twitter API entities provide no title and no description for an external URL +- **THEN** no link preview box SHALL be rendered for that URL diff --git a/openspec/changes/archive/2026-06-07-link-preview-renderer/specs/tweet-url-processing/spec.md b/openspec/changes/archive/2026-06-07-link-preview-renderer/specs/tweet-url-processing/spec.md new file mode 100644 index 00000000..9bd048aa --- /dev/null +++ b/openspec/changes/archive/2026-06-07-link-preview-renderer/specs/tweet-url-processing/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Request entities from Twitter API + +The system SHALL include `entities` in the `tweet.fields` API parameter when fetching tweet data. + +#### Scenario: Tweet fetch includes entities +- **WHEN** the system fetches a tweet from the Twitter API v2 +- **THEN** the request params SHALL include "entities" in the "tweet.fields" value + +### Requirement: Strip self-referencing media URLs from text + +The system SHALL remove t.co URLs from the tweet text when their expanded URL references the tweet's own media (contains `/status/{tweet_id}/photo/`). + +#### Scenario: Tweet with photo attachment URL in text +- **WHEN** a tweet's text contains "Check this out https://t.co/abc123" and entities show that URL expands to "https://x.com/user/status/12345/photo/1" matching the tweet's own ID +- **THEN** the rendered text SHALL be "Check this out" with the t.co URL removed and trailing whitespace trimmed + +#### Scenario: Tweet text is only a media URL +- **WHEN** the entire tweet text is just "https://t.co/abc123" which expands to the tweet's own photo +- **THEN** the rendered text SHALL be empty (the card shows only the photo) + +### Requirement: Classify external URLs and extract metadata + +The system SHALL identify t.co URLs that expand to external domains (not x.com/twitter.com self-references) and extract their metadata from the entities response (title, description, OG image URLs, expanded URL). + +#### Scenario: Tweet with external link +- **WHEN** a tweet contains "Read more: https://t.co/xyz789" and entities show it expands to "https://www.nasa.gov/article" with title "NASA Article" and description "Space news" +- **THEN** the system SHALL produce a link preview data object with title="NASA Article", description="Space news", expanded_url="https://www.nasa.gov/article", and any OG image URLs from entities + +#### Scenario: External link without metadata in entities +- **WHEN** a tweet contains a t.co URL that expands to an external domain but entities provide no title or description +- **THEN** the system SHALL not produce a link preview data object for that URL + +### Requirement: Shorten external URLs for display + +External URLs that produce link previews SHALL be shortened using the system's URL shortener with 365-day expiry before being displayed in the preview domain line. + +#### Scenario: External URL is shortened +- **WHEN** an external link preview is being rendered +- **THEN** the URL shortener SHALL be called with the expanded URL and a 365-day validity period, and the shortened domain SHALL be displayed + +### Requirement: TweetData carries link preview metadata + +The `TweetData` dataclass SHALL include a field for link preview metadata, containing per-link title, description, OG image URL, expanded URL, and domain. + +#### Scenario: Structured tweet data includes link previews +- **WHEN** `as_structured()` is called on a tweet with external URLs that have metadata +- **THEN** the returned `TweetData` SHALL have a populated `link_previews` list with one entry per external URL that has at least title or description + +### Requirement: Tweet text retains non-media external URLs during stripping + +When stripping URLs from text, the system SHALL only remove self-referencing media URLs. External t.co URLs SHALL also be removed from the display text (since they are rendered as preview boxes), but their metadata SHALL be preserved in `link_previews`. + +#### Scenario: Tweet with both media and external URLs +- **WHEN** a tweet text contains one t.co URL expanding to own photo and another expanding to an external site +- **THEN** both t.co URLs SHALL be removed from the display text, the photo renders as an attachment, and the external link renders as a preview box diff --git a/openspec/changes/archive/2026-06-07-link-preview-renderer/tasks.md b/openspec/changes/archive/2026-06-07-link-preview-renderer/tasks.md new file mode 100644 index 00000000..ac57767d --- /dev/null +++ b/openspec/changes/archive/2026-06-07-link-preview-renderer/tasks.md @@ -0,0 +1,39 @@ +## 1. Data Model & URL Processing + +- [x] 1.1 Add `TweetLinkPreview` dataclass (title, description, og_image_url, expanded_url, domain) and `link_previews` field to `TweetData` +- [x] 1.2 Add `entities` to `tweet.fields` in Twitter API request params +- [x] 1.3 Implement URL classification in `__parse_structured`: identify self-referencing media URLs vs external URLs from entities +- [x] 1.4 Strip all t.co URLs from tweet text (both media and external) during structured parsing +- [x] 1.5 Populate `link_previews` list with metadata for external URLs that have at least title or description + +## 2. Link Preview Renderer (standalone) + +- [x] 2.1 Create `src/features/social_cards/link_preview.py` module with layout constants (3:2 aspect ratio, overlay height, text sizes, favicon sizes) +- [x] 2.2 Implement domain shortening utility (extract subdomain.domain.tld from full URL) +- [x] 2.3 Implement favicon fetcher (GET `https://{domain}/favicon.ico`, short timeout, return bytes or None) +- [x] 2.4 Implement OG image color extraction (reuse `_dominant_from_bytes` pattern from theme.py) +- [x] 2.5 Implement with-OG-image layout: sharp image top, feGaussianBlur on clipped bottom region, 30% contrast overlay rect (bottom corners rounded), text rendering (title 2 lines bold, description 3 lines, small favicon + domain) +- [x] 2.6 Implement without-OG-image layout: fully-rounded semi-transparent rect contrasting card background, large favicon left vertically centered, text block right (title 2 lines bold, description 3 lines, small favicon + domain) +- [x] 2.7 Implement text truncation with ellipsis for title (2 lines) and description (3 lines) +- [x] 2.8 Implement multiple link previews stacking (vertical, PHOTO_GAP spacing) + +## 3. Orchestrator Integration + +- [x] 3.1 In `SocialCardOrchestrator.execute`: fetch OG images from `link_previews[].og_image_url` via PhotoDownloader +- [x] 3.2 In `SocialCardOrchestrator.execute`: fetch favicons for each link preview domain +- [x] 3.3 In `SocialCardOrchestrator.execute`: shorten external URLs via URL shortener (365-day expiry) +- [x] 3.4 Pass link preview data (metadata + image bytes + favicon bytes + shortened URLs) to card renderer + +## 4. Card Template Integration + +- [x] 4.1 Add link preview section in `build_svg` between body text and photos section +- [x] 4.2 Call link preview renderer to get SVG defs + content fragments +- [x] 4.3 Update Y cursor after link previews to maintain correct layout spacing + +## 5. Testing & Validation + +- [x] 5.1 Add scratchpad test function that renders cards with external-link tweets and opens results +- [x] 5.2 Verify blur effect renders correctly via resvg +- [x] 5.3 Verify fallback layout (no OG image) renders correctly +- [x] 5.4 Verify text truncation and ellipsis at boundary lengths +- [x] 5.5 Verify self-referencing URLs are stripped from tweet text diff --git a/pyproject.toml b/pyproject.toml index 4b531afe..f25fb606 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "the-agent" -version = "5.16.1" +version = "5.17.0" [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/assets/fonts/GoogleSans-Variable.ttf b/src/assets/fonts/GoogleSans-Variable.ttf new file mode 100644 index 00000000..6df533e0 Binary files /dev/null and b/src/assets/fonts/GoogleSans-Variable.ttf differ diff --git a/src/assets/fonts/Heebo-Variable.ttf b/src/assets/fonts/Heebo-Variable.ttf deleted file mode 100644 index acc085c6..00000000 Binary files a/src/assets/fonts/Heebo-Variable.ttf and /dev/null differ diff --git a/src/assets/fonts/OFL.txt b/src/assets/fonts/OFL.txt index bf178468..5d6f71ca 100644 --- a/src/assets/fonts/OFL.txt +++ b/src/assets/fonts/OFL.txt @@ -1,5 +1,3 @@ -Copyright 2014 The Heebo Project Authors (https://github.com/OdedEzer/heebo) - This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: https://openfontlicense.org diff --git a/src/di/di.py b/src/di/di.py index 27e9e0e8..65e28b38 100644 --- a/src/di/di.py +++ b/src/di/di.py @@ -841,9 +841,9 @@ def photo_downloader(self, bearer_token: str | None = None) -> "PhotoDownloader" return PhotoDownloader(bearer_token = bearer_token) # noinspection PyMethodMayBeStatic - def social_card_orchestrator(self, x_api_tool: ConfiguredTool) -> "SocialCardOrchestrator": + def social_card_orchestrator(self, x_api_tool: ConfiguredTool, vision_tool: ConfiguredTool) -> "SocialCardOrchestrator": from features.social_cards.social_card_orchestrator import SocialCardOrchestrator - return SocialCardOrchestrator(x_api_tool, self) + return SocialCardOrchestrator(x_api_tool, vision_tool, self) def url_shortener( self, diff --git a/src/features/chat/llm_tools/llm_tool_library.py b/src/features/chat/llm_tools/llm_tool_library.py index fadefe26..9c285a5e 100644 --- a/src/features/chat/llm_tools/llm_tool_library.py +++ b/src/features/chat/llm_tools/llm_tool_library.py @@ -496,7 +496,8 @@ def render_social_post(di: DI, url: str) -> str: """ try: x_api_tool = di.tool_choice_resolver.require_tool(SocialCardOrchestrator.TOOL_TYPE, default_tool_for(SocialCardOrchestrator.TOOL_TYPE)) - image_url = di.social_card_orchestrator(x_api_tool).execute(url) + vision_tool = di.tool_choice_resolver.require_tool(SocialCardOrchestrator.VISION_TOOL_TYPE, default_tool_for(SocialCardOrchestrator.VISION_TOOL_TYPE)) + image_url = di.social_card_orchestrator(x_api_tool, vision_tool).execute(url) invoker_chat = di.require_invoker_chat() di.platform_bot_sdk().smart_send_photo( media_mode = invoker_chat.media_mode, diff --git a/src/features/social_cards/card_layout.py b/src/features/social_cards/card_layout.py index 4fb34091..68faffae 100644 --- a/src/features/social_cards/card_layout.py +++ b/src/features/social_cards/card_layout.py @@ -12,10 +12,10 @@ HEADER_HEIGHT = AVATAR_SIZE + CARD_INNER_PAD DIVIDER_OPACITY = 0.2 FOOTER_OPACITY = 0.45 -FONT_SIZE_NAME = 20 -FONT_SIZE_DATE = 15 -FONT_SIZE_BODY = 22 -FONT_SIZE_FOOTER = 14 +FONT_SIZE_NAME = 22 +FONT_SIZE_DATE = 16 +FONT_SIZE_BODY = 26 +FONT_SIZE_FOOTER = 16 LINE_HEIGHT_BODY = 32 # pixels per line in body text DROP_SHADOW_BLUR = 10 DROP_SHADOW_DY = 6 diff --git a/src/features/social_cards/card_renderer.py b/src/features/social_cards/card_renderer.py index bff31dc7..b87af31b 100644 --- a/src/features/social_cards/card_renderer.py +++ b/src/features/social_cards/card_renderer.py @@ -17,6 +17,8 @@ def render( profile_bytes: bytes | None = None, media_bytes: list[bytes] | None = None, short_url: str | None = None, + link_preview_data: list[dict] | None = None, + quoted_tweet_data: dict | None = None, ) -> bytes: media = media_bytes or [] card_width = card_width_from_text(tweet.text) @@ -27,6 +29,8 @@ def render( profile_bytes = profile_bytes, media_bytes = media, short_url = short_url, + link_preview_data = link_preview_data or [], + quoted_tweet_data = quoted_tweet_data, ) font_files = [str(p) for p in _FONTS_DIR.glob("*.ttf") if p.is_file()] return resvg_py.svg_to_bytes( diff --git a/src/features/social_cards/card_template.py b/src/features/social_cards/card_template.py index 64d343a1..e7decef6 100644 --- a/src/features/social_cards/card_template.py +++ b/src/features/social_cards/card_template.py @@ -3,9 +3,8 @@ import re import urllib.request from datetime import datetime, timezone -from pathlib import Path -from PIL import Image, ImageFont +from PIL import Image from features.social_cards.card_layout import ( AVATAR_GAP, @@ -30,66 +29,36 @@ PHOTO_GAP, X_ICON_SIZE, ) +from features.social_cards.card_utils import ( + FONT_NAME, + FONT_PATH, + b64_image, + emoji_split, + escape_xml, + image_mime, + render_text_segments, + rounded_rect_path, + text_width, +) +from features.social_cards.embedded_post import render_embedded_post +from features.social_cards.link_preview import render_link_previews from features.social_cards.theme import ThemeColors from features.web_browsing.twitter_status_fetcher import TweetData from util.config import config -_FONT_PATH = Path(config.fonts_dir) / "Heebo-Variable.ttf" -_FONT_NAME = "Heebo" -_EMOJI_FONT_NAME = "Noto Color Emoji" - _FONT_B64: str | None = None _LOGO_CACHE: dict[str, bytes] = {} _SPECIAL_TOKEN_RE = re.compile(r"(https?://\S+|www\.\S+|@\w+|#\w+|\$[A-Za-z]+)") -_EMOJI_RE = re.compile( - "(?:" - "[\U0001F1E6-\U0001F1FF]" # regional indicators (flags) - "|[\U0001F300-\U0001F5FF]" # misc symbols & pictographs - "|[\U0001F600-\U0001F64F]" # emoticons - "|[\U0001F680-\U0001F6FF]" # transport & map - "|[\U0001F700-\U0001F77F]" # alchemical - "|[\U0001F780-\U0001F7FF]" # geometric extended - "|[\U0001F800-\U0001F8FF]" # supplemental arrows-C - "|[\U0001F900-\U0001F9FF]" # supplemental symbols & pictographs - "|[\U0001FA00-\U0001FAFF]" # symbols & pictographs ext-A - "|[☀-➿]" # misc symbols & dingbats - "|[⌀-⏿]" # misc technical - "|[⬀-⯿]" # misc symbols & arrows - ")" - "[️‍\U0001F3FB-\U0001F3FF]*" # variation selector, ZWJ, skin tones - "(?:" - "(?:" - "[\U0001F1E6-\U0001F1FF]" - "|[\U0001F300-\U0001F5FF]" - "|[\U0001F600-\U0001F64F]" - "|[\U0001F680-\U0001F6FF]" - "|[\U0001F700-\U0001F77F]" - "|[\U0001F780-\U0001F7FF]" - "|[\U0001F800-\U0001F8FF]" - "|[\U0001F900-\U0001F9FF]" - "|[\U0001FA00-\U0001FAFF]" - "|[☀-➿]" - "|[⌀-⏿]" - "|[⬀-⯿]" - ")" - "[️‍\U0001F3FB-\U0001F3FF]*" - ")*", -) - def _font_b64() -> str: global _FONT_B64 if _FONT_B64 is None: - _FONT_B64 = base64.b64encode(_FONT_PATH.read_bytes()).decode("ascii") + _FONT_B64 = base64.b64encode(FONT_PATH.read_bytes()).decode("ascii") return _FONT_B64 -def _b64_image(data: bytes, mime: str = "image/jpeg") -> str: - return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" - - def _fetch_logo(key: str) -> bytes: if key not in _LOGO_CACHE: url = config.logos[key] @@ -123,15 +92,6 @@ def _accent_color(theme: ThemeColors) -> str: return f"#{r:02x}{g:02x}{b:02x}" -def _image_mime(data: bytes) -> str: - try: - img = Image.open(io.BytesIO(data)) - fmt = (img.format or "JPEG").upper() - return {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif", "WEBP": "image/webp"}.get(fmt, "image/jpeg") - except Exception: - return "image/jpeg" - - def _photo_natural_height(data: bytes, display_w: int) -> int: try: img = Image.open(io.BytesIO(data)) @@ -154,29 +114,6 @@ def _photo_sort_key(data: bytes) -> int: return 1 -def _pillow_font(size: int) -> ImageFont.FreeTypeFont: - return ImageFont.truetype(str(_FONT_PATH), size) - - -def _emoji_pillow_font(size: int) -> ImageFont.FreeTypeFont | None: - for p in Path(config.fonts_dir).glob("*.ttf"): - if "emoji" in p.name.lower() or "colr" in p.name.lower(): - return ImageFont.truetype(str(p), size) - return None - - -def _text_width(text: str, size: int) -> int: - font = _pillow_font(size) - return round(font.getlength(text)) - - -def _emoji_text_width(text: str, size: int) -> int: - emoji_font = _emoji_pillow_font(size) - if emoji_font is None: - return _text_width(text, size) - return round(emoji_font.getlength(text)) - - def _word_wrap(text: str, max_width: int, font_size: int) -> list[str]: lines: list[str] = [] for paragraph in text.splitlines(): @@ -187,7 +124,7 @@ def _word_wrap(text: str, max_width: int, font_size: int) -> list[str]: current = "" for word in words: candidate = (current + " " + word).strip() - if _text_width(candidate, font_size) <= max_width: + if text_width(candidate, font_size) <= max_width: current = candidate else: if current: @@ -198,53 +135,6 @@ def _word_wrap(text: str, max_width: int, font_size: int) -> list[str]: return lines or [""] -def _emoji_split(text: str) -> list[tuple[str, bool]]: - out: list[tuple[str, bool]] = [] - pos = 0 - for match in _EMOJI_RE.finditer(text): - s, e = match.span() - if s > pos: - out.append((text[pos:s], False)) - out.append((text[s:e], True)) - pos = e - if pos < len(text): - out.append((text[pos:], False)) - return out or [(text, False)] - - -def _segment_width(text: str, font_size: int, is_emoji: bool) -> int: - if is_emoji: - return _emoji_text_width(text, font_size) - return _text_width(text, font_size) - - -def _render_text_segments( - segments: list[tuple[str, str, str, bool]], - x: int, - y: int, - font_size: int, - fill_default: str, - weight: int = 400, -) -> tuple[list[str], int]: - """Render (text, fill, decoration, is_emoji) tuples as separate elements at computed x. - Avoids the usvg panic caused by font-family switches inside a single with flag emoji. - Bold is achieved via stroke since resvg's variable-font wght axis is inert below size 24.""" - out = [] - cur_x = x - for text, fill, decoration, is_emoji in segments: - family = _EMOJI_FONT_NAME if is_emoji else _FONT_NAME - applied_fill = fill or fill_default - bold_attrs = "" - if weight == 700 and not is_emoji: - bold_attrs = f' stroke="{applied_fill}" stroke-width="0.7" paint-order="stroke"' - out.append( - f'{_escape(text)}', - ) - cur_x += _segment_width(text, font_size, is_emoji) - return out, cur_x - - def _line_to_segments(line: str, normal_fill: str, accent: str) -> list[tuple[str, str, str, bool]]: segments: list[tuple[str, str, str, bool]] = [] parts = _SPECIAL_TOKEN_RE.split(line) @@ -254,7 +144,7 @@ def _line_to_segments(line: str, normal_fill: str, accent: str) -> list[tuple[st is_special = i % 2 == 1 fill = accent if is_special else normal_fill decoration = ' text-decoration="underline"' if is_special else "" - for sub_text, is_emoji in _emoji_split(part): + for sub_text, is_emoji in emoji_split(part): if sub_text: segments.append((sub_text, fill, decoration, is_emoji)) return segments @@ -272,20 +162,6 @@ def _format_datetime(created_at: str | None) -> str: return created_at -def _rounded_rect_path(x: int, y: int, w: int, h: int, tl: int, tr: int, br: int, bl: int) -> str: - return ( - f"M {x + tl},{y} " - f"H {x + w - tr} " - f"Q {x + w},{y} {x + w},{y + tr} " - f"V {y + h - br} " - f"Q {x + w},{y + h} {x + w - br},{y + h} " - f"H {x + bl} " - f"Q {x},{y + h} {x},{y + h - bl} " - f"V {y + tl} " - f"Q {x},{y} {x + tl},{y} Z" - ) - - def _photo_cell_parts( cell_id: str, x: int, @@ -298,7 +174,7 @@ def _photo_cell_parts( br: int, bl: int, ) -> tuple[str, str]: - path = _rounded_rect_path(x, y, w, h, tl, tr, br, bl) + path = rounded_rect_path(x, y, w, h, tl, tr, br, bl) clip = f'' img = ( f' str: cx = CARD_OUTER_PAD # card left edge inner_w = card_width - 2 * CARD_INNER_PAD @@ -327,7 +205,7 @@ def build_svg( # Font defs.append( f'", ) @@ -358,7 +236,7 @@ def build_svg( # Header if profile_bytes: - avatar_b64 = _b64_image(profile_bytes, _image_mime(profile_bytes)) + avatar_b64 = b64_image(profile_bytes, image_mime(profile_bytes)) content.append( f'', @@ -367,7 +245,7 @@ def build_svg( initial = (tweet.user.handle or "?")[0].upper() content.append( f'' - f'{initial}', ) @@ -378,26 +256,26 @@ def build_svg( date_y = name_y + _name_date_span def _name_segments(text: str) -> list[tuple[str, str, str, bool]]: - return [(sub, theme.text_color, "", is_emoji) for sub, is_emoji in _emoji_split(text) if sub] + return [(sub, theme.text_color, "", is_emoji) for sub, is_emoji in emoji_split(text) if sub] if tweet.user.name: - name_elems, name_end_x = _render_text_segments( + name_elems, name_end_x = render_text_segments( _name_segments(tweet.user.name), name_x, name_y, FONT_SIZE_NAME, theme.text_color, weight = 700, ) content.extend(name_elems) - handle_elems, _ = _render_text_segments( + handle_elems, _ = render_text_segments( _name_segments(f" (@{tweet.user.handle})"), name_end_x, name_y, FONT_SIZE_NAME, theme.text_color, weight = 400, ) content.extend(handle_elems) else: - handle_elems, _ = _render_text_segments( + handle_elems, _ = render_text_segments( _name_segments(f"@{tweet.user.handle}"), name_x, name_y, FONT_SIZE_NAME, theme.text_color, weight = 700, ) content.extend(handle_elems) dt_str = _format_datetime(tweet.created_at) if dt_str: content.append( - f'{dt_str}', ) @@ -425,12 +303,37 @@ def _name_segments(text: str) -> list[tuple[str, str, str, bool]]: y += AVATAR_SIZE + CARD_SECTION_GAP - # Divider - content.append( - f'', - ) - y += CARD_SECTION_GAP + # Embedded quoted post (above body text) — replaces divider + if quoted_tweet_data: + quote_line_w = 4 + quote_line_gap = 12 + embed_x = body_x + quote_line_w + quote_line_gap + embed_w = inner_w - quote_line_w - quote_line_gap + ep_defs, ep_content, ep_height = render_embedded_post( + tweet = quoted_tweet_data["tweet"], + x = embed_x, + y = y, + width = embed_w, + theme = theme, + profile_bytes = quoted_tweet_data.get("profile_bytes"), + media_bytes = quoted_tweet_data.get("media_bytes"), + ) + defs.extend(ep_defs) + line_inset = round(ep_height * 0.05) + line_h = ep_height - 2 * line_inset + content.append( + f'', + ) + content.extend(ep_content) + y += ep_height + CARD_SECTION_GAP + else: + # Divider (only when no embedded post) + content.append( + f'', + ) + y += CARD_SECTION_GAP # Tweet body with colored tokens lines = _word_wrap(tweet.text, inner_w, FONT_SIZE_BODY) @@ -440,10 +343,24 @@ def _name_segments(text: str) -> list[tuple[str, str, str, bool]]: segments = _line_to_segments(ln, theme.text_color, accent) if not segments: continue - line_elems, _ = _render_text_segments(segments, body_x, line_y, FONT_SIZE_BODY, theme.text_color) + line_elems, _ = render_text_segments(segments, body_x, line_y, FONT_SIZE_BODY, theme.text_color) content.extend(line_elems) y += len(lines) * LINE_HEIGHT_BODY + CARD_SECTION_GAP + # Link previews (above photos) + if link_preview_data: + lp_defs, lp_content, lp_height = render_link_previews( + link_previews = link_preview_data, + x = body_x, + y = y, + width = inner_w, + theme = theme, + ) + defs.extend(lp_defs) + content.extend(lp_content) + if lp_height > 0: + y += lp_height + CARD_SECTION_GAP + # Photos — sorted portrait → square → landscape if media_bytes: sorted_media = sorted(media_bytes, key = _photo_sort_key) @@ -454,7 +371,7 @@ def _name_segments(text: str) -> list[tuple[str, str, str, bool]]: def _add_cell(photo_data: bytes, cx: int, cy: int, w: int, h: int, tl: int, tr: int, br: int, bl: int) -> None: nonlocal cell - b64 = _b64_image(photo_data, _image_mime(photo_data)) + b64 = b64_image(photo_data, image_mime(photo_data)) clip, img = _photo_cell_parts(f"photo-{cell}", cx, cy, w, h, b64, tl, tr, br, bl) defs.append(clip) content.append(img) @@ -532,8 +449,8 @@ def _add_cell(photo_data: bytes, cx: int, cy: int, w: int, h: int, tl: int, tr: if short_url: display_url = short_url.removeprefix("https://").removeprefix("http://") content.append( - f'{_escape(display_url)}', + f'{escape_xml(display_url)}', ) y += FONT_SIZE_FOOTER + CARD_INNER_PAD @@ -549,7 +466,3 @@ def _add_cell(photo_data: bytes, cx: int, cy: int, w: int, h: int, tl: int, tr: defs_svg = "" + "".join(defs) + "" content_svg = card_rect + "".join(content) return f'{defs_svg}{content_svg}' - - -def _escape(text: str) -> str: - return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) diff --git a/src/features/social_cards/card_utils.py b/src/features/social_cards/card_utils.py new file mode 100644 index 00000000..f8ffca8d --- /dev/null +++ b/src/features/social_cards/card_utils.py @@ -0,0 +1,193 @@ +import base64 +import io +import re +from pathlib import Path + +from PIL import Image, ImageFont + +from util.config import config + +FONT_PATH = Path(config.fonts_dir) / "GoogleSans-Variable.ttf" +FONT_NAME = "Google Sans" +EMOJI_FONT_NAME = "Noto Color Emoji" + +EMOJI_RE = re.compile( + "(?:" + "[\U0001F1E6-\U0001F1FF]" + "|[\U0001F300-\U0001F5FF]" + "|[\U0001F600-\U0001F64F]" + "|[\U0001F680-\U0001F6FF]" + "|[\U0001F700-\U0001F77F]" + "|[\U0001F780-\U0001F7FF]" + "|[\U0001F800-\U0001F8FF]" + "|[\U0001F900-\U0001F9FF]" + "|[\U0001FA00-\U0001FAFF]" + "|[☀-➿]" + "|[⌀-⏿]" + "|[⬀-⯿]" + ")" + "[️‍\U0001F3FB-\U0001F3FF]*" + "(?:" + "(?:" + "[\U0001F1E6-\U0001F1FF]" + "|[\U0001F300-\U0001F5FF]" + "|[\U0001F600-\U0001F64F]" + "|[\U0001F680-\U0001F6FF]" + "|[\U0001F700-\U0001F77F]" + "|[\U0001F780-\U0001F7FF]" + "|[\U0001F800-\U0001F8FF]" + "|[\U0001F900-\U0001F9FF]" + "|[\U0001FA00-\U0001FAFF]" + "|[☀-➿]" + "|[⌀-⏿]" + "|[⬀-⯿]" + ")" + "[️‍\U0001F3FB-\U0001F3FF]*" + ")*", +) + + +def pillow_font(size: int) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(str(FONT_PATH), size) + + +def text_width(text: str, size: int) -> int: + font = pillow_font(size) + return round(font.getlength(text)) + + +def emoji_pillow_font(size: int) -> ImageFont.FreeTypeFont | None: + for p in Path(config.fonts_dir).glob("*.ttf"): + if "emoji" in p.name.lower() or "colr" in p.name.lower(): + return ImageFont.truetype(str(p), size) + return None + + +def emoji_text_width(text: str, size: int) -> int: + emoji_font = emoji_pillow_font(size) + if emoji_font is None: + return text_width(text, size) + return round(emoji_font.getlength(text)) + + +def emoji_split(text: str) -> list[tuple[str, bool]]: + out: list[tuple[str, bool]] = [] + pos = 0 + for match in EMOJI_RE.finditer(text): + s, e = match.span() + if s > pos: + out.append((text[pos:s], False)) + out.append((text[s:e], True)) + pos = e + if pos < len(text): + out.append((text[pos:], False)) + return out or [(text, False)] + + +def segment_width(text: str, font_size: int, is_emoji: bool) -> int: + if is_emoji: + return emoji_text_width(text, font_size) + return text_width(text, font_size) + + +def render_text_segments( + segments: list[tuple[str, str, str, bool]], + x: int, + y: int, + font_size: int, + fill_default: str, + weight: int = 400, +) -> tuple[list[str], int]: + out = [] + cur_x = x + for text, fill, decoration, is_emoji in segments: + family = EMOJI_FONT_NAME if is_emoji else FONT_NAME + applied_fill = fill or fill_default + bold_attrs = "" + if weight == 700 and not is_emoji: + bold_attrs = f' stroke="{applied_fill}" stroke-width="0.7" paint-order="stroke"' + out.append( + f'{escape_xml(text)}', + ) + cur_x += segment_width(text, font_size, is_emoji) + return out, cur_x + + +def b64_image(data: bytes, mime: str = "image/jpeg") -> str: + return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" + + +def image_mime(data: bytes) -> str: + try: + img = Image.open(io.BytesIO(data)) + fmt = (img.format or "JPEG").upper() + return { + "JPEG": "image/jpeg", + "PNG": "image/png", + "GIF": "image/gif", + "WEBP": "image/webp", + "ICO": "image/x-icon", + }.get(fmt, "image/jpeg") + except Exception: + return "image/jpeg" + + +def escape_xml(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + +def rounded_rect_path(x: int, y: int, w: int, h: int, tl: int, tr: int, br: int, bl: int) -> str: + return ( + f"M {x + tl},{y} " + f"H {x + w - tr} " + f"Q {x + w},{y} {x + w},{y + tr} " + f"V {y + h - br} " + f"Q {x + w},{y + h} {x + w - br},{y + h} " + f"H {x + bl} " + f"Q {x},{y + h} {x},{y + h - bl} " + f"V {y + tl} " + f"Q {x},{y} {x + tl},{y} Z" + ) + + +def word_wrap_truncate(text: str, max_width: int, font_size: int, max_lines: int) -> list[str]: + lines: list[str] = [] + words = text.split() + current = "" + remaining = False + for i, word in enumerate(words): + candidate = (current + " " + word).strip() + if text_width(candidate, font_size) <= max_width: + current = candidate + else: + if current: + if len(lines) >= max_lines - 1: + remaining = True + lines.append(trim_trailing_sep(current) + "…") + return lines + lines.append(current) + if text_width(word, font_size) > max_width: + truncated = word + while truncated and text_width(truncated + "…", font_size) > max_width: + truncated = truncated[:-1] + current = truncated + "…" if truncated else word[:1] + "…" + remaining = True + else: + current = word + if i < len(words) - 1 and len(lines) >= max_lines: + remaining = True + break + if current and not remaining: + if len(lines) >= max_lines: + lines[-1] = trim_trailing_sep(lines[-1]) + "…" + else: + lines.append(current) + elif current and remaining: + if len(lines) < max_lines: + lines.append(trim_trailing_sep(current) + "…") + return lines[:max_lines] or [""] + + +def trim_trailing_sep(text: str) -> str: + return text.rstrip(" -–—:|/") diff --git a/src/features/social_cards/embedded_post.py b/src/features/social_cards/embedded_post.py new file mode 100644 index 00000000..ff0bab73 --- /dev/null +++ b/src/features/social_cards/embedded_post.py @@ -0,0 +1,140 @@ +import io +import re + +from PIL import Image + +from features.social_cards.card_utils import ( + FONT_NAME, + b64_image, + emoji_split, + image_mime, + render_text_segments, + rounded_rect_path, + word_wrap_truncate, +) +from features.social_cards.theme import ThemeColors +from features.web_browsing.twitter_status_fetcher import TweetData + +EMBED_PAD = 20 +EMBED_AVATAR_SIZE = 36 +EMBED_AVATAR_GAP = 10 +EMBED_NAME_FONT_SIZE = 16 +EMBED_BODY_FONT_SIZE = 18 +EMBED_BODY_LINE_HEIGHT = 24 +EMBED_BODY_MAX_LINES = 4 +EMBED_PHOTO_MAX_H = 160 +EMBED_PHOTO_CORNER_RADIUS = 12 +EMBED_CORNER_RADIUS = 28 +EMBED_SECTION_GAP = 10 +EMBED_OVERLAY_OPACITY = 0.15 + +_URL_RE = re.compile(r"https?://\S+") + + +def render_embedded_post( + tweet: TweetData, + x: int, + y: int, + width: int, + theme: ThemeColors, + profile_bytes: bytes | None, + media_bytes: bytes | None, +) -> tuple[list[str], list[str], int]: + defs: list[str] = [] + content: list[str] = [] + + R = EMBED_CORNER_RADIUS + pad = EMBED_PAD + inner_w = width - 2 * pad + + clean_text = _URL_RE.sub("", tweet.text).strip() + clean_text = re.sub(r" +", " ", clean_text) + + body_lines = word_wrap_truncate(clean_text, inner_w, EMBED_BODY_FONT_SIZE, EMBED_BODY_MAX_LINES) + body_h = len(body_lines) * EMBED_BODY_LINE_HEIGHT + + photo_h = 0 + photo_data_b64: str | None = None + photo_display_w = inner_w + if media_bytes: + try: + img = Image.open(io.BytesIO(media_bytes)) + natural_h = round(photo_display_w * img.height / img.width) if img.width > 0 else photo_display_w + photo_h = min(natural_h, EMBED_PHOTO_MAX_H) + photo_data_b64 = b64_image(media_bytes, image_mime(media_bytes)) + except Exception: + pass + + header_h = EMBED_AVATAR_SIZE + total_h = pad + header_h + EMBED_SECTION_GAP + body_h + if photo_h > 0: + total_h += EMBED_SECTION_GAP + photo_h + total_h += pad + + # Background rect + hex_bg = theme.gradient_start.lstrip("#") + bg_r, bg_g, bg_b = int(hex_bg[0:2], 16), int(hex_bg[2:4], 16), int(hex_bg[4:6], 16) + luminance = (0.299 * bg_r + 0.587 * bg_g + 0.114 * bg_b) / 255 + overlay_fill = "#ffffff" if luminance < 0.5 else "#000000" + + rect_path = rounded_rect_path(x, y, width, total_h, R, R, R, R) + content.append(f'') + + cur_y = y + pad + + # Avatar + av_x = x + pad + av_cy = cur_y + EMBED_AVATAR_SIZE // 2 + av_cx = av_x + EMBED_AVATAR_SIZE // 2 + clip_id = "embed-avatar-clip" + defs.append(f'') + + if profile_bytes: + av_b64 = b64_image(profile_bytes, image_mime(profile_bytes)) + content.append( + f'', + ) + else: + initial = (tweet.user.handle or "?")[0].upper() + content.append( + f'' + f'{initial}', + ) + + # Name + name_x = av_x + EMBED_AVATAR_SIZE + EMBED_AVATAR_GAP + name_y = cur_y + (EMBED_AVATAR_SIZE + EMBED_NAME_FONT_SIZE) // 2 - 2 + display_name = tweet.user.name or f"@{tweet.user.handle}" + max_name_w = inner_w - EMBED_AVATAR_SIZE - EMBED_AVATAR_GAP + name_lines = word_wrap_truncate(display_name, max_name_w, EMBED_NAME_FONT_SIZE, 1) + name_segments = [(sub, theme.text_color, "", is_emoji) for sub, is_emoji in emoji_split(name_lines[0]) if sub] + name_elems, _ = render_text_segments(name_segments, name_x, name_y, EMBED_NAME_FONT_SIZE, theme.text_color, weight = 700) + content.extend(name_elems) + + cur_y += header_h + EMBED_SECTION_GAP + + # Body text + text_x = x + pad + for line in body_lines: + cur_y += EMBED_BODY_FONT_SIZE + segments = [(sub, theme.text_color, "", is_emoji) for sub, is_emoji in emoji_split(line) if sub] + line_elems, _ = render_text_segments(segments, text_x, cur_y, EMBED_BODY_FONT_SIZE, theme.text_color) + content.extend(line_elems) + cur_y += EMBED_BODY_LINE_HEIGHT - EMBED_BODY_FONT_SIZE + + # Photo (optional, max 1) + if photo_h > 0 and photo_data_b64: + cur_y += EMBED_SECTION_GAP + photo_x = x + pad + PR = EMBED_PHOTO_CORNER_RADIUS + photo_clip_id = "embed-photo-clip" + photo_path = rounded_rect_path(photo_x, cur_y, photo_display_w, photo_h, PR, PR, PR, PR) + defs.append(f'') + content.append( + f'', + ) + + return defs, content, total_h diff --git a/src/features/social_cards/link_preview.py b/src/features/social_cards/link_preview.py new file mode 100644 index 00000000..f4ecc873 --- /dev/null +++ b/src/features/social_cards/link_preview.py @@ -0,0 +1,398 @@ +import io +import re +import urllib.request +from urllib.parse import urlparse + +from PIL import Image + +from features.social_cards.card_layout import PHOTO_CORNER_RADIUS, PHOTO_GAP +from features.social_cards.card_utils import ( + FONT_NAME, + b64_image, + escape_xml, + image_mime, + rounded_rect_path, + word_wrap_truncate, +) +from features.social_cards.theme import ThemeColors +from util import log + +LINK_ASPECT_W = 3 +LINK_ASPECT_H = 2 +OVERLAY_PAD_IMAGE = 12 +OVERLAY_PAD_NO_IMAGE = 18 +OVERLAY_OPACITY_IMAGE = 0.55 +OVERLAY_OPACITY_NO_IMAGE = 0.3 +BLUR_STD_DEV = 12 +TITLE_FONT_SIZE = 20 +TITLE_MAX_LINES = 2 +DESC_FONT_SIZE = 16 +DESC_MAX_LINES = 3 +DOMAIN_FONT_SIZE = 14 +FAVICON_SIZE = 28 +FAVICON_GAP = 8 +TEXT_LINE_HEIGHT_TITLE = 26 +TEXT_LINE_HEIGHT_DESC = 20 +TEXT_LINE_HEIGHT_DOMAIN = 18 +DESC_TOP_GAP = 8 +LINK_PREVIEW_CORNER_RADIUS = 20 + +_FAVICON_TIMEOUT_S = 3 +_OG_FETCH_TIMEOUT_S = 8 +_OG_HEAD_READ_BYTES = 32 * 1024 +_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)" +_OG_META_RE = re.compile(r']+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', re.IGNORECASE) +_OG_META_RE_ALT = re.compile(r']+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']', re.IGNORECASE) + + +def fetch_og_image_url(page_url: str) -> str | None: + try: + req = urllib.request.Request(page_url, headers = {"User-Agent": _USER_AGENT}) + with urllib.request.urlopen(req, timeout = _OG_FETCH_TIMEOUT_S) as resp: + head = resp.read(_OG_HEAD_READ_BYTES).decode("utf-8", errors = "ignore") + match = _OG_META_RE.search(head) or _OG_META_RE_ALT.search(head) + if match: + log.t(f"Found og:image from page: {match.group(1)}") + return match.group(1) + except Exception as e: + log.t(f"OG image fetch from page failed: {e}") + return None + + +_FAVICON_LINK_RE = re.compile( + r']+rel=["\'](?:icon|shortcut icon|apple-touch-icon)["\'][^>]*>', + re.IGNORECASE, +) +_HREF_RE = re.compile(r'href=["\']([^"\']+)["\']', re.IGNORECASE) + + +def fetch_favicon(domain: str, expanded_url: str | None = None) -> bytes | None: + urls_to_try = [] + if expanded_url: + urls_to_try.append(expanded_url) + urls_to_try.append(f"https://{domain}") + for page_url in urls_to_try: + favicon_url = _find_favicon_url(page_url, domain) + if favicon_url: + result = _download_favicon(favicon_url) + if result: + return result + return None + + +def _find_favicon_url(page_url: str, domain: str) -> str | None: + try: + req = urllib.request.Request(page_url, headers = {"User-Agent": _USER_AGENT}) + with urllib.request.urlopen(req, timeout = _OG_FETCH_TIMEOUT_S) as resp: + final_url = resp.url + head = resp.read(_OG_HEAD_READ_BYTES).decode("utf-8", errors = "ignore") + matches = _FAVICON_LINK_RE.findall(head) + for tag in matches: + href_match = _HREF_RE.search(tag) + if href_match: + href = href_match.group(1) + if href.startswith("//"): + return "https:" + href + if href.startswith("/"): + parsed = urlparse(final_url) + return f"{parsed.scheme}://{parsed.hostname}{href}" + if href.startswith("http"): + return href + parsed = urlparse(final_url) + return f"{parsed.scheme}://{parsed.hostname}/{href}" + except Exception as e: + log.t(f"Favicon URL extraction failed for {page_url}: {e}") + return None + + +def _download_favicon(url: str) -> bytes | None: + try: + log.t(f"Downloading favicon from {url}") + req = urllib.request.Request(url, headers = {"User-Agent": _USER_AGENT}) + with urllib.request.urlopen(req, timeout = _FAVICON_TIMEOUT_S) as resp: + content_type = resp.headers.get("Content-Type", "") + if "html" in content_type: + return None + data = resp.read() + if len(data) < 50: + return None + img = Image.open(io.BytesIO(data)) + img = img.convert("RGBA") + img = img.resize((64, 64), Image.LANCZOS) + r, g, b, a = img.split() + gray = img.convert("LA").split()[0] + img = Image.merge("RGBA", (gray, gray, gray, a)) + buf = io.BytesIO() + img.save(buf, format = "PNG") + return buf.getvalue() + except Exception as e: + log.t(f"Favicon download failed for {url}: {e}") + return None + + +def _dominant_color(data: bytes) -> tuple[int, int, int]: + try: + img = Image.open(io.BytesIO(data)).convert("RGB").resize((32, 32)) + quantized = img.quantize(colors = 4) + palette = quantized.getpalette() + if palette: + return (palette[0], palette[1], palette[2]) + except Exception: + pass + return (128, 128, 128) + + +def _contrast_text_color(rgb: tuple[int, int, int]) -> str: + r, g, b = rgb + luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return "#000000" if luminance > 0.5 else "#ffffff" + + +def _contrast_overlay_color(rgb: tuple[int, int, int]) -> str: + r, g, b = rgb + return "#{:02x}{:02x}{:02x}".format(255 - r, 255 - g, 255 - b) + + +def _globe_icon(cx: int, cy: int, r: int, color: str, opacity: float = 0.6) -> str: + return ( + f'' + f'' + f'' + f'' + f'' + f'' + f"" + ) + + +def render_link_previews( + link_previews: list[dict], + x: int, + y: int, + width: int, + theme: ThemeColors, +) -> tuple[list[str], list[str], int]: + defs: list[str] = [] + content: list[str] = [] + cur_y = y + + for i, preview in enumerate(link_previews): + title = preview.get("title") or "" + description = preview.get("description") or "" + domain = preview.get("domain") or "" + og_image_bytes = preview.get("og_image_bytes") + favicon_bytes = preview.get("favicon_bytes") + uid = f"lp-{i}" + + if og_image_bytes: + d, c, h = _render_with_image( + uid, x, cur_y, width, title, description, domain, + og_image_bytes, favicon_bytes, theme, + ) + else: + d, c, h = _render_without_image( + uid, x, cur_y, width, title, description, domain, + favicon_bytes, theme, + ) + + defs.extend(d) + content.extend(c) + cur_y += h + PHOTO_GAP + + total_h = cur_y - y - (PHOTO_GAP if link_previews else 0) + return defs, content, total_h + + +def _render_with_image( + uid: str, + x: int, + y: int, + width: int, + title: str, + description: str, + domain: str, + og_image_bytes: bytes, + favicon_bytes: bytes | None, + theme: ThemeColors, +) -> tuple[list[str], list[str], int]: + defs: list[str] = [] + content: list[str] = [] + + pad = OVERLAY_PAD_NO_IMAGE + R = PHOTO_CORNER_RADIUS + fav_size = FAVICON_SIZE + + header_text_w = width - 2 * pad - fav_size - FAVICON_GAP + desc_text_w = width - 2 * pad + + title_lines = word_wrap_truncate(title, header_text_w, TITLE_FONT_SIZE, 1) if title else [] + desc_lines = word_wrap_truncate(description, desc_text_w, DESC_FONT_SIZE, DESC_MAX_LINES) if description else [] + header_h = TEXT_LINE_HEIGHT_TITLE + TEXT_LINE_HEIGHT_DOMAIN + desc_h = len(desc_lines) * TEXT_LINE_HEIGHT_DESC + overlay_h = 2 * pad + header_h + DESC_TOP_GAP + desc_h + + total_h = round(width * LINK_ASPECT_H / LINK_ASPECT_W) + if overlay_h > total_h: + total_h = overlay_h + pad + overlay_y = y + total_h - overlay_h + + # Clip for entire box + box_clip_id = f"{uid}-box-clip" + box_path = rounded_rect_path(x, y, width, total_h, R, R, R, R) + defs.append(f'') + + # OG image (sharp, full box) + img_b64 = b64_image(og_image_bytes, image_mime(og_image_bytes)) + content.append( + f'', + ) + + # Overlay region (bottom of card) + overlay_actual_h = total_h - (overlay_y - y) + overlay_path = rounded_rect_path(x, overlay_y, width, overlay_actual_h, 0, 0, R, R) + + dominant = _dominant_color(og_image_bytes) + text_color = _contrast_text_color(dominant) + overlay_fill = "#000000" if text_color == "#ffffff" else "#ffffff" + + # Colored overlay panel (no blur — resvg can't composite filter+clip correctly) + content.append( + f'', + ) + + # Layout: favicon on left spanning title+domain, text on right, desc below full width + fav_x = x + pad + text_x = fav_x + fav_size + FAVICON_GAP + cur_y = overlay_y + pad + fav_y = cur_y + (header_h - fav_size) // 2 + + if favicon_bytes: + fav_b64 = b64_image(favicon_bytes, "image/png") + content.append( + f'', + ) + else: + globe_cx = fav_x + fav_size // 2 + globe_cy = fav_y + fav_size // 2 + globe_r = round(fav_size * 0.35) + content.append(_globe_icon(globe_cx, globe_cy, globe_r, text_color, 1.0)) + + # Title (bold, 1 line) next to favicon + cur_y += TITLE_FONT_SIZE + if title_lines: + content.append( + f'{escape_xml(title_lines[0])}', + ) + cur_y += TEXT_LINE_HEIGHT_TITLE - TITLE_FONT_SIZE + + # Domain next to favicon + cur_y += DOMAIN_FONT_SIZE + content.append( + f'{escape_xml(domain)}', + ) + cur_y += TEXT_LINE_HEIGHT_DOMAIN - DOMAIN_FONT_SIZE + + # Description full width below + cur_y += DESC_TOP_GAP + desc_x = x + pad + for line in desc_lines: + cur_y += DESC_FONT_SIZE + content.append( + f'{escape_xml(line)}', + ) + cur_y += TEXT_LINE_HEIGHT_DESC - DESC_FONT_SIZE + + return defs, content, total_h + + +def _render_without_image( + uid: str, + x: int, + y: int, + width: int, + title: str, + description: str, + domain: str, + favicon_bytes: bytes | None, + theme: ThemeColors, +) -> tuple[list[str], list[str], int]: + content: list[str] = [] + + pad = OVERLAY_PAD_NO_IMAGE + fav_size = FAVICON_SIZE + full_w = width - 2 * pad + header_text_w = full_w - fav_size - FAVICON_GAP + + desc_lines = word_wrap_truncate(description, full_w, DESC_FONT_SIZE, DESC_MAX_LINES) if description else [] + header_h = TEXT_LINE_HEIGHT_TITLE + TEXT_LINE_HEIGHT_DOMAIN + desc_h = len(desc_lines) * TEXT_LINE_HEIGHT_DESC + total_h = 2 * pad + header_h + DESC_TOP_GAP + desc_h + + R = LINK_PREVIEW_CORNER_RADIUS + hex_bg = theme.gradient_start.lstrip("#") + bg_r, bg_g, bg_b = int(hex_bg[0:2], 16), int(hex_bg[2:4], 16), int(hex_bg[4:6], 16) + overlay_fill = _contrast_overlay_color((bg_r, bg_g, bg_b)) + text_color = _contrast_text_color((bg_r, bg_g, bg_b)) + + rect_path = rounded_rect_path(x, y, width, total_h, R, R, R, R) + content.append(f'') + + cur_y = y + pad + fav_x = x + pad + fav_y = cur_y + (header_h - fav_size) // 2 + + if favicon_bytes: + fav_b64 = b64_image(favicon_bytes, "image/png") + content.append( + f'', + ) + else: + globe_cx = fav_x + fav_size // 2 + globe_cy = fav_y + fav_size // 2 + globe_r = round(fav_size * 0.35) + content.append(_globe_icon(globe_cx, globe_cy, globe_r, text_color, 1.0)) + + # Title (bold, 1 line) next to favicon + text_x = fav_x + fav_size + FAVICON_GAP + cur_y += TITLE_FONT_SIZE + if title: + title_lines = word_wrap_truncate(title, header_text_w, TITLE_FONT_SIZE, 1) + content.append( + f'{escape_xml(title_lines[0])}', + ) + cur_y += TEXT_LINE_HEIGHT_TITLE - TITLE_FONT_SIZE + + # Domain/URL next to favicon + cur_y += DOMAIN_FONT_SIZE + content.append( + f'{escape_xml(domain)}', + ) + cur_y += TEXT_LINE_HEIGHT_DOMAIN - DOMAIN_FONT_SIZE + + # Gap before description + cur_y += DESC_TOP_GAP + + # Description full width below + desc_x = x + pad + for line in desc_lines: + cur_y += DESC_FONT_SIZE + content.append( + f'{escape_xml(line)}', + ) + cur_y += TEXT_LINE_HEIGHT_DESC - DESC_FONT_SIZE + + return [], content, total_h diff --git a/src/features/social_cards/social_card_orchestrator.py b/src/features/social_cards/social_card_orchestrator.py index 194134fa..cc3bac25 100644 --- a/src/features/social_cards/social_card_orchestrator.py +++ b/src/features/social_cards/social_card_orchestrator.py @@ -4,6 +4,7 @@ from features.external_tools.configured_tool import ConfiguredTool from features.external_tools.external_tool import ToolType from features.social_cards import card_renderer +from features.social_cards.link_preview import fetch_favicon, fetch_og_image_url from features.social_cards.theme import pick_theme from features.web_browsing.photo_downloader import PhotoDownloader from features.web_browsing.twitter_utils import resolve_tweet_id @@ -15,12 +16,15 @@ class SocialCardOrchestrator: TOOL_TYPE: ToolType = ToolType.api_twitter + VISION_TOOL_TYPE: ToolType = ToolType.vision __x_api_tool: ConfiguredTool + __vision_tool: ConfiguredTool __di: DI - def __init__(self, x_api_tool: ConfiguredTool, di: DI): + def __init__(self, x_api_tool: ConfiguredTool, vision_tool: ConfiguredTool, di: DI): self.__x_api_tool = x_api_tool + self.__vision_tool = vision_tool self.__di = di def execute(self, url: str) -> str: @@ -28,7 +32,7 @@ def execute(self, url: str) -> str: if not tweet_id: raise ValidationError(f"Cannot resolve tweet ID from URL: {url}", WEB_FETCH_FAILED) - fetcher = self.__di.twitter_status_fetcher(tweet_id, self.__x_api_tool, self.__x_api_tool) + fetcher = self.__di.twitter_status_fetcher(tweet_id, self.__x_api_tool, self.__vision_tool) tweet = fetcher.as_structured() downloader = PhotoDownloader() @@ -41,6 +45,58 @@ def execute(self, url: str) -> str: media_urls = [m.url or m.preview_url for m in tweet.media if m.url or m.preview_url] media_bytes = downloader.download_many([u for u in media_urls if u]) + # Fetch link preview assets + has_tweet_media = bool(media_bytes) + link_preview_data: list[dict] = [] + for lp in tweet.link_previews: + og_image_bytes: bytes | None = None + if not has_tweet_media: + if lp.og_image_url: + og_image_bytes = downloader.download(lp.og_image_url) + if not og_image_bytes: + fallback_url = fetch_og_image_url(lp.expanded_url) + if fallback_url: + og_image_bytes = downloader.download(fallback_url) + favicon_bytes = fetch_favicon(lp.domain, expanded_url = lp.expanded_url) + short_link: str | None = None + try: + valid_until = datetime.now() + timedelta(days = 365) + short_link = self.__di.url_shortener(lp.expanded_url, valid_until = valid_until).execute() + except Exception as e: + log.w("Link preview URL shortening failed", e) + short_link = lp.expanded_url + link_preview_data.append({ + "title": lp.title, + "description": lp.description, + "domain": lp.domain, + "og_image_bytes": og_image_bytes, + "favicon_bytes": favicon_bytes, + "short_url": short_link, + }) + + # Fetch referenced tweet (quoted or replied-to) if present + quoted_tweet_data: dict | None = None + referenced_id = tweet.quoted_tweet_id or tweet.replied_to_tweet_id + if referenced_id: + try: + qt_fetcher = self.__di.twitter_status_fetcher(referenced_id, self.__x_api_tool, self.__vision_tool) + qt_tweet = qt_fetcher.as_structured() + qt_profile_bytes: bytes | None = None + if qt_tweet.user.profile_image_url: + qt_bigger_url = qt_tweet.user.profile_image_url.replace("_normal", "_bigger") + qt_profile_bytes = downloader.download(qt_bigger_url) + qt_media_bytes: bytes | None = None + qt_media_urls = [m.url or m.preview_url for m in qt_tweet.media if m.url or m.preview_url] + if qt_media_urls: + qt_media_bytes = downloader.download(qt_media_urls[0]) + quoted_tweet_data = { + "tweet": qt_tweet, + "profile_bytes": qt_profile_bytes, + "media_bytes": qt_media_bytes, + } + except Exception as e: + log.w(f"Failed to fetch referenced tweet {referenced_id}", e) + theme = pick_theme(profile_bytes, media_bytes) short_url: str | None = None @@ -58,6 +114,8 @@ def execute(self, url: str) -> str: profile_bytes = profile_bytes, media_bytes = media_bytes, short_url = short_url, + link_preview_data = link_preview_data, + quoted_tweet_data = quoted_tweet_data, ) except Exception as e: raise ExternalServiceError("Card rendering failed", IMAGE_GENERATION_FAILED) from e diff --git a/src/features/web_browsing/twitter_status_fetcher.py b/src/features/web_browsing/twitter_status_fetcher.py index 06f939e6..8568cad5 100644 --- a/src/features/web_browsing/twitter_status_fetcher.py +++ b/src/features/web_browsing/twitter_status_fetcher.py @@ -1,8 +1,11 @@ +import html import json +import re from dataclasses import dataclass, field from datetime import datetime, timedelta from time import sleep from typing import Any +from urllib.parse import urlparse from db.schema.tools_cache import ToolsCache, ToolsCacheSave from di.di import DI @@ -21,6 +24,15 @@ RATE_LIMIT_DELAY_S = 2 +@dataclass +class TweetLinkPreview: + title: str | None + description: str | None + og_image_url: str | None + expanded_url: str + domain: str + + @dataclass class TweetMediaItem: url: str | None @@ -43,6 +55,10 @@ class TweetData: language: str | None created_at: str | None media: list[TweetMediaItem] = field(default_factory = list) + link_previews: list[TweetLinkPreview] = field(default_factory = list) + quoted_tweet_id: str | None = None + is_reply: bool = False + replied_to_tweet_id: str | None = None class TwitterStatusFetcher: @@ -108,7 +124,7 @@ def __fetch_raw(self) -> dict[str, Any]: params = { "expansions": "author_id,attachments.media_keys", "user.fields": "name,username,description,profile_image_url", - "tweet.fields": "lang,text,created_at,note_tweet", + "tweet.fields": "lang,text,created_at,note_tweet,entities,referenced_tweets", "media.fields": "url,type,preview_image_url", } @@ -165,7 +181,27 @@ def __parse_structured(self, response: dict[str, Any]) -> TweetData: ) note_tweet = post_data.get("note_tweet") or {} - text = note_tweet.get("text") or post_data.get("text") or "" + text = html.unescape(note_tweet.get("text") or post_data.get("text") or "") + + entities = note_tweet.get("entities") or post_data.get("entities") or {} + entity_urls = entities.get("urls") or [] + + referenced_tweets = post_data.get("referenced_tweets") or [] + quoted_tweet_id: str | None = None + is_reply = False + replied_to_tweet_id: str | None = None + for ref in referenced_tweets: + ref_type = ref.get("type") + ref_id = ref.get("id") + if ref_type == "quoted" and ref_id: + quoted_tweet_id = ref_id + elif ref_type == "replied_to" and ref_id: + is_reply = True + replied_to_tweet_id = ref_id + + link_previews, text, url_quoted_id = self.__process_urls(text, entity_urls) + if not quoted_tweet_id and url_quoted_id: + quoted_tweet_id = url_quoted_id return TweetData( user = user, @@ -173,8 +209,77 @@ def __parse_structured(self, response: dict[str, Any]) -> TweetData: language = post_data.get("lang") or None, created_at = post_data.get("created_at") or None, media = media_items, + link_previews = link_previews, + quoted_tweet_id = quoted_tweet_id, + is_reply = is_reply, + replied_to_tweet_id = replied_to_tweet_id, ) + def __process_urls( + self, + text: str, + entity_urls: list[dict[str, Any]], + ) -> tuple[list[TweetLinkPreview], str, str | None]: + link_previews: list[TweetLinkPreview] = [] + tco_urls_to_strip: set[str] = set() + quoted_tweet_id: str | None = None + + for entity in entity_urls: + tco_url = entity.get("url") or "" + expanded = entity.get("expanded_url") or "" + + if not tco_url: + continue + + is_media_self_ref = f"/status/{self.__tweet_id}/" in expanded + is_twitter_domain = any( + d in expanded for d in ["x.com/", "twitter.com/"] + ) + + if is_media_self_ref: + tco_urls_to_strip.add(tco_url) + elif is_twitter_domain: + qt_id = self.__extract_tweet_id_from_url(expanded) + if qt_id: + quoted_tweet_id = qt_id + tco_urls_to_strip.add(tco_url) + else: + tco_urls_to_strip.add(tco_url) + title = entity.get("title") or None + description = entity.get("description") or None + if title or description: + images = entity.get("images") or [] + og_image_url = images[0].get("url") if images else None + parsed = urlparse(expanded) + domain = parsed.hostname or "" + if domain.startswith("www."): + domain = domain[4:] + parts = domain.split(".") + if len(parts) > 2: + domain = ".".join(parts[-2:]) + link_previews.append( + TweetLinkPreview( + title = title, + description = description, + og_image_url = og_image_url, + expanded_url = expanded, + domain = domain, + ), + ) + + for tco_url in tco_urls_to_strip: + text = text.replace(tco_url, "") + + text = re.sub(r" +", " ", text).strip() + return link_previews, text, quoted_tweet_id + + @staticmethod + def __extract_tweet_id_from_url(url: str) -> str | None: + parts = url.split("/status/") + if len(parts) == 2: + return parts[1].split("?")[0].split("/")[0] + return None + def __resolve_content(self, response: dict[str, Any]) -> str: try: post_data = response.get("data") or {} diff --git a/test/features/social_cards/test_card_utils.py b/test/features/social_cards/test_card_utils.py new file mode 100644 index 00000000..08efaa4d --- /dev/null +++ b/test/features/social_cards/test_card_utils.py @@ -0,0 +1,205 @@ +import unittest + +from features.social_cards.card_utils import ( + EMOJI_FONT_NAME, + FONT_NAME, + b64_image, + emoji_split, + escape_xml, + image_mime, + render_text_segments, + rounded_rect_path, + segment_width, + text_width, + trim_trailing_sep, + word_wrap_truncate, +) + + +class CardUtilsTest(unittest.TestCase): + + def test_escape_xml_special_chars(self): + assert escape_xml('a & b < c > d "e"') == "a & b < c > d "e"" + + def test_escape_xml_no_special_chars(self): + assert escape_xml("hello world") == "hello world" + + def test_escape_xml_empty(self): + assert escape_xml("") == "" + + def test_trim_trailing_sep_dashes(self): + assert trim_trailing_sep("hello -") == "hello" + + def test_trim_trailing_sep_pipes(self): + assert trim_trailing_sep("title | ") == "title" + + def test_trim_trailing_sep_mixed(self): + assert trim_trailing_sep("text —/") == "text" + + def test_trim_trailing_sep_no_sep(self): + assert trim_trailing_sep("clean text") == "clean text" + + def test_b64_image_default_mime(self): + result = b64_image(b"\x00\x01\x02") + assert result.startswith("data:image/jpeg;base64,") + + def test_b64_image_custom_mime(self): + result = b64_image(b"\x89PNG", "image/png") + assert result.startswith("data:image/png;base64,") + + def test_image_mime_png(self): + import io + + from PIL import Image + buf = io.BytesIO() + Image.new("RGB", (1, 1)).save(buf, format = "PNG") + assert image_mime(buf.getvalue()) == "image/png" + + def test_image_mime_jpeg(self): + import io + + from PIL import Image + buf = io.BytesIO() + Image.new("RGB", (1, 1)).save(buf, format = "JPEG") + assert image_mime(buf.getvalue()) == "image/jpeg" + + def test_image_mime_invalid_data(self): + assert image_mime(b"not an image") == "image/jpeg" + + def test_text_width_returns_positive(self): + width = text_width("hello", 20) + assert width > 0 + + def test_text_width_longer_text_wider(self): + short = text_width("hi", 20) + long = text_width("hello world", 20) + assert long > short + + def test_text_width_larger_font_wider(self): + small = text_width("test", 12) + large = text_width("test", 24) + assert large > small + + def test_rounded_rect_path_format(self): + path = rounded_rect_path(10, 20, 100, 50, 5, 5, 5, 5) + assert path.startswith("M 15,20") + assert "Z" in path + + def test_rounded_rect_path_asymmetric_corners(self): + path = rounded_rect_path(0, 0, 100, 100, 10, 0, 10, 0) + assert "M 10,0" in path + + def test_word_wrap_truncate_short_text(self): + lines = word_wrap_truncate("short", 500, 20, 3) + assert lines == ["short"] + + def test_word_wrap_truncate_single_line_limit(self): + lines = word_wrap_truncate("a b c d e f g h i j k l m n", 50, 20, 1) + assert len(lines) == 1 + assert lines[0].endswith("…") + + def test_word_wrap_truncate_respects_max_lines(self): + long_text = " ".join(["word"] * 50) + lines = word_wrap_truncate(long_text, 100, 14, 3) + assert len(lines) <= 3 + assert lines[-1].endswith("…") + + def test_word_wrap_truncate_no_truncation_needed(self): + lines = word_wrap_truncate("hello world", 500, 20, 5) + assert "…" not in lines[0] + + def test_word_wrap_truncate_empty_text(self): + lines = word_wrap_truncate("", 500, 20, 3) + assert lines == [""] + + def test_word_wrap_truncate_trims_trailing_sep(self): + lines = word_wrap_truncate("title - this is a very long description that wraps", 80, 14, 1) + assert len(lines) == 1 + assert not lines[0].endswith("- …") + assert not lines[0].endswith(" -…") + + # emoji_split tests + + def test_emoji_split_no_emoji(self): + result = emoji_split("hello world") + assert result == [("hello world", False)] + + def test_emoji_split_only_emoji(self): + result = emoji_split("🔥") + assert len(result) == 1 + assert result[0][0] == "🔥" + assert result[0][1] is True + + def test_emoji_split_mixed(self): + result = emoji_split("hello 🌍 world") + assert len(result) == 3 + assert result[0] == ("hello ", False) + assert result[1][1] is True + assert result[2] == (" world", False) + + def test_emoji_split_multiple_emoji(self): + result = emoji_split("🎉🎊") + assert all(is_emoji for _, is_emoji in result) + + def test_emoji_split_empty_string(self): + result = emoji_split("") + assert result == [("", False)] + + # segment_width tests + + def test_segment_width_text(self): + w = segment_width("hello", 20, False) + assert w > 0 + assert w == text_width("hello", 20) + + def test_segment_width_emoji(self): + w = segment_width("🔥", 20, True) + assert w > 0 + + # render_text_segments tests + + def test_render_text_segments_single_text(self): + segments = [("hello", "#ffffff", "", False)] + elems, end_x = render_text_segments(segments, 10, 50, 20, "#ffffff") + assert len(elems) == 1 + assert f'font-family="{FONT_NAME}"' in elems[0] + assert 'fill="#ffffff"' in elems[0] + assert "hello" in elems[0] + assert end_x > 10 + + def test_render_text_segments_emoji_uses_emoji_font(self): + segments = [("🔥", "#000000", "", True)] + elems, _ = render_text_segments(segments, 0, 0, 20, "#000000") + assert len(elems) == 1 + assert f'font-family="{EMOJI_FONT_NAME}"' in elems[0] + + def test_render_text_segments_bold(self): + segments = [("bold", "#111111", "", False)] + elems, _ = render_text_segments(segments, 0, 0, 20, "#111111", weight = 700) + assert 'stroke="#111111"' in elems[0] + assert 'stroke-width="0.7"' in elems[0] + + def test_render_text_segments_multiple(self): + segments = [ + ("hello ", "#ffffff", "", False), + ("🌍", "#ffffff", "", True), + (" world", "#ffffff", "", False), + ] + elems, end_x = render_text_segments(segments, 0, 0, 20, "#ffffff") + assert len(elems) == 3 + assert end_x > 0 + + def test_render_text_segments_escapes_xml(self): + segments = [("