TwitchConnector
in package
implements
Connector, ProvidesActions, Avatars
Twitch, as far as the bridge is concerned.
Owns the TwitchPHP client, the IRC membership, the Helix dispatcher and the device-code recovery, and hands the core Incoming messages and Room descriptions like any other connector. Nothing above this knows what IRC is.
Twitch::run() would call Loop::run() itself, so the client is started
through bootstrap() and Discord::run() is left to drive the loop.
Tags
Table of Contents
Interfaces
- Connector
- One network the bot bridges Discord with.
- ProvidesActions
- A connector that brings commands of its own.
- Avatars
- A connector that can say what somebody looks like.
Constants
- NAME : mixed = 'twitch'
- The name this connector is addressed by, in the store and in chat.
- SCOPES : array<int, string> = [ // Reading and speaking in chat: the relay, a...
- Exactly the scopes the built-in commands need — no more.
- USER_CACHE : mixed = 1000
- How many user rows to keep. Every new chatter is one, for their avatar.
Properties
- $adapter : TwitchAdapter|null
- $bot : Bot
- $config : TwitchConfig
- $dispatcher : RepositoryDispatcher
- $gateway : TwitchGateway|null
- $handlers : array<int, callable(Incoming): void>
- $pending : array<string, PromiseInterface<string|int, array{id: string, login: string, display_name: string, description: string, avatar: string}|null>>
- $twitch : Twitch
- $users : array<string, array{id: string, login: string, display_name: string, description: string, avatar: string}|null>
Methods
- __construct() : mixed
- actions() : array<int, Action>
- Every command this connector defines.
- avatarFor() : PromiseInterface<string|int, string|null>
- A chatter's profile picture, for the Discord copy of what they said.
- boot() : void
- Builds the client and registers everything that must exist before the first message arrives. Called once, when the connector is added.
- getConfig() : TwitchConfig
- getDispatcher() : RepositoryDispatcher
- getTwitch() : Twitch
- joined() : array<int, string>
- Every room the connector is currently in.
- label() : string
- What to call the network in front of a human: `Twitch`, `Telegram`.
- lookupUser() : PromiseInterface<string|int, array{id: string, login: string, display_name: string, description: string, avatar: string}|null>
- One Helix user row by login, cached — including a user that does not exist, so a typo is not re-queried on every relayed message.
- name() : string
- A short, lowercase, stable name — `twitch`, `telegram`.
- normalise() : string|null
- Turns however somebody referred to a room into the form this connector addresses it by, or `null` when it is not a room reference at all.
- onIncoming() : void
- Registers the handler every inbound message is passed to.
- queued() : int
- How many outbound messages are waiting on this connector's own pacing.
- relay() : PromiseInterface<string|int, string|null>
- Renders a Discord message for this network and says it.
- resolve() : PromiseInterface<string|int, Room|null>
- Looks a channel up by login.
- send() : PromiseInterface
- Says something in a room, exactly as given.
- start() : PromiseInterface<string|int, mixed>
- Connects, and starts answering commands in chat.
- stop() : void
- Disconnects cleanly, for shutdown.
- surface() : Surface
- Twitch chat takes 500 characters of flat text on a single line.
-
sync()
: array{join: list
, part: list } - Joins and leaves whatever the routing table now says, and reports what it did.
- dispatch() : void
- Turns a TwitchPHP chat message into the core's own shape.
- promptForDeviceCode() : void
- reauthorizer() : DeviceCodeReauthorizer
- The device-code reauthorizer, and the prompt that reaches a human.
- warnIfCannotRefresh() : void
- warnIfTokenIsSomeoneElse() : void
- Says so when the token belongs to a different account than TWITCH_NICK.
Constants
NAME
The name this connector is addressed by, in the store and in chat.
public
mixed
NAME
= 'twitch'
SCOPES
Exactly the scopes the built-in commands need — no more.
public
array<int, string>
SCOPES
= [
// Reading and speaking in chat: the relay, and every command reply.
'chat:read',
'chat:edit',
// `title`, `game`, `tags`, and `marker`.
'channel:manage:broadcast',
// `followers`.
'moderator:read:followers',
// `clip`.
'clips:edit',
// `ban`, `unban`, `timeout`.
'moderator:manage:banned_users',
// `clear`.
'moderator:manage:chat_messages',
// `announce`.
'moderator:manage:announcements',
// `shoutout`.
'moderator:manage:shoutouts',
// `slow`, `subonly`, `emoteonly`, `followersonly`.
'moderator:manage:chat_settings',
// `vip`, `unvip`.
'channel:manage:vips',
// `mod`, `unmod`.
'channel:manage:moderators',
// `raid`, `unraid`.
'channel:manage:raids',
// `commercial`.
'channel:edit:commercial',
]
Each line names what stops working without it. Nothing is requested speculatively: every extra scope makes the consent screen longer and scarier, and grants the bot authority it has no code to use.
Two families, and the difference decides what the bot can act on:
channel:*requires the token to belong to the broadcaster, or to an account they have added as a channel editor. These only ever work on the channel that authorized the bot.moderator:*works on any channel where the bot account is a moderator, which is what lets one bot moderate many channels.
So title, vip, mod, raid and commercial act on the authorizing
account's own channel, while ban, timeout, clear, announce and
the chat modes work anywhere the bot is modded.
ApiActions can reach endpoints beyond this list. That is deliberate and not a reason to widen it: an uncovered call fails with a MissingScopeException naming the scope it wanted, which is a better outcome than holding every permission on the chance somebody types one.
Verified against the Twitch OpenAPI description rather than the docs pages.
USER_CACHE
How many user rows to keep. Every new chatter is one, for their avatar.
private
mixed
USER_CACHE
= 1000
Properties
$adapter
private
TwitchAdapter|null
$adapter
= null
$bot
private
Bot
$bot
$config read-only
private
TwitchConfig
$config
$dispatcher
private
RepositoryDispatcher
$dispatcher
$gateway
private
TwitchGateway|null
$gateway
= null
$handlers
private
array<int, callable(Incoming): void>
$handlers
= []
$pending
private
array<string, PromiseInterface<string|int, array{id: string, login: string, display_name: string, description: string, avatar: string}|null>>
$pending
= []
Lookups in flight, so a burst from one chatter is one request.
$twitch
private
Twitch
$twitch
$users
private
array<string, array{id: string, login: string, display_name: string, description: string, avatar: string}|null>
$users
= []
login => cached user row, negatives included.
Methods
__construct()
public
__construct(TwitchConfig $config) : mixed
Parameters
- $config : TwitchConfig
actions()
Every command this connector defines.
public
actions() : array<int, Action>
Return values
array<int, Action>avatarFor()
A chatter's profile picture, for the Discord copy of what they said.
public
avatarFor(Incoming $message) : PromiseInterface<string|int, string|null>
Helix's profile_image_url is a public CDN link with nothing secret in
it. Never rejects: a missing avatar is not worth losing the message over.
Parameters
- $message : Incoming
Return values
PromiseInterface<string|int, string|null>boot()
Builds the client and registers everything that must exist before the first message arrives. Called once, when the connector is added.
public
boot(Bot $bot) : void
Parameters
- $bot : Bot
getConfig()
public
getConfig() : TwitchConfig
Return values
TwitchConfiggetDispatcher()
public
getDispatcher() : RepositoryDispatcher
Return values
RepositoryDispatchergetTwitch()
public
getTwitch() : Twitch
Return values
Twitchjoined()
Every room the connector is currently in.
public
joined() : array<int, string>
The startup check compares this against what was restored from disk: a join that silently failed leaves a bridge that works in one direction only, which is invisible from the configuration alone.
Return values
array<int, string>label()
What to call the network in front of a human: `Twitch`, `Telegram`.
public
label() : string
Return values
stringlookupUser()
One Helix user row by login, cached — including a user that does not exist, so a typo is not re-queried on every relayed message.
public
lookupUser(string $login) : PromiseInterface<string|int, array{id: string, login: string, display_name: string, description: string, avatar: string}|null>
Resolves null only when Twitch says there is no such user. A lookup
that failed rejects: it is not proof of absence, and a moderation
command must not treat it as one.
Parameters
- $login : string
Return values
PromiseInterface<string|int, array{id: string, login: string, display_name: string, description: string, avatar: string}|null>name()
A short, lowercase, stable name — `twitch`, `telegram`.
public
name() : string
It is not only a label. It keys this connector's bridges in Store, and it is the top-level slash command the connector publishes, so changing it strands both.
Return values
stringnormalise()
Turns however somebody referred to a room into the form this connector addresses it by, or `null` when it is not a room reference at all.
public
normalise(string $input) : string|null
twitch.tv/Foo and #Foo are both foo; t.me/name is @name. The
result is what gets stored, so it must be stable — normalising one way on
Monday and another on Tuesday orphans every bridge made before the change.
Parameters
- $input : string
Return values
string|nullonIncoming()
Registers the handler every inbound message is passed to.
public
onIncoming(callable $handler) : void
The connector is responsible for setting Incoming::$own on anything the bot itself said. A bridge that repeats its own output is an infinite loop that gets the account banned from both networks within minutes, and only the connector can recognise its own voice.
Parameters
- $handler : callable
queued()
How many outbound messages are waiting on this connector's own pacing.
public
queued() : int
Return values
intrelay()
Renders a Discord message for this network and says it.
public
relay(string $target, Outgoing $message) : PromiseInterface<string|int, string|null>
The rendering is the connector's because the answer is: IRC cannot carry a newline, Telegram's HTML mode needs exactly three characters escaped, and the limits are 500 and 4096. MessageText holds the parts that are the same everywhere — resolving mentions, budgeting attachment links, truncating on a character boundary — so a connector writes only what is genuinely its own.
Resolves to null when there was nothing worth relaying, which is not a
failure: an embed-only message says nothing a chat can repeat.
Parameters
- $target : string
- $message : Outgoing
Return values
PromiseInterface<string|int, string|null> —The id of what was said, when the network has them.
resolve()
Looks a channel up by login.
public
resolve(string $target) : PromiseInterface<string|int, Room|null>
The room is keyed by login — what IRC joins and what the store has held
since the single-platform bot — and carries the Helix user id as its
Room::apiId(), which is what every Helix call wants as
broadcaster_id.
Parameters
- $target : string
Return values
PromiseInterface<string|int, Room|null>send()
Says something in a room, exactly as given.
public
send(string $target, string $text[, array<string|int, mixed> $options = [] ]) : PromiseInterface
For text this bot composed itself — a command's reply, a notice. Relayed chat goes through relay() instead, which is the one that still has to be rendered.
Parameters
- $target : string
- $text : string
- $options : array<string|int, mixed> = []
-
Connector-specific extras — a reply target, a file to attach. Unknown keys are ignored.
Return values
PromiseInterfacestart()
Connects, and starts answering commands in chat.
public
start() : PromiseInterface<string|int, mixed>
bootstrap() resolves once the token is usable and IRC is up; anything
that needs a live client happens inside it. A rejection is handed back
to the core, which reports this connector as down without taking the
others with it.
Return values
PromiseInterface<string|int, mixed>stop()
Disconnects cleanly, for shutdown.
public
stop() : void
surface()
Twitch chat takes 500 characters of flat text on a single line.
public
surface() : Surface
lines: false is not a stylistic note — a newline in a PRIVMSG is an
IRC injection, so the core must never assume one can be sent.
Return values
Surfacesync()
Joins and leaves whatever the routing table now says, and reports what it did.
public
sync(Links $links) : array{join: list, part: list}
A diff rather than "rejoin everything", so one server's change does not blink every other server's bridge offline.
Parameters
- $links : Links
Return values
array{join: listdispatch()
Turns a TwitchPHP chat message into the core's own shape.
private
dispatch(ChatMessage $message) : void
The gateway has already dropped the bridge's own lines, so everything that reaches here was typed by a person — the host included, since the bot speaks as the host's account. Their messages are relayed and their commands answered like anyone else's, at the rank Twitch gives them.
Parameters
- $message : ChatMessage
promptForDeviceCode()
private
promptForDeviceCode(array<string, mixed> $device) : void
Parameters
- $device : array<string, mixed>
reauthorizer()
The device-code reauthorizer, and the prompt that reaches a human.
private
reauthorizer() : DeviceCodeReauthorizer
Device-code is the one grant OAuth::form() allows without a client
secret, so this is the only route back to a working token when none is
configured.
The prompt goes two places: the log, always, and a DM to the bot's owner when one is set. A bot that needs re-authorization and only whispers it into a logfile stays down until somebody looks.
Return values
DeviceCodeReauthorizerwarnIfCannotRefresh()
private
warnIfCannotRefresh() : void
warnIfTokenIsSomeoneElse()
Says so when the token belongs to a different account than TWITCH_NICK.
private
warnIfTokenIsSomeoneElse() : void
Twitch sends chat as whoever the token belongs to, whatever nick the connection claims — so a token approved while logged in as the streamer makes the relay speak as the streamer. Nothing fails, which is what makes it worth saying: the first sign is a relayed message under the wrong name.