AI Implementation feature(1087): Mastodon Post Composition and Publishing (#6)
This commit was merged in pull request #6.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
---
|
||||
type: architecture
|
||||
title: Mastodon Publishing
|
||||
description: The single boundary between the pipeline runner and the Mastodon HTTP API — status composition, length validation, and the publish call.
|
||||
tags: [publishing, mastodon, api, boundary]
|
||||
timestamp: 2026-08-04T18:55:00Z
|
||||
---
|
||||
|
||||
# Purpose
|
||||
|
||||
`tenbackward.publishing` is the **single success boundary** between the
|
||||
pipeline orchestrator and the Mastodon HTTP API. The runner calls
|
||||
`publish_mastodon()` after dedupe and before state is persisted; if
|
||||
the call raises, the retry loop re-attempts and `posted.json` is left
|
||||
untouched. Composition and validation are pure functions that do not
|
||||
touch the network so they are unit-tested without a server.
|
||||
|
||||
# Public API
|
||||
|
||||
| Symbol | Responsibility |
|
||||
|---|---|
|
||||
| `MASTODON_STATUS_LIMIT` | `500` — Mastodon's hard maximum status length. |
|
||||
| `PublishError` | Raised on composition failure, length overflow, or any wrapped API exception. Subclass of `RuntimeError`. |
|
||||
| `build_status_text(prefix, posts, hashtags)` | Pure composer. Returns the final status string. Raises `PublishError("empty_posts: ...")` when `posts` is empty. |
|
||||
| `validate_status(status, limit=MASTODON_STATUS_LIMIT)` | Pure validator. Raises `PublishError("status_too_long: len=N limit=L")` when `len(status) > limit`. Never truncates. |
|
||||
| `publish_mastodon(config, posts, *, client_factory=None)` | Composes, validates, and posts. Returns the composed status string. Wraps every third-party exception in `PublishError(f"publish_failed: {ExcType}: {exc}", ) from exc`. |
|
||||
| `slugify_title(title)` | Re-exported from [matching](/architecture/anniversary-matching.md); used by callers that need to derive the same URL slug the matcher emits. |
|
||||
|
||||
# Status Layout
|
||||
|
||||
`build_status_text` produces exactly one Mastodon status for any number
|
||||
of matching posts, in this shape:
|
||||
|
||||
```text
|
||||
{prefix}
|
||||
|
||||
{title1}
|
||||
{url1}
|
||||
|
||||
{title2}
|
||||
{url2}
|
||||
|
||||
...
|
||||
|
||||
{hashtags}
|
||||
```
|
||||
|
||||
* The prefix comes from `Config.throwback_prefix` (default
|
||||
`Heute vor 10 Jahren:`, overridable via `THROWNBACK_PREFIX`).
|
||||
* Posts are sorted by `(date, path)` so output is deterministic
|
||||
regardless of upstream order.
|
||||
* `hashtags` is a comma-separated string; commas are collapsed to a
|
||||
single space so each tag starts with `#` and no trailing comma
|
||||
is emitted (empty tags are dropped).
|
||||
* A trailing newline is always appended.
|
||||
|
||||
# Composition Examples
|
||||
|
||||
## Single post
|
||||
|
||||
```text
|
||||
Heute vor 10 Jahren:
|
||||
|
||||
Mein erster Post
|
||||
https://blog.example.com/2016/08/04/mein-erster-post/
|
||||
|
||||
#throwback #10backward
|
||||
```
|
||||
|
||||
## Multiple posts on the same day
|
||||
|
||||
When more than one Jekyll post matches the current day, they are
|
||||
**merged into one status** — the runner does not issue separate API
|
||||
calls per match. Ordering is `(date, path)`, so identical dates sort
|
||||
by relative path:
|
||||
|
||||
```text
|
||||
Heute vor 10 Jahren:
|
||||
|
||||
Post A
|
||||
https://blog.example.com/2016/08/04/a/
|
||||
|
||||
Post B
|
||||
https://blog.example.com/2016/08/04/b/
|
||||
|
||||
#throwback #10backward
|
||||
```
|
||||
|
||||
## No posts
|
||||
|
||||
Calling `build_status_text("...", [], "#x")` raises
|
||||
`PublishError("empty_posts: cannot compose status without posts")`
|
||||
before any API call. The runner only invokes the publisher when its
|
||||
candidate list is non-empty, so this guard is a backstop for direct
|
||||
callers and unit tests.
|
||||
|
||||
# Length Validation
|
||||
|
||||
Mastodon's API rejects statuses longer than 500 characters. The
|
||||
spec requires **failing safely** — the bot must never silently
|
||||
truncate content. `validate_status()` enforces this contract: the
|
||||
full status is measured, and overflow raises
|
||||
`PublishError("status_too_long: len=N limit=500")` *before* the API
|
||||
call. The runner's retry loop treats this exactly like any other
|
||||
publish failure.
|
||||
|
||||
# Mastodon API Call
|
||||
|
||||
`_post_status_via_mastodon_py()` constructs a `mastodon.Mastodon`
|
||||
client with `(access_token=config.mastodon_access_token,
|
||||
api_base_url=config.mastodon_base_url)` and calls
|
||||
`status_post(status, visibility=config.visibility)`.
|
||||
|
||||
The `client_factory` keyword argument on `publish_mastodon` lets
|
||||
tests inject a fake client without monkey-patching. Production
|
||||
callers leave it as `None`.
|
||||
|
||||
# Failure Modes
|
||||
|
||||
| Source | Exception surfaced to runner | Cause attached? |
|
||||
|---|---|---|
|
||||
| Empty `posts` argument | `PublishError("empty_posts: ...")` | No (no inner exc). |
|
||||
| Composed status > 500 chars | `PublishError("status_too_long: ...")` | No. |
|
||||
| Any other exception from inside the boundary | `PublishError("publish_failed: {ExcType}: {exc}")` | Yes, via `raise ... from exc`. |
|
||||
|
||||
The runner catches every `Exception` in `_run_once`, so any of the
|
||||
above becomes a `pipeline_error` log line and the retry budget
|
||||
decides whether to give up.
|
||||
|
||||
# Wiring
|
||||
|
||||
```
|
||||
_run_once(config) # main.py
|
||||
├── ensure_repo(...)
|
||||
├── store = PostedStore(config.data_dir)
|
||||
├── candidates = list(_iter_candidates(config)) # find_anniversary_matches
|
||||
├── unposted = [m for m in candidates if not store.is_posted(m.path)]
|
||||
├── if unposted:
|
||||
│ publish_mastodon(config, unposted) # <-- THIS boundary
|
||||
│ store.mark_posted_many(posted_ids) # only after success
|
||||
```
|
||||
|
||||
State is written **only after** `publish_mastodon` returns. A publish
|
||||
failure leaves `posted.json` unchanged and lets the retry budget
|
||||
re-attempt on the next iteration.
|
||||
|
||||
# Configuration Surface
|
||||
|
||||
| Env var | Consumed via | Effect |
|
||||
|---|---|---|
|
||||
| `MASTODON_BASE_URL` | `Config.mastodon_base_url` | Mastodon instance URL. |
|
||||
| `MASTODON_ACCESS_TOKEN` | `Config.mastodon_access_token` | OAuth token passed to the `Mastodon` client. |
|
||||
| `VISIBILITY` | `Config.visibility` | Passed as `visibility=` to `status_post`. |
|
||||
| `THROWNBACK_PREFIX` | `Config.throwback_prefix` | First line of every published status. |
|
||||
| `HASHTAGS` | `Config.hashtags` | Trailing hashtag line; commas become spaces. |
|
||||
|
||||
See [Config Schema](/architecture/config-schema.md) for full validation
|
||||
rules.
|
||||
|
||||
# Key Files
|
||||
|
||||
| Path | Responsibility |
|
||||
|---|---|
|
||||
| `/repo/src/tenbackward/publishing.py` | `MASTODON_STATUS_LIMIT`, `PublishError`, `build_status_text`, `validate_status`, `_post_status_via_mastodon_py`, `publish_mastodon`. |
|
||||
| `/repo/src/tenbackward/matching.py` | `MatchedPost` dataclass and `slugify_title()` reused here. |
|
||||
| `/repo/src/tenbackward/main.py` | Calls `publish_mastodon()` between dedupe and `mark_posted_many`. |
|
||||
| `/repo/tests/test_publishing.py` | Composition, length validation, visibility passthrough, API failure wrapping, and the no-persistence-on-failure contract. |
|
||||
|
||||
# Related
|
||||
|
||||
* [Pipeline Runner](/architecture/pipeline-runner.md)
|
||||
* [Anniversary Matching](/architecture/anniversary-matching.md)
|
||||
* [Config Schema](/architecture/config-schema.md)
|
||||
* [Daily Run Guide](/guides/daily-run.md)
|
||||
Reference in New Issue
Block a user