diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7d828bc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.env +.git +bot +downloads +*.test +*.out diff --git a/.env.example b/.env.example index bde8d7b..6696618 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,44 @@ # Telegram bot token BOT_TOKEN= +# Comma-separated Telegram usernames or numeric user IDs allowed to use the bot. +# Empty means anyone can message the bot, which is not recommended. +AUTHORIZED_TELEGRAM_USERS=bupddotxyz + +# crosspost CLI targets. -bmt means Bluesky, Mastodon, Twitter/X. +CROSSPOST_FLAGS=-bmt + +# Set to 1 to exercise Telegram handling without posting to any platform. +SHITPOST_DRY_RUN=0 + +# Optional crosspost source override for Docker builds. +CROSSPOST_REPO=https://github.com/bupd/crosspost.git +CROSSPOST_REF=main + # Bluesky BLUESKY_HOST= BLUESKY_IDENTIFIER= BLUESKY_PASSWORD= -# TWitter / X +# Twitter / X +AUTH_TOKEN= +# TWITTER_AUTH_TOKEN= +# TWITTER_AUTH_CLIENT=web +# TWITTER_GRAPHQL_ENDPOINT=web +# TWITTER_PROXY= + +# Legacy official Twitter API fallback TWITTER_API_CONSUMER_KEY= TWITTER_API_CONSUMER_SECRET= TWITTER_ACCESS_TOKEN_KEY= TWITTER_ACCESS_TOKEN_SECRET= +# Optional aliases also understood by shitpost before calling crosspost: +# consumer_key= +# consumer_key_secret= +# access_token= +# access_token_secret= + # Mastodon MASTODON_HOST= MASTODON_ACCESS_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 840d2fd..b69e058 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,12 @@ jobs: with: go-version: "1.25" + - name: Verify formatting + run: test -z "$(gofmt -l .)" + + - name: Lint + run: go vet ./... + - name: Build run: go build -v ./... diff --git a/.gitignore b/.gitignore index 6f72f89..178a914 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ go.work.sum # env file .env + +# local build output +bot +downloads/ diff --git a/Dockerfile b/Dockerfile index 505976a..85ef85f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,11 +16,27 @@ RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o bot main.go # ---------------------------------------- # STAGE 2 — RUNTIME WITH BUN & CROSSPOST # ---------------------------------------- -FROM oven/bun:alpine +FROM oven/bun:1.3.10 WORKDIR /app -RUN bun install -g @humanwhocodes/crosspost +ARG CROSSPOST_REPO=https://github.com/bupd/crosspost.git +ARG CROSSPOST_REF=main + +RUN apt-get update && \ + apt-get install -y --no-install-recommends git ca-certificates && \ + git clone --depth=1 --branch "$CROSSPOST_REF" "$CROSSPOST_REPO" /opt/crosspost && \ + cd /opt/crosspost && \ + bun install --frozen-lockfile && \ + bun run build && \ + printf '#!/usr/bin/env sh\nexec bun /opt/crosspost/dist/bin.js "$@"\n' > /usr/local/bin/crosspost && \ + chmod +x /usr/local/bin/crosspost && \ + apt-get purge -y --auto-remove git && \ + rm -rf /var/lib/apt/lists/* + +# Mastodon rejects Bun FormData Blob uploads without a filename as blank files. +RUN sed -i 's/data.append("file", new Blob(\[image.data\], { type }));/data.append("file", new Blob([image.data], { type }), "image.jpg");/' \ + /opt/crosspost/dist/strategies/mastodon.js COPY --from=builder /app/bot /app/bot diff --git a/README.md b/README.md index ba9e524..c8d15bb 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ A lightweight, self-hosted alternative to Postiz, Buffer, and Hootsuite for deve | Platform | Text | Images | Videos | |----------|------|--------|--------| -| Twitter/X | Yes | Yes | Yes | -| Bluesky | Yes | Yes | Yes | -| Mastodon | Yes | Yes | Yes | +| Twitter/X | Yes | Yes | Caption only | +| Bluesky | Yes | Yes | Caption only | +| Mastodon | Yes | Yes | Caption only | | LinkedIn | Yes | Yes | No | | Discord | Yes | Yes | Yes | | Telegram | Yes | Yes | Yes | @@ -129,6 +129,8 @@ For production, use a versioned tag (e.g., `v1.0.0`) to avoid unexpected updates | Variable | Required | Description | |----------|----------|-------------| | `BOT_TOKEN` | Yes | Telegram bot token from BotFather | +| `AUTH_TOKEN` | No | X `auth_token` cookie value used by the emusks-backed crosspost Twitter strategy | +| `TWITTER_AUTH_TOKEN` | No | Explicit X `auth_token` cookie value; overrides the `AUTH_TOKEN` alias when set | | `TWITTER_API_CONSUMER_KEY` | No | Twitter API consumer key | | `TWITTER_API_CONSUMER_SECRET` | No | Twitter API consumer secret | | `TWITTER_ACCESS_TOKEN_KEY` | No | Twitter access token | @@ -147,7 +149,7 @@ crosspost itself may require additional environment variables (API keys, tokens Send any text message to your bot. It will be posted to all configured platforms. ### Media Posts -Send images, videos, or documents with an optional caption. +Send images with an optional caption. Videos and non-image documents are downloaded, but the current `crosspost` CLI path posts their caption text only. ### Alt Text Add alt text for accessibility by ending your caption with `alt:`: @@ -159,16 +161,39 @@ alt: Orange and purple sunset over mountains ## Running Locally (without Docker) 1. Install Go 1.25+ and crosspost CLI: - ```sh - npm install -g @humanwhocodes/crosspost - ``` + ```sh + git clone https://github.com/bupd/crosspost.git + cd crosspost + bun install + bun run build + bun link + ``` 2. Build and run: - ```sh - go build -o bot main.go - export BOT_TOKEN=your_token_here - ./bot - ``` + ```sh + task setup + task up + ``` + +### Taskfile workflow + +```sh +task setup # create .env and download deps +task up # run in a container +task up:dry-run # run in a container without posting +task up:dry-run:detached # run dry-run mode in the background +task up:detached # run in a container in the background +task logs # follow container logs +task down # stop the container +task doctor # check .env shape without printing secret values +task validate # gofmt, go vet, go test, go build +``` + +Set `CROSSPOST_FLAGS=-bmt` to post to Bluesky, Mastodon, and X. Set `AUTH_TOKEN` to your X `auth_token` cookie value to use the emusks-backed X poster in `crosspost`. Set `AUTHORIZED_TELEGRAM_USERS` to your Telegram username or numeric user ID so only you can use the bot. + +Use `task up:dry-run` first. Send a Telegram message to the bot and it will reply with the `crosspost` command it would run without posting anything. + +Run `task doctor` if a platform fails. It prints which keys are present and their lengths without exposing token values. For X, `shitpost` accepts `AUTH_TOKEN` as an alias for `TWITTER_AUTH_TOKEN`, and accepts the legacy official API aliases `consumer_key`, `consumer_key_secret`, `access_token`, and `access_token_secret` before invoking `crosspost`. ## Architecture diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..9e8fb92 --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,116 @@ +version: "3" + +vars: + ENV_FILE: '{{default ".env" .ENV_FILE}}' + IMAGE: '{{default "registry.goharbor.io/bupd/shitpost" .IMAGE}}' + TAG: '{{default "dev" .TAG}}' + CROSSPOST_REPO: '{{default "https://github.com/bupd/crosspost.git" .CROSSPOST_REPO}}' + CROSSPOST_REF: '{{default "main" .CROSSPOST_REF}}' + +tasks: + default: + desc: List available tasks + cmds: + - task --list + + setup: + desc: Create .env from .env.example if missing and download Go deps + cmds: + - cmd: cp .env.example {{.ENV_FILE}} + status: + - test -f {{.ENV_FILE}} + - go mod download + + up: + desc: Run the Telegram crosspost bot in Docker Compose + cmds: + - mkdir -p downloads + - docker compose up --build + + up:dry-run: + desc: Run the Telegram crosspost bot in Docker Compose without posting + cmds: + - mkdir -p downloads + - SHITPOST_DRY_RUN=1 docker compose up --build + + up:dry-run:detached: + desc: Run the Telegram crosspost bot in Docker Compose detached dry-run mode + cmds: + - mkdir -p downloads + - SHITPOST_DRY_RUN=1 docker compose up --build -d + + up:detached: + desc: Run the Telegram crosspost bot in Docker Compose detached mode + cmds: + - mkdir -p downloads + - docker compose up --build -d + + down: + desc: Stop the Docker Compose bot + cmds: + - docker compose down + + logs: + desc: Follow Docker Compose bot logs + cmds: + - docker compose logs -f shitpost-bot + + doctor: + desc: Check .env configuration without printing secret values + cmds: + - sh scripts/doctor.sh {{.ENV_FILE}} + + dev: + desc: Run the Telegram crosspost bot locally without Docker + dotenv: ["{{.ENV_FILE}}"] + cmds: + - go run ./main.go + + dev:dry-run: + desc: Run the Telegram crosspost bot locally without posting + dotenv: ["{{.ENV_FILE}}"] + cmds: + - SHITPOST_DRY_RUN=1 go run ./main.go + + build: + desc: Build the bot binary + cmds: + - go build -o bot ./main.go + + test: + desc: Run Go tests + cmds: + - go test ./... + + lint: + desc: Run Go vet + cmds: + - go vet ./... + + fmt: + desc: Format Go code + cmds: + - gofmt -w main.go + + validate: + desc: Format, lint, test, and build + cmds: + - task: fmt + - task: lint + - task: test + - task: build + + image:build: + desc: Build the container image + cmds: + - docker build --build-arg CROSSPOST_REPO={{.CROSSPOST_REPO}} --build-arg CROSSPOST_REF={{.CROSSPOST_REF}} -t {{.IMAGE}}:{{.TAG}} . + + compose:up: + desc: Run the bot in Docker Compose + cmds: + - task: up + + compose:down: + desc: Stop the Docker Compose bot + cmds: + - task: down diff --git a/docker-compose.yml b/docker-compose.yml index 90c6f67..4b15012 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,15 @@ services: shitpost-bot: image: registry.goharbor.io/bupd/shitpost:latest - build: . + build: + context: . + args: + CROSSPOST_REPO: ${CROSSPOST_REPO:-https://github.com/bupd/crosspost.git} + CROSSPOST_REF: ${CROSSPOST_REF:-main} container_name: shitpost-engine env_file: .env + environment: + SHITPOST_DRY_RUN: ${SHITPOST_DRY_RUN:-} restart: unless-stopped volumes: - ./downloads:/app/downloads diff --git a/main.go b/main.go index c07dfaf..e877bf1 100644 --- a/main.go +++ b/main.go @@ -4,22 +4,29 @@ import ( "fmt" "io" "log" + "mime" "net/http" + "net/url" "os" "os/exec" "path/filepath" + "strconv" "strings" tgbot "github.com/go-telegram-bot-api/telegram-bot-api/v5" _ "github.com/joho/godotenv/autoload" ) +const telegramMessageLimit = 3900 + func main() { - // get bot token from env var token := os.Getenv("BOT_TOKEN") if token == "" { log.Fatal("BOT_TOKEN environment variable is required") } + authorizedUsers := authorizedTelegramUsers() + crosspostFlags := crosspostFlags() + dryRun := envBool("SHITPOST_DRY_RUN") // initialize bot instance bot, err := tgbot.NewBotAPI(token) @@ -28,6 +35,9 @@ func main() { } log.Printf("Authorized as @%s", bot.Self.UserName) + if dryRun { + log.Println("Dry-run mode enabled. Messages will not be posted.") + } // setup update config updateConfig := tgbot.NewUpdate(0) @@ -42,8 +52,8 @@ func main() { continue // <-- changed: skip non-message updates } - if update.Message.From.UserName != "bupddotxyz" { - reply := tgbot.NewMessage(update.Message.From.ID, "Hey "+update.Message.From.UserName+", I'm a bot created by @bupddotxyz. Please ask my lord @bupddotxyz for help. dont message me and waste bandwidth") + if !isAuthorized(update.Message.From, authorizedUsers) { + reply := tgbot.NewMessage(update.Message.From.ID, "This bot is private.") log.Printf("Intruder: %s", update.Message.From.UserName) if _, err := bot.Send(reply); err != nil { log.Println("failed sending message:", err) @@ -59,29 +69,73 @@ func main() { // TEXT HANDLING if msg.Text != "" { - go PostViaCrosspost(bot, msg.Chat.ID, "", "", text) + go PostViaCrosspost(bot, msg.Chat.ID, crosspostFlags, "", "", text, dryRun) } // PHOTO HANDLING (Telegram sends photos as array sorted by size) if len(msg.Photo) > 0 { photo := msg.Photo[len(msg.Photo)-1] // pick highest resolution - handleFile(bot, photo.FileID, msg.Chat.ID, caption) + handleFile(bot, photo.FileID, msg.Chat.ID, caption, "image/jpeg", crosspostFlags, dryRun) } // VIDEO HANDLING if msg.Video != nil { - handleFile(bot, msg.Video.FileID, msg.Chat.ID, caption) + handleFile(bot, msg.Video.FileID, msg.Chat.ID, caption, msg.Video.MimeType, crosspostFlags, dryRun) } // DOCUMENT HANDLING (fallback for files) if msg.Document != nil { - handleFile(bot, msg.Document.FileID, msg.Chat.ID, caption) + handleFile(bot, msg.Document.FileID, msg.Chat.ID, caption, msg.Document.MimeType, crosspostFlags, dryRun) + } + } +} + +func envBool(name string) bool { + value := strings.ToLower(strings.TrimSpace(os.Getenv(name))) + return value == "1" || value == "true" || value == "yes" +} + +func authorizedTelegramUsers() map[string]bool { + configured := strings.TrimSpace(os.Getenv("AUTHORIZED_TELEGRAM_USERS")) + users := map[string]bool{} + + if configured == "" { + return users + } + + for _, user := range strings.Split(configured, ",") { + user = strings.TrimSpace(strings.TrimPrefix(user, "@")) + if user != "" { + users[strings.ToLower(user)] = true } } + + return users +} + +func isAuthorized(user *tgbot.User, authorizedUsers map[string]bool) bool { + if len(authorizedUsers) == 0 { + return true + } + + if authorizedUsers[strconv.FormatInt(user.ID, 10)] { + return true + } + + return authorizedUsers[strings.ToLower(user.UserName)] +} + +func crosspostFlags() []string { + configured := strings.TrimSpace(os.Getenv("CROSSPOST_FLAGS")) + if configured == "" { + return []string{"-bmt"} + } + + return strings.Fields(configured) } // handleFile downloads + saves + sends back the file -func handleFile(bot *tgbot.BotAPI, fileID string, chatID int64, caption string) { +func handleFile(bot *tgbot.BotAPI, fileID string, chatID int64, caption string, mediaType string, crosspostFlags []string, dryRun bool) { file, err := bot.GetFile(tgbot.FileConfig{FileID: fileID}) if err != nil { log.Println("error getting file:", err) @@ -97,8 +151,11 @@ func handleFile(bot *tgbot.BotAPI, fileID string, chatID int64, caption string) // determine local save path filename := filepath.Base(file.FilePath) savePath := filepath.Join("downloads", filename) + if mediaType == "" { + mediaType = mime.TypeByExtension(filepath.Ext(filename)) + } - log.Printf("downloading file: %s, to: %s", url, savePath) + log.Printf("downloading file: %s, to: %s", redactTelegramToken(url), savePath) // download resp, err := http.Get(url) @@ -125,23 +182,41 @@ func handleFile(bot *tgbot.BotAPI, fileID string, chatID int64, caption string) cleanCaption, altText := ParseCaptionAlt(caption) - log.Println("Posting Updates via crosspost") - log.Printf("\n run cmd: crosspost -bmt --image %s --image-alt '%s' '%s'", savePath, altText, cleanCaption) + if !strings.HasPrefix(mediaType, "image/") { + warning := fmt.Sprintf("Downloaded %s, but this bot can only attach image media with the installed crosspost CLI. Posting caption text only.", mediaType) + log.Println(warning) + if cleanCaption != "" { + go PostViaCrosspost(bot, chatID, crosspostFlags, "", "", cleanCaption, dryRun) + } else { + sendReply(bot, chatID, warning) + } + return + } + + log.Println("Posting updates via crosspost") - go PostViaCrosspost(bot, chatID, savePath, altText, cleanCaption) + go PostViaCrosspost(bot, chatID, crosspostFlags, savePath, altText, cleanCaption, dryRun) } // PostViaCrosspost runs crosspost, captures logs, then sends back the file -func PostViaCrosspost(bot *tgbot.BotAPI, chatID int64, savePath, altText, caption string) { - var cmd *exec.Cmd - if savePath == "" && caption != "" { +func PostViaCrosspost(bot *tgbot.BotAPI, chatID int64, crosspostFlags []string, savePath, altText, caption string, dryRun bool) { + args := append([]string{}, crosspostFlags...) + if savePath == "" { log.Println("PostViaCrosspost: img not found, sending as text tweet") - cmd = exec.Command("crosspost", "-bmt", caption) } else { - // build command - cmd = exec.Command("crosspost", "-bmt", "--image", savePath, "--image-alt", altText, caption) + args = append(args, "--image", savePath, "--image-alt", altText) } - // cmd := exec.Command("crosspost", "-b", "--image", savePath, caption) + args = append(args, caption) + + if dryRun { + preview := fmt.Sprintf("DRY RUN: would run `%s`", commandPreview("crosspost", args)) + log.Println(preview) + sendReply(bot, chatID, preview) + return + } + + cmd := exec.Command("crosspost", args...) + cmd.Env = normalizedCrosspostEnv() // capture stdout & stderr stdout, err := cmd.StdoutPipe() @@ -167,42 +242,134 @@ func PostViaCrosspost(bot *tgbot.BotAPI, chatID int64, savePath, altText, captio outBytes, _ := io.ReadAll(stdout) errBytes, _ := io.ReadAll(stderr) + status := "crosspost completed" + // wait until exit if err := cmd.Wait(); err != nil { log.Println("[XPOST] cmd finished with error:", err) + status = fmt.Sprintf("crosspost failed: %v", err) } - // format response caption - newCaption := fmt.Sprintf( - "%s\n\n=== crosspost logs ===\nstdout:\n%s\n\nstderr:\n%s", + logMessage := fmt.Sprintf( + "%s\n\n%s\n\n=== crosspost logs ===\nstdout:\n%s\n\nstderr:\n%s", caption, + status, string(outBytes), string(errBytes), ) if savePath != "" { - // send same file back send := tgbot.NewDocument(chatID, tgbot.FilePath(savePath)) - send.Caption = newCaption + send.Caption = caption if _, err := bot.Send(send); err != nil { log.Println("failed sending message:", err) } log.Println("[XPOST] file sent back successfully") - } else { - reply := tgbot.NewMessage(chatID, newCaption) - log.Println("[XPOST] no file to send back") - if _, err := bot.Send(reply); err != nil { - log.Println("failed sending message:", err) + } + + sendLongReply(bot, chatID, logMessage) + +} + +func commandPreview(name string, args []string) string { + quoted := []string{name} + for _, arg := range args { + quoted = append(quoted, strconv.Quote(arg)) + } + return strings.Join(quoted, " ") +} + +func normalizedCrosspostEnv() []string { + env := envMap(os.Environ()) + setDefaultEnv(env, "TWITTER_AUTH_TOKEN", "AUTH_TOKEN") + setDefaultEnv(env, "TWITTER_API_CONSUMER_KEY", "consumer_key", "TWITTER_CONSUMER_KEY") + setDefaultEnv(env, "TWITTER_API_CONSUMER_SECRET", "consumer_key_secret", "TWITTER_CONSUMER_SECRET") + setDefaultEnv(env, "TWITTER_ACCESS_TOKEN_KEY", "access_token", "access_token_key", "TWITTER_ACCESS_TOKEN") + setDefaultEnv(env, "TWITTER_ACCESS_TOKEN_SECRET", "access_token_secret", "TWITTER_ACCESS_SECRET") + + result := make([]string, 0, len(env)) + for key, value := range env { + result = append(result, key+"="+value) + } + + return result +} + +func envMap(values []string) map[string]string { + env := map[string]string{} + for _, value := range values { + key, val, ok := strings.Cut(value, "=") + if ok { + env[key] = val + } + } + return env +} + +func setDefaultEnv(env map[string]string, target string, aliases ...string) { + if strings.TrimSpace(env[target]) != "" { + return + } + + for _, alias := range aliases { + if value := strings.TrimSpace(env[alias]); value != "" { + env[target] = value + return + } + } +} + +func sendReply(bot *tgbot.BotAPI, chatID int64, text string) { + reply := tgbot.NewMessage(chatID, text) + if _, err := bot.Send(reply); err != nil { + log.Println("failed sending message:", err) + } +} + +func sendLongReply(bot *tgbot.BotAPI, chatID int64, text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + + for len(text) > telegramMessageLimit { + chunk := text[:telegramMessageLimit] + if index := strings.LastIndex(chunk, "\n"); index > 0 { + chunk = chunk[:index] } + sendReply(bot, chatID, chunk) + text = strings.TrimSpace(strings.TrimPrefix(text, chunk)) } + sendReply(bot, chatID, text) +} + +func redactTelegramToken(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return "" + } + + parts := strings.Split(parsed.Path, "/") + for i, part := range parts { + if strings.HasPrefix(part, "bot") { + parts[i] = "bot" + } + } + parsed.Path = strings.Join(parts, "/") + return parsed.String() } // ParseCaptionAlt extracts the alt text from a caption. // Format expected: "... text ... \nalt: something here" func ParseCaptionAlt(caption string) (cleanCaption, altText string) { + caption = strings.TrimSpace(caption) + if caption == "" { + return "", "" + } + // Split by newlines - lines := strings.Split(strings.TrimSpace(caption), "\n") + lines := strings.Split(caption, "\n") // last line last := strings.TrimSpace(lines[len(lines)-1]) diff --git a/scripts/doctor.sh b/scripts/doctor.sh new file mode 100644 index 0000000..2441fe8 --- /dev/null +++ b/scripts/doctor.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env sh +set -eu + +env_file="${1:-.env}" + +if [ ! -f "$env_file" ]; then + printf 'missing %s\n' "$env_file" + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +case "$env_file" in + */*) . "$env_file" ;; + *) . "./$env_file" ;; +esac +set +a + +check_key() { + key="$1" + value=$(printenv "$key" 2>/dev/null || true) + if [ -n "$value" ]; then + printf '%s=present length=%s\n' "$key" "${#value}" + else + printf '%s=missing\n' "$key" + fi +} + +check_alias() { + target="$1" + shift + if [ -n "$(printenv "$target" 2>/dev/null || true)" ]; then + return + fi + + for alias in "$@"; do + value=$(printenv "$alias" 2>/dev/null || true) + if [ -n "$value" ]; then + printf '%s can be derived from %s\n' "$target" "$alias" + return + fi + done +} + +printf 'Telegram\n' +check_key BOT_TOKEN +check_key AUTHORIZED_TELEGRAM_USERS +check_key CROSSPOST_FLAGS +check_key SHITPOST_DRY_RUN + +printf '\nBluesky\n' +check_key BLUESKY_HOST +check_key BLUESKY_IDENTIFIER +check_key BLUESKY_PASSWORD + +printf '\nMastodon\n' +check_key MASTODON_HOST +check_key MASTODON_ACCESS_TOKEN + +printf '\nTwitter/X\n' +check_key AUTH_TOKEN +check_key TWITTER_AUTH_TOKEN +check_key TWITTER_API_CONSUMER_KEY +check_key TWITTER_API_CONSUMER_SECRET +check_key TWITTER_ACCESS_TOKEN_KEY +check_key TWITTER_ACCESS_TOKEN_SECRET +check_alias TWITTER_AUTH_TOKEN AUTH_TOKEN +check_alias TWITTER_API_CONSUMER_KEY consumer_key TWITTER_CONSUMER_KEY +check_alias TWITTER_API_CONSUMER_SECRET consumer_key_secret TWITTER_CONSUMER_SECRET +check_alias TWITTER_ACCESS_TOKEN_KEY access_token access_token_key TWITTER_ACCESS_TOKEN +check_alias TWITTER_ACCESS_TOKEN_SECRET access_token_secret TWITTER_ACCESS_SECRET + +access_token=$(printenv TWITTER_ACCESS_TOKEN_KEY 2>/dev/null || true) +if [ -n "$access_token" ] && ! printf '%s' "$access_token" | grep -q '-'; then + printf '\nwarning: TWITTER_ACCESS_TOKEN_KEY does not look like a typical OAuth 1.0a access token. If X returns 401, confirm this is Access Token, not OAuth2 Client ID.\n' +fi