170 lines
6.5 KiB
Markdown
170 lines
6.5 KiB
Markdown
---
|
|
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` (configured via `THROWBACK_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.mastodon_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. |
|
|
| `MASTODON_VISIBILITY` | `Config.mastodon_visibility` | Passed as `visibility=` to `status_post`. |
|
|
| `THROWBACK_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) |