- TypeScript 99%
- Dockerfile 1%
|
|
||
|---|---|---|
| .agents/skills | ||
| .github | ||
| .vscode | ||
| config | ||
| docs/images | ||
| src | ||
| .claude | ||
| .dockerignore | ||
| .editorconfig | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| biome.jsonc | ||
| bun.lock | ||
| CLAUDE.md | ||
| Dockerfile | ||
| GEMINI.md | ||
| lefthook.yml | ||
| LICENSE | ||
| package.json | ||
| railway.json | ||
| README.md | ||
| tsconfig.json | ||
ywbot — X → Discord tweet relay
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-scraperwith a rotating cookie pool - State plain JSON, atomically written, no database
Contents
- Features
- Quick start
- Account config
- Environment variables
- Twitter cookies
- Commands
- How relaying works
- Data files
- Deployment
- Development
- Troubleshooting
- Limitations
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 | testfor 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:
- Log in to x.com in a browser
- DevTools → Application → Cookies →
https://x.com - Copy the
auth_tokenandct0values
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 @originalheader, soincludeRetweets: falseremoves them entirely. - When
includeRepliesistrue, 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_COOKIESfor a single identity, or writedata/cookies.jsoninside the mounted volume. Runningbun run cookieslocally writes to your localdata/, 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
lastTweetIdper account; only IDs greater than it are new. - A
recentIdsring (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: trueto post that newest tweet too.
Rendering
- Webhook
usernameisName (@handle);avatarURLis 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
pingRoleIdconfigured, 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 @handleheader. - Quote tweets become a blockquote with the quoted author and a link.
t.colinks are stripped and replaced with their expanded URLs.- The last card ends with a
View on Twitterpermalink; 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/@herein 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
ffmpegis onPATH, the relay transcodes them to real.giffiles, 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
- Create a project and connect this repository (the included
railway.jsonbuilds the Dockerfile) - Set
DISCORD_BOT_TOKEN,DISCORD_APPLICATION_ID, and theTWITTER_*variables you need - Add a volume mounted at
/app/data - Invite the bot with the link from
bun run invite - Deploy, then run
bun run deploy-commands --globalonce
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_tokeneventually 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_COUNTtweets 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.