diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..eab5516 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,3 @@ +/public/ +/resources/ +/.hugo_build.lock diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..db5f7b7 --- /dev/null +++ b/ui/README.md @@ -0,0 +1,19 @@ +# shitpost docs UI + +This folder contains a minimal Hugo documentation site for `shitpost`. + +## Local development + +```sh +hugo server --source ui +``` + +## Build + +```sh +hugo --source ui --gc --minify +``` + +## Netlify + +Set the Netlify base directory to `ui`. Netlify will read `ui/netlify.toml`, run Hugo, and publish `ui/public`. diff --git a/ui/content/_index.md b/ui/content/_index.md new file mode 100644 index 0000000..5bbdb46 --- /dev/null +++ b/ui/content/_index.md @@ -0,0 +1,4 @@ +--- +title: "shitpost" +description: "Post once to Telegram, publish everywhere." +--- diff --git a/ui/content/getting-started/_index.md b/ui/content/getting-started/_index.md new file mode 100644 index 0000000..d77f8cc --- /dev/null +++ b/ui/content/getting-started/_index.md @@ -0,0 +1,39 @@ +--- +title: "Getting started" +description: "Run a private Telegram bot that crossposts text and media to your social accounts." +weight: 10 +--- + +`shitpost` is a small Go service that listens to your Telegram bot, downloads incoming media when needed, and calls the `crosspost` CLI with the platform flags you choose. + +The happy path is: + +1. Create a Telegram bot with BotFather. +2. Create a `.env` file from `.env.example`. +3. Add your Telegram token and the platform credentials you need. +4. Start in dry-run mode. +5. Send a message to your bot and inspect the command preview. +6. Disable dry-run when the output looks right. + +```sh +curl -o .env https://raw.githubusercontent.com/bupd/shitpost/main/.env.example +docker run -d --name shitpost \ + --env-file .env \ + -v ./downloads:/app/downloads \ + registry.goharbor.io/bupd/shitpost:latest +``` + +## What it does + +- Accepts Telegram text messages and posts them through `crosspost`. +- Accepts Telegram photos, downloads the largest image, and passes it to `crosspost` with optional alt text. +- Accepts video and document uploads, stores them locally, and posts caption text when the installed `crosspost` media path cannot attach them. +- Replies back in Telegram with the `crosspost` logs or the dry-run command preview. +- Can restrict usage to specific Telegram usernames or numeric user IDs. + +## What to read next + +- [Installation](/getting-started/installation/) for Docker, Compose, and local development. +- [Authentication](/getting-started/authentication/) for Telegram and platform tokens. +- [Configuration](/getting-started/configuration/) for every environment variable. +- [Architecture](/getting-started/architecture/) for the full request flow. diff --git a/ui/content/getting-started/architecture.md b/ui/content/getting-started/architecture.md new file mode 100644 index 0000000..ce0238a --- /dev/null +++ b/ui/content/getting-started/architecture.md @@ -0,0 +1,61 @@ +--- +title: "Architecture" +description: "How Telegram messages move through shitpost, crosspost, and target platforms." +weight: 50 +--- + +`shitpost` is intentionally thin. It owns the Telegram interface, local media download, environment normalization, and Telegram replies. Publishing is delegated to `crosspost`. + +```text +Telegram user + -> Telegram Bot API + -> shitpost Go process + -> crosspost CLI + -> Bluesky / Mastodon / X / other targets +``` + +## Runtime pieces + +| Piece | Responsibility | +| --- | --- | +| Telegram Bot API | Delivers messages, photos, videos, and documents to the bot. | +| `main.go` | Polls updates, authorizes users, parses messages, downloads files, and starts `crosspost`. | +| `downloads/` | Local working directory for Telegram media files. | +| `crosspost` CLI | Publishes to the selected social platforms. | +| `.env` | Holds Telegram and platform credentials. | +| Docker image | Bundles the Go bot, Bun runtime, and built `crosspost` CLI. | + +## Message flow + +1. The bot starts and reads `BOT_TOKEN`, `AUTHORIZED_TELEGRAM_USERS`, `CROSSPOST_FLAGS`, and `SHITPOST_DRY_RUN`. +2. The Telegram SDK opens a long-polling update channel. +3. Non-message updates are ignored. +4. If `AUTHORIZED_TELEGRAM_USERS` is set, the sender username or numeric ID must match. +5. Text messages call `crosspost` directly with the message body. +6. Photos are downloaded from Telegram, saved to `downloads/`, and passed to `crosspost` with `--image`. +7. Captions ending with `alt:` are split into clean caption and image alt text. +8. Videos and non-image documents are downloaded, but only caption text is posted by the current CLI path. +9. The bot sends the captured `crosspost` output back to the Telegram chat. + +## Docker build flow + +The Dockerfile has two stages: + +1. A Go builder compiles the static `bot` binary. +2. A Bun runtime clones `crosspost`, installs dependencies, builds it, writes a `/usr/local/bin/crosspost` wrapper, and copies the Go binary. + +The final container starts `./bot` from `/app` and stores downloaded media in `/app/downloads`. + +## CI and releases + +Pull requests and pushes run formatting, vet, build, and tests. Pushes to `main` also build multi-arch images for `linux/amd64` and `linux/arm64`, sign them, publish to GHCR, and copy `latest` to Harbor. + +Tags beginning with `v` run GoReleaser, publish release archives, build semver image tags, copy them to Harbor, and sign the images. + +## Security model + +- The service is self-hosted; messages and downloaded media stay on your machine or server. +- No web UI is exposed by this project. +- The Telegram bot should be treated as a private command surface. +- `AUTHORIZED_TELEGRAM_USERS` should be set for any real account. +- `.env` should never be committed or shared. diff --git a/ui/content/getting-started/authentication.md b/ui/content/getting-started/authentication.md new file mode 100644 index 0000000..1d1d933 --- /dev/null +++ b/ui/content/getting-started/authentication.md @@ -0,0 +1,93 @@ +--- +title: "Authentication" +description: "Create the Telegram bot and collect the platform credentials that crosspost needs." +weight: 30 +--- + +## Telegram bot token + +1. Open [@BotFather](https://t.me/BotFather) in Telegram. +2. Send `/newbot`. +3. Pick a display name and bot username. +4. Copy the token BotFather returns. +5. Put it in `.env` as `BOT_TOKEN`. + +```dotenv +BOT_TOKEN=123456789:telegram-secret +``` + +Treat the bot token like a password. Anyone with it can control the bot API for that bot. + +## Private bot access + +Set `AUTHORIZED_TELEGRAM_USERS` so only trusted Telegram accounts can trigger posts. + +```dotenv +AUTHORIZED_TELEGRAM_USERS=alice,123456789 +``` + +Accepted values are Telegram usernames without `@`, usernames with `@`, or numeric Telegram user IDs. Separate multiple users with commas. + +Leaving this variable empty means anyone who can message the bot can ask it to post. That is only safe for a throwaway test bot. + +## Bluesky credentials + +Use an app password instead of your account password. + +1. Open Bluesky settings. +2. Go to app passwords. +3. Create a new app password for `shitpost`. +4. Set your handle or email as `BLUESKY_IDENTIFIER`. +5. Set the app password as `BLUESKY_PASSWORD`. + +```dotenv +BLUESKY_HOST=bsky.social +BLUESKY_IDENTIFIER=you.bsky.social +BLUESKY_PASSWORD=xxxx-xxxx-xxxx-xxxx +``` + +## Mastodon credentials + +Create an application from your Mastodon instance preferences. + +1. Open your Mastodon instance in a browser. +2. Go to Preferences, then Development. +3. Create a new application with write scopes. +4. Copy the access token. +5. Set the instance URL and token in `.env`. + +```dotenv +MASTODON_HOST=https://mastodon.social +MASTODON_ACCESS_TOKEN=secret-token +``` + +Some `crosspost` flows may also use `MASTODON_CLIENT_KEY` and `MASTODON_CLIENT_SECRET`; keep them available if your target command needs them. + +## Twitter / X credentials + +`shitpost` supports the `crosspost` Twitter strategies and normalizes several legacy aliases before invoking `crosspost`. + +For the emusks-backed strategy, provide the X `auth_token` cookie value: + +```dotenv +AUTH_TOKEN=your-x-auth-token-cookie +``` + +You can also set the explicit variable: + +```dotenv +TWITTER_AUTH_TOKEN=your-x-auth-token-cookie +``` + +If both are set, `TWITTER_AUTH_TOKEN` wins. + +For official API fallback paths, use OAuth 1.0a credentials: + +```dotenv +TWITTER_API_CONSUMER_KEY= +TWITTER_API_CONSUMER_SECRET= +TWITTER_ACCESS_TOKEN_KEY= +TWITTER_ACCESS_TOKEN_SECRET= +``` + +To find the X `auth_token` cookie, sign in to X in a browser you control, open developer tools, inspect cookies for `x.com`, and copy the value named `auth_token`. Do not paste the full cookie header; only the token value belongs in `.env`. diff --git a/ui/content/getting-started/configuration.md b/ui/content/getting-started/configuration.md new file mode 100644 index 0000000..7360c24 --- /dev/null +++ b/ui/content/getting-started/configuration.md @@ -0,0 +1,87 @@ +--- +title: "Configuration" +description: "Environment variables, target flags, aliases, and safe validation." +weight: 40 +--- + +## Minimal `.env` + +Start with the example file: + +```sh +cp .env.example .env +``` + +The smallest useful dry-run config is: + +```dotenv +BOT_TOKEN=123456789:telegram-secret +AUTHORIZED_TELEGRAM_USERS=your_username +CROSSPOST_FLAGS=-bmt +SHITPOST_DRY_RUN=1 +``` + +`CROSSPOST_FLAGS=-bmt` tells `crosspost` to post to Bluesky, Mastodon, and Twitter/X. Change it to match the destinations you actually configured. + +## Core variables + +| Variable | Required | Purpose | +| --- | --- | --- | +| `BOT_TOKEN` | Yes | Telegram bot token from BotFather. | +| `AUTHORIZED_TELEGRAM_USERS` | Recommended | Comma-separated usernames or numeric IDs allowed to use the bot. Empty means public. | +| `CROSSPOST_FLAGS` | No | Flags passed directly to `crosspost`; defaults to `-bmt`. | +| `SHITPOST_DRY_RUN` | No | Set to `1`, `true`, or `yes` to preview commands without posting. | +| `CROSSPOST_REPO` | No | Source repo used when building the Docker image. | +| `CROSSPOST_REF` | No | Branch, tag, or ref used when building the Docker image. | + +## Platform variables + +| Variable | Platform | Purpose | +| --- | --- | --- | +| `BLUESKY_HOST` | Bluesky | Usually `bsky.social`. | +| `BLUESKY_IDENTIFIER` | Bluesky | Handle or email. | +| `BLUESKY_PASSWORD` | Bluesky | App password. | +| `MASTODON_HOST` | Mastodon | Instance URL, such as `https://mastodon.social`. | +| `MASTODON_ACCESS_TOKEN` | Mastodon | Access token with posting permission. | +| `MASTODON_CLIENT_KEY` | Mastodon | Optional client key for crosspost flows that need it. | +| `MASTODON_CLIENT_SECRET` | Mastodon | Optional client secret for crosspost flows that need it. | +| `AUTH_TOKEN` | X | Alias for the X `auth_token` cookie. | +| `TWITTER_AUTH_TOKEN` | X | Explicit X `auth_token`; overrides `AUTH_TOKEN`. | +| `TWITTER_AUTH_CLIENT` | X | Optional client identity understood by the emusks-backed strategy. | +| `TWITTER_GRAPHQL_ENDPOINT` | X | Optional endpoint profile understood by the emusks-backed strategy. | +| `TWITTER_PROXY` | X | Optional proxy for X requests. | +| `TWITTER_API_CONSUMER_KEY` | X | Official API consumer key fallback. | +| `TWITTER_API_CONSUMER_SECRET` | X | Official API consumer secret fallback. | +| `TWITTER_ACCESS_TOKEN_KEY` | X | Official API access token fallback. | +| `TWITTER_ACCESS_TOKEN_SECRET` | X | Official API access token secret fallback. | + +## Alias normalization + +Before `shitpost` starts `crosspost`, it copies legacy aliases into the variable names `crosspost` expects when the target variable is empty. + +| Target | Accepted aliases | +| --- | --- | +| `TWITTER_AUTH_TOKEN` | `AUTH_TOKEN` | +| `TWITTER_API_CONSUMER_KEY` | `consumer_key`, `TWITTER_CONSUMER_KEY` | +| `TWITTER_API_CONSUMER_SECRET` | `consumer_key_secret`, `TWITTER_CONSUMER_SECRET` | +| `TWITTER_ACCESS_TOKEN_KEY` | `access_token`, `access_token_key`, `TWITTER_ACCESS_TOKEN` | +| `TWITTER_ACCESS_TOKEN_SECRET` | `access_token_secret`, `TWITTER_ACCESS_SECRET` | + +## Validate without leaking secrets + +Run the doctor task when posts fail or before deploying a new `.env`: + +```sh +task doctor +``` + +The doctor script prints whether each key is present and the value length. It does not print secret values. + +## Recommended first run + +```dotenv +SHITPOST_DRY_RUN=1 +CROSSPOST_FLAGS=-bmt +``` + +Send one text message and one image with a caption. If the Telegram replies show the expected command, set `SHITPOST_DRY_RUN=0` and restart the service. diff --git a/ui/content/getting-started/deployment.md b/ui/content/getting-started/deployment.md new file mode 100644 index 0000000..244753f --- /dev/null +++ b/ui/content/getting-started/deployment.md @@ -0,0 +1,77 @@ +--- +title: "Deployment" +description: "Run the bot on a server and deploy these docs on Netlify." +weight: 70 +--- + +## Bot deployment + +For a server, prefer the published container image and a persistent `downloads` directory. + +```sh +mkdir -p /opt/shitpost/downloads +cd /opt/shitpost +curl -o .env https://raw.githubusercontent.com/bupd/shitpost/main/.env.example +``` + +Edit `/opt/shitpost/.env`, then run: + +```sh +docker run -d --name shitpost \ + --restart unless-stopped \ + --env-file /opt/shitpost/.env \ + -v /opt/shitpost/downloads:/app/downloads \ + registry.goharbor.io/bupd/shitpost:latest +``` + +Check logs: + +```sh +docker logs -f shitpost +``` + +## Docker Compose deployment + +The repo includes `docker-compose.yml`: + +```sh +cp .env.example .env +docker compose up --build -d +docker compose logs -f shitpost-bot +``` + +The Compose service is named `shitpost-bot` and the container is named `shitpost-engine`. + +## Image registries + +Published images are available from: + +```sh +docker pull registry.goharbor.io/bupd/shitpost:latest +docker pull ghcr.io/bupd/shitpost:latest +``` + +For production, use a versioned tag when one is available. + +## Docs deployment on Netlify + +This `ui` folder is a Hugo site. In Netlify: + +1. Connect the Git repository. +2. Set the base directory to `ui`. +3. Keep the build command as `hugo --gc --minify`. +4. Keep the publish directory as `public`. + +`ui/netlify.toml` already contains those settings. + +## Local docs preview + +```sh +hugo server --source ui +``` + +Build the static site: + +```sh +hugo --source ui --gc --minify +``` diff --git a/ui/content/getting-started/installation.md b/ui/content/getting-started/installation.md new file mode 100644 index 0000000..512c55d --- /dev/null +++ b/ui/content/getting-started/installation.md @@ -0,0 +1,87 @@ +--- +title: "Installation" +description: "Install and run shitpost with Docker, Docker Compose, or Go." +weight: 20 +--- + +## Requirements + +- Docker or Podman for the recommended setup. +- Go 1.25+ if you run the bot locally. +- A Telegram bot token from [BotFather](https://t.me/BotFather). +- Credentials for each social platform you want `crosspost` to publish to. + +## Option 1: run the image + +Use the published image when you want the simplest production path. + +```sh +mkdir -p downloads +curl -o .env https://raw.githubusercontent.com/bupd/shitpost/main/.env.example +``` + +Edit `.env`, then run: + +```sh +docker run -d --name shitpost \ + --env-file .env \ + -v ./downloads:/app/downloads \ + registry.goharbor.io/bupd/shitpost:latest +``` + +Follow logs: + +```sh +docker logs -f shitpost +``` + +Use a versioned tag for production once releases are available. `latest` tracks the newest build from `main`. + +## Option 2: Docker Compose + +Clone the repo and run the Compose workflow: + +```sh +git clone https://github.com/bupd/shitpost.git +cd shitpost +cp .env.example .env +docker compose up --build +``` + +The service mounts `./downloads` into `/app/downloads` so Telegram media survives container restarts. + +## Option 3: Taskfile workflow + +If you have [Task](https://taskfile.dev/) installed, the repo exposes common workflows: + +```sh +task setup +task up:dry-run +task logs +task down +task doctor +task validate +``` + +Start with `task up:dry-run`. Your Telegram messages will not be posted; the bot replies with the exact `crosspost` command it would run. + +## Option 4: local Go process + +Local mode is useful while changing bot behavior. + +```sh +cp .env.example .env +task dev:dry-run +``` + +The local process expects `crosspost` to be available on `PATH` if dry-run is disabled. The Docker image already installs and wraps `crosspost` for you. + +## Confirm it works + +The bot is ready when logs include: + +```text +Authorized as @your_bot_username +``` + +Send a Telegram message to the bot. In dry-run mode, the reply should start with `DRY RUN: would run`. diff --git a/ui/content/getting-started/troubleshooting.md b/ui/content/getting-started/troubleshooting.md new file mode 100644 index 0000000..f72c29d --- /dev/null +++ b/ui/content/getting-started/troubleshooting.md @@ -0,0 +1,80 @@ +--- +title: "Troubleshooting" +description: "Common startup, auth, media, and platform posting failures." +weight: 80 +--- + +## Bot fails on startup + +Check `BOT_TOKEN` first. If it is missing, the process exits immediately with: + +```text +BOT_TOKEN environment variable is required +``` + +If the token is wrong, Telegram bot creation fails. Regenerate or copy the token again from BotFather. + +## Bot starts but ignores you + +If `AUTHORIZED_TELEGRAM_USERS` is set, your Telegram username or numeric user ID must be in the comma-separated list. + +```dotenv +AUTHORIZED_TELEGRAM_USERS=your_username,123456789 +``` + +Remove the leading `@` or keep it; `shitpost` normalizes both forms. + +## Posts do not appear + +Run the doctor script: + +```sh +task doctor +``` + +Confirm the target platform variables are present. Then run dry-run mode and inspect the command preview: + +```sh +task up:dry-run +``` + +If the command preview is wrong, fix `CROSSPOST_FLAGS`. If the command preview is right but live posting fails, inspect the `crosspost` stderr returned in Telegram. + +## X returns 401 + +Confirm you are using the correct X credential path. + +For the emusks-backed strategy, set only the cookie value: + +```dotenv +AUTH_TOKEN=your-auth-token-cookie-value +``` + +For official API fallback, verify `TWITTER_ACCESS_TOKEN_KEY` is an OAuth 1.0a access token. The doctor script warns if the value does not look like a typical OAuth token. + +## Media does not upload + +Check that `downloads/` exists and is writable by the container. + +```sh +mkdir -p downloads +docker compose up --build +``` + +Images are attached through `crosspost`. Videos and non-image documents currently post caption text only. + +## Logs are too long for Telegram + +Telegram has a message length limit. `shitpost` chunks long replies so you still receive the command result and logs. + +## Reset safely + +Stop and remove the container without deleting your `.env` or downloaded media: + +```sh +docker rm -f shitpost +docker run -d --name shitpost \ + --env-file .env \ + -v ./downloads:/app/downloads \ + registry.goharbor.io/bupd/shitpost:latest +``` diff --git a/ui/content/getting-started/usage.md b/ui/content/getting-started/usage.md new file mode 100644 index 0000000..e296528 --- /dev/null +++ b/ui/content/getting-started/usage.md @@ -0,0 +1,64 @@ +--- +title: "Usage" +description: "Send text, images, captions, and alt text from Telegram." +weight: 60 +--- + +## Text posts + +Send a normal text message to your Telegram bot. `shitpost` passes that text as the final argument to `crosspost`. + +```text +shipping a small self-hosted crossposter today +``` + +In dry-run mode, the bot replies with a command preview. In live mode, it replies with `crosspost` stdout and stderr. + +## Image posts + +Send a photo with an optional caption. Telegram provides multiple photo sizes; `shitpost` picks the largest one, saves it under `downloads/`, and passes it with `--image`. + +```text +caption: a tiny bot doing useful work +``` + +## Alt text + +Add alt text by ending the caption with a final line that starts with `alt:`. + +```text +new deploy view from the homelab +alt: A terminal window showing a successful Docker deployment +``` + +The posted caption becomes `new deploy view from the homelab`. The alt text is passed separately to `crosspost`. + +## Videos and documents + +Telegram videos and documents are downloaded so the bot can acknowledge and inspect them. The current installed `crosspost` CLI path only attaches images, so video and non-image document messages post caption text only. + +If there is no caption, the bot replies with a warning instead of posting an empty update. + +## Dry-run mode + +Dry-run mode is the safest way to test credentials, target flags, captions, and alt text. + +```dotenv +SHITPOST_DRY_RUN=1 +``` + +Restart the service after changing `.env`. The startup logs should include: + +```text +Dry-run mode enabled. Messages will not be posted. +``` + +## Target selection + +`CROSSPOST_FLAGS` is passed directly to `crosspost`. + +```dotenv +CROSSPOST_FLAGS=-bmt +``` + +Use the flags supported by the `crosspost` version you build into the image. The default project configuration targets Bluesky, Mastodon, and Twitter/X. diff --git a/ui/hugo.toml b/ui/hugo.toml new file mode 100644 index 0000000..d9e1ff3 --- /dev/null +++ b/ui/hugo.toml @@ -0,0 +1,70 @@ +baseURL = "https://shitpost.dev/" +languageCode = "en-us" +title = "shitpost" +disableKinds = ["taxonomy", "term"] + +[params] +description = "Telegram-powered social media crossposting for self-hosters." +repo = "https://github.com/bupd/shitpost" +image = "registry.goharbor.io/bupd/shitpost:latest" + +[markup] + [markup.highlight] + noClasses = false + [markup.goldmark.renderer] + unsafe = true + +[[menus.main]] +name = "Home" +url = "/" +weight = 10 + +[[menus.main]] +name = "Getting started" +url = "/getting-started/" +weight = 20 + +[[menus.main]] +name = "GitHub" +url = "https://github.com/bupd/shitpost" +weight = 30 + +[[menus.docs]] +name = "Overview" +url = "/getting-started/" +weight = 10 + +[[menus.docs]] +name = "Installation" +url = "/getting-started/installation/" +weight = 20 + +[[menus.docs]] +name = "Authentication" +url = "/getting-started/authentication/" +weight = 30 + +[[menus.docs]] +name = "Configuration" +url = "/getting-started/configuration/" +weight = 40 + +[[menus.docs]] +name = "Architecture" +url = "/getting-started/architecture/" +weight = 50 + +[[menus.docs]] +name = "Usage" +url = "/getting-started/usage/" +weight = 60 + +[[menus.docs]] +name = "Deployment" +url = "/getting-started/deployment/" +weight = 70 + +[[menus.docs]] +name = "Troubleshooting" +url = "/getting-started/troubleshooting/" +weight = 80 diff --git a/ui/layouts/_default/baseof.html b/ui/layouts/_default/baseof.html new file mode 100644 index 0000000..81f5597 --- /dev/null +++ b/ui/layouts/_default/baseof.html @@ -0,0 +1,18 @@ + + + + + + {{ if .IsHome }}{{ site.Title }}{{ else }}{{ .Title }} | {{ site.Title }}{{ end }} + + + + + + {{ partial "header.html" . }} +
+ {{ block "main" . }}{{ end }} +
+ {{ partial "footer.html" . }} + + diff --git a/ui/layouts/getting-started/list.html b/ui/layouts/getting-started/list.html new file mode 100644 index 0000000..5707017 --- /dev/null +++ b/ui/layouts/getting-started/list.html @@ -0,0 +1,12 @@ +{{ define "main" }} +
+ {{ partial "sidebar.html" . }} +
+

Documentation

+

{{ .Title }}

+ {{ with .Description }}

{{ . }}

{{ end }} + {{ .Content }} + {{ partial "pager.html" . }} +
+
+{{ end }} diff --git a/ui/layouts/getting-started/single.html b/ui/layouts/getting-started/single.html new file mode 100644 index 0000000..5707017 --- /dev/null +++ b/ui/layouts/getting-started/single.html @@ -0,0 +1,12 @@ +{{ define "main" }} +
+ {{ partial "sidebar.html" . }} +
+

Documentation

+

{{ .Title }}

+ {{ with .Description }}

{{ . }}

{{ end }} + {{ .Content }} + {{ partial "pager.html" . }} +
+
+{{ end }} diff --git a/ui/layouts/index.html b/ui/layouts/index.html new file mode 100644 index 0000000..0394f3a --- /dev/null +++ b/ui/layouts/index.html @@ -0,0 +1,59 @@ +{{ define "main" }} +
+
+
+

Telegram-powered crossposting

+

Post once to Telegram. Publish everywhere.

+

shitpost is a tiny self-hosted Go bot that turns your private Telegram chat into a social posting command center.

+ +
+
+
+
docker run -d --name shitpost \
+  --env-file .env \
+  -v ./downloads:/app/downloads \
+  {{ site.Params.image }}
+
+
+
+ +
+
+ 01 +

Private Telegram interface

+

Send text, images, captions, and alt text to one bot instead of opening every social app.

+
+
+ 02 +

Self-hosted by default

+

Your tokens, media, and logs stay on your server. No hosted dashboard is required.

+
+
+ 03 +

Powered by crosspost

+

The Go bot handles Telegram. The `crosspost` CLI handles Bluesky, Mastodon, X, and more.

+
+
+ 04 +

Dry-run first

+

Preview the exact command before anything is published. Fix flags and tokens safely.

+
+
+ +
+
+

Architecture

+

A small wrapper, not a platform.

+

shitpost polls Telegram, checks authorization, downloads media, normalizes environment variables, and starts crosspost. That is the whole shape.

+
+
+ Telegram + shitpost bot + crosspost CLI + Social platforms +
+
+{{ end }} diff --git a/ui/layouts/partials/footer.html b/ui/layouts/partials/footer.html new file mode 100644 index 0000000..6934fb1 --- /dev/null +++ b/ui/layouts/partials/footer.html @@ -0,0 +1,6 @@ + diff --git a/ui/layouts/partials/header.html b/ui/layouts/partials/header.html new file mode 100644 index 0000000..1ec9eda --- /dev/null +++ b/ui/layouts/partials/header.html @@ -0,0 +1,13 @@ + diff --git a/ui/layouts/partials/pager.html b/ui/layouts/partials/pager.html new file mode 100644 index 0000000..74dfadb --- /dev/null +++ b/ui/layouts/partials/pager.html @@ -0,0 +1,17 @@ +{{ $pages := where site.RegularPages "Section" .Section }} +{{ $pages = sort $pages "Weight" }} +{{ $current := . }} +{{ $prev := false }} +{{ $next := false }} +{{ range $index, $page := $pages }} + {{ if eq $page.RelPermalink $current.RelPermalink }} + {{ if gt $index 0 }}{{ $prev = index $pages (sub $index 1) }}{{ end }} + {{ if lt (add $index 1) (len $pages) }}{{ $next = index $pages (add $index 1) }}{{ end }} + {{ end }} +{{ end }} +{{ if or $prev $next }} + +{{ end }} diff --git a/ui/layouts/partials/sidebar.html b/ui/layouts/partials/sidebar.html new file mode 100644 index 0000000..ec644d8 --- /dev/null +++ b/ui/layouts/partials/sidebar.html @@ -0,0 +1,9 @@ + diff --git a/ui/netlify.toml b/ui/netlify.toml new file mode 100644 index 0000000..303ce17 --- /dev/null +++ b/ui/netlify.toml @@ -0,0 +1,19 @@ +[build] +command = "hugo --gc --minify" +publish = "public" + +[build.environment] +HUGO_VERSION = "0.148.2" +HUGO_ENV = "production" +HUGO_ENABLEGITINFO = "true" + +[[headers]] +for = "/*" + [headers.values] + X-Content-Type-Options = "nosniff" + Referrer-Policy = "strict-origin-when-cross-origin" + +[[redirects]] +from = "/docs/*" +to = "/getting-started/:splat" +status = 301 diff --git a/ui/static/css/main.css b/ui/static/css/main.css new file mode 100644 index 0000000..1f32fe1 --- /dev/null +++ b/ui/static/css/main.css @@ -0,0 +1,545 @@ +@layer reset, base, components, utilities; + +@layer reset { + *, + *::before, + *::after { + box-sizing: border-box; + } + + body, + h1, + h2, + h3, + p, + pre { + margin: 0; + } + + img { + display: block; + max-width: 100%; + } +} + +@layer base { + :root { + color-scheme: light; + --bg: #ffffff; + --bg-alt: #f6f8fa; + --bg-soft: #f0f2f5; + --text: #24292f; + --text-muted: #57606a; + --border: rgb(208 215 222 / 70%); + --brand: #2874e4; + --brand-dark: #1456be; + --brand-soft: rgb(40 116 228 / 8%); + --warning-soft: rgb(175 120 0 / 8%); + --radius: 14px; + --shadow: 0 18px 60px rgb(27 31 36 / 8%); + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + + html { + scroll-behavior: smooth; + } + + body { + background: var(--bg); + color: var(--text); + font-family: var(--font-sans); + line-height: 1.65; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + } + + a { + color: var(--brand); + text-decoration: none; + } + + a:hover { + color: var(--brand-dark); + } + + code, + pre { + font-family: var(--font-mono); + } +} + +@layer components { + .shell { + width: min(1120px, calc(100% - 40px)); + margin-inline: auto; + } + + .site-header { + position: sticky; + top: 0; + z-index: 10; + border-bottom: 1px solid var(--border); + background: rgb(255 255 255 / 86%); + backdrop-filter: blur(18px); + } + + .site-nav { + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: 24px; + } + + .brand { + display: inline-flex; + align-items: center; + gap: 10px; + color: var(--text); + font-weight: 700; + } + + .site-nav__links { + display: flex; + align-items: center; + gap: 22px; + font-size: 14px; + font-weight: 500; + } + + .site-nav__links a { + color: var(--text-muted); + } + + .site-nav__links a:hover { + color: var(--brand); + } + + .hero { + position: relative; + overflow: hidden; + padding: 92px 0 72px; + } + + .hero::before { + position: absolute; + inset: -240px -160px auto auto; + width: 540px; + height: 540px; + border-radius: 999px; + background: radial-gradient(circle, rgb(40 116 228 / 22%), transparent 68%); + content: ""; + } + + .hero__content { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(340px, 0.8fr); + gap: 48px; + align-items: center; + } + + .hero h1 { + max-width: 780px; + font-size: clamp(48px, 7vw, 88px); + line-height: 0.94; + letter-spacing: -0.06em; + } + + .hero__lede, + .lead { + max-width: 680px; + color: var(--text-muted); + font-size: 20px; + } + + .hero__copy { + display: grid; + gap: 24px; + } + + .hero__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + } + + .eyebrow { + color: var(--brand); + font-size: 13px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + } + + .button { + display: inline-flex; + min-height: 44px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 8px; + padding: 0 18px; + font-weight: 700; + transition: transform 150ms ease, background 150ms ease, border-color 150ms ease; + } + + .button:active { + transform: scale(0.97); + } + + .button--primary { + background: var(--brand); + color: #fff; + box-shadow: inset 0 -2px rgb(0 0 0 / 12%); + } + + .button--primary:hover { + background: var(--brand-dark); + color: #fff; + } + + .button--secondary { + border-color: rgb(40 116 228 / 24%); + background: var(--brand-soft); + } + + .terminal { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 20px; + background: #121317; + box-shadow: var(--shadow); + } + + .terminal__bar { + display: flex; + gap: 8px; + border-bottom: 1px solid rgb(255 255 255 / 10%); + padding: 14px 16px; + } + + .terminal__bar span { + width: 10px; + height: 10px; + border-radius: 999px; + background: #98c3ff; + opacity: 0.75; + } + + .terminal pre { + overflow-x: auto; + padding: 22px; + color: #e5e7eb; + font-size: 14px; + line-height: 1.7; + } + + .feature-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + padding: 24px 0 88px; + } + + .feature-card, + .flow-card { + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-alt); + box-shadow: 0 1px 2px rgb(27 31 36 / 4%); + } + + .feature-card { + display: grid; + gap: 14px; + padding: 22px; + } + + .feature-card__icon { + width: fit-content; + border-radius: 999px; + background: var(--brand-soft); + color: var(--brand); + font-size: 12px; + font-weight: 800; + padding: 4px 10px; + } + + .feature-card h2 { + font-size: 18px; + line-height: 1.2; + } + + .feature-card p, + .split p { + color: var(--text-muted); + } + + .split { + display: grid; + grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1fr); + gap: 42px; + align-items: center; + padding: 28px 0 96px; + } + + .split h2 { + margin: 8px 0 14px; + font-size: clamp(32px, 4vw, 54px); + line-height: 1; + letter-spacing: -0.04em; + } + + .flow-card { + display: grid; + gap: 10px; + padding: 22px; + } + + .flow-card span { + border: 1px solid var(--border); + border-radius: 10px; + background: #fff; + padding: 14px 16px; + font-weight: 700; + } + + .docs { + display: grid; + grid-template-columns: 250px minmax(0, 760px); + gap: 56px; + align-items: start; + padding: 48px 0 88px; + } + + .sidebar { + position: sticky; + top: 92px; + border-right: 1px solid var(--border); + padding-right: 20px; + } + + .sidebar__title { + margin-bottom: 12px; + color: var(--text); + font-size: 13px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .sidebar__link { + display: block; + border-radius: 8px; + color: var(--text-muted); + font-size: 14px; + font-weight: 600; + padding: 8px 10px; + } + + .sidebar__link:hover, + .sidebar__link--active { + background: var(--brand-soft); + color: var(--brand); + } + + .prose { + min-width: 0; + } + + .prose h1 { + margin-top: 8px; + font-size: clamp(40px, 5vw, 64px); + line-height: 1; + letter-spacing: -0.05em; + } + + .prose .lead { + margin-top: 18px; + margin-bottom: 34px; + } + + .prose h2 { + margin-top: 42px; + margin-bottom: 12px; + padding-top: 6px; + font-size: 28px; + letter-spacing: -0.03em; + } + + .prose h3 { + margin-top: 28px; + margin-bottom: 10px; + } + + .prose p, + .prose ul, + .prose ol, + .prose table, + .prose pre { + margin-top: 16px; + } + + .prose p, + .prose li { + color: var(--text-muted); + } + + .prose strong { + color: var(--text); + } + + .prose code:not(pre code) { + border-radius: 6px; + background: var(--brand-soft); + color: var(--brand-dark); + font-size: 0.9em; + padding: 2px 6px; + } + + .prose pre { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--bg-alt); + padding: 18px; + font-size: 14px; + line-height: 1.7; + } + + .prose table { + display: block; + overflow-x: auto; + width: 100%; + border-collapse: collapse; + font-size: 14px; + } + + .prose th, + .prose td { + border-bottom: 1px solid var(--border); + padding: 10px 12px; + text-align: left; + vertical-align: top; + } + + .prose th { + color: var(--text); + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + } + + .pager { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + margin-top: 56px; + padding-top: 24px; + border-top: 1px solid var(--border); + } + + .pager__item { + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + font-weight: 700; + } + + .pager__item--next { + grid-column: 2; + text-align: right; + } + + .site-footer { + border-top: 1px solid var(--border); + background: var(--bg-alt); + color: var(--text-muted); + } + + .site-footer__inner { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 28px 0; + font-size: 14px; + } +} + +@layer utilities { + .highlight { + border-radius: 12px; + } +} + +@media (max-width: 920px) { + .hero__content, + .split, + .docs { + grid-template-columns: 1fr; + } + + .feature-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .sidebar { + position: static; + border-right: 0; + border-bottom: 1px solid var(--border); + padding-right: 0; + padding-bottom: 16px; + } + + .sidebar nav { + display: flex; + overflow-x: auto; + gap: 6px; + padding-bottom: 4px; + } + + .sidebar__link { + white-space: nowrap; + } +} + +@media (max-width: 640px) { + .shell { + width: min(100% - 28px, 1120px); + } + + .site-nav { + align-items: flex-start; + flex-direction: column; + padding: 14px 0; + } + + .site-nav__links { + width: 100%; + overflow-x: auto; + padding-bottom: 2px; + } + + .hero { + padding-top: 56px; + } + + .feature-grid { + grid-template-columns: 1fr; + } + + .pager { + grid-template-columns: 1fr; + } + + .pager__item--next { + grid-column: auto; + text-align: left; + } + + .site-footer__inner { + flex-direction: column; + } +} diff --git a/ui/static/icon.svg b/ui/static/icon.svg new file mode 100644 index 0000000..952c965 --- /dev/null +++ b/ui/static/icon.svg @@ -0,0 +1,5 @@ + + + + +