ChatRelay
in package
The relay itself: Discord chat out to every connector, and every connector's chat back into Discord.
The hard requirement here is that nothing the bot says can come back to it. A bridge that repeats its own output is an infinite loop that gets the account banned from both networks within minutes, so each direction drops its own traffic as early as it can — see shouldRelayFromDiscord() and Incoming::$own, which the connector sets because only it can recognise its own voice.
With more than one connector installed a single Discord message may go to several networks at once, and a single message from one network still fans out to every Discord channel following that room, across unrelated servers. Both directions are bounded — the first by how many connectors are installed, the second by OutboundPacer. A channel bridged to two networks also carries each network's chat to the other, directly, since the Discord copy of it is a webhook message and never relayed onward.
Edits
An edit is only ever applied as an edit. A network that cannot rewrite a message it sent — IRC — never hears about one, because the only alternative is posting the message a second time, and a second copy of everything somebody corrects a typo in is worse than the typo.
That matters more than it sounds, because Discord calls a lot of things an
edit. When a message containing a link is unfurled into an embed, Discord
sends MESSAGE_UPDATE with the content unchanged. Treating that as an edit
would relay every message with a link in it twice. So an update is acted on
only when the text or the attachments actually changed.
Tags
Table of Contents
Constants
- REMEMBER : mixed = 500
- How many relayed messages to remember for editing, per direction.
Properties
- $bot : Bot
- $commands : array<string, ChatDispatcher>
- $delivered : MessageMap
- $seen : MessageMap
- $sent : MessageMap
Methods
- __construct() : mixed
- attach() : void
- Attaches both directions. Call once; nothing arrives until a connector starts.
- fingerprint() : string
- What a relayed message looked like, for telling a real edit from an update that changed nothing a chat can see.
- isDiscordCommand() : bool
- Whether a Discord message is addressed to the bot rather than to the channel.
- shouldRelayFromDiscord() : bool
- Whether a Discord message is real conversation worth relaying.
- acrossNetworks() : void
- Hands a message on to every *other* network bridged to the same Discord channels, so a channel bridged to Twitch and Telegram is one conversation across all three rather than two that only Discord can see.
- attachments() : array<int, Media>
- Each attachment as a {@see Media}.
- authorName() : string
- bridgedConnectors() : array<int, Connector, 1: string}>
- Every connector this Discord channel is bridged through, and the room on each.
- channelNames() : array<string, string>
- channelsFor() : array<int, Channel>
- Every cached Discord channel following the room a message came from.
- compose() : Outgoing
- composeFromNetwork() : Outgoing
- A message from one network as another should receive it.
- deliver() : void
- describeMedia() : string
- A file, as a link when there is a safe one and as a name otherwise.
- editInDiscord() : bool
- Rewrites every Discord copy of an edited message, returning whether any were remembered.
- fetch() : PromiseInterface<string|int, array{filename: string, content: string}|null>
- fromConnector() : void
- fromDiscord() : void
- fromDiscordEdit() : void
- An edit of something already relayed, and only that.
- incomingKey() : string
- Where a message from a network is remembered. Room and id together, because a message id is only unique within its room on some networks — Telegram numbers each chat from one.
- mirrorableIn() : Media|null
- The one attachment worth copying across as a file.
- photoIn() : Media|null
- The one picture worth handing to the network itself, if there is one.
- relayPhoto() : PromiseInterface<string|int, string|null>
- Hands the picture to a network that can carry it, and falls back to a link in the text when it will not take it.
- relayTo() : void
- renderForDiscord() : string|null
- One incoming message as Discord should show it: the quoted reply, the text, and whatever files came with it.
- roleNames() : array<string, string>
- suffix() : string
- Appended to a relayed name, so it is never mistaken for a Discord account.
- userNames() : array<string, string>
Constants
REMEMBER
How many relayed messages to remember for editing, per direction.
public
mixed
REMEMBER
= 500
Properties
$bot read-only
private
Bot
$bot
$commands
private
array<string, ChatDispatcher>
$commands
= []
connector name => its command matcher
$delivered read-only
private
MessageMap
$delivered
"connector:room:id" => list of Discord copies it became
$seen read-only
private
MessageMap
$seen
"discordId" => fingerprint of what was relayed, to tell an edit from an unfurl
$sent read-only
private
MessageMap
$sent
"connector:discordId" => the id it was given on that network
Methods
__construct()
public
__construct(Bot $bot[, int $remember = self::REMEMBER ]) : mixed
Parameters
- $bot : Bot
- $remember : int = self::REMEMBER
attach()
Attaches both directions. Call once; nothing arrives until a connector starts.
public
attach() : void
fingerprint()
What a relayed message looked like, for telling a real edit from an update that changed nothing a chat can see.
public
static fingerprint(Message $message) : string
Parameters
- $message : Message
Return values
stringisDiscordCommand()
Whether a Discord message is addressed to the bot rather than to the channel.
public
isDiscordCommand(string $content) : bool
Only a registered command counts. Chat is full of ! — "yes!!!", or
another bot's !drop — and treating all of it as a command would quietly
stop relaying a slice of ordinary conversation.
Parameters
- $content : string
Return values
boolshouldRelayFromDiscord()
Whether a Discord message is real conversation worth relaying.
public
shouldRelayFromDiscord(Message $message) : bool
Four rejections, each for its own reason:
- Webhook messages. Every connector's chat is delivered into Discord through a webhook, so this single check is what stops the loop. It has to come first and it has to be unconditional.
- Bot authors. Two bridges sharing a channel would otherwise ping-pong forever, and a bot's output is rarely what a chat wants to read.
- The bot's own messages, which is belt-and-braces: command replies are sent as the bot and would otherwise be echoed out, having already been said there.
- Commands.
!twitch title somethingis an instruction to the bot, not a remark; relaying it would put every command into the stream's chat.
Parameters
- $message : Message
Return values
boolacrossNetworks()
Hands a message on to every *other* network bridged to the same Discord channels, so a channel bridged to Twitch and Telegram is one conversation across all three rather than two that only Discord can see.
private
acrossNetworks(Connector $source, Incoming $incoming) : void
Discord cannot make this hop itself: the copy it receives arrives through a webhook, and webhook messages are dropped unconditionally because that is what stops the loop. So it is made here, from the original.
Each room gets the message once however many channels lead to it, and an edit follows the same rule as everywhere else — applied as an edit where the network can and the copy is remembered, otherwise not at all.
Parameters
attachments()
Each attachment as a {@see Media}.
private
attachments(Message $message) : array<int, Media>
url rather than proxy_url: both are signed and both expire, but url
is the canonical one, and the proxy adds nothing for a recipient who is
going to open it in a browser.
Parameters
- $message : Message
Return values
array<int, Media>authorName()
private
authorName(Message $message) : string
Parameters
- $message : Message
Return values
stringbridgedConnectors()
Every connector this Discord channel is bridged through, and the room on each.
private
bridgedConnectors(string $channelId) : array<int, Connector, 1: string}>
Parameters
- $channelId : string
Return values
array<int, Connector, 1: string}>channelNames()
private
channelNames(Message $message) : array<string, string>
Parameters
- $message : Message
Return values
array<string, string>channelsFor()
Every cached Discord channel following the room a message came from.
private
channelsFor(Connector $connector, Incoming $incoming) : array<int, Channel>
Parameters
Return values
array<int, Channel>compose()
private
compose(Message $message, bool $edited) : Outgoing
Parameters
- $message : Message
- $edited : bool
Return values
OutgoingcomposeFromNetwork()
A message from one network as another should receive it.
private
composeFromNetwork(Connector $source, Incoming $incoming, string $key) : Outgoing
Named with the network it came from, as a Discord copy is. A file only the source network can open — a Telegram photo has no URL that does not carry the bot token — goes as a link to the public page showing it when there is one, and is named otherwise.
Parameters
Return values
Outgoingdeliver()
private
deliver(Connector $connector, Channel $channel, Incoming $incoming, string|null $text, string|null $avatar, array{filename: string, content: string}|null $file) : void
Parameters
describeMedia()
A file, as a link when there is a safe one and as a name otherwise.
private
describeMedia(Media $item) : string
A connector hands over null for the URL when the only link it could
produce carries a credential — a Telegram file URL has the bot token in
its path — so this never leaks one into a channel. A public page showing
the file is the next best thing: reached when a file was too big to copy
across, or the copy failed.
Parameters
- $item : Media
Return values
stringeditInDiscord()
Rewrites every Discord copy of an edited message, returning whether any were remembered.
private
editInDiscord(Connector $connector, Incoming $incoming) : bool
When none are — the original was relayed before the last restart, or so long ago it fell out of memory — the edit is relayed as a new message rather than lost, which on a network that sends whole messages as edits is the only way the correction reaches anyone.
Parameters
Return values
boolfetch()
private
fetch(Connector $connector, Media $media) : PromiseInterface<string|int, array{filename: string, content: string}|null>
Parameters
Return values
PromiseInterface<string|int, array{filename: string, content: string}|null>fromConnector()
private
fromConnector(Connector $connector, Incoming $incoming) : void
Parameters
fromDiscord()
private
fromDiscord(Message $message) : void
Parameters
- $message : Message
fromDiscordEdit()
An edit of something already relayed, and only that.
private
fromDiscordEdit(object $message) : void
Three things are not edits and are dropped: a partial update with no message part (a pin, a flag change), an update to a message this bot never relayed, and an update whose content did not change — which is what an unfurling link looks like.
Parameters
- $message : object
incomingKey()
Where a message from a network is remembered. Room and id together, because a message id is only unique within its room on some networks — Telegram numbers each chat from one.
private
static incomingKey(Connector $connector, Incoming $incoming) : string
Parameters
Return values
stringmirrorableIn()
The one attachment worth copying across as a file.
private
mirrorableIn(Connector $connector, Incoming $incoming) : Media|null
Only one, and only from a connector that can fetch it: a second upload per message multiplies the bytes by every channel it fans out to.
Parameters
Return values
Media|nullphotoIn()
The one picture worth handing to the network itself, if there is one.
private
photoIn(Outgoing $outgoing) : Media|null
Only the first: sending several means a media group, which is a different call on every network that has one, and mixing files with images is not something they agree on. The rest relay as links in the text.
Parameters
- $outgoing : Outgoing
Return values
Media|nullrelayPhoto()
Hands the picture to a network that can carry it, and falls back to a link in the text when it will not take it.
private
relayPhoto(Media|Connector $connector, string $target, Media $photo, Outgoing $outgoing) : PromiseInterface<string|int, string|null>
A network that can carry the picture should, because then it is actually there — a relayed link to Discord's CDN expires in about a day, so it is dead by the time anyone reads the logs.
Parameters
Return values
PromiseInterface<string|int, string|null>relayTo()
private
relayTo(Connector $connector, string $target, Outgoing $outgoing) : void
Parameters
renderForDiscord()
One incoming message as Discord should show it: the quoted reply, the text, and whatever files came with it.
private
renderForDiscord(Incoming $incoming, bool $sourceHasLines[, Media|null $mirrored = null ]) : string|null
Parameters
- $incoming : Incoming
- $sourceHasLines : bool
- $mirrored : Media|null = null
-
An attachment that is being uploaded, so is not named as well.
Return values
string|nullroleNames()
private
roleNames(Message $message) : array<string, string>
Parameters
- $message : Message
Return values
array<string, string>suffix()
Appended to a relayed name, so it is never mistaken for a Discord account.
private
suffix(Connector $connector) : string
Parameters
- $connector : Connector
Return values
stringuserNames()
private
userNames(Message $message) : array<string, string>
Parameters
- $message : Message