vzgcoders / discordphp-bridge
The platform-agnostic half of a Discord chat bridge: routing, persistence, one command catalogue served to every chat, and Components v2 panels. Platforms plug in as connectors.
Requires
- php: ^8.4
- discord-php/http: dev-master as 10.1.7
- psr/log: ^1.0 || ^2.0 || ^3.0
- react/event-loop: ^1.5
- react/filesystem: ^0.2@dev
- react/promise: ^3.2
- team-reflex/discord-php: dev-master
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.64
- phpunit/phpunit: ^11.0 || ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-24 03:05:27 UTC
README
The half of a Discord chat bridge that does not know which network it is bridging to.
Routing, persistence, one command catalogue served to every chat, Components v2 panels, and the rate limiting — with no mention of Twitch, Telegram or anything else. A network plugs in as a connector, in its own composer package.
#general ──────────► twitch.tv/twitchdev
◄──────────
──────────► t.me/mygroup
◄──────────
Why this exists
Two bots that each bridged Discord with one other network turned out to be the
same bot twice. Links, Store, BridgeCheck, RateLimiter, Permissions
and WebhookDelivery were the same class written in two repositories with the
target renamed, and Filesystem was byte-identical. This is that half, with the
platforms taken out of it — so a third network is a new package rather than a
third fork.
Writing a connector
Implement Bridge\Connector. It owns its client,
its socket, its authentication and its idea of what a room is, and hands the
core Incoming messages and
Room descriptions.
final class TwitchConnector implements Connector, ProvidesActions { public function name(): string { return 'twitch'; } // also the slash command public function label(): string { return 'Twitch'; } public function surface(): Surface { return new Surface('twitch', 'Twitch', 500, markdown: false, lines: false, prefix: '!'); } public function boot(Bot $bot): void { /* build the client, before the first message */ } public function start(): PromiseInterface { /* connect; reject if it cannot */ } public function relay(string $target, Outgoing $message): PromiseInterface { /* render, then say */ } public function resolve(string $target): PromiseInterface { /* ?Room */ } public function normalise(string $input): ?string { /* "twitch.tv/X" -> "x" */ } // … sync, send, onIncoming, joined, queued, stop }
Optional interfaces say what the network can do, so the relay asks instead of
assuming: Capability\Editing (rewrite a relayed message), Capability\Media
(carry a picture, and hand over a file's bytes for Discord), Capability\Avatars
(a sender's picture for the Discord copy), Capability\ProvidesActions and
Capability\ProvidesModules.
start() returning a promise is not a formality. A connector that rejects is
logged, reported to the owner, left out of the startup check, and stops the
command prune pass — so a network that failed to come up cannot get its
commands deleted from Discord.
Commands typed in the network's own chat go to
ChatDispatcher: the connector says
who is asking, where, and at what rank, and the core does the rest — the same
access checks, cooldowns and refusals as
every other surface. A command for another network acts on the room that
shares a Discord channel with this one, and is refused rather than guessed when
there is more than one.
Registering it is one line, and it arrives with its configuration commands already written:
$bot->addConnector(new TwitchConnector($config));
Commands are always qualified
A name is only free because no connector has claimed it yet. title belongs to
Twitch today and to something else the moment a fourth network arrives — and
since every connector's commands are offered on every surface, an unqualified
catalogue is one installed package away from two commands answering to the same
word, with the loser looking broken rather than shadowed.
So every command carries the connector that owns it, and
ActionRegistry refuses a collision
rather than letting the last one registered win.
/bridge help | about | list | status the core's own
/twitch link | here | unlink | list | status | reset every connector gets these
channel title | game | tags | info whatever it brings
mod ban | unban | timeout | …
An action is a leaf. Where it sits is its qualifier and its group, and each surface renders what it has room for:
| Surface | Form |
|---|---|
| Discord slash | /twitch channel title text:Back in ten |
| Discord prefix | !twitch channel title Back in ten |
| Any other chat | !twitch title Back in ten |
Discord caps a command at 25 options and allows one level of sub-command group, which is the only reason groups exist. A chat has no such cap, so it drops the group — never the qualifier.
The six bridge verbs are defined once, in
BridgeActions, against whatever
connector they are given, so they cannot be worded differently on two networks.
All of them are limited to whoever the server belongs to, and that gate is the
security model for the whole project: whoever can run link decides which
Discord channel gets copied into a public chat somewhere else.
Surviving a restart
Every bridge lives in one JSON file, grouped by connector:
{ "version": 2,
"links": { "twitch": { "<guild>": { "<channel>": "coffeescrafts" } } },
"labels": { "telegram": { "-1001234567890": "My Group" } } }
JsonFile is what keeps it:
- Writes are atomic — content goes to a temp file and is renamed over the target, so a crash mid-write cannot leave a half-written file where the configuration was.
- The last good copy is kept beside it as
.bak, written after each save, not by copying the file about to be replaced. - A damaged file is never silently replaced. If the JSON does not parse the
backup is tried; if that fails too, the file is preserved as
.corrupt-<timestamp>and the bot starts empty rather than overwriting it on the nextlink. - Entries of the wrong shape are dropped, not loaded, so a hand-edited file cannot take the bot down — and the good entries in it still survive the next write.
A file written by a single-platform bot has no version and no connector level;
it is migrated on load, so an existing installation keeps its bridges with
nothing to re-run.
Ten seconds after startup the bot checks what it restored: can it still see each
Discord channel, does each room still exist, and — the one a restart is
specifically meant to re-establish — is it actually in that room? A join that
silently failed leaves a bridge that works in one direction only. Findings go to
the log, to the owner's DMs, and to the foot of list. Nothing is pruned
automatically: a guild can be briefly unavailable during an outage, and deleting
somebody's configuration over a bad ten seconds is worse than telling them.
Disk I/O and the event loop
A blocking write stops the loop: while it runs, no heartbeat is sent and nothing
is relayed. Saves therefore go through
react/filesystem — which performs them
off the loop only with ext-uv (Linux, macOS and Windows) or ext-eio
(POSIX). With neither, that library's own fallback is file_put_contents()
wrapped in an already-resolved promise, so the bot does the write itself instead
and, since it is blocking anyway, blocks properly: fflush() and fsync(),
which putContents() cannot express. Measured on a Windows host: 3.5 ms
blocking, 0.07 ms with an async backend. The bot logs which one it picked at
startup.
Either way the caller never waits — link answers from memory, the write is
queued, rapid changes coalesce into one write, and Ctrl-C flushes anything
outstanding before the loop stops.
Rate limits
Discord allows 50 requests per second per token, publishes per-route
buckets through X-RateLimit-Bucket, and treats 10,000 rejected requests in ten
minutes as grounds for a Cloudflare ban on the whole host — which is not a
throttle anybody recovers from by waiting.
Merging two bots into one changed the arithmetic: two applications with two tokens and two budgets became one. Five rules follow.
- One
Discord\Http, always. It holds the buckets and the concurrency cap, so a second client would keep its own empty bucket table and race the first into 429s against the same token. Connectors use DiscordPHP's parts and repositories, which already route through it — a test scans the core and every installed connector for anything that does not. - Relayed chat is paced per destination channel by
OutboundPacer, sized to Discord's five-per-five-seconds message sublimit and shared by every connector. One message on a busy network fans out to every channel following that room, and two connectors relaying into the same channel each stay under the limit alone and breach it together. - Command replies stay on interaction endpoints, which Discord documents as exempt from the global limit, so configuration keeps answering while the relay is saturated.
- A 403 is never retried. A channel the bot cannot create a webhook in is remembered, so a missing permission costs one rejected request rather than one per relayed message.
- Boot writes nothing it doesn't have to.
CommandSynccompares each definition against what Discord already has, leniently enough that the fields Discord adds on the way back do not read as a change.
Loop prevention
A bridge that repeats itself is an infinite loop that gets the account banned from both networks within minutes. Each direction drops its own output as early as it can:
- Discord → elsewhere ignores any message carrying a
webhook_id, and any message from a bot. Relayed chat arrives through a webhook, so that first rule is the one doing the work. Other bots are dropped deliberately: two bridges in one channel would otherwise ping-pong forever. - Elsewhere → Discord ignores anything the connector marks as its own. Only the connector can recognise its own voice, so only it can set that flag.
Commands are dropped in both directions too — !twitch title something is an
instruction to the bot, not a remark. Only a registered command counts, so
ordinary chat full of ! still relays.
Layout
src/Bridge/
Bot.php one gateway, one Http, however many connectors
Connector.php what a platform implements
Config.php Environment.php settings, from the environment only
Store.php Links.php persistence, and the routing table
Room.php
Actions/ the six bridge verbs, and /bridge
Capability/ Editing, Media, ProvidesActions, ProvidesModules
Command/ Action, Context, Access, Surface, and the adapters
Message/ Incoming, Outgoing, Media
Relay/ both directions, webhooks, pacing, edit mapping
Builders/ Helpers/ Components v2, custom_id routing
Modules/ the escape hatch from a string-in string-out action
Support/ text, permissions, async disk I/O, the startup check
The logic worth testing is deliberately pure — routing, parsing, sanitisation, pacing, the permission ladder and the command tree are all verifiable without a socket:
composer test
Licence
MIT.