No description
  • TypeScript 99%
  • Dockerfile 1%
Find a file
maeve 5445e204a1
All checks were successful
CI / Lint & Typecheck (push) Successful in 6s
hi
2026-10-27 21:58:42 +01:00
.agents/skills init 2026-10-25 05:13:01 +01:00
.github init 2026-10-25 05:13:01 +01:00
.vscode init 2026-10-25 05:13:01 +01:00
config hi 2026-10-27 21:58:42 +01:00
docs/images init 2026-10-25 05:13:01 +01:00
src hi 2026-10-27 21:58:42 +01:00
.claude init 2026-10-25 05:13:01 +01:00
.dockerignore init 2026-10-25 05:13:01 +01:00
.editorconfig init 2026-10-25 05:13:01 +01:00
.env.example initial fn 2026-10-25 18:31:09 +01:00
.gitignore initial fn 2026-10-25 18:31:09 +01:00
AGENTS.md init 2026-10-25 05:13:01 +01:00
biome.jsonc init 2026-10-25 05:13:01 +01:00
bun.lock initial fn 2026-10-25 18:31:09 +01:00
CLAUDE.md init 2026-10-25 05:13:01 +01:00
Dockerfile hi 2026-10-27 21:58:42 +01:00
GEMINI.md init 2026-10-25 05:13:01 +01:00
lefthook.yml init 2026-10-25 05:13:01 +01:00
LICENSE init 2026-10-25 05:13:01 +01:00
package.json hi 2026-10-27 21:58:42 +01:00
railway.json init 2026-10-25 05:13:01 +01:00
README.md hi 2026-10-27 21:58:42 +01:00
tsconfig.json init 2026-10-25 05:13:01 +01:00

ywbot — X → Discord tweet relay

Bun TypeScript discord.js Biome License

A Discord bot that mirrors tweets from one or more X accounts into Discord channels. Tweets are posted through channel webhooks, so each message carries the account's display name and avatar instead of the bot's.

flowchart LR
  P[Poller<br/>staggered per account] --> A[Cookie pool<br/>rotation + health]
  A --> S[twitter-scraper]
  S --> N[Normalizer]
  N --> F[Filters<br/>RT / replies / quotes / sensitive]
  F --> D[Dedupe + state]
  D --> W[Channel webhook<br/>name + avatar]
  W --> C[Discord]
  • Runtime Bun
  • Library discord.js v14, no privileged intents
  • Scraping @the-convocation/twitter-scraper with a rotating cookie pool
  • State plain JSON, atomically written, no database

Contents


Features

  • Poll multiple accounts on independent, staggered schedules
  • Per-account toggles for retweets, replies, quote tweets, and sensitive content
  • Webhook mirroring: the tweet's author name and avatar, not the bot's
  • Cookie pool with round-robin rotation, per-identity rate-limit cooldowns, and dead-identity detection
  • Cookie file is re-read every 5 minutes — refresh sessions without restarting
  • Crash-safe deduplication: state is persisted after every posted tweet
  • First-run baselines instead of channel floods
  • Media re-uploaded as attachments (images survive pbs.twimg.com link rot)
  • Videos play inline; oversized clips are cut down to fit
  • Animated GIFs are transcoded to real GIFs when ffmpeg is available
  • Long tweets split at word boundaries, with the permalink appended
  • Health alerts in Discord when every Twitter identity dies
  • /relay status | now | test for operations
  • Optional role ping (<@&role> Name posted!) above every card

Quick start

Prerequisites

  • Bun 1.2+
  • A Discord application and bot token (Developer Portal)
  • A throwaway X account you can afford to lose

1. Install

bun install

2. Configure the bot

cp .env.example .env

Fill in at minimum:

DISCORD_BOT_TOKEN=...
DISCORD_APPLICATION_ID=...
DISCORD_GUILD_ID=...   # optional, makes command deploys instant

3. Configure the accounts

cp config/accounts.example.json config/accounts.json
{
  "alertsChannelId": "123456789012345678",
  "accounts": [
    {
      "handle": "yuna",
      "channelId": "123456789012345678",
      "includeRetweets": true
    }
  ]
}

See Account config for every field.

4. Add Twitter cookies

The scraper needs a logged-in session:

  1. Log in to x.com in a browser
  2. DevTools → Application → Cookies → https://x.com
  3. Copy the auth_token and ct0 values
bun run cookies
# paste: auth_token=...; ct0=...; guest_id=...

Or set a single identity in .env:

TWITTER_COOKIES=auth_token=...; ct0=...

5. Invite the bot

The bot needs View Channels, Send Messages, Embed Links, Attach Files, and Manage Webhooks in every relay channel (Manage Webhooks is only needed when the bot creates its own webhooks; skip it if every account sets webhookUrl). Run:

bun run invite

That prints an invite link scoped to exactly those permissions (no Administrator). The development link printed at startup and the i dev shortcut produce the same URL.

6. Deploy commands and run

bun run deploy-commands   # add --global for production
bun run start             # or: bun run dev

On startup, the relay baselines every account (no posts for existing tweets) and then starts polling every 60 seconds.


Account config

config/accounts.json is validated with Zod at boot — an invalid file stops the relay with a clear error rather than silently skipping accounts. A missing file leaves the relay idle with a warning.

Field Type Default Description
handle string required X handle without @ (1–15 chars)
channelId snowflake required Target Discord channel
webhookUrl string null Use an existing webhook instead of creating one
webhookName string Tweet Relay (<handle>) Name for the auto-created webhook
enabled boolean true Pause an account without deleting its entry
includeRetweets boolean true Post retweets. Set false to silence retweet spam
includeReplies boolean false Post replies as well as standalone tweets
includeQuotes boolean true Post quote tweets
skipSensitive boolean true Drop tweets flagged as sensitive
pingRoleId snowflake null Role pinged on every post from this account (overrides the top-level pingRoleId)
backfillOnFirstRun boolean false Post the newest tweet on the first poll instead of only baselining

Top-level:

Field Description
alertsChannelId Channel for auth/health alerts, at most one message per hour
pingRoleId Default role pinged on every post; an account's pingRoleId takes precedence

Notes:

  • Pings are sent as message content above the card: <@&role> Yuna posted!. Role pings do not work from inside embeds, which is why it sits above the card.
  • Only the configured role can be pinged: tweet text is still restricted to allowedMentions: { parse: [], roles: [pingRoleId] }.
  • If the ping doesn't arrive, make sure the role is mentionable in the server.
  • Retweets are rendered under the mirrored account's identity with a 🔁 Retweeted @original header, so includeRetweets: false removes them entirely.
  • When includeReplies is true, the replies timeline is fetched and still passed through the same filters.
  • Two accounts can share a channel: the first one creates the webhook and both reuse it, with the display name and avatar set per message.

Environment variables

Variable Default Description
DISCORD_BOT_TOKEN required Bot token
DISCORD_APPLICATION_ID required Application ID, used for command deploys
DISCORD_GUILD_ID – Deploy commands to one guild (instant)
LOG_LEVEL info debug, info, warn, error
TWITTER_ENABLED true Master switch for the relay
TWITTER_ACCOUNTS_FILE ./config/accounts.json Account mapping
TWITTER_STATE_FILE ./data/state.json Relay state
TWITTER_COOKIES_FILE ./data/cookies.json Cookie pool
TWITTER_COOKIES – One identity as auth_token=…; ct0=…
TWITTER_ENABLE_LOGIN_REFRESH false Try login() automatically when identities die
TWITTER_USERNAME – Login refresh credentials
TWITTER_PASSWORD – Login refresh credentials
TWITTER_EMAIL – Login refresh credentials (optional)
TWITTER_POLL_INTERVAL_SECONDS 60 Per-account poll interval, minimum 30
TWITTER_FETCH_COUNT 20 Tweets fetched per poll (1–200)

HTTP_PROXY, HTTPS_PROXY, and ALL_PROXY are honoured by Bun's fetch if you need to egress through a proxy.


Twitter cookies

Why cookies

X's API is paid for timeline reads, so the bot uses the same reverse-engineered web endpoints the site itself uses. Those require a session: auth_token (long-lived session) and ct0 (CSRF token bound to that session). They must come from the same session — mixing them produces 401s that X may treat as session hijacking.

The pool

Every identity in data/cookies.json (plus TWITTER_COOKIES if set) becomes its own scraper:

  • Rotation — requests round-robin across healthy identities.
  • Rate limits — a 429 parks that identity until x-rate-limit-reset (15 minutes if absent), and the fetch retries on another identity.
  • Rejection — a 401/403 marks the identity dead; it stays dead until its cookies change.
  • Reload — the file is re-read every 5 minutes; changed cookies rebuild that identity's scraper without a restart.
  • Guest fallback — with no identities configured, the bot still starts with a guest scraper so you see actionable errors, but X usually rejects guest timeline reads.

data/cookies.json is written with mode 0600 and is gitignored.

Refreshing

bun run cookies --label burner-2

Add as many identities as you like. If you'd rather automate it, enable login refresh:

TWITTER_ENABLE_LOGIN_REFRESH=true
TWITTER_USERNAME=burner
TWITTER_PASSWORD=...
TWITTER_EMAIL=...

Login refresh runs at startup and on the 5-minute reload timer when no authenticating identity is healthy. It is best-effort: X can answer with a captcha or a 399 from datacenter IPs. On failure the relay keeps running and logs a pointer to bun run cookies.

On a hosted deployment, prefer TWITTER_COOKIES for a single identity, or write data/cookies.json inside the mounted volume. Running bun run cookies locally writes to your local data/, not the remote host.


Commands

Command Permission Description
/relay status Manage Guild Accounts, channels, last tweet, last poll, retweet toggle, identity health
/relay now Manage Guild Poll every enabled account immediately
/relay test <handle> Manage Guild Post the newest tweet for one account without changing state
/ping – WebSocket latency
/info – Bot stats and uptime

/relay test deliberately does not advance state, so the next scheduled poll may repost that tweet if it is newer than the stored cursor.


How relaying works

Polling

Each enabled account gets its own interval, offset by interval / accountCount so requests are spread out. A poll is skipped if the previous one for that account is still running.

What gets posted

Tweets are filtered (isSensitive, isRetweet, isReply, quote) and then sorted by snowflake ID ascending, so a burst appears in chronological order.

Deduplication

  • The state stores lastTweetId per account; only IDs greater than it are new.
  • A recentIds ring (100 entries) guards against out-of-order timelines.
  • State is written after each posted tweet, so a crash cannot replay a whole batch.
  • On the very first poll an account is only baselined to its newest tweet. Set backfillOnFirstRun: true to post that newest tweet too.

Rendering

  • Webhook username is Name (@handle); avatarURL is the account avatar (fetched once per hour and cached). If Discord rejects the avatar, the message is retried without it.
  • Tweets are posted as embed cards with a Twitter-blue accent stripe.
  • With pingRoleId configured, the first card is prefixed with <@&role> Name posted!; the role is the only mention that can ping.
  • Every card repeats the account identity in its author block: avatar, Name (@handle), linked to the tweet.
  • Retweets use the retweeter's identity plus a 🔁 Retweeted @handle header.
  • Quote tweets become a blockquote with the quoted author and a link.
  • t.co links are stripped and replaced with their expanded URLs.
  • The last card ends with a View on Twitter permalink; its footer carries the tweet timestamp.
  • Cards hold up to 4000 characters and split at paragraph/line/word boundaries; the first still image (or animated GIF) is rendered inside the card.
  • Anything that cannot live in an embed (videos, extra photos) is posted afterwards in one follow-up message labelled Video from the tweet above / More photos from the tweet above, so the card always comes first.
  • @everyone/@here in tweet text cannot ping anyone (allowedMentions: { parse: [] }).

Media

  • Up to 4 images per tweet are downloaded and re-uploaded at original resolution (?name=orig), with alt text as the attachment description.
  • Videos are uploaded as MP4 so they play inline instead of showing a single frame. Videos over 10 MB are cut down to the largest prefix that fits (stream copy, so quality is unchanged); if even that fails, the sharpest thumbnail is used.
  • Animated GIFs are MP4s on X, which Discord only shows with a play button. When ffmpeg is on PATH, the relay transcodes them to real .gif files, which Discord animates inline, including inside the embed card; without ffmpeg they are posted as MP4.
  • Transcodes are serialised and retried at lower resolution/frame rate to stay under the upload limit.
  • Extra media is sent after the card in the same webhook identity. If that follow-up fails, it is logged and the card is kept (the tweet is not replayed).
  • If Discord still rejects the card's own upload, the tweet is re-sent without the media so the text is never lost.

Failure handling

  • Network/API errors are logged per account and stored as lastError, visible in /relay status.
  • When no identity is healthy, the alerts channel gets a message (max once per hour).
  • A failing account never blocks the others.

Data files

Both live under data/ by default and are gitignored.

state.json

{
  "version": 1,
  "accounts": {
    "yuna": {
      "lastTweetId": "1840000000000000000",
      "recentIds": ["1840000000000000000"],
      "lastPolledAt": "2026-10-25T04:00:00.000Z",
      "lastError": null
    }
  }
}

cookies.json

{
  "version": 1,
  "identities": [
    {
      "label": "burner-1",
      "cookies": ["auth_token=...", "ct0=..."],
      "source": "manual",
      "updatedAt": "2026-10-25T04:00:00.000Z"
    }
  ]
}

Deleting state.json is safe: the next poll re-baselines instead of flooding the channel. Deleting cookies.json leaves the bot without a session.


Deployment

Docker

docker build -t ywbot .
docker run -d --env-file .env \
  -v "$PWD/data:/app/data" \
  -v "$PWD/config:/app/config" \
  ywbot

/app/data is a declared volume in the image. Without it, state and cookies reset on every deploy — a reset only costs a re-baseline, not duplicate posts.

The image installs ffmpeg (for GIF transcoding). For local runs, install it separately or GIFs will be posted as MP4; everything else works without it.

Railway

  1. Create a project and connect this repository (the included railway.json builds the Dockerfile)
  2. Set DISCORD_BOT_TOKEN, DISCORD_APPLICATION_ID, and the TWITTER_* variables you need
  3. Add a volume mounted at /app/data
  4. Invite the bot with the link from bun run invite
  5. Deploy, then run bun run deploy-commands --global once

Command deployment

bun run deploy-commands            # guild (needs DISCORD_GUILD_ID)
bun run deploy-commands --global   # global, can take up to an hour to propagate

Development

Scripts

Script Description
bun run start Start the bot
bun run dev Watch mode with shortcuts (d sync commands, r restart, i invite, q quit)
bun run deploy-commands Deploy slash commands
bun run invite Print the bot invite URL with the minimum required permissions
bun run cookies Add or refresh a Twitter identity
bun run check Biome lint + format
bun run typecheck TypeScript check

CI runs bun run check and bun run typecheck on every push and PR; lefthook does the same before commits.

Layout

src/
├── index.ts              # Entry point: loads commands/events, logs in
├── client.ts             # Discord client (Guilds intent only)
├── env.ts                # Zod-validated environment
├── config.ts             # accounts.json schema
├── deploy.ts             # Slash command deployment
├── commands/
│   ├── ping.ts
│   ├── info.ts
│   └── relay/index.ts
├── events/
│   ├── ready.ts          # Boots the relay
│   └── interaction-create.ts
├── services/
│   ├── relay.ts          # Polling, filtering, dedupe, posting
│   ├── relay-bootstrap.ts# Wiring, identity reload timer
│   ├── state.ts          # Atomic JSON state
│   ├── discord/
│   │   ├── format.ts     # Chunking, quoting, webhook names
│   │   ├── media.ts      # Image download/upload
│   │   └── webhook.ts    # Webhook discovery/creation
│   └── twitter/
│       ├── auth.ts       # Cookie pool, health, rotation
│       ├── client.ts     # Timeline fetch with identity retry
│       ├── normalize.ts  # scraper Tweet -> NormalizedTweet
│       ├── cookie-store.ts
│       └── login.ts      # Optional login refresh
├── tools/cookies.ts      # Interactive cookie helper
├── types.d.ts            # Command/Event types
└── utils/
    ├── core.ts           # Dynamic command/event loader
    ├── logger.ts         # Pino logger
    └── ...

Adding a command

// src/commands/hello.ts
import { type ChatInputCommandInteraction, SlashCommandBuilder } from 'discord.js';
import type { Command } from '@/types';

export const command: Command<ChatInputCommandInteraction> = {
  data: new SlashCommandBuilder()
    .setName('hello')
    .setDescription('Says hello!'),
  execute: async (interaction) => {
    await interaction.reply('Hello!');
  }
};

Adding an event

// src/events/guild-member-add.ts
import { Events } from 'discord.js';
import type { Event } from '@/types';

export const event: Event<Events.GuildMemberAdd> = {
  name: Events.GuildMemberAdd,
  execute: async (member) => {
    console.log(`${member.user.tag} joined`);
  }
};

Use the @/ alias for internal imports; commands and events are loaded by filename from their directories.


Troubleshooting

Symptom Cause Fix
Tweet relay did not start + "No account config" config/accounts.json missing Copy the example and set handle + channelId
/relay status shows 🔴 guest No cookies configured bun run cookies, or set TWITTER_COOKIES
Identity turns 🔴 with HTTP 401/403 Cookie expired or invalidated Refresh that identity's cookies
Identity stuck 🟡 Rate limited Wait for the reset, add identities, or raise the interval
Could not get a webhook for channel … Missing Manage Webhooks Grant it or set webhookUrl
Nothing posted after adding an account First poll only baselines Expected; set backfillOnFirstRun: true to post the latest
Some tweets missing after downtime Backlog larger than TWITTER_FETCH_COUNT Raise it; the log warns when this happens
Duplicate post Crash between send and state write Rare; delete the duplicate manually
Posts stop after a redeploy No volume, state reset Mount /app/data
X returns 429 constantly Too many accounts per identity Add identities or slow polling

Limitations

  • Terms of service. Scraping X violates its ToS, and the burner account can be banned. Use credentials you can afford to lose.
  • Session expiry. auth_token eventually dies. The pool makes this visible and recoverable, not impossible.
  • Endpoint drift. The scraper depends on X's private web endpoints, which change without notice. When X ships a breaking change, upstream fixes usually land within days.
  • No edits or deletions. Tweets are deduplicated by ID, so an edited tweet is not reposted and a deleted tweet stays in Discord.
  • Backlog limit. Only TWITTER_FETCH_COUNT tweets are read per poll; a larger backlog can lose the oldest entries.
  • Video. Not re-uploaded (Discord upload limits); the permalink is the fallback.
  • Timeline scope. Only the account's own timeline (optionally with replies) is read — not likes, bookmarks, or lists.

License

MIT