95 Commits
Author SHA1 Message Date
SalastilandClaude Sonnet 5.5 5ab8eafceb Merge development into master: 1.2.1, Steam identity
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 17:14:18 -04:00
SalastilandClaude Sonnet 5.5 e4a16278ab 1.2.1
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 17:14:12 -04:00
SalastilandClaude Sonnet 5.5 f3de870d49 Steam: sign in as a desktop client with a stable machine id and a logon id of this install's own, and wait longer before reconnecting
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-15 01:01:33 -04:00
SalastilandClaude Sonnet 5.5 2355fcb6ac Merge development into master: 1.2.0, Twitch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:54:56 -04:00
SalastilandClaude Sonnet 5.5 e9b4470128 1.2.0
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:54:08 -04:00
SalastilandClaude Sonnet 5.5 6d246264dd Twitch: report watching once a minute while the stage plays, so channel points build
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 21:04:28 -04:00
SalastilandClaude Sonnet 5.5 7d726839d3 Twitch emotes: one section per owner with their pictures, no duplicate cells, a changed emote listed with its channel
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 20:44:14 -04:00
SalastilandClaude Sonnet 5.5 25a0c07770 Twitch emote picker: usable emotes first; the channel's picture or Twitch's mark on the rail
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:58:35 -04:00
SalastilandClaude Sonnet 5.5 d348ccfb59 Twitch: drop BetterTTV, FrankerFaceZ and 7TV; the picker shows the channel's whole emote set with the unusable ones greyed out
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:52:53 -04:00
SalastilandClaude Sonnet 5.5 1bb6e3bfd4 Twitch: the picker reads the account's emotes again when stale, and your own line shows each emote as a picture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:42:44 -04:00
SalastilandClaude Sonnet 5.5 81f3aa0f98 Twitch emote rewards: offer the channel's unlockable emotes the account lacks, change only emotes it has; warm the integrity token when rewards open
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 19:25:19 -04:00
SalastilandClaude Sonnet 5.5 84c15e8bf8 Twitch: integrity tokens are made as the account for the account's own calls; emote choices as pictures
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:58:26 -04:00
SalastilandClaude Sonnet 5.5 c4f52e5216 Twitch: Highlight My Message and the emote unlocks as rewards; the account's emotes are read again so unlocks appear and lapse
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:47:32 -04:00
SalastilandClaude Sonnet 5.5 f4533ff9a7 Twitch: list a channel's rewards and redeem them, as the twitch.tv session
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:23:00 -04:00
SalastilandClaude Sonnet 5.5 34d15e9315 Twitch: no bonus claiming (out of scope); the points plaque shows the balance only
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:08:31 -04:00
SalastilandClaude Sonnet 5.5 765f6d07ea Twitch: the live points test can bet on a real prediction with nothing at stake
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 18:03:59 -04:00
SalastilandClaude Sonnet 5.5 f1aec36f97 Twitch: a message highlighted with channel points is not a mention and notifies nobody
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:17:54 -04:00
SalastilandClaude Sonnet 5.5 71d18c8cb4 Twitch: vote in polls, bet on predictions, read the points balance and claim the bonus, as the twitch.tv session
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 17:00:03 -04:00
SalastilandClaude Sonnet 5.5 bd031001ab Twitch: a refusal that is about the account itself does not tell the person to sign in again
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:46:44 -04:00
SalastilandClaude Sonnet 5.5 28481f39e3 IRC: a channel's modes are explained in words when it is joined and when one changes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 16:20:17 -04:00
SalastilandClaude Sonnet 5.5 26a2339ee6 Twitch: Channels and Direct Messages as fixed headings; room mode changes, removals and acts as cards
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 15:03:49 -04:00
SalastilandClaude Sonnet 5.5 a286f45ee8 Twitch: a whisper conversation is named @login so it cannot be the same buffer as that person's channel; a timeout or ban is not said twice
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:48:38 -04:00
SalastilandClaude Sonnet 5.5 6089672200 Twitch: moderation, whispers, shoutouts and blocking are asked for in the one sign-in
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 14:02:36 -04:00
SalastilandClaude Sonnet 5.5 c9b21e960b Twitch: a picked emote is sent as its word; its picker cell is named by the token the box draws
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 12:05:00 -04:00
SalastilandClaude Sonnet 5.5 278f0dd323 Twitch: a sign-in can bring the twitch.tv session with it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:56:28 -04:00
SalastilandClaude Sonnet 5.5 f0cac1df03 Twitch: a signed-in account opens the channels it follows the first time it connects
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 10:23:07 -04:00
SalastilandClaude Sonnet 5.5 3c26bc9bfe Twitch: raids, polls and predictions as they happen, live 7TV and BetterTTV emote changes, block and shoutout
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 09:56:47 -04:00
SalastilandClaude Sonnet 5.5 4c30be62de Twitch: emotes and badges as pictures, third-party emote sets, cheers, moderation by menu and slash command, whispers, rights per channel
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 09:15:00 -04:00
SalastilandClaude Sonnet 5.5 6635af75cf Twitch: what is on air, and the address of its video
A channel's state (live, title, category, viewers, avatar) is read from Twitch's GraphQL in one batch for
every open channel each minute and said as the same stream event Kick's use, so the header chip, the Watch
button and the player are the code Kick already has. The playlist address is built from the player's own
token query, renewed before its twenty minutes are up; what the token says (ads, restricted renditions,
subscribers only, blocked here) is carried. An integrity token, if Twitch ever demands one, is asked for from
the window. A Twitch website session can be attached to an account for video as the person, and followed
channels can be opened by Helix.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 08:53:24 -04:00
SalastilandClaude Sonnet 5.5 cd8a44cbf9 Twitch: Helix sending with IRC as the fallback, recent messages on request
A message goes by Helix when the account agreed to user:write:chat (it says whether Twitch sent it and why
not, and gives its id, which the shown copy then carries) and falls back to IRC when Helix cannot take it.
A reply names its parent to Twitch even when that message has left the scrollback. A channel's recent
past can be read from the third-party recent-messages service, only when asked, never making the roster.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 00:43:21 -04:00
SalastilandClaude Sonnet 5.5 7bd80939a8 Twitch: chat is read and sent over Twitch's IRC, channels opened and remembered
irc.rs reads a line (tags, prefix, command, parameters); chat.rs turns lines into messages (colour, badges,
emote tokens by code point, replies with their thread), cards (subs, gifts told once per batch, raids,
announcements), removed messages (CLEARMSG, CLEARCHAT marks a person's messages), room modes in the header,
a roster counted without a cap; connect.rs is the connection: greeting, paced joins, a channel Twitch never
answers for is said not to exist, say with the 500-character and rate limits, local echo, reconnect.
Rounds 2 and 3 of #55, without history, Helix sending or blocking yet.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 00:32:27 -04:00
SalastilandClaude Sonnet 5.5 33dfca4183 Twitch: an account, signed in by the device code flow, kept good
Accounts (signed in, or an anonymous reader), their tokens in accounts.toml, the device flow against
id.twitch.tv with moho's public client (no secret), renewal with the single-use refresh token written
before anything else, validation, revocation on removal, and a connection that keeps the sign-in good and
stops in AuthFailed when Twitch will not take it. Round 1 of #55.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-14 00:13:32 -04:00
SalastilandClaude Sonnet 5.5 4c17be082b Kick: the points balance is pushed as it changes, and badges_v2 arrive as pictures
The watch session also follows private-channelpoints-<user id>, where Kick says
ChannelPointsUpdatedEvent with the channel and the new balance, and tells the window.
badges_v2 are read - the ones the person chose to wear, ranked highest first as Kick's own
apps rank them - and carried beside the role badges as pictures.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 23:11:25 -04:00
SalastilandClaude Sonnet 5.5 43f5e94c18 Kick: fix the order of the roster test's assertions
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:54:55 -04:00
SalastilandClaude Sonnet 5.5 41e93b8f49 Kick: the count of chatters keeps rising past the list's length
The roster is the most recent 500 speakers (it was 200) and is sent whole each time somebody new joins it;
the number of different people who have spoken is counted apart, never cut off, and sent beside it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:51:34 -04:00
SalastilandClaude Sonnet 5.5 bb318cf7de Kick: replies carry their thread, removed messages are kept and marked, the silent chatrooms.<id> subscription is gone
A reply names the message its chain began with (thread_parent_id, or the quoted message's own place in a
chain when it comes out of history, whose metadata is a string of JSON and was never read for its quote).
A MessageDeletedEvent now marks the message instead of deleting it, with aiModerated and violatedRules.
Six minutes on a 60,000-viewer channel delivered nothing on chatrooms.<room>; it is no longer asked for,
and predictions-channel-<id> is now cancelled with the channel like the rest.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 21:17:29 -04:00
SalastilandClaude Sonnet 5.5 9f0ca86fdb Kick: the stream address comes from the playback-url endpoint, with its expiry, renewed before it runs out
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 19:57:02 -04:00
SalastilandClaude Sonnet 5.5 289b5db54a 1.1.0: Steam
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:48:20 -04:00
Salastil 6108645415 Merge remote-tracking branch 'origin/development' into protocol/steam 2026-10-13 15:46:43 -04:00
SalastilandClaude Sonnet 5.5 dadda7644c Steam: a friend's conversation is not filed under a heading of a service's: one nobody can move, rename or remove
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:35:45 -04:00
SalastilandClaude Sonnet 5.5 d5331f43cd Steam: what people were called is kept between runs, so a start shows names and not numbers, and is replaced when they rename
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:26:11 -04:00
SalastilandClaude Sonnet 5.5 981d7a9d3a Steam: once the start of a conversation's history is known, scrolling to the top does not ask again
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 15:05:25 -04:00
SalastilandClaude Sonnet 5.5 4fd2eaa459 A probe that sends a sticker into a group
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:33:02 -04:00
SalastilandClaude Sonnet 5.5 61fe6b8809 A probe that sends a picture into a group, plain and as a spoiler
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:30:12 -04:00
SalastilandClaude Sonnet 5.5 1b858f2437 Steam: an invitation to a group that arrives as a state change is kept like one that arrives as a message
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 14:10:37 -04:00
SalastilandClaude Sonnet 5.5 8fd22d4ff0 Steam: a friend's invitation to a group is read from the message, kept, and answered; files go into a group's room; probes for the official client
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 13:54:01 -04:00
SalastilandClaude Sonnet 5.5 7dc67f9183 Steam: each friend's conversation is filed under their Steam folder, with the "Steam - " some folders carry left off
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 13:08:11 -04:00
SalastilandClaude Sonnet 5.5 b501ad2fdd Steam: a store link Steam rewrites reads as its address; a banned group's own wording; probes for the official client
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 11:58:57 -04:00
Salastil 0527759ec6 Revert "Steam: a store link Steam rewrites reads as its address; probes for the official client, a group's refusal wording"
This reverts commit dbf97ecd4c.
2026-10-13 11:57:28 -04:00
SalastilandClaude Sonnet 5.5 dbf97ecd4c Steam: a store link Steam rewrites reads as its address; probes for the official client, a group's refusal wording
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 11:56:05 -04:00
SalastilandClaude Sonnet 5.5 3689f92196 Steam: every failure has a sentence, and the client's own patience and wording where it has them
- the result table covers every code Steam defines (0 to 130), checked against the client's constants, with a test that none is left as a bare number; what ends a session for good, what waits and what another server may fix are told apart
- a message that fails in passing is tried again for up to a minute with the same id; refused for good is said in the client's words (not friends, not allowed here, too fast)
- group calls: a ban already in place counts as done, join refusals say removed or banned or a dead link, a picture refused says which of the client's reasons, the site's own result codes are said in words
- the agreement page Steam asks to be accepted is passed on; a sign-in that ran out says so
- voice failures that may pass are marked and tried again by the window with the client's waits; refusals say why
- a group message may be five thousand characters, as the client's box allows (eight thousand went through when tried)

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 08:55:40 -04:00
SalastilandClaude Sonnet 5.5 ef0700dd53 Steam: what the decompiled client does, where this did something else
Found by reading the client's own code against ours before testing:
- a removal or a mute ends at a moment (now plus an hour, a day, a week), not after a length: both were sent as lengths
- being removed or banned arrives as user actions 3 and 10, not only 2, and is said
- server lines say who did it and carry counts for the many kinds
- permissions are the roles a member holds, giving roles is its own permission and not in a community group's chat, and notification level and the unread marker are two settings
- @all and @online, and @name#number for two of one name; nameless groups are named by who is in them
- the text filter uses Steam's own lists with the account's words on top, compiled as the client does (tried against the real lists)
- a Steam Deck is device type 544; the group-wide read position is heard; join refusals say why

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 08:12:11 -04:00
SalastilandClaude Sonnet 5.5 38a6789db7 Steam: a group's picture is served at its hash with a size, not the _full a person's is
Found live with a real picture change: the address built before was a 404.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 07:47:42 -04:00
SalastilandClaude Sonnet 5.5 839444d18b Steam groups: change the picture, and Steam's warnings about members
A picture is sent to Steam's site and then named to the group; Steam's own
result code is said when it refuses (a limited account may not). A warning
about a member is a banner over the group's rooms until it is dismissed, which
tells Steam so it is not raised again.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 07:21:52 -04:00
SalastilandClaude Sonnet 5.5 ecc3c7dab5 Steam voice: the signalling for rooms and calls, read from the Steam client's own code
Voice is WebRTC with Valve's server as the one peer, so the media is the
window's and the daemon carries the calls and notifications: the messages are
generated from the client's JavaScript (checked against a reference encoder),
`steamVoice` relays each call, `steamVoice` events carry what the server says,
and a room's voice occupancy follows `members_in_voice`. Checked against real
Steam: an offer made by Chromium is answered with a relay.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 07:10:52 -04:00
SalastilandClaude Sonnet 5.5 a604801590 Steam chat groups: servers on the rail, rooms as channels
Groups the account is in are read when it signs in and kept: each is a rail
entry with a channel per room and a member list with ranks, roles and what
people are playing. Messages, history, unread and read position, reactions,
deletions and mentions work in a room as they do in a direct message, and
live notifications keep the groups, rooms and members in step. Create, join
by invite link, leave, rename, tagline, rooms, kick, ban, mute, roles and
their permissions, invite links and notification level are calls the window
makes. Checked against real Steam as the throwaway account.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 01:30:16 -04:00
SalastilandClaude Sonnet 5.5 3b4408aeda Steam: chat-group messages checked against Valve's definitions
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:55:15 -04:00
SalastilandClaude Sonnet 5.5 abfe7e6026 Steam account: QR sign-in, sessions, limited and Family View, text filter, privacy, token renewal, remembered machine, muting that follows
Sign in by scanning a code with the phone (begun against real Steam, the code drawn, the
poll answered). The account's facts are read in the background: limited, Family View, the
text filter and its words, privacy. Where it is signed in is listed and any other session
can be signed out. A token within a month of its end is renewed. A machine that signed in
before says so, so Steam Guard is not asked again. A mute set in another Steam client mutes
here. How long a friend has been seen playing is on their card.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:49:18 -04:00
SalastilandClaude Sonnet 5.5 6afefcaddb Steam: invite links (read, make, revoke; a pasted one adds its account) and the blocked list
An invite link's code is the account number in sixteen letters, checked against real
accounts; its links are made, listed and revoked through Steam's website, all against a real
account. Who is blocked is listed from the friends list and lifted with Player.IgnoreFriend.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:32:54 -04:00
SalastilandClaude Sonnet 5.5 b7750f6b8c Steam: friend groups, read and changed (against real Steam), with RPCs
The groups Steam sorts an account's friends into arrive at sign-in and as they change; a
friend can be added to or taken out of a group, and a group made, renamed or deleted. The
messages are checked against Valve's own definitions, and every call was made against a real
account and read back.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:19:35 -04:00
SalastilandClaude Sonnet 5.5 52bcd5ed89 Steam: a game is named (asked of the store by its number), and a friend's name history is read
Steam names a friend's game only when it is not on Steam; the number is asked of the store
once and the name kept, so a conversation's line says what they are playing, and says 'In
game' until the name is known. A friend's earlier names are read from their profile and put
on the card.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-13 00:06:57 -04:00
SalastilandClaude Sonnet 5.5 2da74f985e Steam: the warning banner is put back when a conversation is made or renamed
A conversation known by its number at the start is renamed when Steam says what it is
called, and the renamed one is a new conversation with nothing on it - which is where the
banner went. It is now said again whenever a conversation is made or renamed, and a warning
moves with the conversation in the store.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 23:47:39 -04:00
SalastilandClaude Sonnet 5.5 74d375fb71 Steam: a warning banner stays until dismissed, across a restart
The banner was held in memory only, so a warning raised hours ago and not yet dismissed was
gone with the next start. What is dismissed is now kept, and what is not is put back when
the account connects - including a warning said in the log before anything recorded it.
A notice Steam raises afterwards is new and shows the banner again.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 23:40:51 -04:00
SalastilandClaude Sonnet 5.5 11378211a2 Steam: a warning about a conversation is a red banner until dismissed, and a red line in the log
The notice is held on the conversation and sent to the window; dismissing takes it off and
tells Steam, which does not raise it again. A notice Steam raises afterwards is new, and the
banner comes back. The line in the log is a warning kind of its own.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 23:28:33 -04:00
SalastilandClaude Sonnet 5.5 d5501c068a Steam: a live probe for our own presence and idle detection
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 22:20:37 -04:00
SalastilandClaude Sonnet 5.5 d91c8e051d Steam: a link the sender sends keeps the card (and the picture) Steam made of it
Steam builds the card when a link is sent, and answers with it. The sender's own copy was
stored from that answer with its text only, so the card was lost on the side that sent it.
A card's picture is read too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 22:06:45 -04:00
SalastilandClaude Sonnet 5.5 e5eca4f588 Steam: a web page the client describes becomes a card
Steam's client writes a page it has looked at as [og url=... title=... desc=...]words[/og].
The words become a link and the description a card (title, description, the site it is
from), stored on the message like any other embed - in live messages and in history.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:56:36 -04:00
SalastilandClaude Sonnet 5.5 eea4ef5a0d Steam stickers are named by words: listed, drawn, sent and reacted with
A sticker's name has spaces in it ('Nyan GLaDOS') and Steam writes a sticker message as
[sticker type="Nyan GLaDOS" limit="0"][/sticker], naming it in the type with nothing
inside. Names were held to one word, so the account's stickers were dropped from the
list and a sticker message read as nothing. Its picture's address takes the name
percent-encoded.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:14:56 -04:00
SalastilandClaude Sonnet 5.5 a23725abb0 Steam: the picker asks again what the account has, so stickers earned after sign-in appear
The list is asked for once at sign-in and was never updated. Opening the picker asks
Steam again (not more often than every twenty seconds, and not waiting more than two and
a half) and offers what comes back.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:04:12 -04:00
SalastilandClaude Sonnet 5.5 c26f968e19 Kick: chat moves from Pusher to Kick's realtime service
Kick turned its Pusher application off - its key is refused on every cluster - and
kick.com's own pages now use Centrifugo. The chat socket asks where the service is, opens
as a guest with a token (renewed before its forty-five minutes are up), subscribes the
same public channels without a token, and reads the same events from publications. The
watch-time connection, which was opened with no token, now carries one and is begun again
before it runs out.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:55:08 -04:00
SalastilandClaude Sonnet 5.5 f773e0d015 Kick: chat moves from Pusher to Kick's realtime service
Kick turned its Pusher application off - its key is refused on every cluster - and
kick.com's own pages now use Centrifugo. The chat socket asks where the service is, opens
as a guest with a token (renewed before its forty-five minutes are up), subscribes the
same public channels without a token, and reads the same events from publications. The
watch-time connection, which was opened with no token, now carries one and is begun again
before it runs out.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:52:18 -04:00
SalastilandClaude Sonnet 5.5 28c984137f Steam: friends keep their names across a reconnect, the heartbeat goes on while missed messages are read, and endings are logged
A new session knows its friends by number until their names arrive, and renamed the
conversations to digits meanwhile - and kept them so if the connection dropped again.
A conversation that already has a name keeps it until a new one is heard.

Catching up on missed messages held the connection loop for up to 20 seconds, with no
heartbeat sent. It now beats while it works.

Why a connection ended (a close frame, a failed read, Steam signing the session out) and
how long it lasted are logged at info level.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:40:47 -04:00
SalastilandClaude Sonnet 5.5 face8b8ac7 Steam: a picture or video is read into a marker saying which it is
Steam writes a file as [img src=...] or [video src=... type=video/webm], with the
address again inside as a link. The marker is [steam:image:URL] or [steam:video:URL].

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:21:28 -04:00
SalastilandClaude Sonnet 5.5 a24d298f57 Steam: read a picture's tag as Steam writes it (unquoted, long, its address repeated)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:09:55 -04:00
SalastilandClaude Sonnet 5.5 f3d176749f Steam files: read the upload address as Steam sends it, and send a random hash
Steam writes 'use https' as a number, which was refused as not a truth value, so every
upload stopped after the first step. A repeated hash is also refused by Steam as a
duplicate, so the hash is random as the official client's is. The log no longer carries
the signed address.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 20:03:26 -04:00
SalastilandClaude Sonnet 5.5 b3dd0d6867 Steam: send pictures and videos, with a spoiler, through Steam's website
A file goes through steamcommunity.com/chat/beginfileupload, a PUT, and
commitfileupload, which writes the message and takes the spoiler flag. The web login is
made from the refresh token. The types (jpg jpeg png gif webp avif mp4 webm mpg mpeg ogv)
and the 30 MB limit are the official client's own. A message carrying a picture or video
by its address is read as that address.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 19:37:24 -04:00
SalastilandClaude Sonnet 5.5 b550018e0d Steam offers only what the account was given: emoticons and stickers
Steam holds a short set for each account, and that is all its picker offers.
The account's emoticons and stickers are asked for together at sign-in (the
emoticons alone if the full list does not come), stickers are listed for their
own tab and sent with the command Steam's client sends, and a sticker an
account does not have is refused rather than sent as words. Found and fixed
on the way: the sign-in tests assumed an order of requests that background
asks had made untrue.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 19:10:35 -04:00
SalastilandClaude Sonnet 5.5 71bba20455 Steam round 4: nicknames, notifications, a friend's card, and ending a friendship
What the account keeps about a friend - the name it calls them, whether their
messages notify it - is read at sign-in, followed when it changes on any
client, and set from here without forgetting the rest of what is held. A
friend's card has what they wrote about themselves, their level, the kind of
client they are on, what their game says, and when they were last seen.
Removing a friend and blocking one are asked for and done. Checked through two
real accounts: a nickname set, heard back and cleared, muting, and the card.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 18:38:09 -04:00
SalastilandClaude Sonnet 5.5 c39d9f0c0b Steam: reactions, and Steam's warning about a conversation
Reacting with an emoticon or a sticker goes to Steam, which answers who holds
it now; others reacting is heard live and found again in history. Reactions
are spelled as Steam's own client spells them - an emoticon between colons, a
sticker bare - which is the form it accepts and refuses otherwise. A
conversation Steam flags as suspicious says so once, in words. Verified
through two real accounts.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:52:45 -04:00
SalastilandClaude Sonnet 5.5 4bef0ffa56 Steam messages: emoticons, stickers and links are read; no formatting is offered
Steam's friend chat takes no formatting - found against the real service,
which turns [b], [i] and [u] into literal characters and drops [code],
[quote] and [spoiler] to their contents. What it does keep is emoticons,
stickers and links, so those are what a message is read for: the tags Steam
stored become what the window draws, a bracket somebody typed stays a
bracket, and the account's emoticons are asked for so the picker can offer
them. Verified through two real accounts, whose probes also record what
Steam does to each tag.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:44:15 -04:00
SalastilandClaude Sonnet 5.5 a8fb4b0ff4 Sneedchat's member list carries each person's picture
The roster's user objects have always had an avatar and it was dropped, so
the list showed initials beside names whose pictures the messages drew. The
list is published at once as before; the pictures are fetched afterwards,
together, and laid over whoever is then in the room, so neither the list nor
the room's messages wait on a face. Anybody who arrives later keeps the
pictures everybody else already had.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:22:25 -04:00
SalastilandClaude Sonnet 5.5 b7a3f8925e Presence says what somebody is doing, for the line under their name
An activity - a game, a track, or words they wrote - is carried with each
member's presence. Steam's game and its Busy/Snoozing/Looking-to-trade states,
Discord's activities (the custom status first, then the game or the artist),
and IRC's away message all produce it; a change of words alone is a change.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 16:10:38 -04:00
SalastilandClaude Sonnet 5.5 d7fb208e82 Steam round 2: history, what was missed, reading, and typing
Opening a conversation asks Steam for its recent end and scrolling up for the
part before; both are stored under the id a live message gets, so hearing a
message twice is hearing it once. Signing in reads what arrived while away -
what was sent to us and not looked at anywhere is news, the rest only goes in
the log - and tries again as friends are named. Reading a conversation here
tells Steam, and Steam saying it was read elsewhere clears it here. Typing is
sent and heard. All of it checked against two real accounts, through ignored
probes that sign both in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 15:52:28 -04:00
SalastilandClaude Sonnet 5.5 6bf7027ddb Steam: what a friend is doing is said beside their dot; a live probe
A game ("Playing Counter-Strike 2") or a state the four colours share (Busy,
Snoozing, Looking to trade) is published with each friend's presence and
changes when it does. And an ignored probe that signs two real accounts in and
requires one to hear the other's message - run against mohotester and
salastil, where it passes in a second and a half.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 15:24:33 -04:00
SalastilandClaude Sonnet 5.5 78b9444da6 Steam: a message id is sixteen hex digits, which is what Steam accepts
Found against two live accounts through a scratch daemon: the thirty-two
digits sent until now were refused as "not an account", so no message
could be sent. Sixteen, as Steam's own client makes them, sends and is
heard by the other account. The refusal is now logged in full.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 15:07:00 -04:00
SalastilandClaude Sonnet 5.5 24356461d1 Steam: hear messages the server sends as a plain service notification
Steam delivers what it pushes to a client - an incoming friend message among
it - as service method 146; only 152 was listened for, so a message to a
signed-in account never arrived (and the sender, who stores their own, saw
nothing wrong). Both are heard now, a notification this client does not use
is logged where it can be read, and sends are logged with their outcome.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:58:20 -04:00
SalastilandClaude Sonnet 5.5 c93184d800 Steam: moho signs in only as the mobile app, so it never signs the launcher out
The desktop kind of session is one per account, and signing in as it would
have ended the Steam launcher's. The option is gone from the daemon and the
sign-in form; the mobile chat session is the only one asked for.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:21:34 -04:00
SalastilandClaude Sonnet 5.5 4999054daa Steam: error 24 is said as what it is, a limited account
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:18:32 -04:00
SalastilandClaude Sonnet 5.5 beb392fd9f Steam: a refused friend request is logged, and a plain failure says what to try
Steam answers a bare failure to an add and nothing more. The result is now
logged, and the message names the likeliest causes (a limited account cannot
send requests; a name is the sign-in name, not the display name) as things to
try rather than as the cause.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:15:33 -04:00
SalastilandClaude Sonnet 5.5 aca365ed88 Steam: asking, accepting, declining and ending friendships
Adding is one message to Steam whether it asks or answers, so accepting a
request is adding the person who made it; removing ends a friendship,
withdraws a request, or turns one down. A person can be given as a 17-digit
id, a friend code, [U:1:n], a profile address with the id in it, or an
account name. Who has asked, and who we have asked, is kept per account for
a window opened later, and a new request is announced once.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 14:06:01 -04:00
SalastilandClaude Sonnet 5.5 e7528fee4c Steam: an account signs in, learns its friends, and sends and receives direct messages
The rest of the daemon's side of round 1, joined up: sign-in with the saved token, a connection that stays up and says goodbye, the friends list as direct-message conversations with a dot each, the account's own status, and messages both ways.

connect: one task per account. It asks the directory for a server, connects through the account's route, signs in with the token and stays. How it ended decides what follows: the network (try again, another server, further apart each time), Steam saying wait (rate limited: minutes, not seconds), or the account (the saved sign-in no longer works, or it signed in somewhere else: stop and say so, since trying again would not help and the second would turn two clients into a fight over the account).

publish: a friend is a conversation named for them, moved with its history when they rename themselves - including a rename made while moho was closed - and with a one-person member list that the window reads the dot from. Friends who share a name get their account numbers added, checked to be distinct for every combination of four friends drawn from the strings the decoration itself produces. Nothing is said when nothing changed, because signing in brings hundreds of updates in seconds.

chat: a message is named by Steam's own timestamp and ordinal, so an echo or a replay is stored once; ones sent from the account's other sessions are filed under the friend and signed by us; text is sent as typed, so a "[b]" somebody types is theirs.

login: adding an account as events (status, what Steam is asking for, result) with RPCs for the code and for cancelling, since Steam may want something from a phone. The password is encrypted for Steam and dropped; only the token is kept, and a test reads the file back to check.

Wired into startup, connect and disconnect, sendMessage, the account status and idle, and the list of protocols. 90 tests, among them a whole session against a fake Steam: sign-in, a gzip batch of friends and presence, a message, a stranger, a replay.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 09:39:24 -04:00
SalastilandClaude Sonnet 5.5 e2b47dd3dc Steam: a connection to a connection manager, signing in through Steam Guard, the account in accounts.toml, and the friends model
Four more layers, still not connected to the rest of the daemon.

link: one WebSocket to a connection manager, routed as the account is. A question names a job and is answered by the packet naming the same job, however much else arrives in between; everything unsolicited comes out of a channel in order, with batches taken apart. A connection that ends fails the questions waiting on it and any asked afterwards at once, rather than after their timeout (a test found that case).

auth: the password is encrypted with the key Steam gives for the account, a session is begun, the code Steam Guard asks for is sent, or an approval on the phone is waited for, and the poll returns a refresh token. Only that token is kept. The kind of session asked for decides whether the account's desktop client is signed out by this one; moho asks for the chat kind by default.

accounts: a Steam account in accounts.toml with a token and never a password, a re-login keeping its route, the name Steam reads it by.

friends: who counts as a friend, a persona built up from partial updates, and the conversation names. Names are not unique on Steam and a conversation's name is its identity, so friends sharing a name are told apart by their account number, and the result is checked to be distinct for every combination of four friends drawn from the strings the decoration itself produces.

cm: the directory of servers, least loaded first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 09:21:45 -04:00
SalastilandClaude Sonnet 5.5 792b6f84db Steam: the packets, the messages and the identity, with every message checked against bytes Valve's own definitions produced
The first layer of the Steam backend, and nothing yet that connects: how a packet is laid out on the connection managers' WebSocket (a message type with a protobuf bit, a header length, a header, a body), the batches that hold packets together and gzip them, the hand-written protobuf messages for signing in, the friends list, presence and direct messages, a Steam account's two forms of identity, and Steam's result codes said in words.

The messages are written by hand rather than generated, so a wrong field number would be silent. golden/make_golden.py asks the real definitions (compiled from the extracted .proto files) to encode a fully populated message of each kind, and a test decodes those bytes with each struct and requires the same bytes back. It was checked to fail when a tag is wrong, and the script fails if a struct gains a field it does not fill.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 09:06:44 -04:00
Salastil 4dbcade8fd Update README to remove obsolete module information
Removed outdated module structure details from README.
2026-10-11 20:23:31 -04:00
Salastil e05e33a3b7 Remove unnecessary sections from README
Removed sections about daemon functionality, frontend notes, protocol backends, and on-disk state.
2026-10-11 20:22:48 -04:00
84 changed files with 29968 additions and 287 deletions
Generated
+22 -12
View File
@@ -1325,7 +1325,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36e10ac4bd47bf8d24a4852f001df23b7acbbca6e59d0adec0c2e2c4409dfe5b"
dependencies = [
"heck",
"indexmap 1.9.3",
"indexmap 2.14.0",
"itertools 0.15.0",
"proc-macro-crate",
"proc-macro2",
@@ -1463,7 +1463,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1683,7 +1683,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2682,6 +2682,12 @@ dependencies = [
"png",
]
[[package]]
name = "imagesize"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]]
name = "imara-diff"
version = "0.2.0"
@@ -3680,7 +3686,7 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nobilis"
version = "1.0.0"
version = "1.2.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -3694,6 +3700,7 @@ dependencies = [
"cpal",
"davey",
"dirs 5.0.1",
"flate2",
"fs2",
"futures",
"hex",
@@ -3702,6 +3709,7 @@ dependencies = [
"hyper",
"hyper-util",
"image",
"imagesize",
"irc",
"js_int",
"libc",
@@ -3709,8 +3717,10 @@ dependencies = [
"matrix-sdk-crypto",
"matrix-sdk-sqlite",
"opus2",
"prost",
"qrcode",
"rand 0.8.7",
"regex",
"reqwest",
"rsa",
"ruma-client-api",
@@ -3795,7 +3805,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4626,7 +4636,7 @@ dependencies = [
"derive-deftly",
"libc",
"paste",
"thiserror 1.0.69",
"thiserror 2.0.21",
]
[[package]]
@@ -4697,7 +4707,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5301,7 +5311,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5882,7 +5892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6273,10 +6283,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8237,7 +8247,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+11 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "nobilis"
version = "1.0.0"
version = "1.2.1"
edition = "2021"
description = "Chat daemon: IRC, Discord, Sneedchat and Matrix behind one unified JSON model over a Unix socket"
license = "GPL-3.0-or-later"
@@ -33,6 +33,7 @@ tokio-util = { version = "0.7", features = ["io"] }
# Discord backend (QR remote-auth login + gateway client)
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
regex = "1"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart", "http2", "socks", "stream"] }
# Pulled in transitively by both of the above, but with conflicting default
# crypto-provider features (ring vs aws-lc-rs) - direct so main.rs can pin
@@ -74,6 +75,14 @@ url = "2"
webpki-roots = "0.26"
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
# Steam backend: its connection managers speak protobuf inside a WebSocket,
# and compress a run of packets together as gzip - see backend/steam. The
# messages are written out by hand from Valve's own definitions, so no
# protoc is needed to build, and each is checked against bytes the real
# definitions produced.
prost = { version = "0.14", default-features = false, features = ["derive"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
# Matrix backend (Client-Server API over plain reqwest + full E2EE via
# matrix-sdk-crypto, the standalone vodozemac-backed Olm/Megolm state
# machine matrix-sdk itself uses - see backend/matrix/crypto.rs)
@@ -111,6 +120,7 @@ opus2 = "0.4"
sonora = "0.2"
# Soundboard sounds are MP3 or Ogg Vorbis, decoded here to be mixed into a call.
symphonia = { version = "0.5", default-features = false, features = ["mp3", "ogg", "vorbis"] }
imagesize = "0.13"
# Two calls, both in main.rs: see keep_the_heap_honest() there for why a chat
# daemon has anything to say to its allocator.
-64
View File
@@ -7,31 +7,12 @@ into Kiwi Farms), and translates all of them into one unified JSON model — acc
Frontends talk to that model and never to a protocol. Two exist today: [moho](https://github.com/Moho-Chat/moho),
an Electron desktop client, and dms-chat, a Quickshell/QML shell plugin.
## Why a daemon
Connections, Tor circuits and Matrix sync are expensive to establish and unpleasant to lose.
Keeping them in a long-lived process means a frontend can close, restart, crash or be replaced
mid-session without dropping off IRC or resyncing Matrix from scratch. It also means more than
one frontend can attach at once — the socket accepts multiple clients, each with its own
subscriptions.
```
src/backend/ one module per protocol (irc, discord, sneedchat, matrix, kick)
src/rpc/ the Unix-socket JSON-RPC server
src/runtime.rs protocol-agnostic connection/buffer/message state
src/store.rs SQLite-backed scrollback persistence
src/accounts.rs account and credential persistence
resources/ assets the protocols need (Sneedchat's smiley table)
```
## Building
```bash
cargo build --release
```
Takes no required arguments. `--data-dir` and `--socket-path` override the defaults below.
## Wire protocol
Newline-delimited JSON over a Unix socket at `$XDG_RUNTIME_DIR/nobilis/nobilis.sock`:
@@ -52,51 +33,6 @@ shape of it and what each backend implements. The ones every service shares are
`message`, `presenceChange`, `messageUpdated`, `messageDeleted` and `reactionsChanged` are only
delivered to clients that called `subscribe` for that buffer; everything else broadcasts.
### Notes for frontend authors
- An ordinary message arrives with `kind` `"chat"` from IRC, Discord, Kick and Sneedchat, but
`"message"` from Matrix. Treat both as "something a person said". Every other kind is a log
line or an event - a join, a topic change, a Kick raid or redeemed reward - and which of them
to draw is the frontend's choice; nobilis always records them. A kind a frontend does not know
is best drawn as a log line.
- A message can carry `embeds`: what a service sent with it (a Discord embed, a Matrix link
preview) and what nobilis worked out for it - a YouTube link's title and channel, a GIF's
address, and for a Kick event the amount, the recipients and the colours. They are added
after the message, as a `messageUpdated` with `edited: false`.
- Media that nobilis fetched on the client's behalf (Sneedchat avatars and attachments, Matrix
media, shrunk Kick emotes, Discord thumbnails and stickers, the Discord login QR) is handed over
as a local `file://` path or filesystem path, not a remote URL. This assumes the frontend runs
on the same machine.
- `listSneedchatSmilies` returns a bare filename per smiley, resolved against
`resources/sneedchat-smilies/` in this repository. A frontend that renders them needs its own
copy of that directory.
## Protocol backends
IRC, Discord, Matrix, Kick and Sneedchat, each one module under `src/backend/`. Any account on
any of them can be routed through Tor or a SOCKS5 proxy. What each one implements is in moho's
[ARCHITECTURE.md](https://github.com/Moho-Chat/moho/blob/master/ARCHITECTURE.md).
## On-disk state
| Path | Contents |
|---|---|
| `~/.config/nobilis/accounts.toml` | account config and credentials |
| `~/.config/nobilis/scrollback.db` | SQLite scrollback |
| `~/.config/nobilis/matrix-crypto/` | Matrix E2EE device keys and Olm sessions |
| `~/.config/nobilis/voice.toml` | microphone, speakers, volumes and voice processing |
| `~/.config/nobilis/highlights.toml`, `ignores.toml` | what counts as a mention, and who is ignored |
| `~/.config/nobilis/dcc.toml`, `irc-sts.toml` | DCC settings, and IRC networks that have pinned TLS |
| `~/.config/nobilis/tor-state/`, `tor-cache/` | the embedded Tor client's state |
| `~/.cache/nobilis/` | re-derivable media caches |
| `$XDG_RUNTIME_DIR/nobilis/nobilis.sock` | the control socket |
An `flock` on the data directory keeps a second instance from racing the first; a second process
notices and exits rather than competing for the socket.
State from when this daemon was called `chatd` and lived in the moho repository (`~/.config/moho`,
`~/.cache/moho`) is moved across automatically on first run.
## Licence
GPL-3.0-or-later. See [LICENSE](LICENSE).
+549 -4
View File
@@ -366,6 +366,129 @@ impl KickAccountConfig {
}
}
/// A Steam account.
///
/// What is kept is a **refresh token**, never the password: the password is
/// held for the length of the sign-in (see backend/steam/auth.rs) and then
/// forgotten, and the token is what signs this account in from then on. It is
/// revoked from Steam's own list of signed-in devices, and signing in again
/// replaces it.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct SteamAccountConfig {
/// The name the account signs in with. Lower-case: Steam does not tell
/// "Someone" and "someone" apart, and neither can the id built from it.
pub username: String,
/// The 64-bit id Steam gave the account on sign-in.
#[serde(default)]
pub steamid: u64,
/// What to call this account here. Local: Steam is never told about it.
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub refresh_token: String,
/// Steam's note that this machine was approved, sent with the next
/// sign-in so Steam Guard does not ask for a code every time.
#[serde(default)]
pub guard_data: Option<String>,
/// Which kind of session this account signed in as. Unused now: moho signs in as a desktop client of its own
/// (see backend/steam/auth.rs); kept so files written when it was chosen read the same.
#[serde(default)]
pub session: String,
/// Whether this account's connections go through Tor or the SOCKS5 proxy
/// set in the network settings. Off by default.
#[serde(default)]
pub use_tor: bool,
/// Strict routing: everything the window loads for this account's
/// conversations goes through the route too.
#[serde(default)]
pub strict_route: bool,
}
impl SteamAccountConfig {
pub fn account_id(&self) -> String {
format!("steam:{}", self.username)
}
}
/// A Twitch account: a signed-in person, or an anonymous reader.
///
/// What is kept is the pair of tokens the device flow handed back (see
/// backend/twitch/auth.rs), never a password - Twitch's sign-in happens on
/// twitch.tv, in the person's own browser, and moho only ever sees the result.
/// The access token lasts four hours and is renewed with the refresh token,
/// which Twitch lets go of after thirty days unused.
///
/// An anonymous reader has neither and reads every channel, as a signed-out
/// Kick account does; sending needs the sign-in.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
pub struct TwitchAccountConfig {
/// The login, lower-case: Twitch does not tell "Someone" and "someone"
/// apart, and neither can the id built from it. Empty when anonymous.
#[serde(default)]
pub login: String,
/// Twitch's own number for the account, which is what its API is keyed by
/// and which survives the login being renamed.
#[serde(default)]
pub user_id: String,
/// What to call this account here. Local: Twitch is never told about it.
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub access_token: String,
#[serde(default)]
pub refresh_token: String,
/// When the access token stops working, in Unix seconds.
#[serde(default)]
pub expires_at: i64,
/// What the person agreed to when they signed in.
#[serde(default)]
pub scopes: Vec<String>,
/// The channels this account has open, by login, so they come back.
#[serde(default)]
pub channels: Vec<String>,
/// The `auth-token` of a twitch.tv session this person signed in to for
/// video, if they did. A credential of the same standing as the tokens
/// above and kept the same way; it is what makes their Turbo or their
/// subscriptions count when a stream is played (see backend/twitch/playback.rs).
#[serde(default)]
pub web_token: String,
/// Whether the channels this account follows have been opened already. Once, at first connection: a list
/// read again every time would put back every channel the person had closed.
#[serde(default)]
pub followed_synced: bool,
/// An account with no sign-in, which reads and does not send.
#[serde(default)]
pub anonymous: bool,
/// Whether this account's connections go through Tor or the SOCKS5 proxy
/// set in the network settings. Off by default.
#[serde(default)]
pub use_tor: bool,
/// Strict routing: everything the window loads for this account's
/// conversations goes through the route too.
#[serde(default)]
pub strict_route: bool,
}
/// The id of the one anonymous Twitch account. A `~` cannot be in a Twitch
/// login, so no signed-in account can ever have it.
pub const TWITCH_ANONYMOUS_ID: &str = "twitch:~anonymous";
impl TwitchAccountConfig {
pub fn account_id(&self) -> String {
if self.anonymous {
TWITCH_ANONYMOUS_ID.to_string()
} else {
format!("twitch:{}", self.login)
}
}
/// Whether there is a sign-in behind this account: what "signed in" means
/// for one that never keeps a password.
pub fn is_signed_in(&self) -> bool {
!self.anonymous && !self.access_token.is_empty()
}
}
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
struct AccountsFile {
#[serde(default, rename = "account")]
@@ -382,6 +505,10 @@ struct AccountsFile {
matrix: Vec<MatrixAccountConfig>,
#[serde(default, rename = "kick_account")]
kick: Vec<KickAccountConfig>,
#[serde(default, rename = "steam_account")]
steam: Vec<SteamAccountConfig>,
#[serde(default, rename = "twitch_account")]
twitch: Vec<TwitchAccountConfig>,
}
/// Account/credential persistence at ~/.config/nobilis/accounts.toml,
@@ -395,11 +522,23 @@ pub struct AccountStore {
sneedchat: Mutex<HashMap<String, SneedChatAccountConfig>>,
matrix: Mutex<HashMap<String, MatrixAccountConfig>>,
kick: Mutex<HashMap<String, KickAccountConfig>>,
/// Read by `persist` itself rather than handed to it, so the methods that
/// predate Steam did not each need a sixth argument. Anything that
/// changes this map must let go of its lock before it calls `persist`.
steam: Mutex<HashMap<String, SteamAccountConfig>>,
/// Read by `persist` the same way, for the same reason.
twitch: Mutex<HashMap<String, TwitchAccountConfig>>,
}
impl AccountStore {
/// Where the file is, for a test that reads it back.
#[cfg(test)]
pub fn path_for_tests(&self) -> &std::path::Path {
&self.path
}
pub fn open(path: PathBuf) -> Result<Self> {
let (irc, discord, sneedchat, matrix, kick) = if path.exists() {
let (irc, discord, sneedchat, matrix, kick, steam, twitch) = if path.exists() {
let text = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
let file: AccountsFile = toml::from_str(&text)
@@ -409,11 +548,22 @@ impl AccountStore {
let sneedchat = file.sneedchat.into_iter().map(|a| (a.account_id(), a)).collect();
let matrix = file.matrix.into_iter().map(|a| (a.account_id(), a)).collect();
let kick = file.kick.into_iter().map(|a| (a.account_id(), a)).collect();
(irc, discord, sneedchat, matrix, kick)
let steam = file.steam.into_iter().map(|a| (a.account_id(), a)).collect();
let twitch = file.twitch.into_iter().map(|a| (a.account_id(), a)).collect();
(irc, discord, sneedchat, matrix, kick, steam, twitch)
} else {
(HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new())
(HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new())
};
Ok(Self { path, irc: Mutex::new(irc), discord: Mutex::new(discord), sneedchat: Mutex::new(sneedchat), matrix: Mutex::new(matrix), kick: Mutex::new(kick) })
Ok(Self {
path,
irc: Mutex::new(irc),
discord: Mutex::new(discord),
sneedchat: Mutex::new(sneedchat),
matrix: Mutex::new(matrix),
kick: Mutex::new(kick),
steam: Mutex::new(steam),
twitch: Mutex::new(twitch),
})
}
/// Callers already hold whichever map they just mutated - the others
@@ -433,6 +583,8 @@ impl AccountStore {
sneedchat: sneedchat.values().cloned().collect(),
matrix: matrix.values().cloned().collect(),
kick: kick.values().cloned().collect(),
steam: self.steam.lock().unwrap().values().cloned().collect(),
twitch: self.twitch.lock().unwrap().values().cloned().collect(),
};
let text = toml::to_string_pretty(&file)?;
// Atomic write-temp-then-rename, same spirit as libpurple's periodic
@@ -576,6 +728,100 @@ impl AccountStore {
self.kick.lock().unwrap().values().cloned().collect()
}
pub fn get_steam(&self, account_id: &str) -> Option<SteamAccountConfig> {
self.steam.lock().unwrap().get(account_id).cloned()
}
pub fn all_steam(&self) -> Vec<SteamAccountConfig> {
self.steam.lock().unwrap().values().cloned().collect()
}
/// Upserts: signing in again with the same name is how a token Steam has
/// stopped accepting gets replaced. A route that was on stays on, as with
/// the others - a sign-in from the account's own card does not ask.
pub fn add_steam(&self, mut config: SteamAccountConfig) -> Result<SteamAccountConfig> {
config.username = config.username.trim().to_lowercase();
let id = config.account_id();
{
let mut steam = self.steam.lock().unwrap();
if let Some(old) = steam.get(&id) {
config.strict_route |= old.strict_route;
config.use_tor |= old.use_tor;
if config.display_name.is_none() {
config.display_name = old.display_name.clone();
}
}
steam.insert(id, config.clone());
}
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
Ok(config)
}
/// Changes one Steam account, and writes the file. `false` when there is
/// no such account.
///
/// The lock is let go before the write: `persist` takes it again.
pub fn mutate_steam(&self, account_id: &str, change: impl FnOnce(&mut SteamAccountConfig)) -> Result<bool> {
{
let mut steam = self.steam.lock().unwrap();
match steam.get_mut(account_id) {
None => return Ok(false),
Some(account) => change(account),
}
}
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
Ok(true)
}
pub fn get_twitch(&self, account_id: &str) -> Option<TwitchAccountConfig> {
self.twitch.lock().unwrap().get(account_id).cloned()
}
pub fn all_twitch(&self) -> Vec<TwitchAccountConfig> {
self.twitch.lock().unwrap().values().cloned().collect()
}
/// Upserts: signing in again with the same login is how a token Twitch has
/// stopped accepting gets replaced. What the person set up on the account
/// - its route, its name here, the channels it had open - stays.
pub fn add_twitch(&self, mut config: TwitchAccountConfig) -> Result<TwitchAccountConfig> {
config.login = config.login.trim().to_lowercase();
let id = config.account_id();
{
let mut twitch = self.twitch.lock().unwrap();
if let Some(old) = twitch.get(&id) {
config.strict_route |= old.strict_route;
config.use_tor |= old.use_tor;
if config.display_name.is_none() {
config.display_name = old.display_name.clone();
}
if config.channels.is_empty() {
config.channels = old.channels.clone();
}
if config.web_token.is_empty() {
config.web_token = old.web_token.clone();
}
}
twitch.insert(id, config.clone());
}
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
Ok(config)
}
/// Changes one Twitch account, and writes the file. `false` when there is
/// no such account. The lock is let go before the write.
pub fn mutate_twitch(&self, account_id: &str, change: impl FnOnce(&mut TwitchAccountConfig)) -> Result<bool> {
{
let mut twitch = self.twitch.lock().unwrap();
match twitch.get_mut(account_id) {
None => return Ok(false),
Some(account) => change(account),
}
}
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
Ok(true)
}
/// Upserts, like the others: signing in to Kick again with the same
/// username is how a rejected token gets replaced.
pub fn add_kick(&self, mut config: KickAccountConfig) -> Result<KickAccountConfig> {
@@ -710,6 +956,14 @@ impl AccountStore {
a.use_tor = routed;
a.strict_route = strict;
true
} else if let Some(a) = self.steam.lock().unwrap().get_mut(account_id) {
a.use_tor = routed;
a.strict_route = strict;
true
} else if let Some(a) = self.twitch.lock().unwrap().get_mut(account_id) {
a.use_tor = routed;
a.strict_route = strict;
true
} else {
false
};
@@ -734,6 +988,12 @@ impl AccountStore {
if let Some(a) = self.get_matrix(account_id) {
return pick(a.use_tor, a.strict_route);
}
if let Some(a) = self.get_steam(account_id) {
return pick(a.use_tor, a.strict_route);
}
if let Some(a) = self.get_twitch(account_id) {
return pick(a.use_tor, a.strict_route);
}
self.get_kick(account_id).and_then(|a| pick(a.use_tor, a.strict_route))
}
@@ -807,6 +1067,20 @@ impl AccountStore {
return Ok(true);
}
}
{
let removed = self.steam.lock().unwrap().remove(account_id).is_some();
if removed {
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
return Ok(true);
}
}
{
let removed = self.twitch.lock().unwrap().remove(account_id).is_some();
if removed {
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &self.kick.lock().unwrap())?;
return Ok(true);
}
}
let mut kick = self.kick.lock().unwrap();
if kick.remove(account_id).is_some() {
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &self.matrix.lock().unwrap(), &kick)?;
@@ -965,6 +1239,12 @@ impl AccountStore {
return Ok(true);
}
}
if self.mutate_steam(account_id, |a| a.display_name = value.clone())? {
return Ok(true);
}
if self.mutate_twitch(account_id, |a| a.display_name = value.clone())? {
return Ok(true);
}
let mut kick = self.kick.lock().unwrap();
match kick.get_mut(account_id) {
None => Ok(false),
@@ -995,6 +1275,12 @@ impl AccountStore {
if let Some(a) = self.matrix.lock().unwrap().get(account_id) {
return named(&a.display_name);
}
if let Some(a) = self.steam.lock().unwrap().get(account_id) {
return named(&a.display_name);
}
if let Some(a) = self.twitch.lock().unwrap().get(account_id) {
return named(&a.display_name);
}
self.kick.lock().unwrap().get(account_id).and_then(|a| named(&a.display_name))
}
@@ -1114,6 +1400,8 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
rtc_focus_url: None,
sliding_sync: false,
dehydration: false,
web_session: false,
moderation: false,
}
}
@@ -1153,6 +1441,8 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
rtc_focus_url: None,
sliding_sync: false,
dehydration: false,
web_session: false,
moderation: false,
}
}
@@ -1191,6 +1481,8 @@ pub fn sneedchat_account_to_json(a: &SneedChatAccountConfig, state: &str) -> Acc
rtc_focus_url: None,
sliding_sync: false,
dehydration: false,
web_session: false,
moderation: false,
}
}
@@ -1238,6 +1530,99 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
rtc_focus_url: None,
sliding_sync: false,
dehydration: false,
web_session: false,
moderation: false,
}
}
/// Steam has none of IRC's concepts either. What it has that the others
/// have too is a name to show, and whether it is signed in: `has_password`
/// says there is a token, which is what "signed in" means for an account that
/// never keeps a password.
///
/// `persona_name` is what Steam calls the account, learned on connecting; the
/// sign-in name is shown until then.
pub fn steam_account_to_json(a: &SteamAccountConfig, state: &str, persona_name: Option<&str>) -> Account {
Account {
id: a.account_id(),
service: "steam".to_string(),
status: "online".to_string(),
status_text: String::new(),
auto_idle: false,
display_name: a
.display_name
.clone()
.filter(|s| !s.is_empty())
.or_else(|| persona_name.filter(|n| !n.is_empty()).map(str::to_string))
.unwrap_or_else(|| a.username.clone()),
state: state.to_string(),
autojoin: String::new(),
has_nickserv_password: false,
sasl_enabled: false,
sasl_username: String::new(),
allow_plaintext_sasl: false,
reconnect_for_sasl: false,
sasl_mechanism: String::new(),
has_sasl_certificate: false,
current_nick: String::new(),
highlight_keywords: Vec::new(),
ssl: true,
has_password: !a.refresh_token.is_empty(),
avatar_url: None,
sneedchat_rooms: Vec::new(),
tor_mode: None,
tor_proxy: None,
use_tor: a.use_tor,
route_level: route_level(a.use_tor, a.strict_route),
has_key_backup: false,
rtc_focus_url: None,
sliding_sync: false,
dehydration: false,
web_session: false,
moderation: false,
}
}
/// Twitch has none of IRC's concepts either. `has_password` says there is a
/// token, which is what "signed in" means for an account that never keeps a
/// password; an anonymous reader has none and says so.
pub fn twitch_account_to_json(a: &TwitchAccountConfig, state: &str) -> Account {
Account {
id: a.account_id(),
service: "twitch".to_string(),
status: "online".to_string(),
status_text: String::new(),
auto_idle: false,
display_name: a
.display_name
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| if a.anonymous { "Anonymous".to_string() } else { a.login.clone() }),
state: state.to_string(),
autojoin: String::new(),
has_nickserv_password: false,
sasl_enabled: false,
sasl_username: String::new(),
allow_plaintext_sasl: false,
reconnect_for_sasl: false,
sasl_mechanism: String::new(),
has_sasl_certificate: false,
current_nick: String::new(),
highlight_keywords: Vec::new(),
ssl: true,
has_password: a.is_signed_in(),
avatar_url: None,
sneedchat_rooms: Vec::new(),
tor_mode: None,
tor_proxy: None,
use_tor: a.use_tor,
route_level: route_level(a.use_tor, a.strict_route),
has_key_backup: false,
rtc_focus_url: None,
sliding_sync: false,
dehydration: false,
web_session: !a.web_token.is_empty(),
moderation: crate::backend::twitch::auth::has_moderation(&a.scopes),
}
}
@@ -1280,6 +1665,8 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
rtc_focus_url: a.rtc_focus_url.clone(),
sliding_sync: a.prefer_sliding_sync,
dehydration: a.dehydration_enabled,
web_session: false,
moderation: false,
}
}
@@ -1335,3 +1722,161 @@ mod display_name_tests {
assert!(!store.set_display_name("kick:nobody", "You").expect("set"));
}
}
#[cfg(test)]
mod steam_account_tests {
use super::*;
fn store(name: &str) -> (AccountStore, PathBuf) {
let path = std::env::temp_dir().join(format!("nobilis-steam-accounts-{name}-{}.toml", std::process::id()));
let _ = std::fs::remove_file(&path);
(AccountStore::open(path.clone()).expect("accounts"), path)
}
fn account(name: &str, token: &str) -> SteamAccountConfig {
SteamAccountConfig { username: name.into(), steamid: 76561197960287930, refresh_token: token.into(), session: "chat".into(), ..Default::default() }
}
#[test]
fn a_steam_account_survives_a_restart_with_its_token_and_not_a_password() {
let (s, path) = store("restart");
s.add_steam(account("Someone", "eyJ.token")).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
assert!(text.contains("[[steam_account]]"), "{text}");
assert!(text.contains("eyJ.token"));
assert!(!text.to_lowercase().contains("password"), "no password is ever written: {text}");
let reopened = AccountStore::open(path.clone()).unwrap();
let got = reopened.get_steam("steam:someone").expect("found under its lower-case id");
assert_eq!((got.username.as_str(), got.steamid, got.refresh_token.as_str()), ("someone", 76561197960287930, "eyJ.token"));
let _ = std::fs::remove_file(path);
}
#[test]
fn the_name_is_how_steam_reads_it_so_two_spellings_are_one_account() {
let (s, path) = store("case");
s.add_steam(account("Someone", "a")).unwrap();
s.add_steam(account(" SOMEONE ", "b")).unwrap();
assert_eq!(s.all_steam().len(), 1);
assert_eq!(s.get_steam("steam:someone").unwrap().refresh_token, "b");
let _ = std::fs::remove_file(path);
}
#[test]
fn signing_in_again_keeps_the_route_and_the_local_name() {
let (s, path) = store("relogin");
s.add_steam(account("someone", "old")).unwrap();
s.set_route_level("steam:someone", "strict").unwrap();
s.set_display_name("steam:someone", "Me on Steam").unwrap();
s.add_steam(account("someone", "new")).unwrap();
let got = s.get_steam("steam:someone").unwrap();
assert_eq!(got.refresh_token, "new");
assert!(got.use_tor && got.strict_route, "a re-login must not send a routed account out directly");
assert_eq!(s.route_level_of("steam:someone").as_deref(), Some("strict"));
assert_eq!(s.display_name_override("steam:someone").as_deref(), Some("Me on Steam"));
let _ = std::fs::remove_file(path);
}
#[test]
fn removing_a_steam_account_takes_it_and_only_it() {
let (s, path) = store("remove");
s.add_steam(account("one", "a")).unwrap();
s.add_steam(account("two", "b")).unwrap();
assert!(s.remove("steam:one").unwrap());
assert!(!s.remove("steam:one").unwrap());
assert!(s.get_steam("steam:one").is_none());
assert!(s.get_steam("steam:two").is_some());
assert!(AccountStore::open(path.clone()).unwrap().get_steam("steam:one").is_none(), "the file forgot too");
let _ = std::fs::remove_file(path);
}
#[test]
fn the_account_is_listed_with_a_name_and_whether_it_is_signed_in() {
let mut a = account("someone", "tok");
let listed = steam_account_to_json(&a, "connected", None);
assert_eq!((listed.service.as_str(), listed.id.as_str(), listed.display_name.as_str()), ("steam", "steam:someone", "someone"));
assert!(listed.has_password, "a token is what being signed in means");
// Steam's name for the account stands in until the person picks one.
assert_eq!(steam_account_to_json(&a, "connected", Some("Some One")).display_name, "Some One");
a.display_name = Some("Mine".into());
assert_eq!(steam_account_to_json(&a, "connected", Some("Some One")).display_name, "Mine");
a.refresh_token.clear();
assert!(!steam_account_to_json(&a, "disconnected", None).has_password);
}
#[test]
fn a_steam_account_does_not_disturb_the_others_in_the_file() {
let (s, path) = store("others");
s.add_kick(KickAccountConfig { username: "streamer".into(), ..Default::default() }).unwrap();
s.add_steam(account("someone", "tok")).unwrap();
s.add_kick(KickAccountConfig { username: "streamer".into(), channels: vec!["x".into()], ..Default::default() }).unwrap();
let reopened = AccountStore::open(path.clone()).unwrap();
assert_eq!(reopened.get_kick("kick:streamer").unwrap().channels, vec!["x".to_string()]);
assert!(reopened.get_steam("steam:someone").is_some(), "writing another service's account kept this one");
let _ = std::fs::remove_file(path);
}
}
#[cfg(test)]
mod twitch_account_tests {
use super::*;
fn store(name: &str) -> AccountStore {
let path = std::env::temp_dir().join(format!("nobilis-twitch-accounts-{name}-{}.toml", std::process::id()));
let _ = std::fs::remove_file(&path);
AccountStore::open(path).unwrap()
}
fn account(login: &str, access: &str) -> TwitchAccountConfig {
TwitchAccountConfig { login: login.into(), user_id: "42".into(), access_token: access.into(), refresh_token: "ref".into(), expires_at: 1000, scopes: vec!["chat:read".into()], ..Default::default() }
}
#[test]
fn a_twitch_account_survives_a_restart_with_its_tokens() {
let s = store("restart");
s.add_twitch(account("Someone", "acc")).unwrap();
let text = std::fs::read_to_string(s.path_for_tests()).unwrap();
assert!(text.contains("[[twitch_account]]"), "{text}");
let reopened = AccountStore::open(s.path_for_tests().to_path_buf()).unwrap();
let got = reopened.get_twitch("twitch:someone").expect("found under its lower-case id");
assert_eq!((got.login.as_str(), got.user_id.as_str(), got.access_token.as_str(), got.scopes.len()), ("someone", "42", "acc", 1));
assert!(got.is_signed_in());
}
#[test]
fn an_anonymous_reader_has_an_id_no_login_can_have() {
let s = store("anon");
let saved = s.add_twitch(TwitchAccountConfig { anonymous: true, ..Default::default() }).unwrap();
assert_eq!(saved.account_id(), TWITCH_ANONYMOUS_ID);
assert!(!saved.is_signed_in());
assert!(!TWITCH_ANONYMOUS_ID.trim_start_matches("twitch:").chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
let json = twitch_account_to_json(&saved, "connected");
assert_eq!((json.service.as_str(), json.display_name.as_str(), json.has_password), ("twitch", "Anonymous", false));
}
#[test]
fn signing_in_again_keeps_the_route_the_name_and_the_open_channels() {
let s = store("again");
s.add_twitch(account("someone", "old")).unwrap();
s.mutate_twitch("twitch:someone", |a| {
a.channels = vec!["xqc".into()];
a.display_name = Some("Mine".into());
})
.unwrap();
s.set_route_level("twitch:someone", "strict").unwrap();
let again = s.add_twitch(account("SOMEONE", "new")).unwrap();
assert_eq!(s.all_twitch().len(), 1, "the same login is the same account");
assert_eq!((again.access_token.as_str(), again.channels.clone(), again.display_name.clone()), ("new", vec!["xqc".to_string()], Some("Mine".to_string())));
assert!(again.strict_route && again.use_tor, "a sign-in does not take a route away");
}
#[test]
fn removing_a_twitch_account_removes_it_from_the_file() {
let s = store("remove");
s.add_twitch(account("someone", "acc")).unwrap();
assert!(s.remove("twitch:someone").unwrap());
assert!(s.get_twitch("twitch:someone").is_none());
let reopened = AccountStore::open(s.path_for_tests().to_path_buf()).unwrap();
assert!(reopened.all_twitch().is_empty());
}
}
+11 -1
View File
@@ -482,6 +482,16 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
}
}
}
// What they are doing, now that their conversations exist
// to say it in: READY's presences carry it with the
// status, and the rosters above were made with the
// status alone.
for p in d["presences"].as_array().into_iter().flatten() {
let Some(user_id) = p["user"]["id"].as_str() else { continue };
if let Some(activity) = super::presence::activity_of(&p["activities"]) {
update_presence_in_rosters(state, user_id, p["status"].as_str().unwrap_or("offline"), Some(activity));
}
}
// Conversations are the ones people notice a gap in,
// and there are few enough of them to ask about
// directly. Channels wait until opened - a few hundred
@@ -1320,7 +1330,7 @@ pub(super) async fn run_gateway(state: &AppState, config: &DiscordAccountConfig,
// Also refresh any roster this person appears in, so
// an open channel's list follows them going online or
// away without waiting for a fresh subscription.
update_presence_in_rosters(state, user_id, status);
update_presence_in_rosters(state, user_id, status, super::presence::activity_of(&d["activities"]));
}
// Everything this client does not act on.
//
+1
View File
@@ -629,6 +629,7 @@ mod tests {
buffer_id: "b".into(),
html: None,
components: Vec::new(),
moderated: None,
sender_color: None,
badges: Vec::new(),
from: "x".into(),
+2 -1
View File
@@ -909,6 +909,7 @@ pub(super) fn extract_reply(d: &Value) -> Option<ReplyPreview> {
body: String::new(),
thread: false,
forwarded: true,
thread_root: None,
});
}
let referenced = &d["referenced_message"];
@@ -920,7 +921,7 @@ pub(super) fn extract_reply(d: &Value) -> Option<ReplyPreview> {
let author = &referenced["author"];
let from = author["global_name"].as_str().filter(|s| !s.is_empty()).or_else(|| author["username"].as_str()).unwrap_or("unknown").to_string();
let body = extract_body(referenced).unwrap_or_default();
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false })
Some(ReplyPreview { id: reply_id.to_string(), from, body, thread: false, forwarded: false, thread_root: None })
}
/// Shared by both the initial backfill and extend_history - Discord
+67 -1
View File
@@ -157,6 +157,28 @@ pub fn request_member_list(state: &AppState, buffer_id: &str) -> bool {
/// offline buckets - or a member.
/// One entry of Discord's member window, or nothing if the item is a role
/// header rather than a person.
/// The one line Discord's member list shows for somebody: what they are doing.
///
/// A custom status first, when they have one - it is words they wrote for
/// exactly this place - and otherwise the first thing they are doing. A game is
/// named bare, the gamepad saying what kind of thing it is; a track by its
/// artist where there is one, which is what the member list shows (the full
/// "Listening to Spotify" is in the profile card); anything else (watching,
/// competing) as a sentence of words, since there is no picture for it.
pub(super) fn activity_of(activities: &Value) -> Option<Value> {
let list = activities.as_array()?;
let chosen = list.iter().find(|a| a["type"].as_i64() == Some(4)).or_else(|| list.first())?;
let said = |key: &str| chosen[key].as_str().map(str::trim).filter(|t| !t.is_empty());
match chosen["type"].as_i64().unwrap_or(0) {
4 => said("state").map(|t| json!({ "kind": "custom", "text": t })),
// Streaming and competing are playing as far as a line can say.
0 | 1 | 5 => said("name").map(|t| json!({ "kind": "game", "text": t })),
2 => said("state").or_else(|| said("details")).or_else(|| said("name")).map(|t| json!({ "kind": "music", "text": t })),
3 => said("name").map(|t| json!({ "kind": "custom", "text": format!("Watching {t}") })),
_ => None,
}
}
pub(super) fn member_entry(runtime: &crate::runtime::Runtime, account_id: &str, guild_id: &str, item: &Value) -> Option<Value> {
let member = item.get("member")?;
let user = &member["user"];
@@ -200,6 +222,7 @@ pub(super) fn member_entry(runtime: &crate::runtime::Runtime, account_id: &str,
// present, and their own status word says the rest.
"away": status == "offline",
"status": status,
"activity": activity_of(&member["presence"]["activities"]),
"avatarUrl": super::author_avatar_url(user),
"roles": roles
}))
@@ -368,7 +391,7 @@ pub(super) fn update_member_list(state: &AppState, buffer_id: &str, d: &Value) {
/// PRESENCE_UPDATE arrives for anyone the account can see, which is far more
/// people than are in any open roster - so this only touches buffers that
/// already list them, and says nothing otherwise.
pub(super) fn update_presence_in_rosters(state: &AppState, user_id: &str, status: &str) {
pub(super) fn update_presence_in_rosters(state: &AppState, user_id: &str, status: &str, activity: Option<Value>) {
for buffer in state.runtime.list_buffers() {
let Some(existing) = state.runtime.get_presence(&buffer.id) else { continue };
let Some(members) = existing.as_array() else { continue };
@@ -386,6 +409,7 @@ pub(super) fn update_presence_in_rosters(state: &AppState, user_id: &str, status
// Same rule the initial sync uses; the two disagreeing would
// move someone between groups on their next presence change.
m["away"] = json!(status == "offline");
m["activity"] = activity.clone().unwrap_or(Value::Null);
m
})
.collect();
@@ -395,6 +419,48 @@ pub(super) fn update_presence_in_rosters(state: &AppState, user_id: &str, status
}
}
#[cfg(test)]
mod activity_tests {
use super::*;
fn said(activities: Value) -> Option<String> {
activity_of(&activities).map(|a| format!("{}:{}", a["kind"].as_str().unwrap(), a["text"].as_str().unwrap()))
}
#[test]
fn a_game_is_named_bare_and_a_track_by_its_artist() {
assert_eq!(said(json!([{ "type": 0, "name": "Resident Evil 4" }])).as_deref(), Some("game:Resident Evil 4"));
assert_eq!(said(json!([{ "type": 2, "name": "Spotify", "details": "Judy Is a Punk", "state": "Ramones" }])).as_deref(), Some("music:Ramones"));
assert_eq!(said(json!([{ "type": 2, "name": "Spotify", "details": "Judy Is a Punk" }])).as_deref(), Some("music:Judy Is a Punk"));
assert_eq!(said(json!([{ "type": 2, "name": "Spotify" }])).as_deref(), Some("music:Spotify"));
}
#[test]
fn words_somebody_wrote_come_first_and_have_no_picture() {
assert_eq!(said(json!([{ "type": 0, "name": "Dota 2" }, { "type": 4, "name": "Custom Status", "state": "ask questions", "emoji": { "name": "x" } }])).as_deref(), Some("custom:ask questions"));
assert_eq!(said(json!([{ "type": 3, "name": "a stream" }])).as_deref(), Some("custom:Watching a stream"));
}
#[test]
fn nothing_is_nothing_and_an_empty_line_is_not_a_line() {
assert_eq!(said(json!([])), None);
assert_eq!(said(json!(null)), None);
assert_eq!(said(json!([{ "type": 4, "state": " " }])), None, "a custom status with only an emoji says nothing");
assert_eq!(said(json!([{ "type": 0, "name": "" }])), None);
assert_eq!(said(json!([{ "type": 99, "name": "x" }])), None);
}
#[test]
fn a_member_list_entry_carries_it() {
let runtime = crate::runtime::Runtime::new();
let item = json!({ "member": { "nick": "Gremlin", "user": { "id": "1", "username": "g" }, "presence": { "status": "online", "activities": [{ "type": 4, "state": "ask questions" }] } } });
let entry = member_entry(&runtime, "discord:me", "", &item).unwrap();
assert_eq!(entry["activity"], json!({ "kind": "custom", "text": "ask questions" }));
let quiet = json!({ "member": { "nick": "Q", "user": { "id": "2", "username": "q" }, "presence": { "status": "online" } } });
assert!(member_entry(&runtime, "discord:me", "", &quiet).unwrap()["activity"].is_null());
}
}
#[cfg(test)]
mod window_tests {
use super::*;
+28 -7
View File
@@ -7,6 +7,18 @@
use super::*;
/// The line written in a channel when the server says what modes it has: the letters, and what they mean, so
/// somebody new to IRC need not know them ("#archlinux is +Cnrt -- No CTCP messages to the channel, ...").
pub fn channel_modes_line(channel: &str, modes: &str, params: &[String]) -> String {
if modes.trim_matches('+').is_empty() {
return format!("{channel} has no modes set");
}
match super::modes::describe(modes, params) {
Some(why) => format!("{channel} is {modes} -- {why}"),
None => format!("{channel} is {modes}"),
}
}
pub fn is_channel(target: &str) -> bool {
target.starts_with(['#', '&', '+', '!'])
}
@@ -290,7 +302,10 @@ pub(super) async fn handle_message(
let adding = matches!(m, Mode::Plus(_, None));
let letter = mode_letter(mode);
state.runtime.set_irc_channel_mode(state, account_id, &channel, letter, adding);
let body = format!("{from} sets {}{letter} on {channel}", if adding { "+" } else { "-" });
let mut body = format!("{from} sets {}{letter} on {channel}", if adding { "+" } else { "-" });
if let Some(why) = super::modes::describe_change(letter, adding) {
body = format!("{body} -- {why}");
}
state.runtime.record_message(state, account_id, &channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
continue;
}
@@ -489,7 +504,7 @@ pub(super) async fn handle_message(
// capability. Without it away state is only ever known about people
// who have just been WHOISed or messaged.
Command::AWAY(reason) => {
if state.runtime.set_irc_away(account_id, &from, reason.is_some()) {
if state.runtime.set_irc_away_because(account_id, &from, reason.clone().map(Some)) {
refresh_rosters_containing(state, account_id, &from, channels);
}
}
@@ -847,11 +862,7 @@ pub(super) async fn handle_message(
if let Some(channel) = args.get(1) {
let modes = args.get(2).map(String::as_str).unwrap_or("");
state.runtime.set_irc_channel_modes(state, account_id, channel, modes);
let body = if modes.trim_matches('+').is_empty() {
format!("{channel} has no modes set")
} else {
format!("{channel} is {modes}")
};
let body = channel_modes_line(channel, modes, &args.iter().skip(3).cloned().collect::<Vec<_>>());
state.runtime.record_message(state, account_id, channel, "channel", "*", &body, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
}
}
@@ -1135,6 +1146,16 @@ pub(super) fn buffer_kind_hint(target: &str) -> &'static str {
#[cfg(test)]
mod tests {
#[test]
fn a_channels_modes_are_written_with_what_they_mean() {
assert_eq!(
channel_modes_line("#archlinux", "+Cnrt", &[]),
"#archlinux is +Cnrt -- No CTCP messages to the channel, no messages from outside the channel, registered users only, topic locked to operators."
);
assert_eq!(channel_modes_line("#quiet", "+", &[]), "#quiet has no modes set");
assert_eq!(channel_modes_line("#odd", "+Z", &[]), "#odd is +Z -- +Z is this network's own.");
}
use super::*;
fn person(nick: &str, host: &str) -> Prefix {
+1
View File
@@ -35,6 +35,7 @@ pub mod typing;
pub mod dcc;
pub mod nickserv;
pub mod metadata;
pub mod modes;
pub mod sts;
pub mod sasl;
pub mod latesasl;
+137
View File
@@ -0,0 +1,137 @@
//! What a channel's modes mean, in words.
//!
//! A server answers `/mode #chan` with letters - `+Cnrt` - which a new IRC user cannot be expected to
//! read. The channel line moho writes on joining explains them: "#archlinux is +Cnrt -- No CTCP messages
//! to the channel, no messages from outside the channel, registered users only, topic locked to operators."
//!
//! The letters that mean the same everywhere (RFC 2811 and what every server inherited) are plain; a few
//! that nearly every server agrees on are included; a letter a network invented for itself is named as
//! one rather than guessed at, since a wrong explanation is worse than none.
/// One mode letter's meaning, given its argument if it takes one. `None` for a letter this does not know.
fn meaning(letter: char, param: Option<&str>) -> Option<String> {
let with = |what: &str| param.map(|p| format!("{what} {p}"));
Some(match letter {
'n' => "no messages from outside the channel".into(),
't' => "topic locked to operators".into(),
'm' => "moderated: only voiced people and operators can talk".into(),
'i' => "invite only".into(),
's' => "secret: left out of the channel list".into(),
'p' => "private".into(),
'r' => "registered users only".into(),
'c' => "no colours or formatting".into(),
'C' => "no CTCP messages to the channel".into(),
'S' => "only people connected over TLS".into(),
'g' => "anybody can invite".into(),
'P' => "permanent: stays when it is empty".into(),
'L' => "a larger ban list".into(),
'k' => "needs a key to join".into(),
'l' => match param {
Some(p) => format!("limited to {p} people"),
None => "limited in size".into(),
},
'f' => with("people who cannot join are sent to")?,
'j' => with("joining is throttled to")?,
_ => return None,
})
}
/// Whether a mode letter takes an argument when it is set, for the ones this explains.
fn takes_param(letter: char) -> bool {
matches!(letter, 'k' | 'l' | 'f' | 'j')
}
/// Explains a channel's modes: the string the server sent (`+Cnrt`, `+lk`) and the arguments after it.
/// `None` when there is nothing to say (no modes set).
pub fn describe(modes: &str, params: &[String]) -> Option<String> {
let mut args = params.iter();
let mut said: Vec<String> = Vec::new();
let mut unknown: Vec<char> = Vec::new();
let mut adding = true;
for letter in modes.chars() {
match letter {
'+' => adding = true,
'-' => adding = false,
c if !adding || !c.is_ascii_alphabetic() => {
let _ = c;
}
c => {
let param = takes_param(c).then(|| args.next().map(String::as_str)).flatten();
// A key is a secret: said that there is one, never what it is.
let param = if c == 'k' { None } else { param };
match meaning(c, param) {
Some(text) => said.push(text),
None => unknown.push(c),
}
}
}
}
if !unknown.is_empty() {
let list = unknown.iter().map(|c| format!("+{c}")).collect::<Vec<_>>().join(", ");
said.push(format!("{list} {} this network's own", if unknown.len() == 1 { "is" } else { "are" }));
}
if said.is_empty() {
return None;
}
let mut text = said.join(", ");
// A sentence: a capital to begin, a full stop to end.
if let Some(first) = text.chars().next() {
text.replace_range(..first.len_utf8(), &first.to_uppercase().to_string());
}
Some(format!("{text}."))
}
/// What one mode being set or cleared means, for the line that says somebody changed it. `None` for a letter
/// this does not know, where the line says no more than it did.
pub fn describe_change(letter: char, adding: bool) -> Option<String> {
let text = meaning(letter, None)?;
if adding {
let mut c = text.chars();
return c.next().map(|f| format!("{}{}.", f.to_uppercase(), c.as_str()));
}
Some(format!("No longer in effect: {text}."))
}
#[cfg(test)]
mod tests {
use super::*;
fn d(modes: &str, params: &[&str]) -> Option<String> {
describe(modes, &params.iter().map(|p| p.to_string()).collect::<Vec<_>>())
}
#[test]
fn the_letters_of_a_busy_channel_are_said_in_the_order_the_server_sent_them() {
assert_eq!(
d("+Cnrt", &[]).as_deref(),
Some("No CTCP messages to the channel, no messages from outside the channel, registered users only, topic locked to operators.")
);
assert_eq!(d("+nt", &[]).as_deref(), Some("No messages from outside the channel, topic locked to operators."));
}
#[test]
fn a_limit_says_its_number_and_a_key_is_never_told() {
assert_eq!(d("+lntk", &["50", "hunter2"]).as_deref(), Some("Limited to 50 people, no messages from outside the channel, topic locked to operators, needs a key to join."));
assert!(!d("+k", &["hunter2"]).unwrap().contains("hunter2"));
assert_eq!(d("+f", &["#overflow"]).as_deref(), Some("People who cannot join are sent to #overflow."));
}
#[test]
fn a_letter_nobody_agrees_on_is_named_and_not_guessed_at() {
assert_eq!(d("+nQ", &[]).as_deref(), Some("No messages from outside the channel, +Q is this network's own."));
assert_eq!(d("+QZ", &[]).as_deref(), Some("+Q, +Z are this network's own."));
}
#[test]
fn a_change_says_what_it_did() {
assert_eq!(describe_change('m', true).as_deref(), Some("Moderated: only voiced people and operators can talk."));
assert_eq!(describe_change('t', false).as_deref(), Some("No longer in effect: topic locked to operators."));
assert_eq!(describe_change('Q', true), None);
}
#[test]
fn no_modes_say_nothing() {
assert_eq!(d("+", &[]), None);
assert_eq!(d("", &[]), None);
}
}
+3
View File
@@ -420,6 +420,9 @@ pub(super) fn emit_presence(state: &AppState, account_id: &str, channel: &str, m
// from away-notify where the network has it, and from a WHOIS or
// a bounced message where it does not.
"away": state.runtime.is_irc_away(account_id, nick),
// What they said when they went, under their name: words they
// wrote, so no picture.
"activity": state.runtime.irc_away_reason(account_id, nick).map(|text| json!({ "kind": "custom", "text": text })),
}))
.collect();
let member_list = json!(member_list);
+115 -1
View File
@@ -83,6 +83,53 @@ pub struct Channel {
pub playback_url: Option<String>,
}
/// Where a stream's video is, and until when that address is good.
#[derive(Clone, Debug, PartialEq)]
pub struct Playback {
/// A signed Amazon IVS playlist address: the signature is in the query, so it is fetched with no
/// credential of ours.
pub url: String,
/// When the signature stops being accepted, in unix seconds, where the address says.
pub expires: Option<i64>,
/// Whether the signature opts out of ad insertion, which Kick's own web page is given.
pub ads_opt_out: Option<bool>,
}
impl Playback {
pub fn of(url: &str) -> Playback {
let claims = token_claims(url);
Playback { url: url.to_string(), expires: claims.as_ref().and_then(|c| c["exp"].as_i64()), ads_opt_out: claims.as_ref().and_then(|c| c["aws:ads-opt-out"].as_bool()) }
}
}
/// What a signed playlist address says about itself: the claims of the token in its query.
fn token_claims(url: &str) -> Option<serde_json::Value> {
use base64::Engine;
let token = url.split_once('?')?.1.split('&').find_map(|pair| pair.strip_prefix("token="))?;
let claims = token.split('.').nth(1)?;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(claims.trim_end_matches('=')).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// A live channel's playlist address, asked for on its own.
///
/// The channel payload carries one too, but that is eight kilobytes about everything else and is kept
/// by Kick for a while, so the address in it can be most of the way to its expiry. This answers with
/// the address alone, signed fresh, for a tenth of the bytes; it is the call Kick's apps make. The
/// signature lasts about ten minutes, which is what `Playback::expires` is for.
pub async fn playback(http: &reqwest::Client, slug: &str) -> Result<Playback> {
let res = paced(http.get(format!("{API_ROOT}/api/v2/channels/{slug}/playback-url")).header("Accept", "application/json"), Priority::Background)
.await
.context("asking Kick where the stream is")?;
let status = res.status();
if !status.is_success() {
bail!("Kick answered {status} for the stream's address");
}
let body: serde_json::Value = res.json().await.context("unreadable answer")?;
let url = body["data"].as_str().filter(|u| u.starts_with("https://")).context("Kick sent no address for the stream")?;
Ok(Playback::of(url))
}
/// A stream in progress, as the channel endpoint describes it.
#[derive(Clone, Debug, Default)]
pub struct Live {
@@ -855,8 +902,13 @@ pub struct HistoryMessage {
#[serde(default)]
pub created_at: Option<String>,
pub sender: HistorySender,
/// A string of JSON here, not the object the live socket sends.
#[serde(default)]
pub metadata: Option<serde_json::Value>,
/// Where a reply's chain began. This endpoint has never sent it; the
/// other history one does, and so does the socket.
#[serde(default)]
pub thread_parent_id: Option<String>,
}
#[derive(Deserialize, Debug)]
@@ -884,9 +936,12 @@ pub struct ChatIdentity {
pub color: Option<String>,
#[serde(default)]
pub badges: Vec<Badge>,
/// The pictures: level, event and founder badges, and a channel's own.
#[serde(default)]
pub badges_v2: Vec<BadgeV2>,
}
#[derive(Deserialize, Debug, Clone, Serialize)]
#[derive(Deserialize, Debug, Clone, Serialize, Default)]
pub struct Badge {
/// `moderator`, `subscriber`, `verified`, `og`, `founder`, `vip`...
#[serde(rename = "type")]
@@ -897,6 +952,38 @@ pub struct Badge {
/// Months subscribed, for the badges that count.
#[serde(default)]
pub count: Option<u32>,
/// A picture to draw it with, for the badges Kick sends as one: the level,
/// a channel's or an event's own, the founder's. The role badges above
/// have none and are drawn as words.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
/// Where Kick ranks it, highest first. Only used to put the badges in
/// Kick's order, which the role and the picture badges share.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sort_order: Option<i32>,
}
/// A badge in Kick's newer list, which says what it looks like where the old
/// list names a role. `selected` is the person's own choice to wear it: a
/// level badge comes on every message, and only some people show theirs.
#[derive(Deserialize, Debug, Clone, Default)]
pub struct BadgeV2 {
#[serde(default)]
pub name: String,
#[serde(default)]
pub image_url: Option<String>,
#[serde(default)]
pub selected: Option<bool>,
#[serde(default)]
pub sort_order: Option<i32>,
#[serde(default)]
pub metadata: Option<BadgeV2Metadata>,
}
#[derive(Deserialize, Debug, Clone, Default)]
pub struct BadgeV2Metadata {
#[serde(default)]
pub level: Option<u32>,
}
#[derive(Deserialize)]
@@ -2179,3 +2266,30 @@ pub async fn redeem(http: &reqwest::Client, token: &str, slug: &str, reward_id:
None => bail!("Kick answered {status}"),
}
}
#[cfg(test)]
mod playback_tests {
use super::*;
use base64::Engine;
fn address(claims: serde_json::Value) -> String {
let part = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(claims.to_string());
format!("https://example.live-video.net/api/video/v1/x.m3u8?token=eyJhbGciOiJFUzM4NCJ9.{part}.sig")
}
#[test]
fn an_address_says_when_it_expires_and_whether_it_opts_out_of_ads() {
let p = Playback::of(&address(serde_json::json!({ "exp": 1791932879, "aws:ads-opt-out": true })));
assert_eq!((p.expires, p.ads_opt_out), (Some(1791932879), Some(true)));
let p = Playback::of(&address(serde_json::json!({ "exp": 5 })));
assert_eq!((p.expires, p.ads_opt_out), (Some(5), None), "silence is not a refusal");
}
#[test]
fn an_address_that_is_not_signed_the_way_it_expects_has_no_expiry_and_is_still_an_address() {
for url in ["https://example.net/x.m3u8", "https://example.net/x.m3u8?other=1", "https://example.net/x.m3u8?token=notajwt", "https://example.net/x.m3u8?token=a.!!!.c"] {
let p = Playback::of(url);
assert_eq!((p.url.as_str(), p.expires), (url, None));
}
}
}
+2 -2
View File
@@ -207,7 +207,7 @@ pub async fn refresh_prediction(state: &AppState, buffer_id: &str) {
}
/// Stores a card, writes it down, and tells the client - or says it is gone.
pub(super) fn publish_card(state: &AppState, buffer_id: &str, kind: &str, card: Option<serde_json::Value>) {
pub(crate) fn publish_card(state: &AppState, buffer_id: &str, kind: &str, card: Option<serde_json::Value>) {
// When this was true, stamped here so every card carries one.
//
// The clock is the whole point: `remaining` is a number of seconds that
@@ -263,7 +263,7 @@ pub fn card_is_current(card: &serde_json::Value) -> bool {
}
/// The wall clock, in seconds, as everything here writes it down.
pub(super) fn now_secs() -> i64 {
pub(crate) fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
+260 -44
View File
@@ -79,6 +79,8 @@ pub(super) struct Watched {
/// wants the panel for: to mention them, whisper them, or see who the
/// moderators are.
pub(super) speakers: HashMap<String, Vec<Speaker>>,
/// Every name that has spoken in each channel, for counting.
pub(super) chatters: HashMap<String, std::collections::HashSet<String>>,
}
/// Somebody who has said something, and how they looked saying it.
@@ -91,12 +93,15 @@ pub(super) struct Speaker {
pub(super) badge: Option<String>,
}
/// How many to remember per channel.
/// How many to list per channel.
///
/// A busy stream produces hundreds of distinct names an hour, and a list
/// nobody can scan is no better than an empty one. This is the last two
/// hundred to have spoken, which is the window somebody is actually reading.
pub(super) const SPEAKERS_REMEMBERED: usize = 200;
/// A busy stream produces thousands of distinct names an hour, and the list is
/// sent whole every time a new one joins it and drawn row by row - a few
/// hundred is what that stays cheap for. It is the most recent to have
/// spoken, which is the window somebody is actually reading. How many people
/// have spoken is counted apart from it (`Watched::chatters`) and is not cut
/// off.
pub(super) const SPEAKERS_REMEMBERED: usize = 500;
#[derive(Deserialize)]
pub(super) struct ChatMessage {
@@ -114,6 +119,10 @@ pub(super) struct ChatMessage {
/// the cap. Kick's replies are quotable however old the original is.
#[serde(default)]
metadata: Option<ReplyMetadata>,
/// The message at the top of the chain this one is part of, on a reply.
/// Empty or absent on anything that is not one.
#[serde(default)]
thread_parent_id: Option<String>,
}
#[derive(Deserialize)]
@@ -129,6 +138,12 @@ pub(super) struct OriginalMessage {
id: String,
#[serde(default)]
content: Option<String>,
/// Where the original itself sits: empty when it began the chain, and the
/// chain's first message when it was a reply. It is how a reply read from
/// history, which does not carry the thread on the reply itself, finds its
/// thread.
#[serde(default)]
thread_parent_id: Option<String>,
}
#[derive(Deserialize)]
@@ -163,11 +178,7 @@ pub(super) fn handle_event(
// Rebuilt so the arms below can keep reading `frame.channel`, which is
// how a channel is identified for every event that does not carry an id
// of its own.
let frame = PusherFrame {
event: event.to_string(),
channel: channel.map(str::to_string),
data: serde_json::Value::Null,
};
let frame = Delivery { channel: channel.map(str::to_string) };
match event {
"pusher:error" => {
let msg = payload.get("message").and_then(|m| m.as_str()).unwrap_or("Kick refused the connection");
@@ -177,7 +188,7 @@ pub(super) fn handle_event(
let Ok(msg) = serde_json::from_value::<ChatMessage>(payload) else { return Ok(()) };
let Some(slug) = watched.chatroom(msg.chatroom_id).cloned() else { return Ok(()) };
let from = msg.sender.username.clone().unwrap_or_else(|| "someone".to_string());
let reply_to = reply_preview(msg.metadata.as_ref());
let reply_to = reply_preview(msg.metadata.as_ref(), msg.thread_parent_id.as_deref());
let style = style_of(msg.sender.identity.as_ref());
// Kick has no viewer list to ask for, so the panel is built from
// who is talking - which is who somebody wants to reach anyway.
@@ -192,8 +203,10 @@ pub(super) fn handle_event(
) {
let buffer_id = crate::model::buffer_id(account_id, &slug);
let roster = serde_json::json!(watched.roster(&slug));
let total = watched.chatters(&slug);
state.runtime.set_presence(&buffer_id, roster.clone());
state.events.emit("presenceChange", serde_json::json!({ "bufferId": buffer_id, "members": roster }));
state.runtime.set_presence_total(&buffer_id, total);
state.events.emit("presenceChange", serde_json::json!({ "bufferId": buffer_id, "members": roster, "total": total }));
}
state.runtime.record_message_at(
state,
@@ -261,7 +274,10 @@ pub(super) fn handle_event(
.or_else(|| payload.get("id"))
.and_then(|v| v.as_str());
if let Some(id) = id {
state.runtime.delete_message(state, &crate::model::buffer_id(account_id, &slug), id);
// Kept and marked, not removed: what it said is worth reading
// beside the fact that it is gone, and how a removed message
// is drawn is the window's to decide.
state.runtime.moderate_message(state, &crate::model::buffer_id(account_id, &slug), id, &moderation_of(&payload));
}
}
@@ -441,7 +457,7 @@ pub async fn backfill(
&msg.content,
false,
"chat",
reply_preview_from_value(msg.metadata.as_ref()),
reply_preview_from_value(msg.metadata.as_ref(), msg.thread_parent_id.as_deref()),
Some(msg.id.clone()),
false,
None,
@@ -460,9 +476,33 @@ pub async fn backfill(
///
/// History carries it in the same shape, so this is the one parser rather than
/// a second one that could drift.
pub(super) fn reply_preview_from_value(metadata: Option<&serde_json::Value>) -> Option<crate::model::ReplyPreview> {
let parsed: ReplyMetadata = serde_json::from_value(metadata?.clone()).ok()?;
reply_preview(Some(&parsed))
///
/// An object on the live socket and a string of JSON in history, which is
/// the same object written down: a reply read back from history lost its quote
/// because only the first was understood.
pub(super) fn reply_preview_from_value(metadata: Option<&serde_json::Value>, thread_parent: Option<&str>) -> Option<crate::model::ReplyPreview> {
let metadata = metadata?;
let parsed: ReplyMetadata = match metadata {
serde_json::Value::String(text) => serde_json::from_str(text).ok()?,
other => serde_json::from_value(other.clone()).ok()?,
};
reply_preview(Some(&parsed), thread_parent)
}
/// Who took a message down and for what, as the event says it.
///
/// `aiModerated` and `violatedRules` are on the payload of the event for a
/// message Kick's automatic moderation removed; a person's removal has
/// neither, which reads as not automatic with nothing to say about rules.
pub(super) fn moderation_of(payload: &serde_json::Value) -> crate::model::Moderated {
crate::model::Moderated {
ai: payload.get("aiModerated").and_then(|v| v.as_bool()).unwrap_or(false),
rules: payload
.get("violatedRules")
.and_then(|v| v.as_array())
.map(|rules| rules.iter().filter_map(|r| r.as_str()).map(str::to_string).collect())
.unwrap_or_default(),
}
}
/// The one badge worth marking somebody with in a list.
@@ -487,32 +527,69 @@ pub(super) fn strongest_badge(badges: &[api::Badge]) -> Option<String> {
/// the ones a reader is actually scanning for.
pub(super) fn style_of(identity: Option<&api::ChatIdentity>) -> crate::model::SenderStyle {
let Some(identity) = identity else { return crate::model::SenderStyle::default() };
crate::model::SenderStyle {
color: identity.color.clone().filter(|c| !c.is_empty()),
badges: identity
.badges
// The role badges are drawn as words, and Kick's picture badges beside them
// as pictures - the ones the person chose to wear, in Kick's order. The
// old list's own "level" is dropped as it always was: the picture one says
// the same with a number a reader can see.
let mut badges: Vec<api::Badge> = identity.badges.iter().filter(|b| !matches!(b.kind.as_str(), "level" | "")).cloned().collect();
let mut pictures: Vec<api::Badge> = identity
.badges_v2
.iter()
.filter(|b| !matches!(b.kind.as_str(), "level" | ""))
.cloned()
.collect(),
}
.filter(|b| b.selected.unwrap_or(true) && !b.name.is_empty())
.filter_map(|b| {
let image = b.image_url.clone().filter(|u| u.starts_with("https://"))?;
let level = b.metadata.as_ref().and_then(|m| m.level);
Some(api::Badge {
// Named by what Kick calls it, apart from the role names.
kind: b.name.clone(),
text: match level {
Some(level) if b.name == "level" => format!("Level {level}"),
_ => b.name.clone(),
},
count: None,
image: Some(image),
sort_order: b.sort_order,
})
})
.collect();
// The highest-ranked of them, if there are more than a name can carry.
pictures.sort_by_key(|b| std::cmp::Reverse(b.sort_order.unwrap_or(0)));
pictures.truncate(MAX_BADGE_PICTURES);
badges.extend(pictures);
// Highest first, as Kick's own apps put them, and stable so two of a rank
// stay in the order they came.
badges.sort_by_key(|b| std::cmp::Reverse(b.sort_order.unwrap_or(0)));
crate::model::SenderStyle { color: identity.color.clone().filter(|c| !c.is_empty()), badges }
}
/// How many picture badges one name carries. Some people wear a dozen, and a
/// name with that many beside it is the loudest thing on the line.
const MAX_BADGE_PICTURES: usize = 4;
/// What a reply was replying to, out of the metadata Kick attaches to it.
///
/// Both halves are required rather than filled in with blanks: a preview with
/// no author and no text is drawn as an empty quote above the message, which
/// reads as something failing to load rather than as a reply. Better to show
/// the message plainly than to show a hole above it.
pub(super) fn reply_preview(metadata: Option<&ReplyMetadata>) -> Option<crate::model::ReplyPreview> {
///
/// `thread_parent` is what the message itself says its chain began with. When
/// it says nothing - a reply out of history - the original's own place in its
/// chain gives the same answer, and an original that began one is the root.
pub(super) fn reply_preview(metadata: Option<&ReplyMetadata>, thread_parent: Option<&str>) -> Option<crate::model::ReplyPreview> {
let metadata = metadata?;
let original = metadata.original_message.as_ref()?;
let from = metadata.original_sender.as_ref()?.username.clone()?;
let filled = |s: Option<&str>| s.filter(|s| !s.is_empty()).map(str::to_string);
let thread_root = filled(thread_parent).or_else(|| filled(original.thread_parent_id.as_deref())).unwrap_or_else(|| original.id.clone());
Some(crate::model::ReplyPreview {
id: original.id.clone(),
from,
body: original.content.clone().unwrap_or_default(),
thread: false, forwarded: false })
thread: false,
forwarded: false,
thread_root: Some(thread_root),
})
}
/// One line for something somebody did in a channel, or None if this event is
@@ -912,14 +989,19 @@ mod tests {
w.heard("chan", Speaker { nick: format!("nick{i}"), user_id: None, badge: None });
}
assert_eq!(w.roster("chan").len(), SPEAKERS_REMEMBERED);
// The count is of everybody, not of those listed.
assert_eq!(w.chatters("chan"), SPEAKERS_REMEMBERED + 50);
// The most recent survive; a list nobody can scan is no better than
// an empty one, and the newest names are the ones being read.
assert_eq!(w.roster("chan")[0]["nick"], format!("nick{}", SPEAKERS_REMEMBERED + 49));
// And somebody who spoke long ago and speaks again is not counted twice.
w.heard("chan", Speaker { nick: "nick0".into(), user_id: None, badge: None });
assert_eq!(w.chatters("chan"), SPEAKERS_REMEMBERED + 50);
}
#[test]
fn marks_somebody_by_the_badge_worth_finding_them_by() {
let badge = |kind: &str| api::Badge { kind: kind.into(), text: kind.into(), count: None };
let badge = |kind: &str| api::Badge { kind: kind.into(), text: kind.into(), ..Default::default() };
// Ranked by what a reader is scanning for, not by what is rarest: a
// moderator who is also a subscriber shows as a moderator.
assert_eq!(strongest_badge(&[badge("subscriber"), badge("moderator")]).as_deref(), Some("moderator"));
@@ -950,7 +1032,7 @@ mod tests {
"original_message": { "id": "ddeccb11-29bc-4cf1-ab2a-9b409293de76", "content": "L0L0WERFLGEWRUGHBER" }
}))
.unwrap();
let preview = reply_preview(Some(&metadata)).unwrap();
let preview = reply_preview(Some(&metadata), None).unwrap();
assert_eq!(preview.from, "Greg");
assert_eq!(preview.id, "ddeccb11-29bc-4cf1-ab2a-9b409293de76");
assert_eq!(preview.body, "L0L0WERFLGEWRUGHBER");
@@ -982,7 +1064,7 @@ mod tests {
let msg: ChatMessage = serde_json::from_str(raw).expect("a real reply frame must parse");
assert_eq!(msg.chatroom_id, 2393554);
assert_eq!(msg.sender.username.as_deref(), Some("L3galizeWee"));
let preview = reply_preview(msg.metadata.as_ref()).expect("and carry what it answered");
let preview = reply_preview(msg.metadata.as_ref(), None).expect("and carry what it answered");
assert_eq!(preview.from, "Greg");
assert_eq!(preview.body, "L0L0WERFLGEWRUGHBER");
}
@@ -994,12 +1076,12 @@ mod tests {
let raw = r#"{ "id": "x", "chatroom_id": 1, "content": "hi", "type": "message",
"sender": { "id": 2, "username": "someone" }, "metadata": { "message_ref": "1788305786864" } }"#;
let msg: ChatMessage = serde_json::from_str(raw).unwrap();
assert!(reply_preview(msg.metadata.as_ref()).is_none());
assert!(reply_preview(msg.metadata.as_ref(), None).is_none());
}
#[test]
fn an_ordinary_message_is_not_a_reply() {
assert!(reply_preview(None).is_none());
assert!(reply_preview(None, None).is_none());
}
#[test]
@@ -1008,10 +1090,10 @@ mod tests {
// which is worse than showing the message plainly.
let no_sender: ReplyMetadata =
serde_json::from_value(serde_json::json!({ "original_message": { "id": "x", "content": "y" } })).unwrap();
assert!(reply_preview(Some(&no_sender)).is_none());
assert!(reply_preview(Some(&no_sender), None).is_none());
let no_message: ReplyMetadata =
serde_json::from_value(serde_json::json!({ "original_sender": { "username": "Greg" } })).unwrap();
assert!(reply_preview(Some(&no_message)).is_none());
assert!(reply_preview(Some(&no_message), None).is_none());
}
#[test]
@@ -1260,8 +1342,12 @@ mod tests {
assert_eq!(testkit::lines(&state, "odablock"), vec!["went live", "ended the stream"]);
}
fn backlog(state: &AppState) -> Vec<crate::model::Message> {
state.store.get_backlog(&crate::model::buffer_id("kick:tester", "odablock"), 0, 50).expect("backlog")
}
#[test]
fn a_message_taken_down_leaves_the_log() {
fn a_message_taken_down_stays_in_the_log_marked() {
let state = testkit::simulated_daemon("deleted");
let mut watched = testkit::watching_odablock();
spoken(&state, &mut watched, "keep", "stays");
@@ -1270,7 +1356,94 @@ mod tests {
// Kick nests the id under `message` on one event and sends it flat on another.
feed(&state, &mut watched, "App\\Events\\MessageDeletedEvent", serde_json::json!({ "message": { "id": "nested" } }));
feed(&state, &mut watched, "App\\Events\\MessageDeletedEvent", serde_json::json!({ "id": "flat" }));
assert_eq!(testkit::lines(&state, "odablock"), vec!["stays"]);
let log = backlog(&state);
// Nothing left, and the words of the ones taken down are still there to read.
assert_eq!(log.iter().map(|m| m.body.as_str()).collect::<Vec<_>>(), vec!["stays", "goes", "goes too"]);
assert_eq!(log.iter().map(|m| m.moderated.is_some()).collect::<Vec<_>>(), vec![false, true, true]);
}
#[test]
fn what_the_automatic_moderator_said_is_kept_with_the_message() {
let state = testkit::simulated_daemon("moderated");
let mut watched = testkit::watching_odablock();
spoken(&state, &mut watched, "bad", "something vile");
// As measured on a live channel: the event has its own id, and the message's is nested.
feed(
&state,
&mut watched,
"App\\Events\\MessageDeletedEvent",
serde_json::json!({ "id": "event-id", "message": { "id": "bad" }, "aiModerated": true, "violatedRules": ["hate"] }),
);
let log = backlog(&state);
assert_eq!(log.len(), 1);
assert_eq!(log[0].moderated, Some(crate::model::Moderated { ai: true, rules: vec!["hate".into()] }));
}
#[test]
fn a_person_removing_a_message_is_not_the_automatic_moderator() {
let why = moderation_of(&serde_json::json!({ "message": { "id": "x" } }));
assert_eq!(why, crate::model::Moderated { ai: false, rules: vec![] });
}
#[test]
fn a_reply_names_the_thread_it_belongs_to() {
// The top-level field when there is one: this is a reply to a reply, so what it quotes and
// the thread it is in are different messages.
let metadata: ReplyMetadata = serde_json::from_value(serde_json::json!({
"original_sender": { "id": 1, "username": "Greg" },
"original_message": { "id": "middle", "content": "hello" }
}))
.unwrap();
let preview = reply_preview(Some(&metadata), Some("top")).unwrap();
assert_eq!((preview.id.as_str(), preview.thread_root.as_deref()), ("middle", Some("top")));
// Without it, a reply to a message that began nothing starts the thread itself.
let preview = reply_preview(Some(&metadata), Some("")).unwrap();
assert_eq!(preview.thread_root.as_deref(), Some("middle"));
}
#[test]
fn a_reply_out_of_history_finds_its_thread_in_what_it_quoted() {
// History writes the metadata down as a string and leaves the thread off the reply; the
// quoted message knows its own.
let written = serde_json::Value::String(
serde_json::json!({
"original_sender": { "id": 1, "username": "Greg" },
"original_message": { "id": "middle", "content": "hello", "thread_parent_id": "top" }
})
.to_string(),
);
let preview = reply_preview_from_value(Some(&written), None).expect("a string of JSON is the same metadata");
assert_eq!((preview.id.as_str(), preview.from.as_str(), preview.thread_root.as_deref()), ("middle", "Greg", Some("top")));
}
#[test]
fn replies_are_stored_with_their_thread_and_read_back_whole() {
let state = testkit::simulated_daemon("threads");
let mut watched = testkit::watching_odablock();
feed(&state, &mut watched, "App\\Events\\ChatMessageEvent", serde_json::json!({
"id": "top", "chatroom_id": 2393554, "content": "the question", "created_at": "2026-10-14T00:59:00+00:00",
"sender": { "id": 9, "username": "bob", "identity": null }
}));
for (id, to, root) in [("r1", "top", "top"), ("r2", "r1", "top"), ("elsewhere", "x", "x")] {
feed(
&state,
&mut watched,
"App\\Events\\ChatMessageEvent",
serde_json::json!({
"id": id, "chatroom_id": 2393554, "content": id, "type": "reply",
"created_at": "2026-10-14T01:00:00+00:00",
"sender": { "id": 7, "username": "someone" },
"thread_parent_id": root,
"metadata": { "original_sender": { "id": 1, "username": "Greg" }, "original_message": { "id": to, "content": "quoted" } }
}),
);
}
let buffer = crate::model::buffer_id("kick:tester", "odablock");
let thread = state.store.thread_messages(&buffer, "top").unwrap();
assert_eq!(thread.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), vec!["top", "r1", "r2"]);
let r2 = thread.iter().find(|m| m.id == "r2").unwrap().reply_to.clone().unwrap();
// It still quotes what it answered, and says which thread that is part of.
assert_eq!((r2.id.as_str(), r2.thread, r2.thread_root.as_deref()), ("r1", false, Some("top")));
}
#[test]
@@ -1341,17 +1514,60 @@ mod tests {
#[test]
fn a_sender_is_drawn_in_their_colour_with_the_badges_that_say_something() {
let badge = |kind: &str| api::Badge { kind: kind.into(), text: kind.into(), count: None };
let badge = |kind: &str| api::Badge { kind: kind.into(), text: kind.into(), ..Default::default() };
assert_eq!(style_of(None).color, None);
let style = style_of(Some(&api::ChatIdentity {
color: Some("#75FD46".into()),
badges: vec![badge("moderator"), badge("level"), badge(""), badge("subscriber")],
..Default::default()
}));
assert_eq!(style.color.as_deref(), Some("#75FD46"));
// An engagement level and a nameless badge say nothing about the person.
assert_eq!(style.badges.iter().map(|b| b.kind.as_str()).collect::<Vec<_>>(), vec!["moderator", "subscriber"]);
// A colour that is empty is no colour.
assert_eq!(style_of(Some(&api::ChatIdentity { color: Some(String::new()), badges: vec![] })).color, None);
assert_eq!(style_of(Some(&api::ChatIdentity { color: Some(String::new()), ..Default::default() })).color, None);
}
/// As measured on a live channel: the picture badges come in their own list,
/// each with the person's choice to wear it and where Kick ranks it.
#[test]
fn the_pictures_a_person_chose_to_wear_come_after_their_role_in_kicks_order() {
let identity: api::ChatIdentity = serde_json::from_value(serde_json::json!({
"color": "#E9113C",
"badges": [
{ "type": "moderator", "text": "Moderator", "sort_order": 12 },
{ "type": "sub_gifter", "text": "Sub Gifter", "count": 1, "sort_order": 8 }
],
"badges_v2": [
{ "name": "level", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/chat/badges/48_a.png", "metadata": { "level": 48 }, "selected": true, "sort_order": 1 },
{ "name": "GOAT", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/chat/badges/GOAT_b.png", "selected": true, "sort_order": 4 },
{ "name": "GoldenK", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/chat/badges/GoldenK_c.png", "selected": false, "sort_order": 4 },
{ "name": "kick_founder22", "badge_type": "global", "image_url": "https://ext.cdn.kick.com/chat/badges/f.png", "selected": true, "sort_order": 2 }
]
}))
.unwrap();
let style = style_of(Some(&identity));
// Not the one the person chose not to wear, and highest rank first.
assert_eq!(style.badges.iter().map(|b| b.kind.as_str()).collect::<Vec<_>>(), vec!["moderator", "sub_gifter", "GOAT", "kick_founder22", "level"]);
// Words for the roles, pictures for the rest, and the level says what it is.
assert!(style.badges[0].image.is_none());
assert_eq!(style.badges[2].image.as_deref(), Some("https://ext.cdn.kick.com/chat/badges/GOAT_b.png"));
assert_eq!(style.badges[4].text, "Level 48");
}
#[test]
fn a_badge_picture_is_only_ever_a_web_address_and_a_crowd_of_them_is_cut() {
let wear = |name: &str, url: &str| api::BadgeV2 { name: name.into(), image_url: Some(url.into()), selected: Some(true), ..Default::default() };
let identity = api::ChatIdentity {
badges_v2: vec![
wear("local", "file:///etc/passwd"),
wear("a", "https://x/a.png"), wear("b", "https://x/b.png"), wear("c", "https://x/c.png"),
wear("d", "https://x/d.png"), wear("e", "https://x/e.png"),
],
..Default::default()
};
let kinds: Vec<_> = style_of(Some(&identity)).badges.into_iter().map(|b| b.kind).collect();
assert_eq!(kinds, vec!["a", "b", "c", "d"]);
}
#[test]
@@ -1360,14 +1576,14 @@ mod tests {
"original_sender": { "id": 1, "username": "Greg" },
"original_message": { "id": "abc", "content": "hello" }
});
let preview = reply_preview_from_value(Some(&whole)).unwrap();
let preview = reply_preview_from_value(Some(&whole), None).unwrap();
assert_eq!((preview.id.as_str(), preview.from.as_str(), preview.body.as_str()), ("abc", "Greg", "hello"));
// Half of one is a hole above the message, and is left out.
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_message": { "id": "abc", "content": "hello" } }))).is_none());
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1, "username": "Greg" } }))).is_none());
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1 }, "original_message": { "id": "abc" } }))).is_none());
assert!(reply_preview_from_value(None).is_none());
assert!(reply_preview_from_value(Some(&serde_json::json!("not metadata"))).is_none());
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_message": { "id": "abc", "content": "hello" } })), None).is_none());
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1, "username": "Greg" } })), None).is_none());
assert!(reply_preview_from_value(Some(&serde_json::json!({ "original_sender": { "id": 1 }, "original_message": { "id": "abc" } })), None).is_none());
assert!(reply_preview_from_value(None, None).is_none());
assert!(reply_preview_from_value(Some(&serde_json::json!("not metadata")), None).is_none());
}
#[test]
+39 -3
View File
@@ -11,7 +11,7 @@ use super::*;
///
/// Kick's own channel endpoint carries all of it and this backend was already
/// calling it - the numbers were parsed away and dropped.
pub(super) fn announce_stream(state: &AppState, buffer_id: &str, channel: &api::Channel, following: Option<bool>) {
pub(super) fn announce_stream(state: &AppState, buffer_id: &str, channel: &api::Channel, following: Option<bool>, playback: Option<&api::Playback>) {
let stream = serde_json::json!({
"bufferId": buffer_id,
"live": channel.live.is_some(),
@@ -29,7 +29,10 @@ pub(super) fn announce_stream(state: &AppState, buffer_id: &str, channel: &api::
// whoever wants to watch, because every frontend that shows video
// needs it and the refresh already running is what replaces the
// signed token before it expires.
"playbackUrl": channel.playback_url,
"playbackUrl": playback.map(|p| p.url.clone()),
// When that address stops being accepted, so whoever plays it can ask for a new one in time
// rather than finding out from a segment that is refused.
"playbackExpires": playback.and_then(|p| p.expires),
// Absent rather than false for an account that is not signed in:
// "not following" and "cannot say" are different, and a button that
// offers to follow when it cannot is a button that fails when pressed.
@@ -39,6 +42,38 @@ pub(super) fn announce_stream(state: &AppState, buffer_id: &str, channel: &api::
state.events.emit("kickStream", stream);
}
/// How long before an address stops being accepted a new one is asked for.
const RENEW_BEFORE: i64 = 180;
/// The address of a live channel's video: the one the stream already has while it is good for a few
/// minutes yet, otherwise a new one from the call that gives only that, and failing that the one the
/// channel payload carried.
pub(super) async fn playback_for(state: &AppState, buffer_id: &str, http: &reqwest::Client, channel: &api::Channel) -> Option<api::Playback> {
channel.live.as_ref()?;
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or_default();
if let Some(have) = state.runtime.kick_stream(buffer_id) {
let url = have["playbackUrl"].as_str();
let expires = have["playbackExpires"].as_i64();
if let (Some(url), Some(expires)) = (url, expires) {
if expires - now > RENEW_BEFORE {
return Some(api::Playback::of(url));
}
}
}
match api::playback(http, &channel.slug).await {
Ok(p) => {
if p.ads_opt_out == Some(false) {
tracing::warn!("kick: the address for {} no longer opts out of ads", channel.slug);
}
Some(p)
}
Err(e) => {
tracing::debug!("kick: the address for {}: {e:#}", channel.slug);
channel.playback_url.as_deref().map(api::Playback::of)
}
}
}
/// Whether this account follows the channel, where it can say.
pub(super) async fn following_now(
state: &AppState,
@@ -169,7 +204,8 @@ pub async fn refresh_stream_now(state: &AppState, buffer_id: &str) {
match api::channel(&http, &channel.slug).await {
Ok(fresh) => {
let following = following_now(state, &http, &account_id, &channel.slug, known).await;
announce_stream(state, buffer_id, &fresh, following);
let playback = playback_for(state, buffer_id, &http, &fresh).await;
announce_stream(state, buffer_id, &fresh, following, playback.as_ref());
}
Err(e) => tracing::debug!("kick: refreshing {}: {e:#}", channel.slug),
}
+15 -4
View File
@@ -1,4 +1,4 @@
//! Kick - livestream chat, read over the same Pusher websocket kick.com's own
//! Kick - livestream chat, read over the same realtime websocket kick.com's own
//! player page uses.
//!
//! Two things make this backend shaped differently from the others here.
@@ -11,7 +11,7 @@
//! are subscribed to. That is why the token is optional throughout rather than
//! a precondition for connecting.
//!
//! **One websocket carries every channel.** Pusher multiplexes: each channel
//! **One websocket carries every channel.** The service multiplexes: each channel
//! is a `chatrooms.<id>.v2` subscription on the one connection, so watching
//! twenty streamers costs one socket rather than twenty. Sneedchat next door
//! opens one per room because its protocol has no such verb; this one does,
@@ -21,7 +21,8 @@
//!
//! # How this folder is arranged
//!
//! - `socket` - the Pusher connection, its subscriptions, the frame envelope
//! - `socket` - the realtime connection,
//! - `realtime` - its two HTTP calls and its frames, its subscriptions, the frame envelope
//! - `chat` - what is said in a channel, and who said it
//! - `cards` - polls and predictions, which are the same shape twice
//! - `live` - whether a channel is streaming, and who just started
@@ -34,6 +35,7 @@ pub mod cards;
pub mod chat;
pub mod live;
pub mod pins;
pub mod realtime;
pub mod socket;
pub mod watch;
#[cfg(test)]
@@ -66,6 +68,9 @@ impl Watched {
/// nearly identical. Their badges changing is - somebody subscribes, or
/// is given moderator, and the list should follow.
fn heard(&mut self, slug: &str, speaker: Speaker) -> bool {
// Everybody who has spoken, kept for the count alone: the list below is
// cut off, and what it is cut off at is not how many people have talked.
self.chatters.entry(slug.to_string()).or_default().insert(speaker.nick.clone());
let list = self.speakers.entry(slug.to_string()).or_default();
if let Some(existing) = list.iter_mut().find(|s| s.nick == speaker.nick) {
let changed = existing.badge != speaker.badge;
@@ -77,6 +82,11 @@ impl Watched {
true
}
/// How many different people have spoken in this channel since it was opened.
fn chatters(&self, slug: &str) -> usize {
self.chatters.get(slug).map_or(0, |set| set.len())
}
fn roster(&self, slug: &str) -> Vec<serde_json::Value> {
self.speakers
.get(slug)
@@ -109,17 +119,18 @@ impl Watched {
/// events for a conversation that has been closed.
fn remove(&mut self, slug: &str) -> Vec<String> {
self.speakers.remove(slug);
self.chatters.remove(slug);
let mut names = Vec::new();
if let Some(room) = self.by_chatroom.iter().find(|(_, s)| *s == slug).map(|(i, _)| *i) {
self.by_chatroom.remove(&room);
names.push(format!("chatrooms.{room}.v2"));
names.push(format!("chatrooms.{room}"));
names.push(format!("chatroom_{room}"));
}
if let Some(channel) = self.by_channel.iter().find(|(_, s)| *s == slug).map(|(i, _)| *i) {
self.by_channel.remove(&channel);
names.push(format!("channel.{channel}"));
names.push(format!("channel_{channel}"));
names.push(format!("predictions-channel-{channel}"));
}
names
}
+227
View File
@@ -0,0 +1,227 @@
//! Kick's realtime service, which carries its chat.
//!
//! Kick ran its chat on Pusher and turned that application off: its key is
//! refused on every cluster. What kick.com's own pages use now is Centrifugo,
//! behind two small HTTP calls on `web.kick.com`:
//!
//! 1. Ask where the service is. The answer is a websocket address, and it is
//! given to anybody - the client names itself with an id of its own making.
//! 2. Ask for a token for that id, which the connection is opened with. A guest
//! gets one without signing in; it lasts forty-five minutes and the
//! connection has to be given a fresh one before then or it is closed.
//!
//! Then it is subscribe and listen. A public channel is subscribed with no
//! token at all; what arrives is a *publication*, whose payload is the same
//! `{event, data}` pair Pusher carried, with `data` still a JSON string.
//!
//! Frames are JSON objects, one to a line, and several may share one websocket
//! message. The keepalive is an empty object, sent by the server and answered
//! in kind.
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
const WEB_ROOT: &str = "https://web.kick.com";
const PLATFORM: &str = "web";
/// A name for this client, in the shape of a version 4 UUID, which is what
/// kick.com's own pages make one of.
pub fn client_id() -> String {
use rand::Rng;
let mut rng = rand::thread_rng();
let mut b: [u8; 16] = rng.gen();
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
}
/// Where the service is. Asked, not written down: the address names a region,
/// and a client that pinned one would break the day Kick moved it.
pub async fn address(http: &reqwest::Client, client: &str) -> Result<String> {
let body = json!({
"client": { "id": client, "type": PLATFORM },
"capabilities": { "accepted_providers": [ { "provider": "centrifugo" } ] }
});
let answer = post(http, "/api/v1/realtime/connection", None, body).await.context("asking Kick where its realtime service is")?;
answer["data"]["connections"]
.as_array()
.into_iter()
.flatten()
.find(|c| c["provider"].as_str() == Some("centrifugo"))
.and_then(|c| c["credentials"]["url"].as_str())
.map(str::to_string)
.context("Kick named no realtime service")
}
/// The token a connection is opened (or renewed) with. `bearer` is the signed-in
/// account's own token where the client is one; a guest needs none.
pub async fn token(http: &reqwest::Client, client: &str, bearer: Option<&str>) -> Result<String> {
let answer = post(http, "/api/v1/realtime/auth/connection", bearer, json!({ "client_id": client })).await.context("asking Kick for a realtime token")?;
answer["data"]["token"].as_str().map(str::to_string).context("Kick sent no realtime token")
}
async fn post(http: &reqwest::Client, path: &str, bearer: Option<&str>, body: Value) -> Result<Value> {
let mut request = http.post(format!("{WEB_ROOT}{path}")).header("Accept", "application/json").header("x-app-platform", PLATFORM).json(&body);
if let Some(bearer) = bearer {
request = request.bearer_auth(bearer);
}
let response = request.send().await.context("request failed")?;
let status = response.status();
if !status.is_success() {
bail!("Kick answered {status}");
}
response.json().await.context("unreadable answer")
}
/// Numbers each request, which is how an answer is matched to what asked. One
/// counter for everything that writes to any connection: the numbers only have
/// to differ within a connection, and a shared one cannot repeat.
static NEXT: AtomicU64 = AtomicU64::new(1);
fn id() -> u64 {
NEXT.fetch_add(1, Ordering::Relaxed)
}
pub fn connect(token: &str) -> Value {
json!({ "id": id(), "connect": { "token": token, "name": "js" } })
}
/// A public channel needs no token.
pub fn subscribe(channel: &str) -> Value {
json!({ "id": id(), "subscribe": { "channel": channel } })
}
pub fn unsubscribe(channel: &str) -> Value {
json!({ "id": id(), "unsubscribe": { "channel": channel } })
}
pub fn refresh(token: &str) -> Value {
json!({ "id": id(), "refresh": { "token": token } })
}
pub fn pong() -> Value {
json!({})
}
/// One thing the service said.
#[derive(Debug, PartialEq)]
pub enum Said {
/// The keepalive, to be answered.
Ping,
/// The connection was accepted. `ttl` is how many seconds the token it was
/// opened with is good for, if it expires.
Connected { ttl: Option<u64> },
/// Something was published to a channel: the event it names, and its data
/// read out of the string it arrives in.
Published { channel: String, event: String, data: Value },
/// A request was refused.
Refused(String),
/// The service is ending the connection.
Disconnect(String),
/// Anything else - an answer to a subscription, mostly - which says nothing.
Other,
}
/// Reads one websocket message, which may hold several frames.
pub fn read(text: &str) -> Vec<Said> {
text.lines().filter(|l| !l.trim().is_empty()).filter_map(|l| serde_json::from_str::<Value>(l).ok()).map(|v| read_frame(&v)).collect()
}
fn read_frame(v: &Value) -> Said {
if v.as_object().is_some_and(|o| o.is_empty()) {
return Said::Ping;
}
if let Some(error) = v.get("error") {
let message = error["message"].as_str().unwrap_or("refused");
return Said::Refused(match error["code"].as_u64() {
Some(code) => format!("{message} ({code})"),
None => message.to_string(),
});
}
if let Some(connect) = v.get("connect") {
let ttl = connect.get("expires").and_then(|e| e.as_bool()).filter(|e| *e).and_then(|_| connect["ttl"].as_u64());
return Said::Connected { ttl };
}
if let Some(d) = v.get("disconnect") {
let reason = d["reason"].as_str().unwrap_or("no reason given");
return Said::Disconnect(match d["code"].as_u64() {
Some(code) => format!("{reason} ({code})"),
None => reason.to_string(),
});
}
if let Some(push) = v.get("push") {
let channel = push["channel"].as_str().unwrap_or_default();
if let Some(inner) = push.get("pub").and_then(|p| p.get("data")) {
if let Some(event) = inner["event"].as_str() {
// The payload is a JSON string inside the JSON.
let data = match &inner["data"] {
Value::String(s) => serde_json::from_str(s).unwrap_or(Value::Null),
other => other.clone(),
};
return Said::Published { channel: channel.to_string(), event: event.to_string(), data };
}
}
if let Some(d) = push.get("disconnect") {
return Said::Disconnect(d["reason"].as_str().unwrap_or("unsubscribed").to_string());
}
}
Said::Other
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_client_is_named_like_a_uuid() {
let a = client_id();
assert_eq!(a.len(), 36);
assert_eq!(a.as_bytes()[14], b'4', "version 4");
assert_ne!(a, client_id());
}
/// Frames as the service sends them, from a live channel.
#[test]
fn what_a_chat_channel_sends_is_read() {
let hello = r#"{"id":1,"connect":{"client":"c","version":"6.9.7 PRO","expires":true,"ttl":2700,"ping":25,"pong":true}}"#;
assert_eq!(read(hello), vec![Said::Connected { ttl: Some(2700) }]);
assert_eq!(read(r#"{"id":1,"connect":{"client":"c","ping":25}}"#), vec![Said::Connected { ttl: None }]);
assert_eq!(read("{}"), vec![Said::Ping]);
assert_eq!(read(r#"{"id":2,"subscribe":{}}"#), vec![Said::Other]);
let publication = r#"{"push":{"channel":"chatrooms.668.v2","pub":{"data":{"event":"App\\Events\\ChatMessageEvent","data":"{\"id\":\"a\",\"chatroom_id\":668,\"content\":\"hi\"}"}}}}"#;
let said = read(publication);
assert_eq!(said.len(), 1);
match &said[0] {
Said::Published { channel, event, data } => {
assert_eq!((channel.as_str(), event.as_str()), ("chatrooms.668.v2", "App\\Events\\ChatMessageEvent"));
assert_eq!((data["chatroom_id"].as_u64(), data["content"].as_str()), (Some(668), Some("hi")));
}
other => panic!("{other:?}"),
}
}
#[test]
fn several_frames_in_one_message_are_all_read() {
let both = "{}\n{\"id\":3,\"subscribe\":{}}\n\n{\"id\":4,\"error\":{\"code\":103,\"message\":\"permission denied\"}}";
assert_eq!(read(both), vec![Said::Ping, Said::Other, Said::Refused("permission denied (103)".into())]);
}
#[test]
fn an_ending_is_read_with_its_reason() {
assert_eq!(read(r#"{"disconnect":{"code":3005,"reason":"expired"}}"#), vec![Said::Disconnect("expired (3005)".into())]);
}
#[test]
fn the_requests_are_what_the_service_takes() {
let c = connect("jwt");
assert_eq!((c["connect"]["token"].as_str(), c["connect"]["name"].as_str()), (Some("jwt"), Some("js")));
assert_eq!(subscribe("chatrooms.1.v2")["subscribe"]["channel"], json!("chatrooms.1.v2"));
assert_eq!(unsubscribe("x")["unsubscribe"]["channel"], json!("x"));
assert_eq!(refresh("t")["refresh"]["token"], json!("t"));
assert_ne!(connect("a")["id"], connect("a")["id"], "each request has a number of its own");
assert_eq!(pong(), json!({}));
}
}
+132 -74
View File
@@ -1,28 +1,13 @@
//! The Pusher socket Kick's chat rides on.
//! The realtime socket Kick's chat rides on.
//!
//! Kick does not run a chat server of its own so much as a channel on a
//! commercial pub/sub service, which means connecting is subscribing: this
//! module owns the websocket, the subscription list, and the frame envelope
//! everything else arrives inside.
//! everything else arrives inside. The service's own protocol is in
//! `realtime`; what is said on it is the same events as ever.
use super::*;
/// Kick's own Pusher application, as served to every visitor of kick.com.
///
/// Public by construction: a Pusher *key* is the identifier a browser needs to
/// subscribe to public channels, which is why it appears in their page source
/// and why reading a public chat needs no credential. It is not a secret and
/// grants nothing beyond what a browser on the channel page already has.
pub(super) const PUSHER_KEY: &str = "32cbd69e4b950bf97679";
pub(super) const PUSHER_CLUSTER: &str = "us2";
pub(super) fn pusher_url() -> String {
format!(
"wss://ws-{PUSHER_CLUSTER}.pusher.com/app/{PUSHER_KEY}?protocol=7&client=moho&version=8.4.0&flash=false"
)
}
/// Asked of the connection from elsewhere in the daemon, over a channel,
/// because the socket is owned by its own task and these arrive from RPC.
#[derive(Debug)]
@@ -249,9 +234,10 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
});
}
let (mut socket, _) = crate::net::route::websocket(super::api::SHARED_KEY, pusher_url())
.await
.context("connecting to Kick's chat")?;
// A guest of the realtime service: public chat needs nothing more, and
// what the account signed in as is for sending, which goes by HTTP.
let realtime_client = realtime::client_id();
let (mut socket, token_ttl) = open_realtime(&http, &realtime_client).await?;
// Two maps because Kick uses two numbers. A chat message names the
// chatroom; the events on `channel.<id>` name the channel, which is a
@@ -366,12 +352,11 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
state.runtime.set_conn_state(state, account_id, ConnState::Connected, None);
// Pusher closes a socket it has not heard from. Its own timeout arrives in
// the handshake, but a fixed interval well inside the shortest it ever
// sends is simpler than tracking it and cannot be wrong in the direction
// that matters.
let mut ping = tokio::time::interval(Duration::from_secs(60));
ping.tick().await;
// The connection's token runs out, and a connection past it is closed. A
// fresh one is handed over before then. The keepalive is answered as it
// arrives, in `handle_frame`.
let mut renew = tokio::time::interval(token_ttl);
renew.tick().await;
// Who is live, and how many people are watching. Kick pushes the start and
// the stop of a stream but never the count, so the only way to keep the
@@ -447,10 +432,7 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
}
Some(Command::Part(slug)) => {
for name in watched.remove(&slug) {
let _ = socket.send(WsMessage::Text(serde_json::json!({
"event": "pusher:unsubscribe",
"data": { "channel": name }
}).to_string())).await;
let _ = socket.send(WsMessage::Text(realtime::unsubscribe(&name).to_string())).await;
}
state.runtime.forget_kick_channel(&format!("{account_id}|{slug}"));
}
@@ -463,8 +445,9 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
let (account_id, token) = (account_id.to_string(), live_token.clone().unwrap_or_default());
tokio::spawn(async move { refresh_followed_live(&state, &http, &account_id, &token).await });
}
_ = ping.tick() => {
socket.send(WsMessage::Text(r#"{"event":"pusher:ping","data":{}}"#.to_string())).await
_ = renew.tick() => {
let token = realtime::token(&http, &realtime_client, None).await.context("renewing the chat connection's token")?;
socket.send(WsMessage::Text(realtime::refresh(&token).to_string())).await
.context("Kick's chat connection went away")?;
}
frame = socket.next() => match frame {
@@ -478,6 +461,41 @@ pub(super) async fn run(state: &AppState, config: &KickAccountConfig, account_id
}
}
/// Opens the realtime connection as a guest: where it is, a token for it, and
/// the wait for the service to accept it. Returns how long the token is good
/// for, which is how long until it has to be renewed.
async fn open_realtime(http: &reqwest::Client, client: &str) -> Result<(Socket, Duration)> {
let url = realtime::address(http, client).await?;
let token = realtime::token(http, client, None).await?;
let (mut socket, _) = crate::net::route::websocket(super::api::SHARED_KEY, url.as_str()).await.context("connecting to Kick's chat")?;
socket.send(WsMessage::Text(realtime::connect(&token).to_string())).await.context("opening the connection to Kick's chat")?;
let waited = tokio::time::timeout(Duration::from_secs(20), async {
loop {
match socket.next().await {
None => anyhow::bail!("Kick closed the chat connection before it answered"),
Some(Err(e)) => return Err(e).context("reading from Kick's chat"),
Some(Ok(WsMessage::Text(text))) => {
for said in realtime::read(&text) {
match said {
realtime::Said::Connected { ttl } => return Ok(ttl),
realtime::Said::Refused(why) | realtime::Said::Disconnect(why) => anyhow::bail!("Kick's chat refused the connection: {why}"),
_ => {}
}
}
}
Some(Ok(WsMessage::Close(_))) => anyhow::bail!("Kick closed the chat connection before it answered"),
Some(Ok(_)) => {}
}
}
})
.await
.context("Kick's chat did not answer")??;
// Renewed well before it runs out. Not at the last moment: a renewal that
// is slow or retried must still land inside the token's life.
let ttl = waited.unwrap_or(2700);
Ok((socket, Duration::from_secs((ttl * 2 / 3).max(30))))
}
pub(super) type Socket = tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<crate::net::route::TcpOrSocks>,
>;
@@ -533,7 +551,7 @@ async fn prepare(
// Follow state is not asked for here: this runs for every channel at
// connect, and that would be one extra request per channel before the
// first message arrives. It is filled in when the channel is opened.
announce_stream(state, &buffer.id, &channel, None);
announce_stream(state, &buffer.id, &channel, None, channel.playback_url.as_deref().filter(|_| channel.live.is_some()).map(api::Playback::of).as_ref());
// The streamer's own picture on the channel. Parsed since the first
// version of this backend and used by nothing, so every Kick channel drew
@@ -641,20 +659,24 @@ async fn prepare(
/// Asks the socket for everything that happens in a channel's chat.
///
/// Five subscriptions, not one, because Kick scatters them and only the first
/// Several subscriptions, not one, because Kick scatters them and only the first
/// carries what people say. Watching that one alone is why a chat can look
/// quiet while the stream is visibly reacting to something. Measured against a
/// live subathon rather than guessed - each name here is one an event was
/// actually observed arriving on:
///
/// - `chatrooms.<room>.v2` what people say, and plain subscriptions
/// - `chatrooms.<room>` rewards, and the info lines Kick's own UI draws
/// - `chatroom_<room>` gifted subscriptions, on an older name entirely
/// - `chatroom_<room>` rewards and gifted subscriptions, on an older name entirely
/// - `channel.<channel>` going live, and the channel's own subscriptions
/// - `channel_<channel>` KICKs sent to the streamer, and goal and leaderboard updates
///
/// There used to be a sixth, `chatrooms.<room>` without the `.v2`. Kick's own
/// apps do not ask for it, and six minutes on a channel with 60,000 watching
/// delivered nothing on it - chat came on `.v2` and rewards on `chatroom_` -
/// so it was a subscription to silence, paid for on every channel opened.
///
/// The overlap is real and deliberate on Kick's part: one subscription arrives
/// three times over three of these. Which of the three is believed is settled
/// more than once over several of these. Which of the three is believed is settled
/// in `describe_action`, not here - subscribing to fewer channels would lose
/// whole categories of event to avoid a duplicate that is cheaper to filter.
///
@@ -663,7 +685,6 @@ async fn prepare(
pub(super) async fn subscribe(socket: &mut Socket, chatroom_id: u64, channel_id: u64) -> Result<()> {
for channel in [
format!("chatrooms.{chatroom_id}.v2"),
format!("chatrooms.{chatroom_id}"),
format!("chatroom_{chatroom_id}"),
format!("channel.{channel_id}"),
// A third name for the channel, with an underscore, which is where
@@ -680,27 +701,18 @@ pub(super) async fn subscribe(socket: &mut Socket, chatroom_id: u64, channel_id:
format!("predictions-channel-{channel_id}"),
] {
socket
.send(WsMessage::Text(
serde_json::json!({ "event": "pusher:subscribe", "data": { "auth": "", "channel": channel } })
.to_string(),
))
.send(WsMessage::Text(realtime::subscribe(&channel).to_string()))
.await
.context("subscribing to the channel's chat")?;
}
Ok(())
}
#[derive(Deserialize)]
pub(super) struct PusherFrame {
pub(super) event: String,
#[serde(default)]
pub(super) data: serde_json::Value,
/// Which subscription this arrived on. The most reliable way to know
/// whose channel an event belongs to: a chat message names its room in the
/// payload, but the doing-things events name variously the channel, the
/// chatroom, or nothing at all - while Pusher always says which
/// subscription delivered it.
#[serde(default)]
/// Where an event arrived. The most reliable way to know whose channel it
/// belongs to: a chat message names its room in the payload, but the
/// doing-things events name variously the channel, the chatroom, or nothing at
/// all - while the service always says which subscription delivered it.
pub(super) struct Delivery {
pub(super) channel: Option<String>,
}
@@ -711,23 +723,18 @@ pub(super) async fn handle_frame(
text: &str,
socket: &mut Socket,
) -> Result<()> {
let frame: PusherFrame = match serde_json::from_str(text) {
Ok(f) => f,
Err(_) => return Ok(()),
};
// Pusher nests the real payload as a JSON *string* inside the frame, so
// every event needs unwrapping twice.
let payload = match &frame.data {
serde_json::Value::String(s) => serde_json::from_str::<serde_json::Value>(s).unwrap_or(serde_json::Value::Null),
other => other.clone(),
};
if frame.event == "pusher:ping" {
socket.send(WsMessage::Text(r#"{"event":"pusher:pong","data":{}}"#.to_string())).await?;
return Ok(());
for said in realtime::read(text) {
match said {
realtime::Said::Ping => socket.send(WsMessage::Text(realtime::pong().to_string())).await?,
realtime::Said::Published { channel, event, data } => handle_event(state, account_id, watched, &event, Some(&channel), data)?,
// A refused subscription is a channel that will not arrive, and
// looks like nothing: said in the log, once for each.
realtime::Said::Refused(why) => tracing::warn!("kick[{account_id}]: the chat service refused a request: {why}"),
realtime::Said::Disconnect(why) => anyhow::bail!("Kick's chat ended the connection: {why}"),
realtime::Said::Connected { .. } | realtime::Said::Other => {}
}
}
handle_event(state, account_id, watched, &frame.event, frame.channel.as_deref(), payload)
Ok(())
}
/// The channel behind a conversation, waiting a little for it to arrive.
@@ -823,7 +830,7 @@ mod tests {
let none = serde_json::json!({});
// All four names this connection subscribes to resolve to the same
// streamer, across both of Kick's number spaces.
for name in ["chatrooms.2393554.v2", "chatrooms.2393554", "chatroom_2393554", "channel.2401072"] {
for name in ["chatrooms.2393554.v2", "chatroom_2393554", "channel.2401072"] {
assert_eq!(channel_of(&w, &Some(name.into()), &none).as_deref(), Some("odablock"), "for {name}");
}
}
@@ -883,18 +890,17 @@ mod tests {
names.sort();
assert_eq!(
names,
vec!["channel.2401072", "channel_2401072", "chatroom_2393554", "chatrooms.2393554", "chatrooms.2393554.v2"]
vec!["channel.2401072", "channel_2401072", "chatroom_2393554", "chatrooms.2393554.v2", "predictions-channel-2401072"]
);
// And it is gone from both maps, so a straggling event has nowhere to
// land rather than reopening the buffer that was just closed.
assert_eq!(channel_of(&w, &Some("chatrooms.2393554".into()), &serde_json::json!({})), None);
assert_eq!(channel_of(&w, &Some("chatrooms.2393554.v2".into()), &serde_json::json!({})), None);
assert!(w.remove("odablock").is_empty());
}
#[test]
fn reads_the_id_out_of_a_subscription_name() {
assert_eq!(id_in_subscription("chatrooms.123.v2"), Some(Subscribed::Chatroom(123)));
assert_eq!(id_in_subscription("chatrooms.123"), Some(Subscribed::Chatroom(123)));
assert_eq!(id_in_subscription("chatroom_123"), Some(Subscribed::Chatroom(123)));
assert_eq!(id_in_subscription("channel.123"), Some(Subscribed::Channel(123)));
assert_eq!(id_in_subscription("channel_123"), Some(Subscribed::Channel(123)));
@@ -902,3 +908,55 @@ mod tests {
assert_eq!(id_in_subscription("something-else"), None);
}
}
#[cfg(test)]
mod live {
use super::*;
use crate::backend::kick::testkit::simulated_daemon;
use crate::runtime::ConnState;
/// Against the real service: a signed-out account opens a live channel and
/// has to hear somebody talk in it. Nothing is sent. Ignored because it
/// needs the network and a channel that is live and busy.
///
/// ```text
/// MOHO_KICK_CHANNEL=xqc cargo test --release kick::socket::live -- --ignored --nocapture
/// ```
#[tokio::test]
#[ignore = "needs the network and a live Kick channel"]
async fn a_signed_out_reader_hears_a_live_channel() {
let _ = rustls::crypto::ring::default_provider().install_default();
let _ = tracing_subscriber::fmt().with_env_filter("nobilis::backend::kick=info").with_test_writer().try_init();
let channel = std::env::var("MOHO_KICK_CHANNEL").unwrap_or_else(|_| "xqc".to_string());
let state = simulated_daemon("kick-live-probe");
let config = KickAccountConfig { username: "reader".into(), channels: vec![channel.clone()], ..Default::default() };
let id = config.account_id();
state.accounts.add_kick(config.clone()).unwrap();
spawn(state.clone(), config);
let buffer = crate::model::buffer_id(&id, &channel);
let mut heard = 0;
for _ in 0..240 {
tokio::time::sleep(Duration::from_millis(500)).await;
heard = state.store.get_backlog(&buffer, 0, 200).map(|m| m.iter().filter(|m| m.kind == "chat" || m.kind == "message").count()).unwrap_or(0);
if heard >= 5 && state.runtime.conn_state(&id) == Some(ConnState::Connected) {
break;
}
}
// And for as long as asked, to see that the keepalive is answered and
// that chat keeps arriving after the first minute.
let secs: u64 = std::env::var("MOHO_KICK_SECS").ok().and_then(|s| s.parse().ok()).unwrap_or(0);
let before = heard;
tokio::time::sleep(Duration::from_secs(secs)).await;
let after = state.store.get_backlog(&buffer, 0, 1000).map(|m| m.len()).unwrap_or(0);
println!("after {secs}s more: {} -> {after} message(s); state {:?}", before, state.runtime.conn_state(&id));
if secs > 0 {
assert!(after > before, "chat kept arriving");
assert_eq!(state.runtime.conn_state(&id), Some(ConnState::Connected));
}
println!("state {:?}; heard {heard} message(s) in {channel}", state.runtime.conn_state(&id));
for m in state.store.get_backlog(&buffer, 0, 5).unwrap_or_default() {
println!(" {}: {}", m.from, m.body.chars().take(80).collect::<String>());
}
assert!(heard >= 5, "a live channel's chat should be arriving");
}
}
+85 -13
View File
@@ -79,13 +79,39 @@ pub async fn negotiate(http: &reqwest::Client, token: &str, user_id: &str) -> Re
/// A token for one livestream's channel - what actually says who is watching.
pub async fn channel_token(http: &reqwest::Client, token: &str, livestream_id: u64) -> Result<String> {
let body = serde_json::json!({ "channel": channel_name(livestream_id) });
subscription_token(http, token, &channel_name(livestream_id)).await
}
/// A token to subscribe to one of Kick's private channels, by name.
pub async fn subscription_token(http: &reqwest::Client, token: &str, channel: &str) -> Result<String> {
let body = serde_json::json!({ "channel": channel });
let resp = post(http, token, &format!("{WEB_ROOT}/api/v1/realtime/auth/channel"), body)
.await
.context("asking Kick to let us watch")?;
resp["data"]["token"].as_str().map(str::to_string).context("Kick sent no subscription token")
}
/// Where Kick says what this account's points now are, in every channel at once.
///
/// Keyed by the *account*, not the streamer - Kick's own app subscribes to it
/// the same way, once - and each event names the channel it is about.
pub fn points_channel_name(user_id: &str) -> String {
format!("private-channelpoints-{user_id}")
}
/// What a points event says: which channel, and what the balance there now is.
///
/// Kick's app does exactly this with it - finds the channel by `channel_id`
/// and replaces the balance it holds with `balance`. A balance is a whole
/// number of points, though the number is read leniently: it has come as a
/// float elsewhere on this API.
pub fn points_update(data: &serde_json::Value) -> Option<(u64, i64)> {
let number = |v: &serde_json::Value| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)).or_else(|| v.as_str().and_then(|s| s.parse().ok()));
let channel = data.get("channel_id").and_then(|v| v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())))?;
let balance = number(data.get("balance")?)?;
Some((channel, balance))
}
/// The channel watch time is counted on.
pub fn channel_name(livestream_id: u64) -> String {
format!("private-livestream.{livestream_id}")
@@ -112,16 +138,17 @@ async fn post(http: &reqwest::Client, token: &str, url: &str, body: serde_json::
///
/// Deliberately the smallest possible client. This subscribes and then listens
/// to nothing: the point is to be present, not to read what the channel
/// publishes - chat already arrives on the Pusher socket, and duplicating it
/// publishes - chat already arrives on the chat socket, and duplicating it
/// here would mean two sources for one conversation.
pub mod frames {
use serde_json::{json, Value};
/// Opens the connection. No token: the negotiation answers with a URL and
/// nothing else, so the connection is anonymous and the per-channel token
/// below is what identifies anybody.
pub fn connect(id: u64) -> Value {
json!({ "id": id, "connect": {} })
/// Opens the connection, with the token the service gives this account for
/// it. The service closes one that has none, and one whose token has run
/// out - which is why a session here is ended and begun again well before
/// that.
pub fn connect(id: u64, token: &str) -> Value {
json!({ "id": id, "connect": { "token": token, "name": "js" } })
}
pub fn subscribe(id: u64, channel: &str, token: &str) -> Value {
@@ -160,6 +187,17 @@ pub mod frames {
/// two viewers, and merging them would credit one for the other's time.
pub type Watching = std::collections::BTreeMap<String, u64>;
/// Tells the window what this account's points in a channel now are.
fn announce_points(state: &crate::state::AppState, account_id: &str, data: &serde_json::Value) {
let Some((channel_id, balance)) = points_update(data) else {
tracing::debug!("kick[{account_id}]: a points event with no channel or balance: {data}");
return;
};
// A channel this window has not opened has nowhere to show it.
let Some(buffer_id) = state.runtime.kick_buffer_of_channel(account_id, channel_id) else { return };
state.events.emit("kickPoints", serde_json::json!({ "bufferId": buffer_id, "points": balance }));
}
/// Keeps one connection open for as long as anything is being watched.
///
/// Restarted rather than repaired when the socket dies: a subscription is
@@ -200,15 +238,31 @@ async fn session(state: &crate::state::AppState, account_id: &str, user_id: &str
next_id += 1;
next_id
};
socket.send(Message::Text(frames::connect(id()).to_string())).await.context("opening the connection")?;
let connection_token = super::realtime::token(&http, user_id, Some(token)).await?;
socket.send(Message::Text(frames::connect(id(), &connection_token).to_string())).await.context("opening the connection")?;
// The token is good for forty-five minutes. A session that has been up for
// thirty is ended, and the next one opens with a new one.
let begun = std::time::Instant::now();
// What is subscribed now, so the set on screen can be followed as it
// changes without tearing the connection down.
let mut subscribed: std::collections::BTreeMap<String, u64> = Default::default();
// The balance, pushed as it changes: the earn every five minutes, a bet, a
// reward bought somewhere else. The window asks for it too, once a minute,
// which is the fallback for a channel that is not live and so is not here.
let points = points_channel_name(user_id);
match subscription_token(&http, token, &points).await {
Ok(jwt) => {
socket.send(Message::Text(frames::subscribe(id(), &points, &jwt).to_string())).await?;
tracing::info!("kick[{account_id}]: following {points}");
}
Err(e) => tracing::debug!("kick[{account_id}]: cannot follow points: {e:#}"),
}
loop {
let wanted = state.runtime.kick_watching(account_id);
if wanted.is_empty() {
if wanted.is_empty() || begun.elapsed() > std::time::Duration::from_secs(30 * 60) {
return Ok(());
}
@@ -251,6 +305,12 @@ async fn session(state: &crate::state::AppState, account_id: &str, user_id: &str
Message::Text(text) => {
for line in text.lines().filter(|l| !l.trim().is_empty()) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else { continue };
if let super::realtime::Said::Published { channel, event, data } = super::realtime::read(line).into_iter().next().unwrap_or(super::realtime::Said::Other) {
if channel == points && event.ends_with("ChannelPointsUpdatedEvent") {
announce_points(state, account_id, &data);
}
continue;
}
if frames::is_ping(&value) {
socket.send(Message::Text(frames::pong().to_string())).await?;
} else if let Some(why) = frames::error_in(&value) {
@@ -291,6 +351,19 @@ mod tests {
assert_eq!(frames::pong(), json!({}));
}
/// As Kick's own app reads it: the channel, and the balance that replaces the one held.
#[test]
fn a_points_event_names_the_channel_and_the_new_balance() {
assert_eq!(points_channel_name("1234"), "private-channelpoints-1234");
assert_eq!(points_update(&json!({ "channel_id": 5312671, "balance": 1450 })), Some((5312671, 1450)));
// However the number is written.
assert_eq!(points_update(&json!({ "channel_id": "668", "balance": 12.0 })), Some((668, 12)));
assert_eq!(points_update(&json!({ "channel_id": 668, "balance": "90" })), Some((668, 90)));
// Without both it says nothing.
assert_eq!(points_update(&json!({ "channel_id": 668 })), None);
assert_eq!(points_update(&json!({ "balance": 5 })), None);
}
#[test]
fn a_subscription_carries_the_token_that_names_the_watcher() {
let f = frames::subscribe(2, "private-livestream.1", "jwt");
@@ -299,11 +372,10 @@ mod tests {
assert_eq!(f["id"], json!(2));
}
/// The connection itself is anonymous - the negotiation answers with a URL
/// and no token, so nothing is sent here.
/// The service closes a connection that is opened with no token.
#[test]
fn the_connection_carries_no_token() {
assert_eq!(frames::connect(1), json!({ "id": 1, "connect": {} }));
fn the_connection_carries_its_token() {
assert_eq!(frames::connect(1, "jwt"), json!({ "id": 1, "connect": { "token": "jwt", "name": "js" } }));
}
#[test]
+2 -2
View File
@@ -24,7 +24,7 @@ pub(super) fn relation_preview(state: &AppState, buffer_id: &str, content: &Valu
let in_thread = thread.is_some();
let target = thread.or_else(|| protocol::reply_target(content))?;
match state.store.get_message(buffer_id, target) {
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target.to_string(), from: m.from, body: m.body, thread: in_thread, forwarded: false }),
Ok(Some(m)) => Some(crate::model::ReplyPreview { id: target.to_string(), from: m.from, body: m.body, thread: in_thread, forwarded: false, thread_root: None }),
_ => Some(crate::model::ReplyPreview { id: target.to_string(), thread: in_thread, ..Default::default() }),
}
}
@@ -348,7 +348,7 @@ pub(super) async fn handle_timeline_event(
badges: vec![crate::backend::kick::api::Badge {
kind: "unverified".to_string(),
text: "Sent from a device you have not verified".to_string(),
count: None,
..Default::default()
}],
}),
_ => None,
+2
View File
@@ -15,3 +15,5 @@ pub mod irc;
pub mod kick;
pub mod matrix;
pub mod sneedchat;
pub mod steam;
pub mod twitch;
+1 -1
View File
@@ -237,7 +237,7 @@ pub(super) async fn handle_frame(state: &AppState, http: &http::HttpClient, host
let resp = protocol::ServerResponse::parse(frame);
if !resp.users_joined.is_empty() || !resp.users_left.is_empty() {
update_roster(state, account_id, buffer_name, &resp.users_joined, &resp.users_left);
update_roster(state, http, host, account_id, buffer_name, &resp.users_joined, &resp.users_left);
}
if let Some(text) = &resp.plaintext {
+9
View File
@@ -13,9 +13,18 @@ use super::*;
/// migrate_caches_to_xdg_cache_dir moves any pre-existing directory here
/// once at startup.
pub(super) fn avatar_cache_dir() -> std::path::PathBuf {
// A test that fetches a picture must not leave it in somebody's real cache.
#[cfg(test)]
if let Some(dir) = TEST_CACHE.lock().unwrap().clone() {
return dir;
}
dirs::cache_dir().unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".cache")).join("nobilis").join("sneedchat-avatars")
}
/// Where tests keep avatars instead, when one has said so.
#[cfg(test)]
pub(super) static TEST_CACHE: std::sync::Mutex<Option<std::path::PathBuf>> = std::sync::Mutex::new(None);
/// Evicts the oldest-written files in `dir` until it's back under
/// `max_bytes`. Oldest-by-mtime rather than true LRU (a cache hit doesn't
/// bump a file's timestamp) - simpler, no extra dependency, and "the files
+152 -17
View File
@@ -279,52 +279,100 @@ pub(super) const SITE_OWNER_ID: &str = "1";
pub(super) fn update_roster(
state: &AppState,
http: &super::http::HttpClient,
host: &str,
account_id: &str,
buffer_name: &str,
joined: &[protocol::WireUser],
left: &[String],
) {
let buffer_id = crate::model::buffer_id(account_id, buffer_name);
let mut by_id: std::collections::BTreeMap<String, String> = state
let mut by_id = published_roster(state, &buffer_id);
for user in joined {
// Whatever picture they already had stays until a new one is fetched.
let known = by_id.get(&user.id).and_then(|(_, a)| a.clone());
by_id.insert(user.id.clone(), (user.username.clone(), known));
}
for id in left {
by_id.remove(id);
}
publish_roster(state, &buffer_id, &by_id);
// The pictures, after the list is already on screen: fetching one goes over
// the site's own route and is slow the first time, and neither the list nor
// the room's messages should wait for a face. Fetched together, then laid
// over the roster as it is by then and said again.
let wanted: Vec<(String, String)> = joined
.iter()
.filter_map(|u| Some((u.id.clone(), u.avatar_url.clone().filter(|a| !a.is_empty())?)))
.collect();
if !wanted.is_empty() {
tokio::spawn({
let (state, http, host) = (state.clone(), http.clone(), host.to_string());
async move { add_pictures(&state, &http, &host, &buffer_id, wanted).await }
});
}
}
/// The roster as last published: who is there, and the picture each has.
fn published_roster(state: &AppState, buffer_id: &str) -> std::collections::BTreeMap<String, (String, Option<String>)> {
state
.runtime
.get_presence(&buffer_id)
.get_presence(buffer_id)
.and_then(|v| serde_json::from_value::<Vec<serde_json::Value>>(v).ok())
.unwrap_or_default()
.into_iter()
.filter_map(|m| {
let id = m.get("userId")?.as_str()?.to_string();
let nick = m.get("nick")?.as_str()?.to_string();
Some((id, nick))
let avatar = m.get("avatarUrl").and_then(|a| a.as_str()).map(str::to_string);
Some((id, (nick, avatar)))
})
.collect();
.collect()
}
for user in joined {
by_id.insert(user.id.clone(), user.username.clone());
/// Fetches pictures and puts them on whoever is still in the room.
async fn add_pictures(state: &AppState, http: &super::http::HttpClient, host: &str, buffer_id: &str, wanted: Vec<(String, String)>) {
let found = futures::future::join_all(wanted.iter().map(|(id, raw)| async move { (id.clone(), super::media::cached_avatar_path(http, host, id, raw).await) })).await;
// Read afresh: people came and went while these were being fetched, and a
// roster built from before would bring back whoever has left.
let mut now = published_roster(state, buffer_id);
let mut changed = false;
for (id, path) in found {
if let (Some(path), Some(entry)) = (path, now.get_mut(&id)) {
if entry.1.as_deref() != Some(path.as_str()) {
entry.1 = Some(path);
changed = true;
}
}
}
for id in left {
by_id.remove(id);
if changed {
publish_roster(state, buffer_id, &now);
}
}
let mut members: Vec<(String, String)> = by_id.into_iter().collect();
members.sort_by(|(_, a), (_, b)| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
/// Orders and publishes a roster: sorted here rather than left to the client,
/// since every other backend hands over a sorted one and a five-hundred-name
/// list in map order would be unreadable.
fn publish_roster(state: &AppState, buffer_id: &str, by_id: &std::collections::BTreeMap<String, (String, Option<String>)>) {
let mut members: Vec<(&String, &(String, Option<String>))> = by_id.iter().collect();
members.sort_by(|(_, (a, _)), (_, (b, _))| a.to_lowercase().cmp(&b.to_lowercase()).then_with(|| a.cmp(b)));
let member_list = serde_json::json!(members
.into_iter()
.map(|(id, nick)| {
.map(|(id, (nick, avatar))| {
// "~" is the owner prefix the frontend already ranks by, shared
// with IRC rather than inventing a Sneedchat-only convention.
let prefix = if id == SITE_OWNER_ID { "~" } else { "" };
serde_json::json!({ "nick": nick, "userId": id, "prefix": prefix, "away": false })
serde_json::json!({ "nick": nick, "userId": id, "prefix": prefix, "away": false, "avatarUrl": avatar })
})
.collect::<Vec<_>>());
// Persisted as well as broadcast, so a client that subscribes later gets
// the roster from subscribe's replay rather than waiting for the next
// arrival or departure - which in a quiet room could be a long wait.
state.runtime.set_presence(&buffer_id, member_list.clone());
state.events.emit(
"presenceChange",
serde_json::json!({ "bufferId": buffer_id, "members": member_list }),
);
state.runtime.set_presence(buffer_id, member_list.clone());
state.events.emit("presenceChange", serde_json::json!({ "bufferId": buffer_id, "members": member_list }));
}
#[cfg(test)]
@@ -363,3 +411,90 @@ pub(super) mod buffer_name_tests {
assert_eq!(room_name_of("fishtank"), "fishtank");
}
}
#[cfg(test)]
mod roster_tests {
use super::*;
use crate::backend::kick::testkit::simulated_daemon;
use crate::backend::sneedchat::http::{CookieJar, HttpClient};
use crate::net::tor::Transport;
const ACCOUNT: &str = "sneedchat:test";
fn client() -> HttpClient {
HttpClient::new(Transport::Direct, CookieJar::new(), "test".to_string())
}
fn user(id: &str, name: &str, avatar: Option<&str>) -> protocol::WireUser {
serde_json::from_value(serde_json::json!({ "id": id, "username": name, "avatar_url": avatar })).unwrap()
}
fn members(state: &AppState) -> Vec<(String, Option<String>)> {
let list = state.runtime.get_presence(&crate::model::buffer_id(ACCOUNT, "general")).unwrap();
list.as_array().unwrap().iter().map(|m| (m["nick"].as_str().unwrap().to_string(), m["avatarUrl"].as_str().map(str::to_string))).collect()
}
#[tokio::test]
async fn the_list_is_published_at_once_with_nobody_waiting_on_a_picture() {
let state = simulated_daemon("sneedchat-roster-plain");
update_roster(&state, &client(), "example.invalid", ACCOUNT, "general", &[user("2", "bob", None), user("1", "Amy", None)], &[]);
assert_eq!(members(&state), [("Amy".to_string(), None), ("bob".to_string(), None)], "sorted, and no picture is no picture");
update_roster(&state, &client(), "example.invalid", ACCOUNT, "general", &[], &["2".to_string()]);
assert_eq!(members(&state).len(), 1);
}
/// A tiny picture on a local server, fetched into a scratch cache.
async fn picture_server() -> String {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
// A PNG's signature and a few more bytes: enough to be sniffed as one.
let png: Vec<u8> = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0x0d, b'I', b'H', b'D', b'R', 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0];
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let png = png.clone();
tokio::spawn(async move {
let mut buf = [0u8; 2048];
let _ = sock.read(&mut buf).await;
let head = format!("HTTP/1.1 200 OK\r\nContent-Type: image/png\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", png.len());
let _ = sock.write_all(head.as_bytes()).await;
let _ = sock.write_all(&png).await;
});
}
});
format!("http://{addr}")
}
#[tokio::test]
async fn pictures_follow_the_list_and_stay_through_later_arrivals() {
let dir = std::env::temp_dir().join(format!("nobilis-roster-avatars-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
*crate::backend::sneedchat::media::TEST_CACHE.lock().unwrap() = Some(dir.clone());
let base = picture_server().await;
let state = simulated_daemon("sneedchat-roster-pictures");
update_roster(&state, &client(), "example.invalid", ACCOUNT, "general", &[user("7", "Amy", Some(&format!("{base}/a.png")))], &[]);
// On screen first, without the picture.
assert_eq!(members(&state), [("Amy".to_string(), None)]);
let mut with = None;
for _ in 0..100 {
with = members(&state)[0].1.clone();
if with.is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
let path = with.expect("the picture arrives after the list");
assert!(path.contains("7."), "a file named for the user: {path}");
// Somebody arrives, and Amy keeps hers.
update_roster(&state, &client(), "example.invalid", ACCOUNT, "general", &[user("8", "Bob", None)], &[]);
let list = members(&state);
assert_eq!(list.iter().find(|m| m.0 == "Amy").unwrap().1.as_deref().map(|p| p.contains("7.")), Some(true));
assert_eq!(list.iter().find(|m| m.0 == "Bob").unwrap().1, None);
*crate::backend::sneedchat::media::TEST_CACHE.lock().unwrap() = None;
let _ = std::fs::remove_dir_all(&dir);
}
}
+424
View File
@@ -0,0 +1,424 @@
//! What the account is, and what Steam holds back from it.
//!
//! A person signing in to a chat client wants to know why something does not
//! work as much as that it does not: an account Steam calls *limited* cannot
//! write to everybody, Family View can hold the friends list back, the text
//! filter changes what is read, and the sessions listed on the account are the
//! places it is signed in. This reads those, shows them, and acts on the ones
//! that are the client's to act on.
//!
//! Also here: keeping the account's sign-in alive. The token that signs it in
//! lasts months, and a client that waits for it to run out finds out by being
//! signed out; Steam will give a fresh one while the old one is still good.
use super::link::Link;
use super::proto::*;
use crate::accounts::SteamAccountConfig;
use crate::state::AppState;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
/// `EAccountFlags`: a limited account, and one Steam keeps limited.
const LIMITED: u32 = 4096 | 8192;
/// Family View's feature for friends and chat: a bit in the settings, which
/// when the account is locked is the answer to "is this allowed".
const FRIENDS_FEATURE: u32 = 4;
pub fn is_limited(account_flags: u32) -> bool {
account_flags & LIMITED != 0
}
/// Steam's own lists of what is rude, as patterns, one to a line: what the client itself fetches
/// from the community site. `profanity` is dropped by the setting that allows it, and `clean` is
/// what is let through whatever else says.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Dictionary {
pub banned: Vec<String>,
pub profanity: Vec<String>,
pub clean: Vec<String>,
}
/// The words the account has asked to have hidden, and the patterns they and Steam's lists make.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TextFilter {
/// `ETextFilterSetting`: 0 and 3 use none of Steam's lists, 2 only the banned one, 1 both.
pub setting: i32,
pub ignore_friends: bool,
pub banned: Vec<String>,
pub clean: Vec<String>,
pub compiled: Compiled,
}
/// What the lists and the account's own words make: one pattern for what is hidden and one for
/// what is let through, each only as good as the lines in them that are patterns at all.
#[derive(Clone, Debug, Default)]
pub struct Compiled {
hidden: Option<regex::Regex>,
allowed: Option<regex::Regex>,
}
impl PartialEq for Compiled {
fn eq(&self, other: &Self) -> bool {
self.hidden.as_ref().map(|r| r.as_str()) == other.hidden.as_ref().map(|r| r.as_str()) && self.allowed.as_ref().map(|r| r.as_str()) == other.allowed.as_ref().map(|r| r.as_str())
}
}
/// The lines that are patterns this can read, as one alternation. A line that is not one is left
/// out, as the client leaves out one its own engine cannot compile.
fn alternation(lines: impl IntoIterator<Item = String>) -> Option<String> {
let usable: Vec<String> = lines.into_iter().map(|l| l.trim().trim_start_matches('\u{feff}').to_string()).filter(|l| !l.is_empty() && regex::Regex::new(&format!(r"(?i)\b({l})\b")).is_ok()).collect();
(!usable.is_empty()).then(|| usable.join("|"))
}
impl TextFilter {
/// Builds the patterns the client builds: Steam's lists as the setting says, and the account's own
/// words on top whatever the setting is.
pub fn compile(setting: i32, banned: &[String], clean: &[String], dictionary: &Dictionary) -> Compiled {
let mut hidden: Vec<String> = match setting {
0 | 3 => Vec::new(),
2 => dictionary.banned.clone(),
_ => dictionary.profanity.iter().chain(&dictionary.banned).cloned().collect(),
};
hidden.extend(banned.iter().cloned());
let allowed: Vec<String> = dictionary.clean.iter().chain(clean).cloned().collect();
Compiled {
hidden: alternation(hidden).and_then(|a| regex::Regex::new(&format!(r"(?i)\b({a})\b")).ok()),
allowed: alternation(allowed).and_then(|a| regex::Regex::new(&format!(r"(?i)^(\b({a})\b)$")).ok()),
}
}
}
/// Steam's lists, asked of the community site once a run.
static DICTIONARY: LazyLock<Mutex<Option<Arc<Dictionary>>>> = LazyLock::new(Default::default);
async fn dictionary(key: &str) -> Arc<Dictionary> {
if let Some(held) = DICTIONARY.lock().unwrap().clone() {
return held;
}
let router = crate::net::route::router();
let http = router.client_if("steam", router.routed(key), |b| b.timeout(std::time::Duration::from_secs(30)).user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))));
let fetch = |kind: &'static str| {
let http = http.clone();
async move {
let url = format!("https://community.fastly.steamstatic.com/textfilter/gettextfilterdictionary?type={kind}&language=english&v=1");
let raw = http.get(url).send().await.ok().filter(|r| r.status().is_success())?.bytes().await.ok()?;
Some(super::web::plain(&raw).lines().map(str::to_string).collect::<Vec<_>>())
}
};
let (banned, profanity, clean) = tokio::join!(fetch("banned"), fetch("profanity"), fetch("clean_public"));
let dictionary = Arc::new(Dictionary { banned: banned.unwrap_or_default(), profanity: profanity.unwrap_or_default(), clean: clean.unwrap_or_default() });
// Kept only when it came: a list that did not is asked for again next time.
if !dictionary.banned.is_empty() {
*DICTIONARY.lock().unwrap() = Some(dictionary.clone());
}
dictionary
}
/// What is known about the account, as the window reads it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Facts {
pub limited: bool,
/// Whether Family View is on, and if it holds friends and chat back.
pub family_view: Option<(bool, bool)>,
pub filter: Option<TextFilter>,
pub privacy: Option<PrivacySettings>,
}
fn privacy_word(state: Option<i32>) -> Value {
match state {
Some(1) => json!("private"),
Some(2) => json!("friends"),
Some(3) => json!("public"),
_ => Value::Null,
}
}
impl Facts {
pub fn to_json(&self) -> Value {
json!({
"limited": self.limited,
"familyView": self.family_view.map(|(enabled, holds_friends)| json!({ "enabled": enabled, "holdsFriendsBack": holds_friends })),
"textFilter": self.filter.as_ref().map(|f| json!({
"on": f.compiled.hidden.is_some(),
"setting": match f.setting { 1 => "on", 2 => "on, profanity allowed", 3 => "off", _ => "not in use" },
"ignoreFriends": f.ignore_friends,
"words": f.banned.len(),
})),
"privacy": self.privacy.as_ref().map(|p| json!({
"profile": privacy_word(p.privacy_state),
"inventory": privacy_word(p.privacy_state_inventory),
"games": privacy_word(p.privacy_state_ownedgames),
"playtime": privacy_word(p.privacy_state_playtime),
"friends": privacy_word(p.privacy_state_friendslist),
})),
})
}
}
static HELD: LazyLock<Mutex<HashMap<String, Arc<Facts>>>> = LazyLock::new(Default::default);
pub fn held(account_id: &str) -> Facts {
HELD.lock().unwrap().get(account_id).map(|f| (**f).clone()).unwrap_or_default()
}
pub fn clear(account_id: &str) {
HELD.lock().unwrap().remove(account_id);
}
fn keep(state: &AppState, account_id: &str, facts: Facts) {
let changed = HELD.lock().unwrap().insert(account_id.to_string(), Arc::new(facts.clone())).map(|old| *old != facts).unwrap_or(true);
if changed {
state.events.emit("steamAccountFacts", json!({ "accountId": account_id, "facts": facts.to_json() }));
}
}
/// Reads what Steam says of the account. Each part on its own: one that is
/// refused leaves the rest.
pub async fn load(state: AppState, link: Link, account_id: String, steamid: u64, account_flags: u32) {
// What is known at once, before the rest is asked for.
keep(&state, &account_id, Facts { limited: is_limited(account_flags), ..Default::default() });
let parental_request = GetParentalSettingsRequest { steamid: Some(steamid) };
let (preferences, words, privacy, parental) = tokio::join!(
link.call::<_, GetCommunityPreferencesResponse>("Player.GetCommunityPreferences#1", &GetCommunityPreferencesRequest {}, true),
link.call::<_, GetTextFilterWordsResponse>("Player.GetTextFilterWords#1", &GetTextFilterWordsRequest {}, true),
link.call::<_, GetPrivacySettingsResponse>("Player.GetPrivacySettings#1", &GetPrivacySettingsRequest {}, true),
link.call::<_, GetParentalSettingsResponse>("Parental.GetParentalSettings#1", &parental_request, true),
);
let filter = match preferences.ok().and_then(|p| p.preferences) {
Some(p) => {
let words = words.ok().and_then(|w| w.words).unwrap_or_default();
let setting = p.text_filter_setting.unwrap_or(0);
// Steam's own lists are needed only where the setting uses them.
let lists = if matches!(setting, 1 | 2) { (*dictionary(&account_id).await).clone() } else { Dictionary::default() };
let compiled = TextFilter::compile(setting, &words.text_filter_custom_banned_words, &words.text_filter_custom_clean_words, &lists);
Some(TextFilter { setting, ignore_friends: p.text_filter_ignore_friends.unwrap_or(true), banned: words.text_filter_custom_banned_words, clean: words.text_filter_custom_clean_words, compiled })
}
None => None,
};
let family_view = parental.ok().and_then(|p| p.settings).map(|s| {
let enabled = s.is_enabled.unwrap_or(false);
(enabled, enabled && s.enabled_features.unwrap_or(0) & (1 << FRIENDS_FEATURE) == 0)
});
keep(&state, &account_id, Facts { limited: is_limited(account_flags), family_view, filter, privacy: privacy.ok().and_then(|p| p.privacy_settings) });
}
/// A message with the words the account hides masked, for somebody the filter applies to: a stranger
/// always, and a friend unless the account says friends are not filtered.
pub fn filtered(account_id: &str, from_friend: bool, text: &str) -> String {
let facts = HELD.lock().unwrap().get(account_id).cloned();
let Some(filter) = facts.as_ref().and_then(|f| f.filter.as_ref()) else { return text.to_string() };
if (from_friend && filter.ignore_friends) || filter.compiled.hidden.is_none() {
return text.to_string();
}
mask(text, &filter.compiled)
}
/// Replaces each whole word the patterns hide with a heart for every letter, unless the word is one the
/// allowed pattern says is clean, as Steam does.
pub fn mask(text: &str, patterns: &Compiled) -> String {
let Some(hidden) = &patterns.hidden else { return text.to_string() };
hidden
.replace_all(text, |found: &regex::Captures| {
let word = &found[0];
if patterns.allowed.as_ref().is_some_and(|a| a.is_match(word)) {
word.to_string()
} else {
"\u{2665}".repeat(word.chars().count())
}
})
.into_owned()
}
/// A place the account is signed in.
#[derive(Debug, PartialEq)]
pub struct Session {
pub id: u64,
pub description: String,
/// `EAuthTokenPlatformType`: 1 launcher, 2 browser, 3 phone.
pub platform: i32,
pub last_seen: u32,
pub place: String,
pub current: bool,
}
fn place(event: Option<&TokenUsageEvent>) -> String {
event.map(|e| [&e.city, &e.state, &e.country].iter().filter_map(|p| p.as_deref()).filter(|p| !p.is_empty()).collect::<Vec<_>>().join(", ")).unwrap_or_default()
}
pub fn read_sessions(answer: &EnumerateTokensResponse) -> Vec<Session> {
let mut sessions: Vec<Session> = answer
.refresh_tokens
.iter()
.filter_map(|t| {
let id = t.token_id?;
Some(Session {
id,
description: t.token_description.clone().filter(|d| !d.is_empty()).unwrap_or_else(|| "a signed-in session".to_string()),
platform: t.platform_type.unwrap_or(0),
last_seen: t.last_seen.as_ref().and_then(|l| l.time).or(t.time_updated).unwrap_or(0),
place: place(t.last_seen.as_ref()),
current: answer.requesting_token == Some(id),
})
})
.collect();
sessions.sort_by_key(|s| std::cmp::Reverse(s.last_seen));
sessions
}
/// Where the account is signed in.
pub async fn sessions(link: &Link) -> Result<Vec<Session>, String> {
let answer: EnumerateTokensResponse = link
.call("Authentication.EnumerateTokens#1", &EnumerateTokensRequest { include_revoked: Some(false) }, true)
.await
.map_err(|e| super::chat::refusal(&e))?;
Ok(read_sessions(&answer))
}
/// Signs one of them out, for good: whoever has it is no longer signed in.
pub async fn revoke(link: &Link, steamid: u64, token_id: u64) -> Result<(), String> {
let _: RevokeRefreshTokenResponse = link
.call("Authentication.RevokeRefreshToken#1", &RevokeRefreshTokenRequest { token_id: Some(token_id), steamid: Some(steamid) }, true)
.await
.map_err(|e| super::chat::refusal(&e))?;
Ok(())
}
/// When a token stops working, in unix seconds, from its own claims.
pub fn expires_at(token: &str) -> Option<u64> {
use base64::Engine;
let payload = token.split('.').nth(1)?;
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
serde_json::from_slice::<Value>(&raw).ok()?["exp"].as_u64()
}
/// How close to running out is too close: Steam renews a token while it is good, and a
/// month leaves a long time to try again after a failure.
const RENEW_WITHIN_SECONDS: u64 = 30 * 24 * 3600;
pub fn due_for_renewal(token: &str, now: u64) -> bool {
expires_at(token).is_some_and(|exp| exp.saturating_sub(now) < RENEW_WITHIN_SECONDS)
}
/// Asks Steam for a fresh token if this one is near its end, and keeps it.
pub async fn renew_if_due(state: &AppState, link: &Link, config: &SteamAccountConfig) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
if !due_for_renewal(&config.refresh_token, now) {
return;
}
let request = GenerateAccessTokenRequest { refresh_token: Some(config.refresh_token.clone()), steamid: Some(config.steamid), renewal_type: Some(1) };
match link.call::<_, GenerateAccessTokenResponse>("Authentication.GenerateAccessTokenForApp#1", &request, true).await {
Ok(answer) => {
if let Some(fresh) = answer.refresh_token.filter(|t| !t.is_empty() && *t != config.refresh_token) {
let id = config.account_id();
let _ = state.accounts.mutate_steam(&id, |a| a.refresh_token = fresh);
tracing::info!("steam[{id}]: the sign-in token was renewed");
}
}
Err(e) => tracing::info!("steam[{}]: the sign-in token could not be renewed: {e}", config.account_id()),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_limited_account_is_known_by_its_flags() {
assert!(is_limited(4096));
assert!(is_limited(8192 | 1));
assert!(!is_limited(1 | 16384));
}
fn lists() -> Dictionary {
Dictionary { banned: vec!["bann?ed".into(), "[unclosed".into()], profanity: vec!["f+u+d+ge".into()], clean: vec!["banned together".into(), "bannedx".into()] }
}
#[test]
fn what_the_lists_and_the_account_hide_is_hidden_as_whole_words_with_a_heart_each() {
let own = ["heck".to_string(), "Darn".to_string()];
let on = TextFilter::compile(1, &own, &["darn".to_string()], &lists());
assert_eq!(mask("what the heck, a Heckle and DARN it", &on), "what the \u{2665}\u{2665}\u{2665}\u{2665}, a Heckle and DARN it", "the account's own words, and what it marked clean is let through");
assert_eq!(mask("fuuudge and banned and bannedx", &on), "\u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665} and \u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665} and bannedx", "Steam's lists, with a line that is no pattern left out and a clean word let through");
assert_eq!(mask("banned together", &on), "\u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665} together", "as in the client, a clean entry lets through only a match that is the whole of it");
assert_eq!(mask("nothing here", &on), "nothing here");
// The setting that allows profanity uses only the banned list.
let mild = TextFilter::compile(2, &[], &[], &lists());
assert_eq!(mask("fudge banned", &mild), "fudge \u{2665}\u{2665}\u{2665}\u{2665}\u{2665}\u{2665}");
// Switched off: Steam's lists are not used, but the account's own words still are, as in the client.
let off = TextFilter::compile(3, &own, &[], &lists());
assert_eq!(mask("banned heck", &off), "banned \u{2665}\u{2665}\u{2665}\u{2665}");
}
#[test]
fn the_filter_applies_to_strangers_and_to_friends_only_if_the_account_says_so() {
let id = "steam:filter-test";
let words = || TextFilter { setting: 1, ignore_friends: true, banned: vec!["heck".into()], clean: vec![], compiled: TextFilter::compile(1, &["heck".to_string()], &[], &Dictionary::default()) };
HELD.lock().unwrap().insert(id.into(), Arc::new(Facts { filter: Some(words()), ..Default::default() }));
assert_eq!(filtered(id, false, "oh heck"), "oh \u{2665}\u{2665}\u{2665}\u{2665}");
assert_eq!(filtered(id, true, "oh heck"), "oh heck", "friends are not filtered by default");
HELD.lock().unwrap().insert(id.into(), Arc::new(Facts { filter: Some(TextFilter { ignore_friends: false, ..words() }), ..Default::default() }));
assert_eq!(filtered(id, true, "oh heck"), "oh \u{2665}\u{2665}\u{2665}\u{2665}");
assert_eq!(filtered("steam:nobody", false, "oh heck"), "oh heck", "no filter known");
clear(id);
}
/// Steam's real lists from the community site, and what they make of a sentence. The words used are the
/// mildest on the list. Needs the network.
#[tokio::test]
#[ignore = "needs the network"]
async fn steams_own_lists_are_fetched_and_hide_what_they_say() {
let _ = rustls::crypto::ring::default_provider().install_default();
let d = dictionary("steam:filter-probe").await;
println!("FILTER lists: {} banned, {} profanity, {} clean", d.banned.len(), d.profanity.len(), d.clean.len());
let on = TextFilter::compile(1, &[], &[], &d);
let mild = TextFilter::compile(2, &[], &[], &d);
println!("FILTER on: {:?}", mask("what the fuck, a fukushima trip and pussycat", &on));
println!("FILTER mild: {:?}", mask("what the fuck, a fukushima trip and pussycat", &mild));
assert!(d.banned.len() > 20 && d.profanity.len() > 20);
assert_eq!(mask("what the fuck", &on), "what the \u{2665}\u{2665}\u{2665}\u{2665}");
assert_eq!(mask("fukushima pussycat", &on), "fukushima pussycat", "clean words are let through");
assert_eq!(mask("what the fuck", &mild), "what the fuck", "the setting that allows profanity");
}
#[test]
fn the_sessions_are_listed_newest_first_with_the_one_in_use_marked() {
let answer = EnumerateTokensResponse {
refresh_tokens: vec![
RefreshTokenDescription { token_id: Some(1), token_description: Some("old phone".into()), platform_type: Some(3), last_seen: Some(TokenUsageEvent { time: Some(100), city: Some("Leeds".into()), country: Some("GB".into()), ..Default::default() }), ..Default::default() },
RefreshTokenDescription { token_id: Some(2), token_description: Some("moho on host".into()), platform_type: Some(3), time_updated: Some(900), ..Default::default() },
RefreshTokenDescription { token_description: Some("no id".into()), ..Default::default() },
],
requesting_token: Some(2),
};
let got = read_sessions(&answer);
assert_eq!(got.iter().map(|s| (s.id, s.description.as_str(), s.current)).collect::<Vec<_>>(), [(2, "moho on host", true), (1, "old phone", false)]);
assert_eq!(got[1].place, "Leeds, GB");
}
#[test]
fn a_token_is_renewed_when_it_is_within_a_month_of_its_end() {
use base64::Engine;
let token = |exp: u64| format!("h.{}.s", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"sub":"1","exp":{exp}}}"#)));
let now = 1_800_000_000;
assert!(due_for_renewal(&token(now + 10 * 24 * 3600), now));
assert!(due_for_renewal(&token(now - 5), now), "one that has run out is as due as one can be");
assert!(!due_for_renewal(&token(now + 100 * 24 * 3600), now));
assert!(!due_for_renewal("not a token", now), "nothing is asked for a token that cannot be read");
}
#[test]
fn the_facts_read_in_words_for_the_window() {
let facts = Facts {
limited: true,
family_view: Some((true, true)),
filter: Some(TextFilter { setting: 2, ignore_friends: true, banned: vec!["a".into(), "b".into()], clean: vec![], compiled: Compiled::default() }),
privacy: Some(PrivacySettings { privacy_state: Some(3), privacy_state_friendslist: Some(1), ..Default::default() }),
};
let j = facts.to_json();
assert_eq!((j["limited"].as_bool(), j["familyView"]["holdsFriendsBack"].as_bool(), j["textFilter"]["words"].as_u64()), (Some(true), Some(true), Some(2)));
assert_eq!((j["privacy"]["profile"].as_str(), j["privacy"]["friends"].as_str(), j["privacy"]["games"].is_null()), (Some("public"), Some("private"), true));
assert_eq!(Facts::default().to_json()["familyView"], Value::Null);
}
}
+52
View File
@@ -0,0 +1,52 @@
//! What a game is called.
//!
//! Steam tells a friend's client which game somebody is in by its number, and
//! names it only for a game that is not on Steam. The name of one that is is
//! Steam's own to say - the store has it - so it is asked for the first time a
//! number is seen and kept for as long as moho runs. A game is not asked about
//! twice, and one the store does not know stays a number: shown as "In game",
//! which is true, rather than as a guess.
//!
//! It is the account's request and goes the account's way: through its route.
use serde_json::Value;
/// How the store is asked.
const STORE: &str = "https://store.steampowered.com/api/appdetails";
/// The name out of the store's answer, which is keyed by the number asked for.
pub fn name_in(answer: &Value, app: u64) -> Option<String> {
let entry = answer.get(app.to_string())?;
if entry.get("success").and_then(|s| s.as_bool()) != Some(true) {
return None;
}
let name = entry.get("data")?.get("name")?.as_str()?.trim();
(!name.is_empty()).then(|| name.chars().take(80).collect())
}
/// Asks the store what a game is called.
pub async fn lookup(key: &str, app: u64) -> Option<String> {
let router = crate::net::route::router();
if router.routed(key) && router.ready_for_general().await.is_err() {
return None;
}
let http = router.client_if("steam", router.routed(key), |b| b.timeout(std::time::Duration::from_secs(15)).user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))));
let answer: Value = http.get(STORE).query(&[("appids", app.to_string()), ("filters", "basic".to_string())]).send().await.ok()?.json().await.ok()?;
name_in(&answer, app)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_name_is_read_out_of_the_stores_answer() {
let said = json!({ "730": { "success": true, "data": { "type": "game", "name": "Counter-Strike 2", "steam_appid": 730 } } });
assert_eq!(name_in(&said, 730).as_deref(), Some("Counter-Strike 2"));
assert_eq!(name_in(&said, 731), None, "another game is not this one");
assert_eq!(name_in(&json!({ "99": { "success": false } }), 99), None, "a game the store does not know has no name");
assert_eq!(name_in(&json!({ "5": { "success": true, "data": { "name": " " } } }), 5), None);
assert_eq!(name_in(&json!([]), 5), None);
}
}
+602
View File
@@ -0,0 +1,602 @@
//! Signing in: turning a name and a password into a token that can be used
//! to sign in again without them.
//!
//! This is Steam's modern authentication service, and it happens on the same
//! connection that will carry the chat, before the account has signed in:
//!
//! 1. Ask for a public key for the account name, and encrypt the password with
//! it. The key changes, which is why it is asked for each time; the
//! password never goes over the connection in any other form.
//! 2. Begin a session with the name and the encrypted password. Steam answers
//! with what it wants before it will let the session through: nothing, a
//! code from the Steam Guard authenticator, a code it emailed, or an
//! approval tapped on a phone.
//! 3. Send the code if one is wanted, and poll the session. A phone approval
//! needs nothing sent - polling is how the answer is found.
//! 4. The poll eventually returns a **refresh token**. That, and not the
//! password, is what is kept: a long-lived credential that signs the
//! account in, and that can be revoked from Steam without changing the
//! password.
//!
//! The password is held for the length of this exchange and no longer.
use super::link::{CallError, Link};
use super::proto::*;
use anyhow::{Context, Result};
use base64::Engine;
use rsa::{BigUint, Pkcs1v15Encrypt, RsaPublicKey};
use std::time::Duration;
/// What moho signs in as: a desktop Steam client, the way every other third-party client does.
///
/// This is part of the token Steam issues and decides how Steam treats the connection that uses it. It was the
/// mobile app's chat session (platform 3, app type 2, website "Mobile") until Steam raised an account alert for an
/// unexpected device: a "phone" that reports a Linux desktop's operating system, has no machine id and logs on again
/// every time its connection is closed does not look like any real device. SteamKit2, ArchiSteamFarm, node-steam-user,
/// node-steam-session and steam-vent all sign in as the desktop client (platform 1, website "Client"), and so does this.
///
/// `EAuthTokenPlatformType` 1 is the desktop client. It has no app type.
const PLATFORM_TYPE: i32 = 1;
const WEBSITE_ID: &str = "Client";
/// `EGamingDeviceType` 1 is an ordinary PC, as node-steam-session sends for the desktop client.
const GAMING_DEVICE_TYPE: u32 = 1;
/// This install's own random number, made once and kept next to the daemon's other files, from which its machine
/// id and logon id are drawn. One per install and not per account: a machine is one machine however many accounts
/// sign in on it, and two installs (a test window, a second computer) are two.
pub fn install_id() -> [u8; 16] {
// A test never writes to the real data directory.
#[cfg(test)]
return [7u8; 16];
#[cfg(not(test))]
{
static ID: std::sync::OnceLock<[u8; 16]> = std::sync::OnceLock::new();
*ID.get_or_init(|| {
let path = crate::default_data_dir().join("steam-install-id");
if let Some(read) = std::fs::read_to_string(&path).ok().and_then(|t| hex::decode(t.trim()).ok()).and_then(|b| <[u8; 16]>::try_from(b).ok()) {
return read;
}
let made: [u8; 16] = rand::random();
let _ = std::fs::create_dir_all(path.parent().unwrap_or(std::path::Path::new(".")));
let _ = std::fs::write(&path, hex::encode(made));
made
})
}
}
/// The machine id Steam's client sends: a binary key-value object of three SHA-1 hashes (named BB3, FF2 and 3B3 in the
/// client), here of strings made from the install's number, so it is the same each time and different for each install.
pub fn machine_id_from(install: &[u8; 16]) -> Vec<u8> {
use sha1::{Digest, Sha1};
let mut out = vec![0u8];
out.extend_from_slice(b"MessageObject\0");
for name in ["BB3", "FF2", "3B3"] {
let hash = hex::encode(Sha1::digest(format!("moho {name} {}", hex::encode(install)).as_bytes()));
out.push(1);
out.extend_from_slice(name.as_bytes());
out.push(0);
out.extend_from_slice(hash.as_bytes());
out.push(0);
}
out.extend_from_slice(&[8, 8]);
out
}
pub fn machine_id() -> Vec<u8> {
machine_id_from(&install_id())
}
/// What tells this install's sessions from another's on the same account: Steam ends a session when a later one with
/// the same logon id arrives, and lets those with different ones live side by side. The desktop client sends its own
/// private address here; this sends a number of its own, never zero (which is "none").
pub fn logon_id_from(install: &[u8; 16]) -> u32 {
(u32::from_le_bytes([install[0], install[1], install[2], install[3]]) | 1) ^ 0xBAAD_F00D
}
pub fn logon_id() -> u32 {
logon_id_from(&install_id())
}
/// `EOSType` as the connection reports it: Linux 6.x is -184 and Windows 10
/// is 16, sent as the unsigned number the field is.
pub fn os_type() -> u32 {
if cfg!(windows) {
16
} else {
(-203i32) as u32
}
}
/// What the account's Steam Guard settings are asking for.
#[derive(Clone, Debug, PartialEq)]
pub enum Guard {
/// Nothing: the session goes through.
None,
/// A code Steam emailed to the account.
EmailCode,
/// A code from the Steam Guard authenticator on the person's phone.
DeviceCode,
/// An approval tapped in the Steam app on the person's phone.
DeviceConfirmation,
/// An approval by following a link in an email.
EmailConfirmation,
/// Something this client does not know how to answer.
Other(i32),
}
impl Guard {
fn from_wire(kind: i32) -> Guard {
match kind {
1 => Guard::None,
2 => Guard::EmailCode,
3 => Guard::DeviceCode,
4 => Guard::DeviceConfirmation,
5 => Guard::EmailConfirmation,
other => Guard::Other(other),
}
}
fn code_type(&self) -> Option<i32> {
match self {
Guard::EmailCode => Some(2),
Guard::DeviceCode => Some(3),
_ => None,
}
}
}
/// One way Steam will accept for getting the session through, with the hint it
/// gives (an email address with most of it hidden, for the emailed code).
#[derive(Clone, Debug, PartialEq)]
pub struct Confirmation {
pub guard: Guard,
pub hint: Option<String>,
}
/// A sign-in that has begun and is waiting on Steam Guard, or on the poll.
#[derive(Clone, Debug)]
pub struct Begun {
pub client_id: u64,
pub request_id: Vec<u8>,
/// How often Steam asks to be polled.
pub interval: Duration,
pub steamid: u64,
pub confirmations: Vec<Confirmation>,
}
impl Begun {
/// Whether the session goes through with nothing more from the person.
pub fn needs_nothing(&self) -> bool {
self.confirmations.is_empty() || self.confirmations.iter().any(|c| c.guard == Guard::None)
}
}
/// What a finished sign-in leaves.
#[derive(Clone, Debug, PartialEq)]
pub struct Tokens {
pub refresh_token: String,
pub access_token: String,
pub account_name: Option<String>,
/// Steam's note of this machine having been approved, to send next time so
/// it is not asked again.
pub guard_data: Option<String>,
}
/// The password, encrypted with the public key Steam gave for the account.
pub fn encrypt_password(modulus_hex: &str, exponent_hex: &str, password: &str) -> Result<String> {
let n = BigUint::parse_bytes(modulus_hex.trim().as_bytes(), 16).context("Steam's password key has no modulus")?;
let e = BigUint::parse_bytes(exponent_hex.trim().as_bytes(), 16).context("Steam's password key has no exponent")?;
let key = RsaPublicKey::new(n, e).context("Steam's password key is not a usable key")?;
let encrypted = key.encrypt(&mut rand::thread_rng(), Pkcs1v15Encrypt, password.as_bytes()).context("encrypting the password")?;
Ok(base64::engine::general_purpose::STANDARD.encode(encrypted))
}
/// What Steam shows next to this session in the account's list of signed-in
/// devices, and on the phone when it asks for an approval.
pub fn device_name() -> String {
let host = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.ok()
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty());
match host {
Some(host) => format!("moho on {host}"),
None => "moho".to_string(),
}
}
fn device_details() -> DeviceDetails {
DeviceDetails {
device_friendly_name: Some(device_name()),
platform_type: Some(PLATFORM_TYPE),
os_type: Some(os_type() as i32),
gaming_device_type: Some(GAMING_DEVICE_TYPE),
// Sent with a password sign-in, as the desktop client's is.
machine_id: Some(machine_id()),
..DeviceDetails::default()
}
}
/// How often to poll, from what Steam asked for: never faster than twice a
/// second, so a server that says zero is not hammered, and never slower than
/// every thirty, so one that says an hour is not left alone. Five seconds when
/// it said nothing.
fn poll_interval(asked: Option<f32>) -> Duration {
Duration::from_secs_f32(asked.filter(|s| s.is_finite()).unwrap_or(5.0).clamp(0.5, 30.0))
}
/// Starts a sign-in with a name and a password.
///
/// `guard_data` is what a previous sign-in on this machine was given to
/// remember it by, if there was one.
pub async fn begin(link: &Link, account_name: &str, password: &str, guard_data: Option<&str>) -> Result<Begun, CallError> {
let key: GetPasswordRsaPublicKeyResponse = link
.call("Authentication.GetPasswordRSAPublicKey#1", &GetPasswordRsaPublicKeyRequest { account_name: Some(account_name.to_string()) }, false)
.await?;
let encrypted = encrypt_password(key.publickey_mod.as_deref().unwrap_or_default(), key.publickey_exp.as_deref().unwrap_or_default(), password)
.map_err(|e| CallError::Unreadable(format!("{e:#}")))?;
let request = BeginAuthViaCredentialsRequest {
device_friendly_name: Some(device_name()),
account_name: Some(account_name.to_string()),
encrypted_password: Some(encrypted),
encryption_timestamp: key.timestamp,
remember_login: Some(true),
platform_type: Some(PLATFORM_TYPE),
// Persistent: a token that outlives this run, which is the point.
persistence: Some(1),
website_id: Some(WEBSITE_ID.to_string()),
device_details: Some(device_details()),
guard_data: guard_data.filter(|g| !g.is_empty()).map(str::to_string),
..BeginAuthViaCredentialsRequest::default()
};
let begun: BeginAuthViaCredentialsResponse = link.call("Authentication.BeginAuthSessionViaCredentials#1", &request, false).await?;
Ok(Begun {
client_id: begun.client_id.unwrap_or_default(),
request_id: begun.request_id.unwrap_or_default(),
interval: poll_interval(begun.interval),
steamid: begun.steamid.unwrap_or_default(),
confirmations: begun
.allowed_confirmations
.into_iter()
.map(|c| Confirmation { guard: Guard::from_wire(c.confirmation_type.unwrap_or_default()), hint: c.associated_message.filter(|m| !m.is_empty()) })
.collect(),
})
}
/// Gives Steam the code that was asked for.
pub async fn send_code(link: &Link, begun: &Begun, guard: &Guard, code: &str) -> Result<(), CallError> {
let Some(code_type) = guard.code_type() else {
return Err(CallError::Unreadable("that kind of confirmation takes no code".to_string()));
};
let _: UpdateAuthWithGuardCodeResponse = link
.call(
"Authentication.UpdateAuthSessionWithSteamGuardCode#1",
&UpdateAuthWithGuardCodeRequest { client_id: Some(begun.client_id), steamid: Some(begun.steamid), code: Some(code.trim().to_string()), code_type: Some(code_type) },
false,
)
.await?;
Ok(())
}
/// What a failure while waiting for a sign-in to be approved means: for this, Steam's "not found"
/// and "expired" are the same - the sign-in ran out before anybody approved it - which the Steam
/// client treats as one thing; the agreement page and the rate limit are said as the general table
/// says them.
pub fn waiting_failure(error: CallError) -> String {
match error {
CallError::Refused(super::eresult::FILE_NOT_FOUND | super::eresult::EXPIRED, _) => "that sign-in ran out before it was approved; start again".to_string(),
other => other.to_string(),
}
}
/// Starts a sign-in that a phone will approve by scanning a code. Answers with
/// the address the code carries; the session is then polled as any other.
pub async fn begin_qr(link: &Link) -> Result<(Begun, String), CallError> {
let request = BeginAuthViaQrRequest { device_friendly_name: Some(device_name()), platform_type: Some(PLATFORM_TYPE), device_details: Some(device_details()), website_id: Some(WEBSITE_ID.to_string()) };
let begun: BeginAuthViaQrResponse = link.call("Authentication.BeginAuthSessionViaQR#1", &request, false).await?;
let url = begun.challenge_url.filter(|u| !u.is_empty()).ok_or_else(|| CallError::Unreadable("Steam gave no code to scan".to_string()))?;
Ok((
Begun { client_id: begun.client_id.unwrap_or_default(), request_id: begun.request_id.unwrap_or_default(), interval: poll_interval(begun.interval), steamid: 0, confirmations: Vec::new() },
url,
))
}
/// Who a token is for: its subject, in the middle part of the token. A scan
/// does not say, and an account is kept by its number.
pub fn steamid_of_token(token: &str) -> Option<u64> {
let payload = token.split('.').nth(1)?;
let raw = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?;
let claims: serde_json::Value = serde_json::from_slice(&raw).ok()?;
claims["sub"].as_str()?.parse().ok()
}
/// What one look at a session that is waiting for a scan found.
pub struct Polled {
pub tokens: Option<Tokens>,
/// The code runs out and is replaced: the address to draw now.
pub new_challenge: Option<String>,
}
/// Asks once whether the session has gone through. `None` means not yet.
pub async fn poll(link: &Link, begun: &mut Begun) -> Result<Option<Tokens>, CallError> {
Ok(poll_session(link, begun).await?.tokens)
}
/// The same, and what else the answer said.
pub async fn poll_session(link: &Link, begun: &mut Begun) -> Result<Polled, CallError> {
let answer: PollAuthSessionResponse = link
.call(
"Authentication.PollAuthSessionStatus#1",
&PollAuthSessionRequest { client_id: Some(begun.client_id), request_id: Some(begun.request_id.clone()), token_to_revoke: None },
false,
)
.await?;
// A session that was handed a new id carries on under it.
if let Some(id) = answer.new_client_id.filter(|id| *id != 0) {
begun.client_id = id;
}
let new_challenge = answer.new_challenge_url.filter(|u| !u.is_empty());
let tokens = answer.refresh_token.filter(|t| !t.is_empty()).map(|refresh_token| Tokens {
refresh_token,
access_token: answer.access_token.unwrap_or_default(),
account_name: answer.account_name.filter(|n| !n.is_empty()),
guard_data: answer.new_guard_data.filter(|g| !g.is_empty()),
});
Ok(Polled { tokens, new_challenge })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::steam::link::{self, fake::*};
#[test]
fn a_sign_in_that_ran_out_is_said_so_and_the_rest_by_the_general_table() {
assert!(waiting_failure(CallError::Refused(9, None)).contains("ran out"));
assert!(waiting_failure(CallError::Refused(27, None)).contains("ran out"));
assert!(waiting_failure(CallError::Refused(84, None)).contains("limiting"));
let page = waiting_failure(CallError::Refused(118, Some("https://login.steampowered.com/agree".into())));
assert!(page.contains("agreement") && page.contains("https://login.steampowered.com/agree"), "{page}");
}
use crate::backend::steam::wire::Packet;
use prost::Message;
use rsa::traits::PublicKeyParts;
use rsa::RsaPrivateKey;
use std::sync::{Arc, Mutex};
fn keypair() -> (RsaPrivateKey, String, String) {
let private = RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap();
let public = private.to_public_key();
let (n, e) = (public.n().to_str_radix(16), public.e().to_str_radix(16));
(private, n, e)
}
#[test]
fn the_password_is_encrypted_so_only_steams_key_can_read_it() {
let (private, n, e) = keypair();
let encrypted = encrypt_password(&n, &e, "hunter2").unwrap();
assert!(!encrypted.contains("hunter2"));
let bytes = base64::engine::general_purpose::STANDARD.decode(&encrypted).unwrap();
assert_eq!(private.decrypt(Pkcs1v15Encrypt, &bytes).unwrap(), b"hunter2");
// Encrypted afresh each time, so two sign-ins do not look the same.
assert_ne!(encrypted, encrypt_password(&n, &e, "hunter2").unwrap());
}
#[test]
fn a_key_steam_did_not_really_send_is_refused_not_used() {
assert!(encrypt_password("", "010001", "x").is_err());
assert!(encrypt_password("zz", "010001", "x").is_err());
assert!(encrypt_password("0A", "010001", "x").is_err(), "too small to hold a password");
}
#[test]
fn what_steam_asks_for_is_told_apart() {
assert_eq!(Guard::from_wire(3), Guard::DeviceCode);
assert_eq!(Guard::from_wire(2), Guard::EmailCode);
assert_eq!(Guard::from_wire(4), Guard::DeviceConfirmation);
assert_eq!(Guard::from_wire(5), Guard::EmailConfirmation);
assert_eq!(Guard::from_wire(1), Guard::None);
assert_eq!(Guard::DeviceCode.code_type(), Some(3));
assert_eq!(Guard::EmailCode.code_type(), Some(2));
assert_eq!(Guard::DeviceConfirmation.code_type(), None, "an approval takes no code");
assert_eq!(Guard::from_wire(99), Guard::Other(99));
}
/// A Steam that wants a code from the authenticator, then issues tokens.
fn steam_with_guard(private: &RsaPrivateKey, n: String, e: String, seen: Arc<Mutex<Vec<Packet>>>) -> Script {
let _ = private;
let mut polls = 0;
Box::new(move |req| {
seen.lock().unwrap().push(req.clone());
let method = req.header.target_job_name.clone().unwrap_or_default();
let reply = match method.as_str() {
"Authentication.GetPasswordRSAPublicKey#1" => {
GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1790000005000) }.encode_to_vec()
}
"Authentication.BeginAuthSessionViaCredentials#1" => BeginAuthViaCredentialsResponse {
client_id: Some(1234),
request_id: Some(vec![9, 8, 7]),
interval: Some(0.5),
steamid: Some(76561197960287930),
allowed_confirmations: vec![
AllowedConfirmation { confirmation_type: Some(3), associated_message: None },
AllowedConfirmation { confirmation_type: Some(4), associated_message: Some("Steam app".into()) },
],
..Default::default()
}
.encode_to_vec(),
"Authentication.UpdateAuthSessionWithSteamGuardCode#1" => UpdateAuthWithGuardCodeResponse::default().encode_to_vec(),
"Authentication.PollAuthSessionStatus#1" => {
polls += 1;
if polls < 2 {
PollAuthSessionResponse::default().encode_to_vec()
} else {
PollAuthSessionResponse {
refresh_token: Some("the-refresh-token".into()),
access_token: Some("the-access-token".into()),
account_name: Some("someone".into()),
new_guard_data: Some("remember-me".into()),
..Default::default()
}
.encode_to_vec()
}
}
other => panic!("unexpected call {other}"),
};
Some(vec![answer(&req, 1, reply)])
})
}
#[tokio::test]
async fn signing_in_with_a_code_goes_through_every_step_in_order() {
let (private, n, e) = keypair();
let seen = Arc::new(Mutex::new(Vec::new()));
let url = serve(steam_with_guard(&private, n, e, seen.clone())).await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
let mut begun = begin(&link, "someone", "hunter2", Some("old-guard")).await.unwrap();
assert_eq!(begun.steamid, 76561197960287930);
assert_eq!(begun.interval, Duration::from_millis(500));
assert_eq!(begun.confirmations[0].guard, Guard::DeviceCode);
assert_eq!(begun.confirmations[1], Confirmation { guard: Guard::DeviceConfirmation, hint: Some("Steam app".into()) });
assert!(!begun.needs_nothing());
send_code(&link, &begun, &Guard::DeviceCode, " AB12C ").await.unwrap();
assert_eq!(poll(&link, &mut begun).await.unwrap(), None, "not yet");
let tokens = poll(&link, &mut begun).await.unwrap().expect("tokens now");
assert_eq!(tokens.refresh_token, "the-refresh-token");
assert_eq!(tokens.account_name.as_deref(), Some("someone"));
assert_eq!(tokens.guard_data.as_deref(), Some("remember-me"));
let seen = seen.lock().unwrap();
let methods: Vec<_> = seen.iter().map(|p| p.header.target_job_name.clone().unwrap()).collect();
assert_eq!(
methods,
[
"Authentication.GetPasswordRSAPublicKey#1",
"Authentication.BeginAuthSessionViaCredentials#1",
"Authentication.UpdateAuthSessionWithSteamGuardCode#1",
"Authentication.PollAuthSessionStatus#1",
"Authentication.PollAuthSessionStatus#1",
]
);
// None of it as the account: there is no account signed in yet.
assert!(seen.iter().all(|p| p.header.steamid.is_none()));
let begin_req = BeginAuthViaCredentialsRequest::decode(seen[1].body.as_slice()).unwrap();
assert_eq!(begin_req.account_name.as_deref(), Some("someone"));
assert_eq!(begin_req.encryption_timestamp, Some(1790000005000));
assert_eq!(begin_req.persistence, Some(1));
assert_eq!(begin_req.guard_data.as_deref(), Some("old-guard"));
assert_eq!(begin_req.platform_type, Some(1), "it signs in as a desktop client, like every other third-party client");
assert_eq!(begin_req.website_id.as_deref(), Some("Client"));
let details = begin_req.device_details.unwrap();
assert_eq!((details.platform_type, details.app_type, details.gaming_device_type), (Some(1), None, Some(1)));
assert_eq!(details.machine_id.as_deref(), Some(machine_id().as_slice()), "and with its install's machine id");
assert!(!begin_req.encrypted_password.unwrap().contains("hunter2"));
let code_req = UpdateAuthWithGuardCodeRequest::decode(seen[2].body.as_slice()).unwrap();
assert_eq!((code_req.client_id, code_req.steamid), (Some(1234), Some(76561197960287930)));
assert_eq!(code_req.code.as_deref(), Some("AB12C"), "spaces around a pasted code are dropped");
assert_eq!(code_req.code_type, Some(3));
let poll_req = PollAuthSessionRequest::decode(seen[3].body.as_slice()).unwrap();
assert_eq!((poll_req.client_id, poll_req.request_id), (Some(1234), Some(vec![9, 8, 7])));
}
#[tokio::test]
async fn a_wrong_password_says_so_and_a_code_where_none_is_wanted_is_refused() {
let (_private, n, e) = keypair();
let url = serve(Box::new(move |req| {
let method = req.header.target_job_name.clone().unwrap_or_default();
Some(vec![if method.contains("GetPasswordRSAPublicKey") {
answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1) }.encode_to_vec())
} else {
answer(&req, 5, Vec::new())
}])
}))
.await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
let err = begin(&link, "someone", "wrong", None).await.unwrap_err();
assert!(matches!(err, CallError::Refused(5, _)), "{err:?}");
assert!(err.to_string().contains("password"));
let begun = Begun { client_id: 1, request_id: vec![], interval: Duration::from_secs(1), steamid: 1, confirmations: vec![] };
assert!(send_code(&link, &begun, &Guard::DeviceConfirmation, "123").await.is_err(), "an approval takes no code");
}
#[tokio::test]
async fn an_account_without_steam_guard_needs_nothing_more() {
let (_private, n, e) = keypair();
let url = serve(Box::new(move |req| {
let method = req.header.target_job_name.clone().unwrap_or_default();
Some(vec![if method.contains("GetPasswordRSAPublicKey") {
answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1) }.encode_to_vec())
} else {
answer(
&req,
1,
BeginAuthViaCredentialsResponse {
client_id: Some(1),
request_id: Some(vec![1]),
interval: Some(1.0),
steamid: Some(2),
allowed_confirmations: vec![AllowedConfirmation { confirmation_type: Some(1), associated_message: None }],
..Default::default()
}
.encode_to_vec(),
)
}])
}))
.await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
assert!(begin(&link, "someone", "x", None).await.unwrap().needs_nothing());
}
#[test]
fn the_polling_interval_is_kept_to_something_sensible() {
assert_eq!(poll_interval(Some(0.0)), Duration::from_millis(500));
assert_eq!(poll_interval(Some(3600.0)), Duration::from_secs(30));
assert_eq!(poll_interval(Some(2.0)), Duration::from_secs(2));
assert_eq!(poll_interval(None), Duration::from_secs(5));
assert_eq!(poll_interval(Some(f32::NAN)), Duration::from_secs(5), "not a number is not an answer");
assert_eq!(poll_interval(Some(-3.0)), Duration::from_millis(500));
}
#[test]
fn the_session_is_named_for_the_machine_it_is_on() {
assert!(device_name().starts_with("moho"));
assert_eq!(os_type(), if cfg!(windows) { 16 } else { 4294967093 });
}
#[test]
fn a_token_says_whose_it_is_in_its_subject() {
use base64::Engine;
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"iss":"steam","sub":"76561197960287930","aud":["mobile"]}"#);
assert_eq!(steamid_of_token(&format!("eyJhbGciOiJFZERTQSJ9.{payload}.signature")), Some(76561197960287930));
assert_eq!(steamid_of_token("not a token"), None);
assert_eq!(steamid_of_token("a.b.c"), None);
}
#[test]
fn the_machine_id_is_a_key_value_object_of_three_hashes_that_is_the_same_for_one_install_and_not_for_another() {
let one = machine_id_from(&[1u8; 16]);
assert_eq!(one, machine_id_from(&[1u8; 16]));
assert_ne!(one, machine_id_from(&[2u8; 16]));
// 0x00 "MessageObject" 0x00, then three strings named BB3, FF2 and 3B3 of forty hex digits, then 0x08 0x08.
assert!(one.starts_with(b"\0MessageObject\0\x01BB3\0"));
assert!(one.ends_with(&[0, 8, 8]));
let text = String::from_utf8_lossy(&one).to_string();
for name in ["BB3", "FF2", "3B3"] {
assert!(text.contains(name));
}
assert_eq!(one.len(), 1 + 14 + 3 * (1 + 3 + 1 + 40 + 1) + 2);
assert_ne!(logon_id_from(&[1u8; 16]), 0);
assert_ne!(logon_id_from(&[1u8; 16]), logon_id_from(&[2u8; 16]));
// Never the same as the zero a client with no id of its own sends.
assert_ne!(logon_id_from(&[0xFF; 16]), 0);
}
}
+468
View File
@@ -0,0 +1,468 @@
//! Steam's BBCode, read into the markup the rest of moho already draws.
//!
//! A message on Steam is text with tags in it: `[b]bold[/b]`, `[url=...]`,
//! `[emoticon]steamhappy[/emoticon]`. The window has no BBCode reader for
//! Steam and does not need one: it draws Discord's markdown, spoilers and
//! quotes, and a handful of stand-ins for pictures. So what is stored is the
//! message as that dialect - `**bold**`, `[text](url)`, `[steam:emote:steamhappy]`
//! - and everything after this point treats it like any other body.
//!
//! The text is the sender's and is never trusted to be well formed. An
//! unknown tag is text; a tag that is never closed is text; a close with
//! nothing open is text. Nothing here produces markup the window does not
//! already treat as untrusted, and nothing is lost for being misread - what is
//! unreadable is shown as it was written.
use crate::model::Embed;
/// A bracket that is only a bracket: followed by a zero-width space, which is
/// what stops anything downstream taking `[b]` for formatting.
pub const LITERAL_BRACKET: &str = "[\u{200b}";
/// What a tag becomes, given what was inside it already read. A card Steam
/// describes goes in `cards` as well as being words.
fn render(tag: &str, attr: Option<&str>, inner: &str, cards: &mut Vec<Embed>) -> String {
let wrapped = |mark: &str| if inner.trim().is_empty() { inner.to_string() } else { format!("{mark}{inner}{mark}") };
match tag {
"b" | "h1" | "h2" | "h3" | "h4" | "h5" => wrapped("**"),
"i" => wrapped("*"),
// The window reads `[u]` itself; Markdown's `__` it draws as bold.
"u" if inner.trim().is_empty() => inner.to_string(),
"u" => format!("[u]{inner}[/u]"),
"strike" | "s" => wrapped("~~"),
"spoiler" => wrapped("||"),
"quote" | "pullquote" => {
let quoted: Vec<String> = inner.trim().lines().map(|l| format!("> {l}")).collect();
format!("\n{}\n", quoted.join("\n"))
}
// Steam writes extra attributes after the address (`fromclient=1`), which
// are not part of it.
"url" => match attr.and_then(|a| a.split_whitespace().next()).filter(|u| is_web(u)) {
Some(url) if inner.trim().is_empty() || inner.trim() == url => url.to_string(),
Some(url) => format!("[{}]({url})", inner.replace(']', "\\]")),
// `[url]address[/url]`: the address is what was written.
None => inner.to_string(),
},
"emoticon" => picture("emote", inner).unwrap_or_else(|| inner.to_string()),
// Named by its `type`, with nothing inside: `[sticker type="Nyan GLaDOS"
// limit="0"][/sticker]`. A name inside is read too.
"sticker" => {
let named = attr.and_then(|a| attribute(a, "type")).filter(|n| !n.is_empty()).unwrap_or(inner.trim());
if super::emoticons::valid_sticker(named) { format!("[steam:sticker:{named}]") } else { inner.to_string() }
}
// A web page Steam's client has looked at and is describing: `[og url="..."
// title="..." desc="..."]words[/og]`. The words are a link; the description
// is a card, drawn as any other link's is.
"og" => match attr.and_then(|a| attribute(a, "url")).filter(|u| is_web(u)) {
Some(url) => {
let field = |name: &str| attr.and_then(|a| attribute(a, name)).map(str::trim).filter(|v| !v.is_empty()).map(str::to_string);
let image_url = field("img").filter(|i| is_web(i));
cards.push(Embed { url: Some(url.to_string()), title: field("title"), description: field("desc"), provider: host_of(url), image_url, ..Default::default() });
let words = inner.trim();
if words.is_empty() || words == url { url.to_string() } else { format!("[{}]({url})", words.replace(']', "\\]")) }
}
None => inner.to_string(),
},
// What Steam writes for a file sent through its website: the address
// as an attribute, and again as a link inside, which is not read. A
// marker the window draws as the picture or video it names.
"img" | "video" | "looping_media" if attr.and_then(|a| attribute(a, "src")).is_some_and(is_web) => {
let kind = if tag == "img" { "image" } else { "video" };
format!("\n[steam:{kind}:{}]\n", attr.and_then(|a| attribute(a, "src")).unwrap_or_default())
}
// A picture is its address, which is what the link previews read.
"img" | "docimg" => format!("\n{}\n", inner.trim()),
"p" => format!("{inner}\n"),
// Tags that only lay out or colour: what is inside is the message.
"center" | "c" | "color" | "bgcolor" | "size" | "smalltext" | "list" | "olist" | "dynamiclink" | "doclink" | "expand" | "table" | "tr" | "td" | "th" => inner.to_string(),
// A person named in a group: the words are the name, with its @.
"mention" => inner.to_string(),
// A friend's invitation to a group chat: `[invite chatgroupid="7" chatgroupname="Raiders"]`.
// Said in words; what it offers is answered on the Invitations tile.
"invite" => {
let name = attr.and_then(|a| attribute(a, "chatgroupname")).unwrap_or_default().replace(['[', ']'], "");
if name.trim().is_empty() { "Group chat invitation".to_string() } else { format!("Group chat invitation: {}", name.trim()) }
}
// What Steam's own servers wrap a store address in (`[steamstore app="220"]`):
// the address is the message, and the window makes a card of it as of any link.
"steamstore" => inner.to_string(),
// Effects are an animation over the window, with nothing to say.
"roomeffect" => String::new(),
_ => inner.to_string(),
}
}
/// The site an address is on, without a leading `www.`: what a card says it is from.
fn host_of(url: &str) -> Option<String> {
let rest = url.split_once("://")?.1;
let host = rest.split(['/', '?', '#']).next()?.rsplit('@').next()?.split(':').next()?;
let host = host.strip_prefix("www.").unwrap_or(host);
(!host.is_empty()).then(|| host.to_string())
}
/// Every tag that is read; anything else stays as the text it was typed as.
const KNOWN: &[&str] = &[
"b", "i", "u", "strike", "s", "spoiler", "h1", "h2", "h3", "h4", "h5", "quote", "pullquote", "url", "emoticon", "sticker", "img", "docimg", "p", "center", "c", "color", "bgcolor", "size",
"video", "looping_media", "og", "smalltext", "list", "olist", "dynamiclink", "doclink", "expand", "table", "tr", "td", "th", "roomeffect", "code", "noparse", "hr", "*", "mention", "steamstore", "invite",
];
/// Whose contents are not read: code is code, and noparse says so.
const RAW: &[&str] = &["code", "noparse"];
/// An attribute written as `name="value"`, `name=value`, or among others
/// (`src="..." width="..."`): the value, unquoted.
pub(super) fn attribute<'a>(attrs: &'a str, name: &str) -> Option<&'a str> {
let lower = attrs.to_ascii_lowercase();
let mut from = 0;
while let Some(at) = lower[from..].find(&format!("{name}=")) {
let start = from + at;
let boundary = start == 0 || lower.as_bytes()[start - 1].is_ascii_whitespace();
let value = &attrs[start + name.len() + 1..];
if boundary {
return Some(match value.strip_prefix('"') {
Some(quoted) => quoted.split('"').next().unwrap_or_default(),
None => value.split_whitespace().next().unwrap_or_default(),
});
}
from = start + 1;
}
None
}
fn is_web(url: &str) -> bool {
let lower = url.to_ascii_lowercase();
(lower.starts_with("http://") || lower.starts_with("https://")) && !url.contains(char::is_whitespace)
}
/// The stand-in for a picture: an emoticon or a sticker by name. A name is
/// letters, digits, underscore and hyphen, with the colons somebody typed
/// dropped; anything else is not a picture's name.
fn picture(kind: &str, name: &str) -> Option<String> {
let name = name.trim().trim_matches(':');
(!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')).then(|| format!("[steam:{kind}:{name}]"))
}
/// A tag read at the start of `s`: its name, its `=value` or attributes, whether it closes, and how
/// much text it took.
struct Tag<'a> {
name: String,
attr: Option<&'a str>,
closing: bool,
len: usize,
}
fn read_tag(s: &str) -> Option<Tag<'_>> {
let rest = s.strip_prefix('[')?;
let end = rest.find(']')?;
// Long enough for a picture's tag, which carries its address twice and
// its size.
if end > 1000 {
return None;
}
let inside = &rest[..end];
let (closing, inside) = match inside.strip_prefix('/') {
Some(i) => (true, i),
None => (false, inside),
};
let name_len = inside.find(|c: char| !(c.is_ascii_alphanumeric() || c == '*')).unwrap_or(inside.len());
let name = inside[..name_len].to_ascii_lowercase();
if name.is_empty() || !KNOWN.contains(&name.as_str()) {
return None;
}
let after = &inside[name_len..];
let attr = match after.chars().next() {
None => None,
Some('=') => Some(after[1..].trim_matches('"')),
// `[sticker type="x" limit="1"]`: attributes, which are not read.
Some(c) if c.is_whitespace() => Some(after.trim()),
Some(_) => return None,
};
Some(Tag { name, attr, closing, len: end + 2 })
}
/// The text of a message as moho draws it, and the cards Steam described in it.
pub struct Rendered {
pub text: String,
pub cards: Vec<Embed>,
}
/// The text of a message as moho draws it.
pub fn to_markup(bbcode: &str) -> String {
render_message(bbcode).text
}
/// A message read into what moho draws, with its cards.
pub fn render_message(bbcode: &str) -> Rendered {
let mut cards = Vec::new();
let text = convert(bbcode, &mut cards);
Rendered { text, cards }
}
fn convert(bbcode: &str, cards: &mut Vec<Embed>) -> String {
struct Open {
tag: String,
attr: Option<String>,
literal: String,
out: String,
}
let mut stack: Vec<Open> = Vec::new();
let mut top = String::new();
let mut i = 0;
let bytes = bbcode;
macro_rules! out {
() => {
stack.last_mut().map(|f| &mut f.out).unwrap_or(&mut top)
};
}
while i < bytes.len() {
let rest = &bytes[i..];
// `\[` is a bracket somebody typed, which Steam escapes so it is not a tag.
// Drawn as a bracket with nothing visible after it, so that the window,
// which reads `[b]` for itself, does not read it as a tag either.
if let Some(r) = rest.strip_prefix("\\[") {
out!().push_str(LITERAL_BRACKET);
i = bytes.len() - r.len();
continue;
}
if rest.starts_with('[') {
if let Some(tag) = read_tag(rest) {
if !tag.closing && RAW.contains(&tag.name.as_str()) {
// Everything up to the matching close, unread.
let lower = rest.to_ascii_lowercase();
let close = format!("[/{}]", tag.name);
if let Some(at) = lower[tag.len..].find(&close) {
let inner = &rest[tag.len..tag.len + at];
let drawn = if tag.name == "code" {
code(inner)
} else {
inner.to_string()
};
out!().push_str(&drawn);
i += tag.len + at + close.len();
continue;
}
} else if !tag.closing && tag.name == "hr" {
out!().push('\n');
i += tag.len;
continue;
} else if !tag.closing && tag.name == "*" {
out!().push_str("\n• ");
i += tag.len;
continue;
} else if !tag.closing && !RAW.contains(&tag.name.as_str()) {
stack.push(Open { tag: tag.name, attr: tag.attr.map(str::to_string), literal: rest[..tag.len].to_string(), out: String::new() });
i += tag.len;
continue;
} else if tag.closing {
if let Some(pos) = stack.iter().rposition(|f| f.tag == tag.name) {
// Whatever was left open inside it is closed with it:
// shown as the tag text it was, with what it held.
while stack.len() > pos + 1 {
let f = stack.pop().unwrap();
let text = format!("{}{}", f.literal, f.out);
out!().push_str(&text);
}
let f = stack.pop().unwrap();
let drawn = render(&f.tag, f.attr.as_deref(), &f.out, cards);
out!().push_str(&drawn);
i += tag.len;
continue;
}
}
}
}
let c = rest.chars().next().unwrap();
out!().push(c);
i += c.len_utf8();
}
// Opened and never closed: the tag as typed, then what followed it.
while let Some(f) = stack.pop() {
let text = format!("{}{}", f.literal, f.out);
out!().push_str(&text);
}
top.trim().to_string()
}
/// The longest run of one character in some text.
fn longest_run(text: &str, of: char) -> usize {
text.split(|c| c != of).map(str::len).max().unwrap_or(0)
}
/// Code, as the backticks the window reads: inline for a line, a block for
/// several, and a fence longer than anything inside it so it cannot end early.
fn code(inner: &str) -> String {
let longest = longest_run(inner, '`');
if inner.contains('\n') {
let fence = "`".repeat(longest.max(2) + 1);
format!("\n{fence}\n{}\n{fence}\n", inner.trim_matches('\n'))
} else {
let fence = "`".repeat(longest + 1);
let pad = if inner.starts_with('`') || inner.ends_with('`') { " " } else { "" };
format!("{fence}{pad}{inner}{pad}{fence}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_inline_formats_become_the_marks_the_window_draws() {
assert_eq!(to_markup("[b]bold[/b] and [i]italic[/i] and [u]under[/u] and [strike]struck[/strike]"), "**bold** and *italic* and [u]under[/u] and ~~struck~~");
assert_eq!(to_markup("a [spoiler]secret[/spoiler] b"), "a ||secret|| b");
assert_eq!(to_markup("[B]shouted[/B]"), "**shouted**", "tags are not case sensitive");
assert_eq!(to_markup("[h1]Title[/h1]"), "**Title**");
}
#[test]
fn they_nest_and_an_empty_one_says_nothing() {
assert_eq!(to_markup("[b][i]both[/i][/b]"), "***both***");
assert_eq!(to_markup("[b][/b]x"), "x");
assert_eq!(to_markup("a[b] [/b]b"), "a b");
}
#[test]
fn links_are_markdown_links_or_the_address_itself() {
assert_eq!(to_markup("[url=https://example.com/a?b=1]the site[/url]"), "[the site](https://example.com/a?b=1)");
assert_eq!(to_markup("see [url]https://example.com[/url] now"), "see https://example.com now");
assert_eq!(to_markup("[url=https://example.com][/url]"), "https://example.com");
// Not a web address, so not a link: what was said is shown.
assert_eq!(to_markup("[url=javascript:alert(1)]click[/url]"), "click");
// Steam's own writing of a link, with its extra attribute; the address is the first word.
assert_eq!(to_markup("[url=https://example.com/x fromclient=1]a link[/url]"), "[a link](https://example.com/x)");
assert_eq!(to_markup("see [url=https://example.com/x fromclient=1]https://example.com/x[/url]!"), "see https://example.com/x!", "a link that is its own label is just the address");
assert_eq!(to_markup("[url=\"https://example.com\"]quoted[/url]"), "[quoted](https://example.com)");
assert_eq!(to_markup("[url=https://e.com]a ] b[/url]"), "[a \\] b](https://e.com)", "a bracket in the label cannot end it early");
}
#[test]
fn a_group_invitation_is_said_in_words() {
assert_eq!(to_markup("[invite chatgroupid=\"78995476\" chatgroupname=\"moho official test\"][/invite]"), "Group chat invitation: moho official test");
assert_eq!(to_markup("[invite chatgroupid=\"7\"][/invite]"), "Group chat invitation");
}
#[test]
fn a_store_address_wrapped_by_steam_is_the_address() {
assert_eq!(to_markup("see [steamstore app=\"220\"]https://store.steampowered.com/app/220/[/steamstore]"), "see https://store.steampowered.com/app/220/");
}
#[test]
fn emoticons_and_stickers_are_named_pictures() {
assert_eq!(to_markup("hi [emoticon]steamhappy[/emoticon]!"), "hi [steam:emote:steamhappy]!");
assert_eq!(to_markup("[emoticon]:steamhappy:[/emoticon]"), "[steam:emote:steamhappy]", "colons are not part of the name");
assert_eq!(to_markup("[sticker type=\"Nyan GLaDOS\" limit=\"0\"][/sticker]"), "[steam:sticker:Nyan GLaDOS]", "as Steam writes one: named by its type");
assert_eq!(to_markup("[sticker type=\"Keyboard Headcrab\" limit=\"0\"][/sticker]"), "[steam:sticker:Keyboard Headcrab]");
assert_eq!(to_markup("a [sticker type=\"Cats\" limit=\"1\"]CatSurprise[/sticker] b"), "a [steam:sticker:Cats] b", "the type is the name");
assert_eq!(to_markup("[sticker][/sticker]"), "", "no name, no sticker");
assert_eq!(to_markup("[sticker]CatSurprise[/sticker]"), "[steam:sticker:CatSurprise]", "or the name inside");
// Anything that is not a name is not a picture, and is shown as it was.
assert_eq!(to_markup("[emoticon]a b[/emoticon]"), "a b");
assert_eq!(to_markup("[emoticon]x]y[/emoticon]"), "x]y");
assert_eq!(to_markup("[emoticon][/emoticon]"), "");
}
#[test]
fn quotes_are_lines_of_their_own() {
assert_eq!(to_markup("before [quote]one\ntwo[/quote] after"), "before \n> one\n> two\n after");
assert_eq!(to_markup("[quote=somebody]said this[/quote]"), "> said this");
}
#[test]
fn code_and_noparse_are_not_read() {
assert_eq!(to_markup("a [code][b]not bold[/b][/code] b"), "a `[b]not bold[/b]` b");
assert_eq!(to_markup("[noparse][b]literally[/b][/noparse]"), "[b]literally[/b]");
assert_eq!(to_markup("[code]line one\nline two[/code]"), "```\nline one\nline two\n```");
// A backtick inside code cannot end it: the fence is longer than any run.
assert_eq!(to_markup("[code]a `tick` b[/code]"), "``a `tick` b``");
// An unclosed code is text, and what follows is still read.
assert_eq!(to_markup("[code]open [b]x[/b]"), "[code]open **x**");
}
#[test]
fn what_cannot_be_read_is_shown_as_it_was_written() {
assert_eq!(to_markup("[nosuchtag]x[/nosuchtag]"), "[nosuchtag]x[/nosuchtag]");
assert_eq!(to_markup("[b]never closed"), "[b]never closed");
assert_eq!(to_markup("closed [/b] with nothing open"), "closed [/b] with nothing open");
assert_eq!(to_markup("[b]wrong [i]order[/b] here[/i]"), "**wrong [i]order** here[/i]", "closing an outer tag closes what was left open inside it");
assert_eq!(to_markup("a [ lone bracket and [b"), "a [ lone bracket and [b");
assert_eq!(to_markup(""), "");
}
#[test]
fn an_escaped_bracket_is_a_bracket_somebody_typed() {
assert_eq!(to_markup("not \\[b]bold\\[/b]"), "not [\u{200b}b]bold[\u{200b}/b]", "a bracket the window will not take for a tag");
assert_eq!(to_markup("\\[emoticon]x\\[/emoticon]"), "[\u{200b}emoticon]x[\u{200b}/emoticon]");
assert_eq!(to_markup("\\[steam:emote:x]"), "[\u{200b}steam:emote:x]", "nor for a picture");
}
#[test]
fn lists_effects_and_layout_do_not_get_in_the_way() {
assert_eq!(to_markup("[list][*]one[*]two[/list]"), "• one\n• two");
assert_eq!(to_markup("[color=red]coloured[/color] [size=5]big[/size]"), "coloured big");
assert_eq!(to_markup("[roomeffect]fireworks[/roomeffect]hello"), "hello");
assert_eq!(to_markup("a[hr]b"), "a\nb");
assert_eq!(to_markup("[img]https://example.com/a.png[/img]"), "https://example.com/a.png");
let ugc = "https://images.steamusercontent.com/ugc/1269778906054997137/522C3130A7C6AE1FC52CB49820609316A34ECD01/";
let video = "https://cdn.steamusercontent.com/ugc/9279387166147417773/0642C6E9889E1C2419DD7F85743538C67DD8CA36/";
// As Steam writes them: the address unquoted, long, and again inside as a link.
let picture = format!("[img src={ugc} thumbnail_src={ugc} srcset=\"\" width=479 height=450][url={ugc}]{ugc}[/url][/img]");
assert_eq!(to_markup(&picture), format!("[steam:image:{ugc}]"));
let clip = format!("[video src={video} type=video/webm steamvideo=true][url={video}]{video}[/url][/video]");
assert_eq!(to_markup(&clip), format!("[steam:video:{video}]"));
assert_eq!(to_markup(&format!("[spoiler]{clip}[/spoiler]")), format!("||\n[steam:video:{video}]\n||"), "hidden, as the sender chose");
assert_eq!(to_markup(&format!("look {picture} nice")), format!("look \n[steam:image:{ugc}]\n nice"));
assert_eq!(to_markup("[img src=\"https://x.test/a.png\" width=\"9\"][/img]"), "[steam:image:https://x.test/a.png]", "quoted too");
}
#[test]
fn plain_text_passes_through_untouched_including_what_is_not_ascii() {
assert_eq!(to_markup("héllo wörld 日本語 😀 [not a tag"), "héllo wörld 日本語 😀 [not a tag");
assert_eq!(to_markup("2 < 3 && <b>html</b> is text"), "2 < 3 && <b>html</b> is text");
}
#[test]
fn a_hostile_message_cannot_loop_or_blow_the_stack() {
let nested = format!("{}x{}", "[b]".repeat(5000), "[/b]".repeat(5000));
let drawn = to_markup(&nested);
assert!(drawn.contains('x'));
let unclosed = "[b][i][u]".repeat(2000);
let _ = to_markup(&unclosed);
let brackets = format!("{}{}", "[".repeat(10000), "]".repeat(10000));
assert_eq!(to_markup(&brackets).len(), 20000);
}
#[test]
fn a_page_steam_describes_is_words_and_a_card() {
// As the official client sends one.
let sent = "[og url=\"http://x.salastil.com\" title=\"Home\" desc=\"A privacy-focused Twitter/X frontend\"]x.salastil.com[/og]";
let rendered = render_message(sent);
assert_eq!(rendered.text, "[x.salastil.com](http://x.salastil.com)", "a link, on the words that were shown");
assert_eq!(rendered.cards.len(), 1);
let card = &rendered.cards[0];
assert_eq!(
(card.url.as_deref(), card.title.as_deref(), card.description.as_deref(), card.provider.as_deref()),
(Some("http://x.salastil.com"), Some("Home"), Some("A privacy-focused Twitter/X frontend"), Some("x.salastil.com"))
);
// The words, with others around them; a repeat of the address; no description.
let more = render_message("see [og url=\"https://www.example.com/a?b=1\" title=\"T\"]https://www.example.com/a?b=1[/og] ok");
assert_eq!(more.text, "see https://www.example.com/a?b=1 ok");
assert_eq!((more.cards[0].provider.as_deref(), more.cards[0].description.as_deref()), (Some("example.com"), None), "no leading www, and no description invented");
// A picture on the card, where the page has one.
let pictured = render_message("[og url=\"https://x.test/p\" img=\"https://community.steamstatic.com/chat/image/abc/share_image\" title=\"T\" desc=\"D\"]https://x.test/p[/og]");
assert_eq!(pictured.cards[0].image_url.as_deref(), Some("https://community.steamstatic.com/chat/image/abc/share_image"));
assert_eq!(pictured.text, "https://x.test/p");
// Not a web address: nothing to make a card of.
assert!(render_message("[og url=\"javascript:alert(1)\" title=\"x\"]words[/og]").cards.is_empty());
assert_eq!(to_markup("[og title=\"x\"]just words[/og]"), "just words");
assert!(render_message("plain https://example.com").cards.is_empty());
}
#[test]
fn a_link_keeps_its_address_and_its_words() {
assert_eq!(to_markup("[url=http://salastil.com]salastil.com[/url]"), "[salastil.com](http://salastil.com)");
assert_eq!(to_markup("[url=https://salastil.com]https://salastil.com[/url]"), "https://salastil.com");
assert_eq!(to_markup("[url=https://example.com/a fromclient=1]look[/url]"), "[look](https://example.com/a)");
}
}
+89
View File
@@ -0,0 +1,89 @@
//! The people an account has blocked.
//!
//! A blocked person is not a friend and has no conversation, so there is nowhere
//! in the conversation list to lift a block from. They are listed on the page
//! where friends are added, by name once Steam has said it, and unblocked there.
use super::friends::Roster;
use super::steamid;
use crate::state::AppState;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
static HELD: LazyLock<Mutex<HashMap<String, Value>>> = LazyLock::new(Default::default);
/// Who is blocked, as the window reads it: the 64-bit id as text and a name.
pub fn snapshot(roster: &Roster) -> Value {
Value::Array(
roster
.blocked()
.into_iter()
.map(|a| json!({ "steamid": steamid::text_of(a), "name": roster.persona(a).and_then(|p| p.name.clone()).unwrap_or_else(|| steamid::text_of(a)) }))
.collect(),
)
}
pub fn held(account_id: &str) -> Value {
HELD.lock().unwrap().get(account_id).cloned().unwrap_or_else(|| json!([]))
}
pub fn clear(account_id: &str) {
HELD.lock().unwrap().remove(account_id);
}
/// Tells the window who is blocked, when that is different from last time.
pub fn publish(state: &AppState, account_id: &str, roster: &Roster) {
let now = snapshot(roster);
{
let mut all = HELD.lock().unwrap();
if all.get(account_id) == Some(&now) || (now == json!([]) && !all.contains_key(account_id)) {
return;
}
all.insert(account_id.to_string(), now.clone());
}
state.events.emit("steamBlocked", json!({ "accountId": account_id, "blocked": now }));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::steam::proto::*;
#[test]
fn the_blocked_are_listed_by_name_once_it_is_known_and_by_number_before() {
let mut roster = Roster::default();
roster.apply_friends_list(&ClientFriendsList {
friends: vec![
FriendsListEntry { ulfriendid: Some(steamid::steamid_of(100)), efriendrelationship: Some(1) },
FriendsListEntry { ulfriendid: Some(steamid::steamid_of(200)), efriendrelationship: Some(3) },
FriendsListEntry { ulfriendid: Some(steamid::steamid_of(300)), efriendrelationship: Some(6) },
],
..Default::default()
});
assert_eq!(snapshot(&roster), json!([{ "steamid": steamid::text_of(100), "name": steamid::text_of(100) }, { "steamid": steamid::text_of(300), "name": steamid::text_of(300) }]));
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(100)), player_name: Some("Spammer".into()), ..Default::default() }], ..Default::default() });
assert_eq!(snapshot(&roster)[0]["name"], "Spammer");
}
#[test]
fn it_is_told_once_and_again_only_when_it_changes() {
let state = crate::backend::kick::testkit::simulated_daemon("steam-blocked");
let mut rx = state.events.subscribe();
let mut roster = Roster::default();
publish(&state, "steam:blocked-test", &roster);
assert!(rx.try_recv().is_err(), "nobody blocked, nothing to say");
roster.apply_friends_list(&ClientFriendsList { friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(100)), efriendrelationship: Some(1) }], ..Default::default() });
publish(&state, "steam:blocked-test", &roster);
publish(&state, "steam:blocked-test", &roster);
let mut told = 0;
while let Ok(e) = rx.try_recv() {
if e.name == "steamBlocked" {
told += 1;
}
}
assert_eq!(told, 1);
assert_eq!(held("steam:blocked-test").as_array().map(Vec::len), Some(1));
clear("steam:blocked-test");
}
}
+601
View File
@@ -0,0 +1,601 @@
//! Direct messages: reading what arrives, and sending.
//!
//! A message to a friend is a call to Steam's `FriendMessages` service, and a
//! message from one arrives as a notification from it. Steam stamps each with
//! the second it was sent and an ordinal that orders messages sharing a
//! second, and the pair is the message's identity here: the same message seen
//! twice - once as the answer to our own send, once as the echo to another of
//! our sessions, or replayed after a reconnect - is stored once.
//!
//! Text is shown as Steam holds it with its formatting codes taken off (it
//! sends both). Drawing the formatting itself is a later piece of work; until
//! then a link keeps its text and loses its address.
use super::eresult;
use super::friends::Roster;
use super::link::{CallError, Link};
use super::proto::*;
use super::publish::Conversations;
use super::steamid;
use crate::state::AppState;
/// `EChatEntryType`: an ordinary message. Typing, invitations and the other
/// kinds share the channel and are not messages.
pub const CHAT_MESSAGE: i32 = 1;
/// How long a message may be. Steam's own limit is 2,000 characters per
/// message in its client; longer is refused by the server with an error that
/// says nothing of why, so it is said here first.
pub const MAX_LENGTH: usize = 2000;
/// What a message is called here: when Steam stamped it, and where it came
/// among those stamped the same second.
pub fn message_id(timestamp: u32, ordinal: u32) -> String {
format!("{timestamp}:{ordinal}")
}
/// A message read out of a notification.
#[derive(Debug, PartialEq)]
pub struct Incoming {
/// The other person in the conversation.
pub account: u32,
pub text: String,
pub timestamp: u32,
pub id: String,
/// Sent by us, from another of our sessions.
pub from_us: bool,
/// The web pages Steam's client described in it, which are cards.
pub embeds: Vec<crate::model::Embed>,
/// An invitation to a group chat, if that is what it is.
pub invite: Option<(u64, Option<u64>, String)>,
}
/// Reads a notification, or says it is not a message to show.
pub fn read_incoming(n: &FriendMessagesIncoming) -> Option<Incoming> {
if n.chat_entry_type != Some(CHAT_MESSAGE) {
return None;
}
let account = n.steamid_friend.and_then(steamid::account_of)?;
// The message as Steam formatted it, read into what moho draws; the plain
// copy only if that leaves nothing, which is a message made of tags alone.
let rendered = super::bbcode::render_message(n.message.as_deref().unwrap_or_default());
let embeds = rendered.cards;
let text = Some(rendered.text)
.filter(|t| !t.trim().is_empty())
.or_else(|| n.message_no_bbcode.as_deref().map(|t| t.trim_end().to_string()))
.unwrap_or_default();
if text.trim().is_empty() {
return None;
}
let timestamp = n.rtime32_server_timestamp.unwrap_or_default();
let invite = super::chatgroups::invite_in(n.message.as_deref().unwrap_or_default());
Some(Incoming { account, text, timestamp, id: message_id(timestamp, n.ordinal.unwrap_or_default()), from_us: n.local_echo.unwrap_or(false), embeds, invite })
}
/// Who is typing to us, from a notification that says so.
pub fn read_typing(n: &FriendMessagesIncoming) -> Option<u32> {
(n.chat_entry_type == Some(TYPING) && n.local_echo != Some(true)).then_some(n.steamid_friend).flatten().and_then(steamid::account_of)
}
/// Puts a message in the conversation it belongs to, starting the
/// conversation if this is the first word from somebody.
///
/// Returns whether anything was stored, and whether the person is new to the
/// roster and so unknown - the caller asks Steam who they are.
pub fn record_incoming(state: &AppState, account_id: &str, roster: &mut Roster, shown: &mut Conversations, own_name: &str, incoming: &Incoming) -> (bool, bool) {
let stranger = roster.note_conversation(incoming.account) || (roster.persona(incoming.account).is_none() && !roster.is_friend(incoming.account));
shown.publish(state, roster);
let Some(conversation) = shown.name_of(incoming.account).map(str::to_string) else { return (false, stranger) };
let sender = if incoming.from_us {
own_name.to_string()
} else {
roster.persona(incoming.account).and_then(|p| p.name.clone()).unwrap_or_else(|| conversation.clone())
};
let stored = state.runtime.record_message_at(
state,
account_id,
&conversation,
"dm",
&sender,
// Read through the account's text filter, if it has one on: as Steam's own client does.
&(if incoming.from_us { incoming.text.clone() } else { super::account::filtered(account_id, roster.is_friend(incoming.account), &incoming.text) }),
false,
"chat",
None,
Some(incoming.id.clone()),
false,
roster.persona(incoming.account).filter(|_| !incoming.from_us).and_then(|p| p.avatar.clone()),
incoming.embeds.clone(),
Vec::new(),
(!incoming.from_us).then(|| steamid::text_of(incoming.account)),
Some(i64::from(incoming.timestamp)).filter(|t| *t > 0),
None,
None,
);
// What it offers is waiting on an answer, if it came from somebody else.
if stored && !incoming.from_us {
if let Some(found) = incoming.invite.clone() {
super::chatgroups::note_invite(state, account_id, &sender, found);
}
}
(stored, stranger)
}
/// Why a message could not be sent, in words for a person.
pub fn refusal(error: &CallError) -> String {
match error {
CallError::Refused(code, _) => eresult::describe(*code),
other => other.to_string(),
}
}
/// How long the Steam client keeps trying to send one message before it gives up on it.
const SEND_FOR: std::time::Duration = std::time::Duration::from_secs(60);
/// Whether a failure to send may be gone a moment later: a server that did not answer, was busy or
/// had a problem of its own - and not one that is about the account, the person or the room.
fn passing(error: &CallError) -> bool {
match error {
CallError::TimedOut => true,
CallError::Refused(code, _) => matches!(*code, eresult::FAIL | eresult::BUSY | eresult::TIMEOUT | eresult::SERVICE_UNAVAILABLE | 32 | 33 | 35 | 36 | 37 | 55),
CallError::Closed | CallError::Unreadable(_) => false,
}
}
/// Sends a message with the Steam client's own patience: a passing failure is tried again after one
/// second, then two, then four, for up to a minute; being refused for good is said at once, in the
/// words the client uses - not a friend (or not allowed here) and sending too fast.
///
/// `unsafe_to_repeat` is for a send that has no id of its own to stop Steam taking it twice: it is
/// repeated only when Steam said it failed, never when it merely did not answer.
pub async fn send_with_patience<Q: prost::Message, A: prost::Message + Default>(link: &Link, method: &str, request: &Q, in_group: bool, unsafe_to_repeat: bool) -> Result<A, String> {
let began = std::time::Instant::now();
let mut wait = 1;
loop {
match link.call::<Q, A>(method, request, true).await {
Ok(answer) => return Ok(answer),
Err(CallError::Refused(eresult::ACCESS_DENIED, _)) => {
return Err(if in_group { "you may not write in this group right now; you may be muted there, or not allowed".to_string() } else { eresult::describe(eresult::ACCOUNT_NOT_FRIENDS) })
}
Err(CallError::Refused(eresult::RATE_LIMIT_EXCEEDED, _)) => return Err("you are sending too fast; wait a moment".to_string()),
Err(error) if passing(&error) && !(unsafe_to_repeat && matches!(error, CallError::TimedOut)) && began.elapsed() < SEND_FOR => {
tracing::info!("steam: {method} did not go ({error}); trying again in {wait}s");
tokio::time::sleep(std::time::Duration::from_secs(wait)).await;
wait *= 2;
}
Err(error) => return Err(refusal(&error)),
}
}
}
/// Sends a message to the person a conversation is with.
///
/// `recipient` is their 64-bit id as the conversation holds it. Returns the
/// id Steam gave the message and the second it stamped it, which the caller
/// stores under so that an echo of it is recognised.
pub async fn send(link: &Link, recipient: &str, text: &str) -> Result<Sent, String> {
let steamid: u64 = recipient.parse().ok().filter(|id| steamid::account_of(*id).is_some()).ok_or("this conversation is not with a Steam account")?;
let text = text.trim_end();
if text.trim().is_empty() {
return Err("there is nothing to send".to_string());
}
if text.chars().count() > MAX_LENGTH {
return Err(format!("Steam takes messages of up to {MAX_LENGTH} characters; this one is {}", text.chars().count()));
}
let request = FriendMessagesSendRequest {
steamid: Some(steamid),
chat_entry_type: Some(CHAT_MESSAGE),
message: Some(text.to_string()),
// The text is BBCode, as Steam's own client sends it: the composer
// writes the formatting as tags and escapes any bracket somebody typed
// as `\[`, so a "[b]" they meant literally stays what they meant.
contains_bbcode: Some(true),
echo_to_sender: Some(false),
low_priority: Some(false),
client_message_id: Some(client_message_id()),
};
let response: FriendMessagesSendResponse = send_with_patience(link, "FriendMessages.SendMessage#1", &request, false, false).await.inspect_err(|why| tracing::info!("steam: SendMessage was refused: {why}"))?;
tracing::debug!("steam: sent {text:?}; Steam stored {:?} ({:?} without tags)", response.modified_message, response.message_without_bb_code);
let timestamp = response.server_timestamp.unwrap_or_default();
// What Steam stored, which is the text with its emoticons found: ":steamhappy:"
// comes back as a tag, and the log should hold what the other side will see.
let said = response.modified_message.as_deref().filter(|m| !m.trim().is_empty()).unwrap_or(text);
let rendered = super::bbcode::render_message(said);
Ok(Sent { id: message_id(timestamp, response.ordinal.unwrap_or_default()), timestamp, shown: rendered.text, embeds: rendered.cards })
}
/// A message that Steam took.
#[derive(Debug)]
pub struct Sent {
/// The id Steam gave it, which an echo of it is recognised by.
pub id: String,
/// The second Steam stamped it.
pub timestamp: u32,
/// What to put in the log: the message as moho draws it.
pub shown: String,
/// The cards Steam made of any links in it, which the sender's own copy
/// holds as the other side's does.
pub embeds: Vec<crate::model::Embed>,
}
/// What a typing notice is, in `chat_entry_type`.
pub const TYPING: i32 = 2;
/// Tells somebody we are writing to them. A message of the other kind with no
/// text, which is how Steam's own client says it; sent without waiting, since
/// nothing depends on it arriving.
pub fn send_typing(link: &Link, recipient: &str) {
let Some(steamid) = recipient.parse::<u64>().ok().filter(|id| steamid::account_of(*id).is_some()) else { return };
let link = link.clone();
tokio::spawn(async move {
let request = FriendMessagesSendRequest { steamid: Some(steamid), chat_entry_type: Some(TYPING), ..Default::default() };
let _: Result<FriendMessagesSendResponse, _> = link.call("FriendMessages.SendMessage#1", &request, true).await;
});
}
/// A fresh name for one send, so that sending it twice cannot say it twice.
fn client_message_id() -> String {
use rand::Rng;
// Sixteen hex digits, as Steam's own client makes them.
let n: u64 = rand::thread_rng().gen();
format!("{n:016x}")
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn typing_is_a_message_of_the_other_kind_with_no_words_and_not_a_message() {
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let url = serve({
let seen = seen.clone();
Box::new(move |p| {
seen.lock().unwrap().push(p.clone());
Some(vec![answer(&p, 1, FriendMessagesSendResponse::default().encode_to_vec())])
})
})
.await;
let (link, _told) = link::open_with("test", &url, Duration::from_secs(5)).await.unwrap();
send_typing(&link, "76561197960265729");
// Nothing to send to when it is not somebody's id.
send_typing(&link, "not an id");
for _ in 0..100 {
if !seen.lock().unwrap().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
tokio::time::sleep(Duration::from_millis(50)).await;
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].header.target_job_name.as_deref(), Some("FriendMessages.SendMessage#1"));
let body = FriendMessagesSendRequest::decode(seen[0].body.as_slice()).unwrap();
assert_eq!((body.steamid, body.chat_entry_type, body.message), (Some(76561197960265729), Some(2), None));
}
#[test]
fn an_incoming_message_is_read_from_its_tags() {
let said = |message: &str, plain: &str| FriendMessagesIncoming {
steamid_friend: Some(steamid::steamid_of(7)),
chat_entry_type: Some(1),
message: Some(message.into()),
message_no_bbcode: Some(plain.into()),
rtime32_server_timestamp: Some(5),
..Default::default()
};
assert_eq!(read_incoming(&said("look [b]here[/b] [emoticon]steamhappy[/emoticon]", "look here :steamhappy:")).unwrap().text, "look **here** [steam:emote:steamhappy]");
// Nothing but tags: the plain copy is what there is to show.
assert_eq!(read_incoming(&said("[roomeffect]x[/roomeffect]", "sends an effect")).unwrap().text, "sends an effect");
assert_eq!(read_incoming(&said(" ", " ")), None, "and nothing at all is not a message");
}
#[test]
fn somebody_typing_is_told_from_a_message_and_from_ourselves_elsewhere() {
let from = |kind, echo| FriendMessagesIncoming { steamid_friend: Some(steamid::steamid_of(7)), chat_entry_type: Some(kind), local_echo: Some(echo), ..Default::default() };
assert_eq!(read_typing(&from(2, false)), Some(7));
assert_eq!(read_typing(&from(1, false)), None, "a message is not typing");
assert_eq!(read_typing(&from(2, true)), None, "our own typing, from another client, is not somebody else's");
assert_eq!(read_typing(&FriendMessagesIncoming { chat_entry_type: Some(2), ..Default::default() }), None, "from nobody");
}
/// Steam refuses a message whose id is the wrong shape - as "not an
/// account", which says nothing of the id - and the shape that is accepted
/// is what its own client makes: sixteen hex digits. Found against a live
/// account, where thirty-two were refused and sixteen sent.
#[test]
fn a_message_id_is_sixteen_hex_digits_and_not_the_same_twice() {
let a = client_message_id();
let b = client_message_id();
assert_eq!(a.len(), 16);
assert!(a.bytes().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b);
}
use crate::backend::kick::testkit::simulated_daemon;
use crate::backend::steam::friends::REL_FRIEND;
use crate::backend::steam::link::{self, fake::*};
use crate::backend::steam::wire::emsg;
use std::time::Duration;
use prost::Message;
const ACCOUNT: &str = "steam:tester";
fn notice(friend: u32, text: &str, ts: u32, ordinal: u32) -> FriendMessagesIncoming {
FriendMessagesIncoming {
steamid_friend: Some(steamid::steamid_of(friend)),
chat_entry_type: Some(1),
message: Some(text.to_string()),
message_no_bbcode: Some(text.to_string()),
rtime32_server_timestamp: Some(ts),
ordinal: Some(ordinal),
..Default::default()
}
}
#[test]
fn a_message_is_named_by_when_steam_stamped_it_and_its_place_in_that_second() {
assert_eq!(message_id(1790000000, 0), "1790000000:0");
assert_ne!(message_id(1790000000, 0), message_id(1790000000, 1), "two in the same second are two messages");
let read = read_incoming(&notice(22202, "hello", 1790000000, 3)).unwrap();
assert_eq!((read.account, read.text.as_str(), read.id.as_str(), read.from_us), (22202, "hello", "1790000000:3", false));
}
#[test]
fn what_is_not_a_message_to_show_is_not_one() {
let mut typing = notice(22202, "x", 1, 0);
typing.chat_entry_type = Some(2);
assert_eq!(read_incoming(&typing), None, "typing is not a message");
let mut other = notice(22202, "x", 1, 0);
other.chat_entry_type = None;
assert_eq!(read_incoming(&other), None);
let mut empty = notice(22202, " ", 1, 0);
empty.message = Some(" ".into());
assert_eq!(read_incoming(&empty), None);
let mut group = notice(22202, "x", 1, 0);
group.steamid_friend = Some((1u64 << 56) | (7 << 52) | (1 << 32) | 5);
assert_eq!(read_incoming(&group), None, "not from a person");
let mut nobody = notice(22202, "x", 1, 0);
nobody.steamid_friend = None;
assert_eq!(read_incoming(&nobody), None);
}
#[test]
fn the_text_is_the_message_and_trailing_space_is_dropped() {
assert_eq!(read_incoming(&notice(1, "plain words", 1, 0)).unwrap().text, "plain words");
let mut raw_only = notice(1, "", 1, 0);
raw_only.message_no_bbcode = None;
raw_only.message = Some("raw words ".into());
assert_eq!(read_incoming(&raw_only).unwrap().text, "raw words");
}
#[test]
fn a_message_we_sent_from_another_session_is_ours() {
let mut echo = notice(22202, "sent from my phone", 5, 0);
echo.local_echo = Some(true);
assert!(read_incoming(&echo).unwrap().from_us);
}
#[test]
fn a_page_steam_described_arrives_as_a_message_with_a_card() {
let state = simulated_daemon("steam-chat-card");
let mut roster = Roster::default();
roster.apply_friends_list(&ClientFriendsList {
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(22202)), efriendrelationship: Some(REL_FRIEND) }],
..Default::default()
});
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
let mut shown = Conversations::new(ACCOUNT);
let raw = "[og url=\"http://x.salastil.com\" title=\"Home\" desc=\"A privacy-focused Twitter/X frontend\"]x.salastil.com[/og]";
let incoming = read_incoming(&notice(22202, raw, 1790000100, 0)).unwrap();
assert_eq!(incoming.text, "[x.salastil.com](http://x.salastil.com)");
assert!(record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &incoming).0);
let history = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 10).unwrap();
let card = &history[0].embeds[0];
assert_eq!((card.url.as_deref(), card.title.as_deref(), card.provider.as_deref()), (Some("http://x.salastil.com"), Some("Home"), Some("x.salastil.com")));
}
#[test]
fn a_friends_invitation_to_a_group_waits_to_be_answered() {
let state = simulated_daemon("steam-chat-invite");
let account = "steam:invitee";
let mut roster = Roster::default();
roster.apply_friends_list(&ClientFriendsList {
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(22202)), efriendrelationship: Some(REL_FRIEND) }],
..Default::default()
});
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
let mut shown = Conversations::new(account);
let raw = "[invite chatgroupid=\"78995476\" chatgroupname=\"Raiders\"][/invite]";
let incoming = read_incoming(&notice(22202, raw, 1790000200, 0)).unwrap();
assert_eq!(incoming.text, "Group chat invitation: Raiders");
assert_eq!(incoming.invite, Some((78995476, None, "Raiders".to_string())));
assert!(record_incoming(&state, account, &mut roster, &mut shown, "Me", &incoming).0);
let waiting = crate::backend::steam::chatgroups::invites_json(account);
assert_eq!(waiting.len(), 1);
assert_eq!((waiting[0]["roomId"].as_str(), waiting[0]["name"].as_str(), waiting[0]["inviter"].as_str()), (Some("78995476"), Some("Raiders"), Some("Gabe")));
// The same one again is still one; what we sent ourselves is nobody's to answer.
let again = read_incoming(&notice(22202, raw, 1790000300, 0)).unwrap();
record_incoming(&state, account, &mut roster, &mut shown, "Me", &again);
assert_eq!(crate::backend::steam::chatgroups::invites_json(account).len(), 1);
crate::backend::steam::chatgroups::clear(account);
}
#[test]
fn a_message_lands_in_the_friends_conversation_once() {
let state = simulated_daemon("steam-chat-record");
let mut roster = Roster::default();
roster.apply_friends_list(&ClientFriendsList {
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(22202)), efriendrelationship: Some(REL_FRIEND) }],
..Default::default()
});
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
let mut shown = Conversations::new(ACCOUNT);
let incoming = read_incoming(&notice(22202, "hello there", 1790000000, 0)).unwrap();
let (stored, stranger) = record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &incoming);
assert!(stored && !stranger);
let (again, _) = record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &incoming);
assert!(!again, "the same message twice is one message");
let history = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 10).unwrap();
assert_eq!(history.len(), 1);
assert_eq!((history[0].from.as_str(), history[0].body.as_str(), history[0].ts), ("Gabe", "hello there", 1790000000));
assert_eq!(history[0].id, "1790000000:0");
}
#[test]
fn what_we_sent_from_elsewhere_is_filed_under_the_friend_and_signed_by_us() {
let state = simulated_daemon("steam-chat-echo");
let mut roster = Roster::default();
roster.apply_friends_list(&ClientFriendsList {
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(22202)), efriendrelationship: Some(REL_FRIEND) }],
..Default::default()
});
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
let mut shown = Conversations::new(ACCOUNT);
state.runtime.set_own_identity(ACCOUNT, "Me");
let mut echo = notice(22202, "from my phone", 1790000001, 0);
echo.local_echo = Some(true);
record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &read_incoming(&echo).unwrap());
let history = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 10).unwrap();
assert_eq!(history.len(), 1, "in Gabe's conversation, not a new one");
assert_eq!(history[0].from, "Me");
assert!(history[0].is_own);
}
#[test]
fn somebody_who_writes_first_gets_a_conversation_and_is_asked_after() {
let state = simulated_daemon("steam-chat-stranger");
let mut roster = Roster::default();
let mut shown = Conversations::new(ACCOUNT);
let incoming = read_incoming(&notice(777, "hi, we have not met", 1790000002, 0)).unwrap();
let (stored, stranger) = record_incoming(&state, ACCOUNT, &mut roster, &mut shown, "Me", &incoming);
assert!(stored, "a message from a stranger is still a message");
assert!(stranger, "and the caller should find out who they are");
let name = shown.name_of(777).unwrap().to_string();
assert_eq!(name, steamid::text_of(777));
assert_eq!(state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, &name), 0, 10).unwrap().len(), 1);
}
#[tokio::test]
async fn sending_names_the_recipient_sends_the_tags_and_logs_what_steam_stored() {
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
let url = serve({
let seen = seen.clone();
Box::new(move |req| {
*seen.lock().unwrap() = Some(req.clone());
Some(vec![answer(&req, 1, FriendMessagesSendResponse { modified_message: Some("hi [b]there[/b] [emoticon]steamhappy[/emoticon]".into()), server_timestamp: Some(1790000003), ordinal: Some(2), message_without_bb_code: Some("hi there".into()) }.encode_to_vec())])
})
})
.await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
link.set_identity(link::Identity { steamid: 76561197960287930, session: 4 });
let sent = send(&link, "76561197960265729", "hi [b]there[/b] :steamhappy: \n").await.unwrap();
assert_eq!((sent.id.as_str(), sent.timestamp), ("1790000003:2", 1790000003));
assert_eq!(sent.shown, "hi **there** [steam:emote:steamhappy]", "what is logged is what Steam stored, drawn as moho draws it");
let req = seen.lock().unwrap().clone().unwrap();
assert_eq!(req.emsg, emsg::SERVICE_METHOD_CALL_FROM_CLIENT, "as the signed-in account");
assert_eq!(req.header.target_job_name.as_deref(), Some("FriendMessages.SendMessage#1"));
let body = FriendMessagesSendRequest::decode(req.body.as_slice()).unwrap();
assert_eq!(body.steamid, Some(76561197960265729));
assert_eq!(body.message.as_deref(), Some("hi [b]there[/b] :steamhappy:"), "trailing space is dropped; the tags are sent as they came");
assert_eq!((body.chat_entry_type, body.contains_bbcode), (Some(1), Some(true)));
assert_eq!(body.client_message_id.as_deref().map(str::len), Some(16));
}
#[tokio::test]
async fn a_link_sent_keeps_the_card_steam_made_of_it() {
let url = serve(Box::new(|req| {
let stored = "[og url=\"https://x.test/p\" img=\"https://community.steamstatic.com/chat/image/a/share_image\" title=\"A page\" desc=\"About it\"]https://x.test/p[/og]";
Some(vec![answer(&req, 1, FriendMessagesSendResponse { modified_message: Some(stored.into()), server_timestamp: Some(1790000004), ordinal: Some(0), message_without_bb_code: None }.encode_to_vec())])
}))
.await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
link.set_identity(link::Identity { steamid: 76561197960287930, session: 4 });
let sent = send(&link, "76561197960265729", "https://x.test/p").await.unwrap();
assert_eq!(sent.shown, "https://x.test/p", "the words are what was sent");
assert_eq!(sent.embeds.len(), 1, "and the card is the sender's too");
assert_eq!((sent.embeds[0].title.as_deref(), sent.embeds[0].description.as_deref()), (Some("A page"), Some("About it")));
assert!(sent.embeds[0].image_url.is_some());
}
#[tokio::test]
async fn what_cannot_be_sent_is_said_before_it_is_tried() {
let heard = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let url = serve({
let heard = heard.clone();
Box::new(move |req| {
heard.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(vec![answer(&req, 1, FriendMessagesSendResponse::default().encode_to_vec())])
})
})
.await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
for (to, text, why) in [
("not a number", "hi", "not with a Steam account"),
("5", "hi", "not with a Steam account"),
("76561197960265729", " ", "nothing to send"),
] {
let err = send(&link, to, text).await.unwrap_err();
assert!(err.contains(why), "{to:?} {text:?}: {err}");
}
let err = send(&link, "76561197960265729", &"x".repeat(2001)).await.unwrap_err();
assert!(err.contains("2000"), "{err}");
assert_eq!(heard.load(std::sync::atomic::Ordering::SeqCst), 0, "none of those reached Steam");
// Exactly the limit is fine.
send(&link, "76561197960265729", &"x".repeat(2000)).await.unwrap();
assert_eq!(heard.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn a_send_that_fails_in_passing_is_tried_again_with_the_same_id_and_one_that_is_refused_is_not() {
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let url = serve({
let seen = seen.clone();
Box::new(move |req| {
let mut seen = seen.lock().unwrap();
seen.push(FriendMessagesSendRequest::decode(req.body.as_slice()).unwrap().client_message_id);
// Fails the first time, as a busy server does, then goes through.
let code = if seen.len() == 1 { eresult::SERVICE_UNAVAILABLE } else { 1 };
Some(vec![answer(&req, code, FriendMessagesSendResponse { server_timestamp: Some(5), ordinal: Some(0), ..Default::default() }.encode_to_vec())])
})
})
.await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
let sent = send(&link, "76561197960265729", "hi").await.unwrap();
assert_eq!(sent.timestamp, 5);
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2, "tried again after the first failure");
assert_eq!(seen[0], seen[1], "under the same id, so Steam cannot take it twice");
}
#[tokio::test]
async fn being_refused_for_good_is_said_at_once_in_the_clients_words() {
for (code, words, in_group) in [(eresult::ACCESS_DENIED, "friends", false), (eresult::ACCESS_DENIED, "may not write in this group", true), (eresult::RATE_LIMIT_EXCEEDED, "too fast", false), (eresult::LIMITED_USER_ACCOUNT, "limited account", false)] {
let count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let url = serve({
let count = count.clone();
Box::new(move |req| {
count.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(vec![answer(&req, code, Vec::new())])
})
})
.await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
let err = send_with_patience::<FriendMessagesSendRequest, FriendMessagesSendResponse>(&link, "FriendMessages.SendMessage#1", &FriendMessagesSendRequest::default(), in_group, false).await.unwrap_err();
assert!(err.contains(words), "{code}: {err}");
assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1, "{code} is not tried again");
}
}
#[tokio::test]
async fn steams_refusals_are_said_in_words() {
let url = serve(Box::new(|req| Some(vec![answer(&req, eresult::ACCOUNT_NOT_FRIENDS, Vec::new())]))).await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
let err = send(&link, "76561197960265729", "hello").await.unwrap_err();
assert!(err.contains("friends"), "{err}");
}
}
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
//! Finding a connection manager to talk to.
//!
//! Steam has no fixed address for its servers. A client asks a public
//! directory for the current ones and connects to one: each answer names an
//! endpoint, the datacentre it is in, and how loaded it is. The directory is
//! plain HTTPS and needs no account, so it is asked through the same route the
//! account will use - an account sent through Tor does not tell Valve its real
//! address on the way to choosing a server.
use anyhow::{bail, Context, Result};
use rand::seq::SliceRandom;
use serde::Deserialize;
use std::time::Duration;
const DIRECTORY: &str = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/";
/// One connection manager, as the directory describes it.
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct Cm {
/// `host:port`, the address of its WebSocket.
pub endpoint: String,
/// The datacentre it is in.
#[serde(default)]
pub dc: String,
/// How the directory describes its kind: `websockets` for these.
#[serde(default, rename = "type")]
pub kind: String,
/// Connections, roughly, as a percentage of what it takes.
#[serde(default)]
pub load: f64,
/// The same weighted by the datacentre's other servers; what to rank by.
#[serde(default)]
pub wtd_load: f64,
}
impl Cm {
/// Where its WebSocket is.
pub fn url(&self) -> String {
format!("wss://{}/cmsocket/", self.endpoint)
}
}
#[derive(Deserialize)]
struct Directory {
response: Inner,
}
#[derive(Deserialize)]
struct Inner {
#[serde(default)]
serverlist: Vec<Cm>,
#[serde(default)]
success: Option<bool>,
#[serde(default)]
message: String,
}
/// Reads the directory's answer.
pub fn parse(json: &str) -> Result<Vec<Cm>> {
let answer: Directory = serde_json::from_str(json).context("reading Steam's server list")?;
if answer.response.success == Some(false) {
bail!("Steam's server list said no{}", if answer.response.message.is_empty() { String::new() } else { format!(": {}", answer.response.message) });
}
let servers: Vec<Cm> = answer.response.serverlist.into_iter().filter(|cm| cm.kind.is_empty() || cm.kind == "websockets").collect();
if servers.is_empty() {
bail!("Steam's server list had no servers in it");
}
Ok(servers)
}
/// The order to try them in: the least loaded first.
///
/// The best few are shuffled among themselves. Everybody who ranks by load
/// would otherwise pick the very same server, which is how the quietest one
/// stops being quiet, and a client that retries after a failure would walk
/// the same list from the same start every time.
pub fn ranked(mut servers: Vec<Cm>) -> Vec<Cm> {
servers.sort_by(|a, b| a.wtd_load.partial_cmp(&b.wtd_load).unwrap_or(std::cmp::Ordering::Equal).then(a.load.partial_cmp(&b.load).unwrap_or(std::cmp::Ordering::Equal)));
let head = servers.len().min(4);
servers[..head].shuffle(&mut rand::thread_rng());
servers
}
/// Asks the directory, as `key` is routed.
pub async fn discover(key: &str, cell: u32) -> Result<Vec<Cm>> {
let router = crate::net::route::router();
let http = router.client_if("steam", router.routed(key), |b| b.timeout(Duration::from_secs(20)).user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))));
let body = http
.get(DIRECTORY)
.query(&[("cellid", cell.to_string()), ("cmtype", "websockets".to_string()), ("realm", "steamglobal".to_string()), ("maxcount", "20".to_string())])
.send()
.await
.context("asking Steam for a server")?
.error_for_status()
.context("Steam's server list was refused")?
.text()
.await
.context("reading Steam's server list")?;
Ok(ranked(parse(&body)?))
}
#[cfg(test)]
mod tests {
use super::*;
/// As the directory answered when this was written.
const REAL: &str = r#"{"response":{"serverlist":[
{"endpoint":"cmp1-iad1.steamserver.net:443","legacy_endpoint":"cmp1-iad1.steamserver.net:443","type":"websockets","dc":"iad1","realm":"steamglobal","load":9,"wtd_load":0},
{"endpoint":"cmp2-iad1.steamserver.net:443","legacy_endpoint":"cmp2-iad1.steamserver.net:443","type":"websockets","dc":"iad1","realm":"steamglobal","load":9,"wtd_load":3.76304769515991211}
],"success":true,"message":""}}"#;
#[test]
fn the_directorys_answer_is_read() {
let servers = parse(REAL).unwrap();
assert_eq!(servers.len(), 2);
assert_eq!(servers[0].endpoint, "cmp1-iad1.steamserver.net:443");
assert_eq!(servers[0].dc, "iad1");
assert_eq!(servers[1].wtd_load, 3.763_047_695_159_912);
assert_eq!(servers[0].url(), "wss://cmp1-iad1.steamserver.net:443/cmsocket/");
}
#[test]
fn a_refusal_or_an_empty_list_is_an_error_that_says_so() {
let refused = parse(r#"{"response":{"serverlist":[],"success":false,"message":"rate limited"}}"#).unwrap_err();
assert!(format!("{refused:#}").contains("rate limited"));
assert!(parse(r#"{"response":{"serverlist":[],"success":true}}"#).is_err());
assert!(parse("<html>not json</html>").is_err());
// Only the kind this client speaks counts.
let other = r#"{"response":{"serverlist":[{"endpoint":"1.2.3.4:27017","type":"netfilter"}],"success":true}}"#;
assert!(parse(other).is_err());
}
#[test]
fn the_least_loaded_come_first_and_all_of_them_are_kept() {
let cm = |name: &str, wtd: f64| Cm { endpoint: name.into(), dc: String::new(), kind: "websockets".into(), load: 0.0, wtd_load: wtd };
let list: Vec<Cm> = (0..10).map(|i| cm(&format!("s{i}"), (10 - i) as f64)).collect();
let order = ranked(list);
assert_eq!(order.len(), 10);
// The four best, in some order, then the rest strictly by load.
let mut best: Vec<&str> = order[..4].iter().map(|c| c.endpoint.as_str()).collect();
best.sort();
assert_eq!(best, ["s6", "s7", "s8", "s9"]);
let rest: Vec<&str> = order[4..].iter().map(|c| c.endpoint.as_str()).collect();
assert_eq!(rest, ["s5", "s4", "s3", "s2", "s1", "s0"]);
}
#[test]
fn who_goes_first_among_the_best_is_not_always_the_same() {
let cm = |name: &str| Cm { endpoint: name.into(), dc: String::new(), kind: "websockets".into(), load: 0.0, wtd_load: 0.0 };
let firsts: std::collections::HashSet<String> = (0..200).map(|_| ranked((0..4).map(|i| cm(&format!("s{i}"))).collect())[0].endpoint.clone()).collect();
assert!(firsts.len() > 1, "always {firsts:?}");
}
}
File diff suppressed because it is too large Load Diff
+284
View File
@@ -0,0 +1,284 @@
//! What an account may put in a message beyond words: its emoticons, stickers
//! and effects, which are a short list Steam holds for each account.
//!
//! Steam names an emoticon by a word between colons, and draws it from a
//! picture at an address built from the name; a sticker likewise, and is sent as
//! a command (`/sticker name`) that Steam turns into the picture. Which an
//! account may use is what Steam says it has - the free ones every account gets,
//! and what has been earned or bought - so it is asked for rather than listed
//! here, and it is all the picker offers: there is no other set to choose from.
//!
//! In a message an emoticon is only its name; in the window it is a picture
//! whose address is built from that name, so the same name works for an
//! emoticon the account does not have when somebody else sent it.
use super::link::Link;
use super::proto::{ClientEmoticonList, ClientGetEmoticonList, GetEmoticonListRequest, GetEmoticonListResponse};
use super::wire::emsg;
use crate::model::{EmojiEntry, EmojiSource};
use crate::state::AppState;
/// The id of the source that holds the stickers. Kept in the same catalogue as the
/// emoticons but never offered as a section of emoji: they are sent, not typed.
pub const STICKERS: &str = "steam-stickers";
/// The picture for an emoticon, which is also what the window draws.
pub fn picture(name: &str) -> String {
format!("https://community.fastly.steamstatic.com/economy/emoticon/{name}")
}
/// The picture for a sticker. Its name has spaces in it, so it is written the way
/// an address takes them.
pub fn sticker_picture(name: &str) -> String {
let encoded: String = name.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect();
format!("https://community.fastly.steamstatic.com/economy/sticker/{encoded}")
}
/// A sticker's name as Steam holds it: words, with spaces and a little punctuation
/// in them ("Nyan GLaDOS"), unlike an emoticon's, which is one word. Nothing that
/// could end a tag, a quote or a command early.
pub fn valid_sticker(name: &str) -> bool {
!name.is_empty()
&& name.chars().count() <= 64
&& name == name.trim()
&& name.chars().all(|c| c.is_alphanumeric() || " _-'.!&()+,".contains(c))
}
/// Whether the account has this sticker: what it is asked to send must be one it
/// holds, since a command naming any other would be sent as the words it is.
pub fn is_owned_sticker(state: &AppState, account_id: &str, name: &str) -> bool {
state.runtime.emoji_catalogue(account_id).iter().filter(|s| s.id == STICKERS).any(|s| s.emoji.iter().any(|e| e.name == name))
}
fn valid(name: &str) -> bool {
!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
/// A name list in the order the picker offers it: most used first, then by
/// name, each once, and only what is a name.
fn ordered(mut have: Vec<(String, u32)>, valid: fn(&str) -> bool) -> Vec<String> {
have.retain(|(n, _)| valid(n));
have.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.to_lowercase().cmp(&b.0.to_lowercase())));
have.dedup_by(|a, b| a.0 == b.0);
have.into_iter().map(|(n, _)| n).collect()
}
/// The emoticons the picker offers.
fn emoticon_entries(list: &ClientEmoticonList) -> Vec<EmojiEntry> {
let have = list.emoticons.iter().filter_map(|e| Some((e.name.as_deref()?.trim_matches(':').to_string(), e.use_count.unwrap_or(0)))).collect();
// The id says which service's picture it is, so the window builds the
// right token for it; the name is what is typed between colons.
ordered(have, valid).into_iter().map(|name| EmojiEntry { id: format!("steam:{name}"), url: Some(picture(&name)), name, animated: false, locked: false }).collect()
}
/// The stickers the picker offers.
fn sticker_entries(list: &ClientEmoticonList) -> Vec<EmojiEntry> {
let have = list.stickers.iter().filter_map(|e| Some((e.name.as_deref()?.to_string(), e.use_count.unwrap_or(0)))).collect();
ordered(have, valid_sticker).into_iter().map(|name| EmojiEntry { id: name.clone(), url: Some(sticker_picture(&name)), name, animated: false, locked: false }).collect()
}
fn source(id: &str, name: &str, emoji: Vec<EmojiEntry>) -> EmojiSource {
EmojiSource { id: id.to_string(), name: name.to_string(), service: "steam".to_string(), kind: "text".to_string(), icon_url: None, buffers: Vec::new(), sendable_anywhere: true, emoji }
}
/// Who is waiting to hear the list again, and when it was last asked for.
static REFRESHES: std::sync::LazyLock<std::sync::Mutex<std::collections::HashMap<String, Heard>>> = std::sync::LazyLock::new(Default::default);
/// What is held about one account's list: who waits for it, and when it came.
type Heard = (std::sync::Arc<tokio::sync::Notify>, Option<std::time::Instant>);
/// How long a list is taken to be current without asking again.
const FRESH_FOR: std::time::Duration = std::time::Duration::from_secs(20);
/// How long to wait for Steam's answer before offering what is held.
const WAIT_FOR: std::time::Duration = std::time::Duration::from_millis(2500);
/// Asks Steam again what the account has, and waits for the answer.
///
/// The list is asked for once at sign-in and changes when something is earned or
/// bought - a sticker added in the Steam client while moho is open is not in
/// what was heard. So the picker asks as it opens. A list heard in the last few
/// seconds is not asked for again, and a Steam that does not answer costs only
/// the wait, after which what is held is offered as it was.
pub async fn refresh(state: &AppState, account_id: &str) {
let Some(link) = state.runtime.steam_link(account_id) else { return };
let notify = {
let mut held = REFRESHES.lock().unwrap();
let entry = held.entry(account_id.to_string()).or_insert_with(|| (Default::default(), None));
if entry.1.is_some_and(|at| at.elapsed() < FRESH_FOR) {
return;
}
entry.0.clone()
};
let heard = notify.notified();
tokio::pin!(heard);
heard.as_mut().enable();
if link.send(emsg::CLIENT_GET_EMOTICON_LIST, &ClientGetEmoticonList {}).is_err() {
return;
}
let _ = tokio::time::timeout(WAIT_FOR, heard).await;
}
/// Keeps what Steam says the account has, as what the picker offers.
pub fn keep(state: &AppState, account_id: &str, list: &ClientEmoticonList) {
let (emoticons, stickers) = (emoticon_entries(list), sticker_entries(list));
tracing::debug!("steam[{account_id}]: {} emoticon(s), {} sticker(s), {} effect(s)", emoticons.len(), stickers.len(), list.effects.len());
state.runtime.set_emoji_source(account_id, source("steam", "Steam", emoticons));
state.runtime.set_emoji_source(account_id, source(STICKERS, "Steam stickers", stickers));
let mut held = REFRESHES.lock().unwrap();
let entry = held.entry(account_id.to_string()).or_insert_with(|| (Default::default(), None));
entry.1 = Some(std::time::Instant::now());
entry.0.notify_waiters();
}
/// Asks Steam for what the account has. The full list - stickers and effects as
/// well - is asked for and arrives as a message of its own, which the
/// connection hands to `keep`; the emoticons alone are asked for in the
/// ordinary way at the same time, so there is a picker while that is awaited
/// and one if it never comes. Quietly nothing on failure: no picker section is
/// better than an error at sign-in.
pub async fn load(state: AppState, link: Link, account_id: String) {
let _ = link.send(emsg::CLIENT_GET_EMOTICON_LIST, &ClientGetEmoticonList {});
match link.call::<_, GetEmoticonListResponse>("Player.GetEmoticonList#1", &GetEmoticonListRequest {}, true).await {
Ok(l) => {
// The full list may have come first; what it says is not replaced
// by this, which knows less.
if state.runtime.emoji_catalogue(&account_id).iter().all(|s| s.id != STICKERS) {
let list = ClientEmoticonList {
emoticons: l.emoticons.into_iter().map(|e| super::proto::EmoticonOwned { name: e.name, count: e.count, time_last_used: e.time_last_used, use_count: e.use_count, time_received: e.time_received, appid: e.appid }).collect(),
..Default::default()
};
keep(&state, &account_id, &list);
}
}
Err(e) => tracing::info!("steam[{account_id}]: could not ask for emoticons: {e}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::steam::proto::{EmoticonOwned, StickerOwned};
fn have(name: &str, used: u32) -> EmoticonOwned {
EmoticonOwned { name: Some(name.into()), use_count: Some(used), ..Default::default() }
}
fn sticker(name: &str, used: u32) -> StickerOwned {
StickerOwned { name: Some(name.into()), use_count: Some(used), ..Default::default() }
}
#[test]
fn the_picker_gets_the_most_used_first_then_by_name_and_each_once() {
let list = ClientEmoticonList { emoticons: vec![have("zed", 0), have("Alpha", 0), have("steamhappy", 9), have("steamhappy", 9), have(":colon:", 3), have("bad name", 100), have("", 5)], ..Default::default() };
let got = emoticon_entries(&list);
assert_eq!(got.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), ["steamhappy", "colon", "Alpha", "zed"], "a name that is not a name is not offered");
assert_eq!(got[0].id, "steam:steamhappy");
assert_eq!(got[0].url.as_deref(), Some("https://community.fastly.steamstatic.com/economy/emoticon/steamhappy"));
}
#[test]
fn stickers_are_offered_by_name_with_their_own_pictures() {
let list = ClientEmoticonList { stickers: vec![sticker("b", 0), sticker("CatSurprise", 3), sticker("Nyan GLaDOS", 9), sticker("bad[name]", 20), sticker(" padded", 8)], ..Default::default() };
let got = sticker_entries(&list);
assert_eq!(
got.iter().map(|e| (e.id.as_str(), e.url.as_deref().unwrap())).collect::<Vec<_>>(),
[
("Nyan GLaDOS", "https://community.fastly.steamstatic.com/economy/sticker/Nyan%20GLaDOS"),
("CatSurprise", "https://community.fastly.steamstatic.com/economy/sticker/CatSurprise"),
("b", "https://community.fastly.steamstatic.com/economy/sticker/b")
],
"a name has spaces in it and an address does not; a name that could end a tag is not offered"
);
}
#[test]
fn only_a_sticker_the_account_has_may_be_sent() {
let state = crate::backend::kick::testkit::simulated_daemon("steam-sticker-owned");
keep(&state, "steam:me", &ClientEmoticonList { stickers: vec![sticker("CatSurprise", 1)], ..Default::default() });
assert!(is_owned_sticker(&state, "steam:me", "CatSurprise"));
assert!(!is_owned_sticker(&state, "steam:me", "Other"));
assert!(!is_owned_sticker(&state, "steam:somebody", "CatSurprise"), "another account's is not this one's");
assert!(!is_owned_sticker(&state, "steam:me", "Cat Surprise; and more"));
keep(&state, "steam:me", &ClientEmoticonList { stickers: vec![sticker("Nyan GLaDOS", 1)], ..Default::default() });
assert!(is_owned_sticker(&state, "steam:me", "Nyan GLaDOS"), "a name with spaces in it is one");
}
#[test]
fn the_full_list_is_kept_as_two_sources() {
let state = crate::backend::kick::testkit::simulated_daemon("steam-emoticons-keep");
keep(&state, "steam:me", &ClientEmoticonList { emoticons: vec![have("steamsad", 1)], stickers: vec![sticker("CatSurprise", 1), sticker("Wave", 0)], effects: vec![] });
let sources = state.runtime.emoji_catalogue("steam:me");
let by = |id: &str| sources.iter().find(|s| s.id == id).map(|s| s.emoji.len());
assert_eq!((by("steam"), by(STICKERS)), (Some(1), Some(2)));
}
#[tokio::test]
async fn the_list_is_asked_for_again_as_the_picker_opens_and_not_twice_in_a_row() {
use crate::backend::steam::link::{self, fake::*};
use prost::Message;
let state = crate::backend::kick::testkit::simulated_daemon("steam-emoticons-refresh");
let asked = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let url = serve(Box::new({
let asked = asked.clone();
move |p| {
if p.emsg != emsg::CLIENT_GET_EMOTICON_LIST {
return Some(vec![]);
}
asked.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// Steam says what the account has now: three stickers it did not have at sign-in.
let now = ClientEmoticonList { emoticons: vec![have("a", 0)], stickers: vec![sticker("A", 0), sticker("B", 0), sticker("C", 0)], effects: vec![] };
Some(vec![tell(emsg::CLIENT_EMOTICON_LIST, now.encode_to_vec())])
}
}))
.await;
let (link, mut told) = link::open_with("test", &url, std::time::Duration::from_secs(2)).await.unwrap();
state.runtime.set_steam_link("steam:refreshing", link);
// What the connection does with that message.
let hold = state.clone();
tokio::spawn(async move {
while let Some(packet) = told.recv().await {
if packet.emsg == emsg::CLIENT_EMOTICON_LIST {
keep(&hold, "steam:refreshing", &ClientEmoticonList::decode(packet.body.as_slice()).unwrap());
}
}
});
let stickers = |state: &AppState| state.runtime.emoji_catalogue("steam:refreshing").iter().find(|s| s.id == STICKERS).map(|s| s.emoji.len());
assert_eq!(stickers(&state), None);
refresh(&state, "steam:refreshing").await;
assert_eq!(stickers(&state), Some(3), "what was earned after sign-in is there");
refresh(&state, "steam:refreshing").await;
assert_eq!(asked.load(std::sync::atomic::Ordering::SeqCst), 1, "a list just heard is not asked for again");
// An account that is not connected has nothing to ask.
refresh(&state, "steam:nobody").await;
}
#[tokio::test]
async fn the_emoticons_alone_are_offered_when_the_full_list_never_comes_and_do_not_replace_it_when_it_does() {
use crate::backend::steam::link::{self, fake::*};
use prost::Message;
let state = crate::backend::kick::testkit::simulated_daemon("steam-emoticons-fallback");
let url = serve(Box::new(|p| {
if p.emsg == emsg::CLIENT_GET_EMOTICON_LIST {
// Not answered.
return Some(vec![]);
}
assert_eq!(p.header.target_job_name.as_deref(), Some("Player.GetEmoticonList#1"));
Some(vec![answer(&p, 1, GetEmoticonListResponse { emoticons: vec![crate::backend::steam::proto::EmoticonInfo { name: Some("steamsad".into()), ..Default::default() }] }.encode_to_vec())])
}))
.await;
let (link, _told) = link::open_with("test", &url, std::time::Duration::from_secs(2)).await.unwrap();
load(state.clone(), link, "steam:me".to_string()).await;
let sources = state.runtime.emoji_catalogue("steam:me");
assert_eq!(sources.iter().find(|s| s.id == "steam").map(|s| s.emoji.len()), Some(1));
assert_eq!(sources.iter().find(|s| s.id == STICKERS).map(|s| s.emoji.len()), Some(0));
// The full list, once it has come, is not overwritten by a later fallback.
keep(&state, "steam:other", &ClientEmoticonList { emoticons: vec![have("a", 0), have("b", 0)], stickers: vec![sticker("S", 0)], effects: vec![] });
let (link, _told) = link::open_with("test", &serve(Box::new(|p| Some(vec![answer(&p, 1, GetEmoticonListResponse::default().encode_to_vec())]))).await, std::time::Duration::from_secs(2)).await.unwrap();
load(state.clone(), link, "steam:other".to_string()).await;
let other = state.runtime.emoji_catalogue("steam:other");
assert_eq!(other.iter().find(|s| s.id == STICKERS).map(|s| s.emoji.len()), Some(1));
assert_eq!(other.iter().find(|s| s.id == "steam").map(|s| s.emoji.len()), Some(2));
}
}
+250
View File
@@ -0,0 +1,250 @@
//! Steam's result codes, said in words.
//!
//! Every answer from Steam carries one of these, a number from a list of over
//! a hundred. Most are only ever seen by the one team that sends them; what is
//! here is the ones a person signing in or sending a message can reach, put as
//! what it means for them, and everything else is named by its number so a
//! report of it can be looked up.
pub const OK: i32 = 1;
pub const FILE_NOT_FOUND: i32 = 9;
pub const INVALID_STATE: i32 = 11;
pub const COMMUNITY_COOLDOWN: i32 = 116;
pub const MUST_AGREE_TO_SSA: i32 = 118;
pub const FAIL: i32 = 2;
pub const NO_CONNECTION: i32 = 3;
pub const INVALID_PASSWORD: i32 = 5;
pub const LOGGED_IN_ELSEWHERE: i32 = 6;
pub const INVALID_PROTOCOL_VER: i32 = 7;
pub const INVALID_PARAM: i32 = 8;
pub const BUSY: i32 = 10;
pub const ACCESS_DENIED: i32 = 15;
pub const TIMEOUT: i32 = 16;
pub const BANNED: i32 = 17;
pub const ACCOUNT_NOT_FOUND: i32 = 18;
pub const INVALID_STEAM_ID: i32 = 19;
pub const SERVICE_UNAVAILABLE: i32 = 20;
pub const NOT_LOGGED_ON: i32 = 21;
pub const INSUFFICIENT_PRIVILEGE: i32 = 24;
pub const LIMIT_EXCEEDED: i32 = 25;
pub const REVOKED: i32 = 26;
pub const DUPLICATE_REQUEST: i32 = 29;
pub const EXPIRED: i32 = 27;
pub const BLOCKED: i32 = 40;
pub const IGNORED: i32 = 41;
pub const NO_MATCH: i32 = 42;
pub const ACCOUNT_DISABLED: i32 = 43;
pub const TRY_ANOTHER_CM: i32 = 48;
pub const SUSPENDED: i32 = 51;
pub const ACCOUNT_LOGON_DENIED: i32 = 63;
pub const INVALID_LOGIN_AUTH_CODE: i32 = 65;
pub const EXPIRED_LOGIN_AUTH_CODE: i32 = 71;
pub const IP_LOGIN_RESTRICTION_FAILED: i32 = 72;
pub const ACCOUNT_LOCKED_DOWN: i32 = 73;
pub const REQUIRE_PASSWORD_RE_ENTRY: i32 = 77;
pub const RATE_LIMIT_EXCEEDED: i32 = 84;
pub const ACCOUNT_LOGIN_DENIED_NEED_TWO_FACTOR: i32 = 85;
pub const ACCOUNT_LOGIN_DENIED_THROTTLE: i32 = 87;
pub const TWO_FACTOR_CODE_MISMATCH: i32 = 88;
pub const ACCOUNT_NOT_FRIENDS: i32 = 111;
pub const LIMITED_USER_ACCOUNT: i32 = 112;
pub const ACCOUNT_HAS_BEEN_DELETED: i32 = 114;
pub const CLIENT_NO_LONGER_SUPPORTED: i32 = 119;
/// What a result means to a person, in a sentence that stands on its own.
///
/// Every number Steam defines up to 130 has a sentence here, so a failure is never reported as a
/// bare number; the numbers are the ones the Steam client's own code uses for the ones it names
/// (checked against its constants), and the rest follow the public list. A number past the end of
/// that list is named by its number so a report of it can be looked up.
pub fn describe(code: i32) -> String {
let said = match code {
0 => "Steam gave no result",
OK => "it worked",
FAIL => "Steam reported a failure without saying why",
NO_CONNECTION => "there is no connection to Steam just now",
4 => "Steam gave no result",
INVALID_PASSWORD => "Steam says that password is not right",
LOGGED_IN_ELSEWHERE => "this account signed in somewhere else, which ended this session",
INVALID_PROTOCOL_VER | CLIENT_NO_LONGER_SUPPORTED => "Steam no longer accepts this version of moho's connection",
INVALID_PARAM => "Steam did not understand what was asked",
FILE_NOT_FOUND => "Steam could not find what was asked for",
BUSY | SERVICE_UNAVAILABLE | TIMEOUT => "Steam is not answering just now; try again in a moment",
INVALID_STATE => "that cannot be done in the state it is in",
12 => "Steam does not accept that name",
13 => "Steam does not accept that email address",
14 => "that name is already taken",
ACCESS_DENIED => "Steam refused the request",
BANNED | SUSPENDED => "this account is suspended",
ACCOUNT_NOT_FOUND => "Steam has no account by that name",
INVALID_STEAM_ID => "Steam does not recognise that as an account",
NOT_LOGGED_ON => "the connection is not signed in",
22 => "Steam is still working on that",
23 => "Steam could not secure the connection",
INSUFFICIENT_PRIVILEGE => "this account is not allowed to do that; on Steam that usually means it is a limited account, which has to spend $5 before it can send friend requests",
LIMIT_EXCEEDED => "that is over a limit Steam keeps; wait a while or do less of it",
REVOKED | EXPIRED | REQUIRE_PASSWORD_RE_ENTRY => "the saved sign-in is no longer valid; sign in again",
28 => "that has already been used",
DUPLICATE_REQUEST => "a request to this person is already waiting",
30 => "this account already has that",
31 => "Steam could not place this connection",
32 | 33 => "Steam could not save that just now; try again in a moment",
34 => "this session was replaced by another sign-in",
35 | 36 => "Steam's server could not be reached just now; try again in a moment",
37 => "Steam had a problem on its side; try again in a moment",
38 => "Steam ended the connection",
39 => "Steam could not find that cart",
BLOCKED => "this person has blocked you",
IGNORED => "this person is ignoring you",
NO_MATCH => "Steam found nothing that matches",
ACCOUNT_DISABLED | ACCOUNT_HAS_BEEN_DELETED => "this account is disabled",
44 => "Steam is read-only just now; try again later",
45 => "this account cannot use that feature",
46 => "Steam gave an administrator's answer",
47 => "Steam needs a newer version of the client",
TRY_ANOTHER_CM => "that Steam server asked to be tried another way",
49 => "a password is needed to end the other session",
50 => "this account is already signed in elsewhere",
52 => "that was cancelled",
53 | 54 => "Steam could not store that",
55 => "Steam's server could not complete the call; try again in a moment",
56 => "this account has no password set",
57..=60 => "Steam could not link that account",
61 => "Steam does not accept that password",
62 => "that is the same as before",
ACCOUNT_LOGON_DENIED => "Steam wants the code it emailed to this account",
64 => "that password has been used before",
INVALID_LOGIN_AUTH_CODE => "that code is not right",
66 => "Steam wants a code, and this account has no email to send it to",
67 | 68 => "this device cannot do that",
69 => "Family View is holding this back; unlock it in Steam to continue",
70 => "Steam could not reach the other service",
EXPIRED_LOGIN_AUTH_CODE => "that code has expired; ask for another",
IP_LOGIN_RESTRICTION_FAILED => "Steam will not let this account sign in from this address",
ACCOUNT_LOCKED_DOWN => "this account is locked until its owner secures it",
74 => "this account must verify its email address first",
75 | 76 => "Steam gave an answer that could not be used",
78 => "that is out of the range Steam accepts",
79 => "Steam reported an unexpected error",
80 => "that feature is switched off",
81 | 82 => "this device is not allowed to do that",
83 => "that is not available where this account is",
RATE_LIMIT_EXCEEDED | ACCOUNT_LOGIN_DENIED_THROTTLE => "Steam is limiting how often this can be tried; wait a while before trying again",
ACCOUNT_LOGIN_DENIED_NEED_TWO_FACTOR => "Steam wants the code from the Steam Guard authenticator",
86 => "that has been deleted",
TWO_FACTOR_CODE_MISMATCH => "that Steam Guard code is not right",
89 => "that activation code is not right",
90 => "this account is linked to more than one partner",
91 => "nothing changed",
92 => "no phone is attached to this account",
93 => "the clock on this computer is too far from Steam's; set it right and try again",
94 => "Steam could not send the code to the phone",
95..=97 => "this account or phone has done that too many times; wait a while",
98..=100 => "Steam could not settle that",
101 => "Steam wants a captcha answered, which only its own page can do",
102..=104 => "Steam refused that",
105 => "this address is banned from Steam",
106 => "that has expired",
107 => "this account does not have the funds",
108 => "too many are waiting; try again later",
109 => "no licence was found",
110 => "Steam could not send that much at once",
ACCOUNT_NOT_FRIENDS => "you can only message people who are your friends",
LIMITED_USER_ACCOUNT => "a limited account cannot do that; Steam asks for a purchase first",
113 => "that cannot be removed",
115 => "a licence was cancelled",
COMMUNITY_COOLDOWN => "Steam asks this account to wait before doing that again",
117 => "no launcher was given",
MUST_AGREE_TO_SSA => "Steam wants the subscriber agreement accepted first, which only its own page can do",
120 => "this is a different Steam realm",
121 | 122 => "Steam gave an answer that could not be read",
123 => "this account needs a verified phone number first",
124 | 125 => "the device's power does not allow that",
126 => "the saved sign-in is no longer valid; sign in again",
127 => "a phone number that is internet-only cannot be used",
128 => "that is not supported",
129 => "that is over the family's size limit",
130 => "the offline copy is no longer good",
other => return format!("Steam answered with error {other}"),
};
said.to_string()
}
/// Whether the saved sign-in is dead, so only a fresh sign-in can help.
pub fn means_sign_in_again(code: i32) -> bool {
matches!(code, INVALID_PASSWORD | REVOKED | EXPIRED | REQUIRE_PASSWORD_RE_ENTRY | ACCOUNT_NOT_FOUND | ACCOUNT_LOGON_DENIED | ACCOUNT_LOGIN_DENIED_NEED_TWO_FACTOR | 126)
}
/// Whether the answer is about the account or this client and so the same on every server and every
/// try: signed in elsewhere, replaced, suspended, locked down, banned by address, a version Steam no
/// longer takes, a clock that is wrong, a page only Steam's own site can show. Retrying only repeats
/// it - and for the first two takes the other session back and forth.
pub fn is_final(code: i32) -> bool {
matches!(code, LOGGED_IN_ELSEWHERE | 34 | 50 | BANNED | SUSPENDED | ACCOUNT_DISABLED | ACCOUNT_HAS_BEEN_DELETED | ACCOUNT_LOCKED_DOWN | IP_LOGIN_RESTRICTION_FAILED | 105 | INVALID_PROTOCOL_VER | CLIENT_NO_LONGER_SUPPORTED | 69 | 74 | 93 | MUST_AGREE_TO_SSA)
}
/// Whether Steam is limiting how often this may be tried, so the answer is to wait before trying
/// again, not to try another server.
pub fn is_rate_limit(code: i32) -> bool {
matches!(code, RATE_LIMIT_EXCEEDED | ACCOUNT_LOGIN_DENIED_THROTTLE | LIMIT_EXCEEDED | COMMUNITY_COOLDOWN)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_ones_a_person_reaches_are_said_plainly() {
assert!(describe(INVALID_PASSWORD).contains("password"));
assert!(describe(RATE_LIMIT_EXCEEDED).contains("limiting"));
assert!(describe(ACCOUNT_NOT_FRIENDS).contains("friends"));
assert!(describe(INSUFFICIENT_PRIVILEGE).contains("limited account"));
assert!(describe(NO_MATCH).contains("nothing"));
assert!(describe(DUPLICATE_REQUEST).contains("already"));
assert!(describe(LIMITED_USER_ACCOUNT).contains("limited account"));
assert!(describe(TWO_FACTOR_CODE_MISMATCH).contains("Steam Guard"));
}
#[test]
fn every_result_steam_defines_has_a_sentence_and_only_a_number_past_the_list_is_named_by_number() {
for code in 0..=130 {
let said = describe(code);
assert!(!said.starts_with("Steam answered with error"), "{code} has no sentence");
assert!(!said.is_empty());
}
assert_eq!(describe(131), "Steam answered with error 131");
assert_eq!(describe(9999), "Steam answered with error 9999");
assert_eq!(describe(-1), "Steam answered with error -1");
}
#[test]
fn what_ends_a_session_for_good_what_waits_and_what_another_server_may_fix_are_told_apart() {
for code in [LOGGED_IN_ELSEWHERE, 34, 50, SUSPENDED, ACCOUNT_LOCKED_DOWN, 105, CLIENT_NO_LONGER_SUPPORTED, 93, MUST_AGREE_TO_SSA] {
assert!(is_final(code), "{code} is the same on every try");
}
for code in [RATE_LIMIT_EXCEEDED, ACCOUNT_LOGIN_DENIED_THROTTLE, LIMIT_EXCEEDED, COMMUNITY_COOLDOWN] {
assert!(is_rate_limit(code) && !is_final(code));
}
assert!(means_sign_in_again(126));
for code in [FAIL, TIMEOUT, SERVICE_UNAVAILABLE, 37, 55] {
assert!(!is_final(code) && !is_rate_limit(code) && !means_sign_in_again(code), "{code} may be better elsewhere");
}
}
#[test]
fn the_ones_the_clients_own_code_treats_specially_say_what_it_says() {
// 15 is the client's "not friends" on a direct message, 84 its rate limit, 118 its agreement page
assert!(describe(MUST_AGREE_TO_SSA).contains("agreement"));
assert!(describe(COMMUNITY_COOLDOWN).contains("wait"));
assert!(describe(69).contains("Family View"));
assert!(describe(93).contains("clock"));
}
#[test]
fn some_failures_are_the_servers_and_some_are_the_accounts() {
assert!(means_sign_in_again(REVOKED));
assert!(means_sign_in_again(INVALID_PASSWORD));
assert!(!means_sign_in_again(RATE_LIMIT_EXCEEDED), "waiting helps, signing in again does not");
assert!(!means_sign_in_again(SERVICE_UNAVAILABLE));
}
}
+669
View File
@@ -0,0 +1,669 @@
//! Who the friends are, how they are doing, and what each conversation with
//! them is called.
//!
//! Steam sends the friends list and the details of the people on it as two
//! separate streams: a list of ids and how each stands with us (a friend, a
//! request, blocked), and, as they become known and as they change, each
//! person's name, state, game and picture. A change is a partial update -
//! only what changed is in it - so a person is built up from many messages
//! and this is where they are put together.
//!
//! What this does not do is talk to the rest of the daemon: it is plain data
//! in and out, so the rules (who counts as a friend, what a conversation is
//! called when two friends share a name) can be tested without a connection.
use super::proto::{ClientFriendsList, ClientPersonaState, PerFriendPreferences, PersonaFriend};
use super::steamid;
use std::collections::HashMap;
/// `EFriendRelationship`.
pub const REL_NONE: u32 = 0;
/// Somebody has asked to be our friend.
pub const REL_REQUEST_RECIPIENT: u32 = 2;
pub const REL_FRIEND: u32 = 3;
/// We have asked somebody to be ours.
pub const REL_REQUEST_INITIATOR: u32 = 4;
/// `ENotificationSetting` never: this friend's messages do not notify.
pub const NOTIFY_NEVER: i32 = 2;
/// What is known about a person from their persona updates.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Persona {
pub name: Option<String>,
/// Steam's state number; see `presence`.
pub state: u32,
/// The game they are in, when they are in one and Steam has said its name.
pub game: Option<String>,
/// Its number, which is all Steam says of a game that is on Steam.
pub app: Option<u64>,
/// When this was first seen to be what they are playing, unix seconds. Steam does not
/// say how long anybody has been in a game, so this is only since moho noticed.
pub playing_since: Option<u32>,
/// The avatar's hash as hex.
pub avatar: Option<String>,
/// `EPersonaStateFlag`: which kind of client they are on, among other things.
pub flags: u32,
/// Whether they are on a Steam Deck.
pub on_deck: bool,
/// What kind of device Steam says they are on (`EGamingDeviceType`).
pub device: u32,
/// The last time they were seen, unix seconds.
pub last_seen: Option<u32>,
/// What the game they are in says about what they are doing, where it says.
pub rich: Option<String>,
}
/// `EGamingDeviceType` as the Steam client reads it: a Steam Deck, and a Steam Machine.
pub const DEVICE_STEAM_DECK: u32 = 544;
pub const DEVICE_STEAM_MACHINE: u32 = 545;
/// The picture Steam shows for somebody who has not set one.
const DEFAULT_AVATAR: &str = "fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb";
/// Where an avatar is served from. A hash of all zeros is how Steam says
/// "none set", and is not an image - the default has a hash of its own.
pub fn avatar_url(hash: &[u8]) -> String {
let hex: String = hash.iter().map(|b| format!("{b:02x}")).collect();
let hex = if hex.is_empty() || hash.iter().all(|b| *b == 0) { DEFAULT_AVATAR.to_string() } else { hex };
format!("https://avatars.steamstatic.com/{hex}_full.jpg")
}
/// A name made safe to be a conversation's name: no line breaks or other
/// control characters, no padding.
pub fn clean_name(name: &str) -> String {
name.chars().filter(|c| !c.is_control()).collect::<String>().trim().to_string()
}
#[derive(Default)]
pub struct Roster {
relationships: HashMap<u32, u32>,
personas: HashMap<u32, Persona>,
/// What the account keeps about each friend: the name it calls them, and how they may notify it.
preferences: HashMap<u32, PerFriendPreferences>,
/// People with a conversation who are not friends: somebody who wrote to
/// us first. They need a conversation of their own and a name that does
/// not clash with anyone else's, but they are not on the friends list.
talking: std::collections::HashSet<u32>,
/// What the games people are in are called, as the store has told us.
app_names: HashMap<u64, String>,
/// What people were called when last seen, kept between runs: the name a conversation has from
/// the start of a session, in place of the person's number, until Steam says who they are now.
remembered: HashMap<u32, String>,
}
impl Roster {
/// Takes in what people were called last time.
pub fn remember(&mut self, names: impl IntoIterator<Item = (u32, String)>) {
self.remembered.extend(names);
}
/// What somebody is called now, if Steam or the account has said: the name the account gives
/// them, otherwise their own. Nothing for somebody not heard of yet - the number is not a name.
pub fn live_name(&self, account: u32) -> Option<String> {
self.nickname(account)
.map(clean_name)
.filter(|n| !n.is_empty())
.or_else(|| self.personas.get(&account).and_then(|p| p.name.as_deref()).map(clean_name).filter(|n| !n.is_empty()))
}
/// What somebody is called, for showing: what they are called now when that is known, and
/// otherwise what they were called last time. Nothing for somebody never heard of.
pub fn display_name(&self, account: u32) -> Option<String> {
self.live_name(account).or_else(|| self.remembered.get(&account).map(|n| clean_name(n)).filter(|n| !n.is_empty()))
}
/// The names that are not what is remembered of them: everybody Steam or the account has named,
/// whether or not they are in a conversation, since a member of a group is as much shown by
/// name. Taken as remembered from now, and handed back to be kept.
pub fn names_to_keep(&mut self) -> Vec<(u32, String)> {
let accounts: Vec<u32> = self.personas.keys().copied().chain(self.preferences.keys().copied()).collect();
let mut fresh = Vec::new();
for account in accounts {
let Some(name) = self.live_name(account) else { continue };
if self.remembered.get(&account) != Some(&name) {
self.remembered.insert(account, name.clone());
fresh.push((account, name));
}
}
fresh
}
/// Notes what a game is called.
pub fn set_app_name(&mut self, app: u64, name: String) {
self.app_names.insert(app, name);
}
/// The game somebody is in, in words: the name Steam gave, or the one the
/// store gave for its number, or - while that is being asked - only that it
/// is a game.
pub fn game_of(&self, account: u32) -> Option<String> {
let persona = self.personas.get(&account)?;
if let Some(game) = persona.game.clone() {
return Some(game);
}
let app = persona.app?;
Some(self.app_names.get(&app).cloned().unwrap_or_else(|| "In game".to_string()))
}
/// Games people are in whose names are not known: what to ask the store.
/// Only a game on Steam, whose number fits the store's; one that is not has
/// a name from Steam, or none to be had.
pub fn unnamed_apps(&self) -> Vec<u64> {
let mut apps: Vec<u64> = self.personas.values().filter(|p| p.game.is_none()).filter_map(|p| p.app).filter(|a| *a <= u64::from(u32::MAX) && !self.app_names.contains_key(a)).collect();
apps.sort_unstable();
apps.dedup();
apps
}
/// Takes in the friends list. A full list replaces what was held; an
/// incremental one is only the people whose standing changed. Returns the
/// accounts whose standing is now different.
pub fn apply_friends_list(&mut self, list: &ClientFriendsList) -> Vec<u32> {
let mut changed = Vec::new();
if !list.bincremental.unwrap_or(false) {
// Anyone not mentioned any more is no longer on the list.
let now: std::collections::HashSet<u32> = list.friends.iter().filter_map(|f| f.ulfriendid.and_then(steamid::account_of)).collect();
let gone: Vec<u32> = self.relationships.keys().copied().filter(|a| !now.contains(a)).collect();
for account in gone {
self.relationships.remove(&account);
changed.push(account);
}
}
for entry in &list.friends {
let Some(account) = entry.ulfriendid.and_then(steamid::account_of) else { continue };
let relationship = entry.efriendrelationship.unwrap_or(REL_NONE);
let before = self.relationships.get(&account).copied().unwrap_or(REL_NONE);
if relationship == REL_NONE {
self.relationships.remove(&account);
} else {
self.relationships.insert(account, relationship);
}
if before != relationship {
changed.push(account);
}
}
changed
}
/// Takes in persona updates, which carry only what changed. Returns the
/// accounts whose persona is now different.
pub fn apply_persona(&mut self, update: &ClientPersonaState) -> Vec<u32> {
let mut changed = Vec::new();
for friend in &update.friends {
let Some(account) = friend.friendid.and_then(steamid::account_of) else { continue };
let held = self.personas.entry(account).or_default();
let before = held.clone();
merge(held, friend);
if *held != before {
changed.push(account);
}
}
changed
}
/// Notes that there is a conversation with this person, friend or not.
/// Returns whether that is new.
pub fn note_conversation(&mut self, account: u32) -> bool {
!self.is_friend(account) && self.talking.insert(account)
}
/// Everybody a conversation exists with: the friends, and whoever else
/// has written. In a steady order.
pub fn participants(&self) -> Vec<u32> {
let mut out = self.friends();
out.extend(self.talking.iter().copied().filter(|a| !self.is_friend(*a)));
out.sort_unstable();
out
}
/// The people who are friends, in a steady order.
pub fn friends(&self) -> Vec<u32> {
let mut out: Vec<u32> = self.relationships.iter().filter(|(_, r)| **r == REL_FRIEND).map(|(a, _)| *a).collect();
out.sort_unstable();
out
}
/// The people who have asked to be our friend, in a steady order.
/// Everybody the account has blocked or ignored: Steam's "blocked" and the two
/// ways it says "ignored".
pub fn blocked(&self) -> Vec<u32> {
let mut out: Vec<u32> = self.relationships.iter().filter(|(_, r)| matches!(**r, 1 | 5 | 6)).map(|(a, _)| *a).collect();
out.sort_unstable();
out
}
pub fn asked_us(&self) -> Vec<u32> {
self.with_relationship(REL_REQUEST_RECIPIENT)
}
/// The people we have asked, who have not yet said yes.
pub fn asked_by_us(&self) -> Vec<u32> {
self.with_relationship(REL_REQUEST_INITIATOR)
}
fn with_relationship(&self, wanted: u32) -> Vec<u32> {
let mut out: Vec<u32> = self.relationships.iter().filter(|(_, r)| **r == wanted).map(|(a, _)| *a).collect();
out.sort_unstable();
out
}
pub fn is_friend(&self, account: u32) -> bool {
self.relationships.get(&account) == Some(&REL_FRIEND)
}
/// Takes in what the account keeps about a friend. Returns whether the name
/// it calls them changed, which is what moves a conversation.
pub fn set_preferences(&mut self, prefs: &PerFriendPreferences) -> bool {
let Some(account) = prefs.accountid else { return false };
let before = self.nickname(account).map(str::to_string);
let bare = PerFriendPreferences { accountid: Some(account), ..Default::default() };
if *prefs == bare {
self.preferences.remove(&account);
} else {
self.preferences.insert(account, prefs.clone());
}
before.as_deref() != self.nickname(account)
}
/// Whether the account has muted this friend's messages, on any of its clients.
pub fn muted(&self, account: u32) -> bool {
self.preferences.get(&account).is_some_and(|p| p.notifications_showmessages == Some(NOTIFY_NEVER))
}
/// Sets only what the account calls a friend, keeping the rest it holds.
/// Returns whether that name changed.
pub fn set_nickname(&mut self, account: u32, nickname: Option<&str>) -> bool {
let mut prefs = self.preferences.get(&account).cloned().unwrap_or(PerFriendPreferences { accountid: Some(account), ..Default::default() });
prefs.nickname = nickname.map(str::to_string);
self.set_preferences(&prefs)
}
/// What the account calls this friend, if it has said.
pub fn nickname(&self, account: u32) -> Option<&str> {
self.preferences.get(&account).and_then(|p| p.nickname.as_deref()).map(str::trim).filter(|n| !n.is_empty())
}
pub fn persona(&self, account: u32) -> Option<&Persona> {
self.personas.get(&account)
}
/// What each friend's conversation is called.
///
/// Their name, since that is what a person recognises - but names are not
/// unique on Steam, and a conversation's name is also its identity, so two
/// friends called the same would share one and each message would go to
/// whichever was first. Whenever two or more friends share a name (read
/// without regard to case) every one of them has their account number
/// added to it, which is stable and tells them apart; when only one is
/// left the number is dropped again. Somebody not yet named is called by
/// what they were called last time, and failing that by their id.
///
/// Distinct is the guarantee, not a hope: a friend can be called anything,
/// including exactly what another friend's name becomes once decorated, so
/// the result is checked and anyone still sharing a name is marked again,
/// differently, until nobody is.
/// Whether anything is known to call this person: a nickname the account
/// gave them, or the name they have. Without either, `conversation_names`
/// has only their number to offer.
pub fn has_name(&self, account: u32) -> bool {
self.nickname(account).map(clean_name).is_some_and(|n| !n.is_empty())
|| self.personas.get(&account).and_then(|p| p.name.as_deref()).map(clean_name).is_some_and(|n| !n.is_empty())
}
pub fn conversation_names(&self) -> HashMap<u32, String> {
// What the account calls them, if it has said; otherwise what they
// call themselves.
let base = |account: u32| -> String {
self.live_name(account)
// Not yet said this time: what they were called last, which is far likelier to be right than a number.
.or_else(|| self.remembered.get(&account).map(|n| clean_name(n)).filter(|n| !n.is_empty()))
.unwrap_or_else(|| steamid::text_of(account))
};
let people = self.participants();
let mut names: HashMap<u32, String> = people.iter().map(|a| (*a, base(*a))).collect();
// Plain, then with the number in round brackets, in square, in curly;
// and for the last resort a character no cleaned name can contain.
let marks: [fn(&str, u32) -> String; 4] = [
|n, a| format!("{n} ({a})"),
|n, a| format!("{n} [{a}]"),
|n, a| format!("{n} {{{a}}}"),
|n, a| format!("{n}\u{1f}{a}"),
];
for mark in marks {
let mut by_name: HashMap<String, Vec<u32>> = HashMap::new();
for (account, name) in &names {
by_name.entry(name.to_lowercase()).or_default().push(*account);
}
let clashing: Vec<u32> = by_name.into_values().filter(|group| group.len() > 1).flatten().collect();
if clashing.is_empty() {
break;
}
for account in clashing {
names.insert(account, mark(&base(account), account));
}
}
names
}
}
fn merge(held: &mut Persona, update: &PersonaFriend) {
if let Some(name) = update.player_name.as_deref().map(clean_name).filter(|n| !n.is_empty()) {
held.name = Some(name);
}
if let Some(state) = update.persona_state {
held.state = state;
}
// A game is cleared by saying there is none, not by leaving it out.
if update.gameid == Some(0) || update.game_played_app_id == Some(0) {
held.game = None;
held.app = None;
held.playing_since = None;
}
if let Some(app) = update.game_played_app_id.map(u64::from).filter(|a| *a > 0).or(update.gameid.filter(|g| *g > 0)) {
if held.app != Some(app) {
// A different game: what was said of the last is not said of this one.
held.game = None;
held.playing_since = Some(std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as u32).unwrap_or(0));
}
held.app = Some(app);
}
if let Some(game) = update.game_name.as_deref().filter(|g| !g.is_empty()) {
held.game = Some(game.to_string());
}
if let Some(hash) = update.avatar_hash.as_deref().filter(|h| !h.is_empty()) {
held.avatar = Some(avatar_url(hash));
}
if let Some(flags) = update.persona_state_flags {
held.flags = flags;
}
// The Steam Deck is said two ways: a flag, and the kind of device it is (544), which is what
// the Steam client itself goes by; and 545 is a Steam Machine.
if let Some(deck) = update.on_steam_deck {
held.on_deck = deck;
}
if let Some(kind) = update.gaming_device_type {
held.device = kind;
held.on_deck = held.on_deck || kind == DEVICE_STEAM_DECK;
}
if let Some(seen) = [update.last_seen_online, update.last_logoff, update.last_logon].into_iter().flatten().filter(|t| *t > 0).max() {
held.last_seen = Some(held.last_seen.map_or(seen, |old| old.max(seen)));
}
// A game's own words for what is going on: its "status" line, which is
// plain text where the game sets one. Cleared with the game.
if !update.rich_presence.is_empty() {
held.rich = update.rich_presence.iter().find(|kv| kv.key.as_deref() == Some("status")).and_then(|kv| kv.value.clone()).filter(|v| !v.trim().is_empty());
}
if update.gameid == Some(0) || update.game_played_app_id == Some(0) {
held.rich = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::steam::proto::{FriendsListEntry, KeyValue};
// The other standings a list carries, which matter here only as not being a friend.
const REL_BLOCKED: u32 = 1;
#[test]
fn a_person_is_called_what_they_were_until_steam_says_who_they_are_and_what_is_said_is_kept() {
let mut r = Roster::default();
r.apply_friends_list(&ClientFriendsList {
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(7)), efriendrelationship: Some(REL_FRIEND) }],
..Default::default()
});
r.remember([(7, "Old Name".to_string())]);
assert_eq!(r.conversation_names()[&7], "Old Name", "not the number, before Steam has said");
assert_eq!(r.display_name(7).as_deref(), Some("Old Name"));
assert!(r.names_to_keep().is_empty(), "nothing new to keep");
r.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(7)), player_name: Some("New Name".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
assert_eq!(r.conversation_names()[&7], "New Name", "and what they are called now once it is said");
assert_eq!(r.names_to_keep(), vec![(7, "New Name".to_string())], "which is kept in place of the old");
assert!(r.names_to_keep().is_empty());
// Somebody never heard of is only a number.
assert_eq!(r.display_name(8), None);
}
#[test]
fn a_nickname_is_what_a_friend_is_called_and_goes_back_to_their_name_when_taken() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND)]));
r.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), player_name: Some("Gabe".into()), ..Default::default() }], ..Default::default() });
assert_eq!(r.conversation_names()[&10], "Gabe");
assert!(r.set_nickname(10, Some("Buddy")), "a new name is a change");
assert_eq!(r.conversation_names()[&10], "Buddy");
assert!(!r.set_nickname(10, Some("Buddy")), "the same again is not");
// Muting keeps the nickname, and the other way about.
r.set_preferences(&PerFriendPreferences { accountid: Some(10), nickname: Some("Buddy".into()), notifications_showmessages: Some(NOTIFY_NEVER), ..Default::default() });
assert_eq!(r.preferences.get(&10).and_then(|p| p.notifications_showmessages), Some(NOTIFY_NEVER));
assert!(r.set_nickname(10, None));
assert_eq!(r.conversation_names()[&10], "Gabe");
assert_eq!(r.preferences.get(&10).and_then(|p| p.notifications_showmessages), Some(NOTIFY_NEVER), "taking the name back does not unmute");
// Two friends given the same nickname are still told apart.
r.set_nickname(10, Some("Pal"));
r.set_nickname(20, Some("pal"));
let names = r.conversation_names();
assert_ne!(names[&10].to_lowercase(), names[&20].to_lowercase());
}
#[test]
fn the_kind_of_client_and_last_seen_are_kept_from_their_updates() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND)]));
r.apply_persona(&ClientPersonaState {
friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), persona_state_flags: Some(512), last_logoff: Some(100), last_seen_online: Some(300), on_steam_deck: Some(false), rich_presence: vec![KeyValue { key: Some("status".into()), value: Some("In a match".into()) }], ..Default::default() }],
..Default::default()
});
let p = r.persona(10).unwrap();
assert_eq!((p.flags, p.last_seen, p.rich.as_deref()), (512, Some(300), Some("In a match")));
// A later update only says what changed: the newest time wins, and the game ending ends its words.
r.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), last_logoff: Some(200), gameid: Some(0), ..Default::default() }], ..Default::default() });
let p = r.persona(10).unwrap();
assert_eq!((p.last_seen, p.rich.as_deref(), p.flags), (Some(300), None, 512));
}
#[test]
fn requests_are_told_apart_by_who_made_them() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (30, REL_REQUEST_RECIPIENT), (20, REL_REQUEST_RECIPIENT), (50, REL_REQUEST_INITIATOR), (60, REL_BLOCKED)]));
assert_eq!(r.asked_us(), [20, 30]);
assert_eq!(r.asked_by_us(), [50]);
assert_eq!(r.friends(), [10]);
// Saying yes turns a request into a friend.
r.apply_friends_list(&list(true, &[(20, REL_FRIEND)]));
assert_eq!(r.asked_us(), [30]);
assert!(r.is_friend(20));
}
fn entry(account: u32, relationship: u32) -> FriendsListEntry {
FriendsListEntry { ulfriendid: Some(steamid::steamid_of(account)), efriendrelationship: Some(relationship) }
}
fn list(incremental: bool, entries: &[(u32, u32)]) -> ClientFriendsList {
ClientFriendsList { bincremental: Some(incremental), friends: entries.iter().map(|(a, r)| entry(*a, *r)).collect(), ..Default::default() }
}
fn named(account: u32, name: &str, state: u32) -> PersonaFriend {
PersonaFriend { friendid: Some(steamid::steamid_of(account)), player_name: Some(name.into()), persona_state: Some(state), ..Default::default() }
}
fn persona(friends: Vec<PersonaFriend>) -> ClientPersonaState {
ClientPersonaState { friends, ..Default::default() }
}
#[test]
fn only_friends_are_friends_not_requests_or_the_blocked() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_REQUEST_RECIPIENT), (30, REL_BLOCKED), (40, REL_FRIEND), (50, REL_REQUEST_INITIATOR)]));
assert_eq!(r.friends(), vec![10, 40]);
assert!(r.is_friend(10) && !r.is_friend(20) && !r.is_friend(99));
}
#[test]
fn an_incremental_list_changes_the_people_it_names_and_leaves_the_rest() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND)]));
let changed = r.apply_friends_list(&list(true, &[(20, REL_NONE), (30, REL_FRIEND), (10, REL_FRIEND)]));
assert_eq!(r.friends(), vec![10, 30]);
changed.iter().for_each(|a| assert!([20, 30].contains(a), "10 did not change, so it is not reported: {changed:?}"));
assert_eq!(changed.len(), 2);
}
#[test]
fn a_full_list_forgets_whoever_it_no_longer_names() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND)]));
let changed = r.apply_friends_list(&list(false, &[(20, REL_FRIEND)]));
assert_eq!(r.friends(), vec![20]);
assert_eq!(changed, vec![10]);
}
#[test]
fn an_id_that_is_not_a_person_is_skipped_not_a_panic() {
let mut r = Roster::default();
let group = (1u64 << 56) | (7 << 52) | (1 << 32) | 5;
let bad = ClientFriendsList { friends: vec![FriendsListEntry { ulfriendid: Some(group), efriendrelationship: Some(3) }, FriendsListEntry { ulfriendid: None, efriendrelationship: Some(3) }], ..Default::default() };
assert!(r.apply_friends_list(&bad).is_empty());
assert!(r.friends().is_empty());
}
#[test]
fn a_persona_is_built_up_from_partial_updates() {
let mut r = Roster::default();
r.apply_persona(&persona(vec![named(10, "Alex", 1)]));
// A later update with only a state, and one with only a game.
r.apply_persona(&persona(vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), persona_state: Some(3), ..Default::default() }]));
r.apply_persona(&persona(vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), game_name: Some("Portal 2".into()), gameid: Some(620), ..Default::default() }]));
let p = r.persona(10).unwrap();
assert_eq!(p.name.as_deref(), Some("Alex"), "a name left out is not a name taken away");
assert_eq!(p.state, 3);
assert_eq!(p.game.as_deref(), Some("Portal 2"));
// Quitting the game is said by saying there is none.
r.apply_persona(&persona(vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), gameid: Some(0), ..Default::default() }]));
assert_eq!(r.persona(10).unwrap().game, None);
}
#[test]
fn only_a_real_change_is_reported_as_one() {
let mut r = Roster::default();
assert_eq!(r.apply_persona(&persona(vec![named(10, "Alex", 1)])), vec![10]);
assert!(r.apply_persona(&persona(vec![named(10, "Alex", 1)])).is_empty(), "the same again is not news");
assert_eq!(r.apply_persona(&persona(vec![named(10, "Alex", 3)])), vec![10]);
}
#[test]
fn a_name_is_made_fit_to_be_a_conversation() {
assert_eq!(clean_name(" Alex\n"), "Alex");
assert_eq!(clean_name("A\u{0}l\u{7}ex"), "Alex");
assert_eq!(clean_name("日本語 名前"), "日本語 名前");
assert_eq!(clean_name("\n\t "), "");
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND)]));
r.apply_persona(&persona(vec![named(10, " ", 1)]));
assert_eq!(r.persona(10).unwrap().name, None, "a blank name is not a name");
}
#[test]
fn friends_are_called_by_name_and_unnamed_ones_by_their_id() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND)]));
r.apply_persona(&persona(vec![named(10, "Alex", 1)]));
let names = r.conversation_names();
assert_eq!(names[&10], "Alex");
assert_eq!(names[&20], steamid::text_of(20));
}
#[test]
fn two_friends_with_the_same_name_do_not_share_a_conversation() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND), (30, REL_FRIEND)]));
r.apply_persona(&persona(vec![named(10, "Alex", 1), named(20, "alex", 1), named(30, "Sam", 1)]));
let names = r.conversation_names();
// Case is not what tells people apart, so these two collide...
assert_eq!(names[&10], "Alex (10)");
assert_eq!(names[&20], "alex (20)");
assert_eq!(names[&30], "Sam");
let distinct: std::collections::HashSet<_> = names.values().map(|n| n.to_lowercase()).collect();
assert_eq!(distinct.len(), 3, "every conversation has a name of its own");
// ...and when one of them is renamed the other goes back to plain.
r.apply_persona(&persona(vec![named(20, "Robin", 1)]));
let names = r.conversation_names();
assert_eq!((names[&10].as_str(), names[&20].as_str()), ("Alex", "Robin"));
}
#[test]
fn the_numbers_cannot_be_fooled_by_a_name_that_looks_like_one() {
// Somebody calling themselves "Alex (20)" must not collide with the
// friend who is made "Alex (20)" by sharing a name.
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND), (20, REL_FRIEND), (30, REL_FRIEND)]));
r.apply_persona(&persona(vec![named(10, "Alex", 1), named(20, "Alex", 1), named(30, "Alex (20)", 1)]));
let names = r.conversation_names();
let distinct: std::collections::HashSet<_> = names.values().map(|n| n.to_lowercase()).collect();
assert_eq!(distinct.len(), 3, "{names:?}");
}
/// Whatever people call themselves, no two conversations share a name.
/// Every way four friends can be given names from a pool made of exactly
/// the strings the decoration produces, which is the worst case there is.
#[test]
fn however_friends_are_named_no_two_conversations_are_called_the_same() {
let pool = ["a", "A", "a (1)", "a (2)", "a [1]", "a [2]", "a (1) [1]"];
let mut checked = 0;
for combo in 0..pool.len().pow(4) {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(1, REL_FRIEND), (2, REL_FRIEND), (3, REL_FRIEND), (4, REL_FRIEND)]));
let mut at = combo;
let updates: Vec<PersonaFriend> = (1..=4u32)
.map(|account| {
let name = pool[at % pool.len()];
at /= pool.len();
named(account, name, 1)
})
.collect();
r.apply_persona(&persona(updates));
let names = r.conversation_names();
let distinct: std::collections::HashSet<String> = names.values().map(|n| n.to_lowercase()).collect();
assert_eq!(distinct.len(), 4, "combination {combo} gave {names:?}");
checked += 1;
}
assert_eq!(checked, 7 * 7 * 7 * 7);
}
#[test]
fn somebody_who_writes_first_gets_a_conversation_without_becoming_a_friend() {
let mut r = Roster::default();
r.apply_friends_list(&list(false, &[(10, REL_FRIEND)]));
assert!(r.note_conversation(77), "new");
assert!(!r.note_conversation(77), "not new twice");
assert!(!r.note_conversation(10), "a friend already has one");
assert_eq!(r.friends(), vec![10]);
assert_eq!(r.participants(), vec![10, 77]);
r.apply_persona(&persona(vec![named(10, "Alex", 1), named(77, "Alex", 1)]));
let names = r.conversation_names();
assert_ne!(names[&10].to_lowercase(), names[&77].to_lowercase(), "a stranger sharing a friend's name does not share their conversation");
// Once they are a friend they are only counted once.
r.apply_friends_list(&list(true, &[(77, REL_FRIEND)]));
assert_eq!(r.participants(), vec![10, 77]);
}
#[test]
fn a_steam_deck_is_known_by_the_kind_of_device_as_the_steam_client_knows_it() {
let mut roster = Roster::default();
let said = |kind: u32| ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(10)), gaming_device_type: Some(kind), persona_state: Some(1), ..Default::default() }], ..Default::default() };
roster.apply_persona(&said(544));
assert!(roster.persona(10).unwrap().on_deck, "544 is a Steam Deck");
roster.apply_persona(&said(545));
assert_eq!(roster.persona(10).unwrap().device, DEVICE_STEAM_MACHINE);
}
#[test]
fn the_avatar_is_where_steam_serves_it_and_none_set_is_the_default_picture() {
let hash: Vec<u8> = (0..20).collect();
assert_eq!(avatar_url(&hash), "https://avatars.steamstatic.com/000102030405060708090a0b0c0d0e0f10111213_full.jpg");
assert_eq!(avatar_url(&[0; 20]), "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg");
assert_eq!(avatar_url(&[]), avatar_url(&[0; 20]));
}
}
+312
View File
@@ -0,0 +1,312 @@
//! Becoming friends and ceasing to be: asking, answering and ending.
//!
//! On Steam these are two messages. `ClientAddFriend` is both "I would like
//! to be your friend" and "yes, I would like that too", depending on whether
//! the other has already asked - so accepting a request is adding the person
//! who made it. `ClientRemoveFriend` ends a friendship, takes back a request
//! we made, and turns down one made to us.
//!
//! Who is waiting on whom is kept in the roster; what the window is shown of
//! it is built here.
use super::friends::Roster;
use super::link::Link;
use super::proto::{ClientAddFriend, ClientAddFriendResponse, ClientRemoveFriend};
use super::wire::emsg;
use super::{eresult, steamid};
use crate::state::AppState;
use serde_json::{json, Value};
use std::collections::HashSet;
/// Who somebody means when they type a person into the add box.
#[derive(Debug, PartialEq)]
pub enum Target {
/// Somebody's number, however it was written.
Id(u64),
/// An account name, which Steam looks up itself.
Name(String),
}
/// Reads what a person typed: the 17-digit id, the number a friend's "friend
/// code" is, the `[U:1:n]` form, a profile address that carries the id, or
/// else an account name.
pub fn parse_target(text: &str) -> Result<Target, String> {
let text = text.trim().trim_end_matches('/');
if text.is_empty() {
return Err("type who to add: a Steam id, a friend code, or an account name".to_string());
}
if let Some(inner) = text.strip_prefix("[U:1:").and_then(|t| t.strip_suffix(']')) {
return inner.parse::<u32>().map(|n| Target::Id(steamid::steamid_of(n))).map_err(|_| format!("{text} is not a Steam id"));
}
// An invite link, `s.team/p/<code>/<token>`, or a profile address in the
// same code's form: it says which account, and is asked as a request.
if text.contains("s.team/p/") || text.contains("/user/") {
return super::invites::account_of_link(text).map(|n| Target::Id(steamid::steamid_of(n))).ok_or_else(|| "that is not a Steam invite link".to_string());
}
// A profile address. Only the numeric form says who it is; a custom name
// in one is looked up on the website, which this does not talk to.
if let Some((_, rest)) = text.split_once("steamcommunity.com/") {
return match rest.split_once('/') {
Some(("profiles", id)) => parse_target(id),
Some(("id", _)) => Err("that profile address uses a custom name, which moho cannot look up; use the id from the number-form address, or the account name".to_string()),
_ => Err("that is not a profile address".to_string()),
};
}
if text.bytes().all(|b| b.is_ascii_digit()) {
let n: u64 = text.parse().map_err(|_| format!("{text} is too long to be a Steam id"))?;
// A friend code is the account number alone; a full id is that plus
// the fixed part for a person.
return match steamid::account_of(n) {
Some(_) => Ok(Target::Id(n)),
None if n <= u64::from(u32::MAX) => Ok(Target::Id(steamid::steamid_of(n as u32))),
None => Err(format!("{text} is not a person's Steam id")),
};
}
Ok(Target::Name(text.to_string()))
}
/// A friendship that was begun, or a request that was answered.
#[derive(Debug, PartialEq)]
pub struct Added {
pub steamid: u64,
pub name: Option<String>,
}
/// Asks somebody to be our friend, or says yes to their request.
pub async fn add(link: &Link, target: &Target) -> Result<Added, String> {
let request = match target {
Target::Id(id) => ClientAddFriend { steamid_to_add: Some(*id), accountname_or_email_to_add: None },
Target::Name(name) => ClientAddFriend { steamid_to_add: None, accountname_or_email_to_add: Some(name.clone()) },
};
let answer: ClientAddFriendResponse = link.ask(emsg::CLIENT_ADD_FRIEND, &request).await.map_err(|e| e.to_string())?;
let code = answer.eresult.unwrap_or(eresult::FAIL);
tracing::info!("steam: asking to add {target:?}: result {code}, id {:?}", answer.steam_id_added);
if code != eresult::OK {
return Err(refusal_of(code, target));
}
Ok(Added { steamid: answer.steam_id_added.unwrap_or_default(), name: answer.persona_name_added.filter(|n| !n.is_empty()) })
}
/// What a refusal of an add means to the person who asked.
///
/// Steam answers a plain failure to a good many things and says nothing more.
/// What is known to cause it is said alongside, as what to try rather than as
/// the cause, because Steam does not say which it was.
fn refusal_of(code: i32, target: &Target) -> String {
// What an add means by some answers is not what they mean elsewhere.
let words = match code {
eresult::NO_MATCH | eresult::ACCOUNT_NOT_FOUND => "Steam found nobody by that name or number".to_string(),
eresult::LIMIT_EXCEEDED => "a friends list can only hold so many people, and one of the two is full".to_string(),
eresult::ACCESS_DENIED => "this person's settings do not allow a friend request from you".to_string(),
other => eresult::describe(other),
};
if code != eresult::FAIL {
return words;
}
let mut out = format!("{words} (error {code}).");
out.push_str(" A new account that has not spent anything on Steam is \"limited\", and Steam does not let a limited account send friend requests - though it can accept one sent to it.");
if matches!(target, Target::Name(_)) {
out.push_str(" An account name here is the sign-in name, not the name shown to friends; the Steam id or friend code is more reliable.");
}
out
}
/// Ends a friendship, takes back a request, or turns one down. Steam sends
/// no answer to this; the friends list that follows is the answer.
pub fn remove(link: &Link, steamid: u64) -> Result<(), String> {
steamid::account_of(steamid).ok_or_else(|| "that is not a person's Steam id".to_string())?;
link.send(emsg::CLIENT_REMOVE_FRIEND, &ClientRemoveFriend { friendid: Some(steamid) }).map_err(|e| e.to_string())
}
fn person(roster: &Roster, account: u32) -> Value {
json!({
"steamid": steamid::text_of(account),
"name": roster.persona(account).and_then(|p| p.name.clone()).filter(|n| !n.is_empty()),
"avatarUrl": roster.persona(account).and_then(|p| p.avatar.clone()),
})
}
/// What the window is shown: who has asked, and who we have asked.
pub fn snapshot(roster: &Roster) -> Value {
json!({
"incoming": roster.asked_us().into_iter().map(|a| person(roster, a)).collect::<Vec<_>>(),
"outgoing": roster.asked_by_us().into_iter().map(|a| person(roster, a)).collect::<Vec<_>>(),
})
}
/// Keeps the window's picture of the requests current, and says out loud
/// when somebody new has asked. `told` holds who the window has already been
/// told about, so a name arriving a moment after the request, or the list
/// being sent again, does not announce the same person twice.
pub fn publish(state: &AppState, account_id: &str, roster: &Roster, told: &mut HashSet<u32>) {
let now = snapshot(roster);
if state.runtime.steam_requests(account_id) == now {
return;
}
state.runtime.set_steam_requests(account_id, now.clone());
let asking = roster.asked_us();
told.retain(|a| asking.contains(a));
for account in asking {
if told.insert(account) {
state.events.emit("steamFriendRequest", json!({ "accountId": account_id, "request": person(roster, account) }));
}
}
state.events.emit("steamFriendRequests", json!({ "accountId": account_id, "requests": now }));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::steam::link::fake::serve;
use crate::backend::steam::proto::{ClientFriendsList, FriendsListEntry};
use prost::Message;
/// Steam's answer to an add: its own message, matched to the question by job.
fn reply(request: &crate::backend::steam::wire::Packet, body: Vec<u8>) -> crate::backend::steam::wire::Packet {
use crate::backend::steam::{proto::Header, wire::Packet};
Packet::new(emsg::CLIENT_ADD_FRIEND_RESPONSE, Header { jobid_target: request.header.jobid_source, ..Header::default() }, body)
}
#[test]
fn a_person_can_be_named_every_way_steam_writes_them() {
let id = steamid::steamid_of(22202);
assert_eq!(parse_target("76561197960287930"), Ok(Target::Id(id)));
assert_eq!(parse_target(" 76561197960287930\n"), Ok(Target::Id(id)));
assert_eq!(parse_target("22202"), Ok(Target::Id(id)), "a friend code is the account number");
assert_eq!(parse_target("https://s.team/p/dknq-cpfb/QWERTYUI"), Ok(Target::Id(steamid::steamid_of(664476208))), "an invite link says whose it is");
assert!(parse_target("https://s.team/p/not-code/TOKEN").is_err());
assert_eq!(parse_target("[U:1:22202]"), Ok(Target::Id(id)));
assert_eq!(parse_target("https://steamcommunity.com/profiles/76561197960287930/"), Ok(Target::Id(id)));
assert_eq!(parse_target("mohotester"), Ok(Target::Name("mohotester".to_string())));
}
#[test]
fn what_cannot_be_read_is_said_rather_than_guessed_at() {
assert!(parse_target("").is_err());
assert!(parse_target(" ").is_err());
assert!(parse_target("https://steamcommunity.com/id/somebody").unwrap_err().contains("custom name"));
assert!(parse_target("https://steamcommunity.com/groups/x").is_err());
assert!(parse_target("[U:1:abc]").is_err());
// A group's id, which has the digits of an id and is not a person's.
assert!(parse_target("103582791429521412").is_err());
}
#[tokio::test]
async fn adding_by_id_sends_the_id_and_reports_who_was_added() {
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
let url = serve(Box::new({
let seen = seen.clone();
move |packet| {
*seen.lock().unwrap() = Some((packet.emsg, ClientAddFriend::decode(packet.body.as_slice()).unwrap()));
let body = ClientAddFriendResponse { eresult: Some(1), steam_id_added: Some(steamid::steamid_of(7)), persona_name_added: Some("Seven".into()) };
Some(vec![reply(&packet, body.encode_to_vec())])
}
}))
.await;
let (link, _told) = crate::backend::steam::link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
let added = add(&link, &Target::Id(steamid::steamid_of(7))).await.unwrap();
assert_eq!(added, Added { steamid: steamid::steamid_of(7), name: Some("Seven".into()) });
let (kind, sent) = seen.lock().unwrap().take().unwrap();
assert_eq!(kind, emsg::CLIENT_ADD_FRIEND);
assert_eq!(sent.steamid_to_add, Some(steamid::steamid_of(7)));
assert_eq!(sent.accountname_or_email_to_add, None, "one or the other, never both");
}
#[tokio::test]
async fn adding_by_name_sends_the_name() {
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
let url = serve(Box::new({
let seen = seen.clone();
move |packet| {
*seen.lock().unwrap() = Some(ClientAddFriend::decode(packet.body.as_slice()).unwrap());
let body = ClientAddFriendResponse { eresult: Some(1), steam_id_added: Some(steamid::steamid_of(8)), persona_name_added: None };
Some(vec![reply(&packet, body.encode_to_vec())])
}
}))
.await;
let (link, _told) = crate::backend::steam::link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
let added = add(&link, &Target::Name("mohotester".into())).await.unwrap();
assert_eq!(added.name, None, "an empty or absent name is not a name");
assert_eq!(seen.lock().unwrap().take().unwrap().accountname_or_email_to_add.as_deref(), Some("mohotester"));
}
#[tokio::test]
async fn a_refusal_is_said_in_words_and_a_missing_result_is_a_failure() {
for (code, words) in [(Some(42), "nobody"), (Some(29), "already"), (Some(40), "blocked"), (Some(24), "limited account"), (None, "limited")] {
let url = serve(Box::new(move |packet| {
let body = ClientAddFriendResponse { eresult: code, ..Default::default() };
Some(vec![reply(&packet, body.encode_to_vec())])
}))
.await;
let (link, _told) = crate::backend::steam::link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
let err = add(&link, &Target::Name("x".into())).await.unwrap_err();
assert!(err.contains(words), "{code:?} -> {err}");
if code.is_none() {
assert!(err.contains("sign-in name"), "a name is not the same as an id: {err}");
}
}
}
#[tokio::test]
async fn removing_sends_the_id_and_refuses_what_is_not_a_person() {
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
let url = serve(Box::new({
let seen = seen.clone();
move |packet| {
*seen.lock().unwrap() = Some((packet.emsg, ClientRemoveFriend::decode(packet.body.as_slice()).unwrap()));
Some(vec![])
}
}))
.await;
let (link, _told) = crate::backend::steam::link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
assert!(remove(&link, 103582791429521412).is_err());
assert!(seen.lock().unwrap().is_none(), "nothing was sent for a bad id");
remove(&link, steamid::steamid_of(9)).unwrap();
for _ in 0..100 {
if seen.lock().unwrap().is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let (kind, sent) = seen.lock().unwrap().take().unwrap();
assert_eq!((kind, sent.friendid), (emsg::CLIENT_REMOVE_FRIEND, Some(steamid::steamid_of(9))));
}
fn roster_with_requests() -> Roster {
let mut r = Roster::default();
let entry = |a: u32, rel: u32| FriendsListEntry { ulfriendid: Some(steamid::steamid_of(a)), efriendrelationship: Some(rel) };
r.apply_friends_list(&ClientFriendsList { bincremental: Some(false), friends: vec![entry(10, 3), entry(20, 2), entry(30, 4)], ..Default::default() });
r
}
#[test]
fn the_snapshot_lists_who_asked_and_who_was_asked() {
let now = snapshot(&roster_with_requests());
assert_eq!(now["incoming"][0]["steamid"], steamid::text_of(20));
assert_eq!(now["outgoing"][0]["steamid"], steamid::text_of(30));
assert_eq!(now["incoming"].as_array().unwrap().len(), 1, "a friend is not a request");
assert!(now["incoming"][0]["name"].is_null(), "not named until Steam says");
}
#[tokio::test]
async fn somebody_asking_is_announced_once() {
let state = crate::backend::kick::testkit::simulated_daemon("steam-friendship");
let mut events = state.events.subscribe();
let roster = roster_with_requests();
let mut told = HashSet::new();
publish(&state, "steam:me", &roster, &mut told);
publish(&state, "steam:me", &roster, &mut told);
let mut asked = 0;
let mut lists = 0;
while let Ok(event) = events.try_recv() {
match event.name {
"steamFriendRequest" => asked += 1,
"steamFriendRequests" => lists += 1,
_ => {}
}
}
assert_eq!((asked, lists), (1, 1), "the same list again says nothing");
assert_eq!(state.runtime.steam_requests("steam:me")["incoming"][0]["steamid"], steamid::text_of(20));
}
}
+202
View File
@@ -0,0 +1,202 @@
Header 09ba5600000100100110aef6ffffffffffffff0118da055111000000000000005912000000000000006226467269656e644d65737361676573436c69656e742e496e636f6d696e674d6573736167652331680172026e6f800201
Multi 088020120a1f8b08007a6970706564
ClientHello 08ad8004
ClientHeartBeat 0801
ClientLogon 08ad8004180928eb0d3207656e676c69736838cb0140015a050defbeaddea80102f2010a0102036d616368696e65800204880202920307736f6d656f6e65f80501820604484f5354a006858080808020b00601e2061165794a2e726566726573682e746f6b656ef80601
ClientLogonResponse 0801181b2d803bb16a3003380b50057207736f6d656f6e65a101ba56000001001001aa01024742d801878080808040f0018980808080808002f8018080808020
ClientLogOff
ClientLoggedOff 0806
ClientAccountInfo 0a08536f6d65204f6e651202474228033808880101
FriendsListEntry 09ba560000010010011003
ClientFriendsList 0801120b09ba560000010010011003120b090100000001001001100218fa0120022801
KeyValue 0a06737461747573120a496e2061206d61746368
PersonaFriend 09ba56000001001001100318da0530800450017a08536f6d65204f6e65fa0114000102030405060708090a0b0c0d0e0f10111213e80280e2cfaa06f002e4e2cfaa06f802c8e3cfaa06ba0310436f756e7465722d537472696b652032c103da02000000000000ba04190a0d737465616d5f646973706c6179120823506c6179696e67ba04110a06737461747573120744757374204949880501980502
ClientPersonaState 08d208120e09ba5600000100100110017a0141120e09010000000100100110077a0142
ClientRequestFriendData 08d20811ba56000001001001110100000001001001
ClientChangeStatus 08071208536f6d65204f6e65180120012801300138014001
GetCommunityPreferencesRequest
CommunityPreferences 1880f7c4d5062001280230003807
GetCommunityPreferencesResponse 0a0428013001
GetTextFilterWordsRequest
TextFilterWords 0a01610a01621201631803
GetTextFilterWordsResponse 0a030a0178
GetPrivacySettingsRequest
PrivacySettings 080310021801200328023001
GetPrivacySettingsResponse 0a020802
GetParentalSettingsRequest 51ba56000001001001
ParentalSettings 09ba560000010010014801500f6803
GetParentalSettingsResponse 0a0448015008
EnumerateTokensRequest 0801
TokenUsageEvent 0881f7c4d506220247422a03454e4732064c6f6e646f6e
RefreshTokenDescription 090300000000010000120c6d6f686f206f6e20686f73741882f7c4d506200328014a06080122024742520a080232064c6f6e646f6e
EnumerateTokensResponse 0a12090500000000000000120570686f6e652003110500000000000000
RevokeRefreshTokenRequest 09030000000001000011ba56000001001001
RevokeRefreshTokenResponse
ChatRoomState 08818080802012027332180120072009280c300d3a027337400f
ChatRoomMember 080818022014300d388780808020388880808020
ChatRole 08818080802012027332180a
ChatRoomGroupHeaderState 08818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d00101
ChatRoomGroupState 0a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d388780808020388880808020
UserChatRoomState 0881808080201009180a20032802300d3801400f
UserChatRoomGroupState 08818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001
ChatGroupSummary 08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b00101
ChatRoomSummaryPair 0a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001125d08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b00101
GetMyChatRoomGroupsRequest
GetMyChatRoomGroupsResponse 0a89010a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001125d08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b00101
GetChatRoomGroupStateRequest 088180808020
GetChatRoomGroupStateResponse 0a8f010a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d388780808020388880808020
SetSessionActiveGroupsRequest 0881808080200882808080201082808080201083808080201806
SetSessionActiveGroupsResponse 0a8f010a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d3887808080203888808080201082808080201083808080201a0c088180808020120408071001
ChatSendRequest 0881808080201082808080201a04746578742001
ChatSendResponse 0a0273311009180a22027334
ChatHistoryRequest 088180808020108280808020180a200b280c300d380e
ServerMessage 080212027332180a
ChatMessageReaction 080112027332180a2001
ChatHistoryMessage 080810091a0474657874200b2a08080212027332180a30013a0a080112027332180a2001
ChatHistoryResponse 0a24080810091a0474657874200b2a08080212027332180a30013a0a080112027332180a20012001
ChatMentions 08011001180a180c
ChatIncoming 08818080802010828080802019ba56000001001001220474657874280c320808011001180a180c380e4208080212027332180a4a02733952037331305a03733131
ChatAck 088180808020108280808020180a
ChatModifiedMessage 080810091801
ChatModified 0881808080201082808080201a06080810091801
ChatMemberChange 0881808080201214080818022014300d3887808080203888808080201801
ChatHeaderChange 0a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d00101
ChatRoomsChange 0881808080201082808080201a16088180808020120273321801280c300d3a027337400f
ChatUserStateChange 088180808020122808818080802010091a140881808080201009180a20032802300d3801400f20032802300d380140011a5d08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b001012002
CreateChatGroupRequest 09ba5600000100100111ba560000010010011a02733321ba56000001001001210100000001001001
CreateChatGroupResponse 088180808020128f010a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d3887808080203888808080201a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001
JoinChatGroupRequest 08818080802012027332188380808020
JoinChatGroupResponse 0a8f010a4308818080802012027332681470157a037331358201030102038801918080802092010c08818080802012027332180a9a01080881808080203801ca0103733235d001011214080818022014300d3887808080203888808080202084808080202a16088180808020120273321801280c300d3a027337400f3a14080818022014300d3887808080203888808080201a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d38014001208480808020280c
LeaveChatGroupRequest 088180808020
LeaveChatGroupResponse
CreateChatRoomRequest 088180808020120273321801
CreateChatRoomResponse 0a16088180808020120273321801280c300d3a027337400f
DeleteChatRoomRequest 088180808020108280808020
DeleteChatRoomResponse
RenameChatRoomRequest 0881808080201082808080201a027333
RenameChatRoomResponse
RenameChatGroupRequest 08818080802012027332
RenameChatGroupResponse
CreateInviteLinkRequest 0881808080201009188380808020
CreateInviteLinkResponse 0a0273311009
GetInviteLinksRequest 088180808020
InviteLinkInfo 0a02733111ba56000001001001180a208480808020
GetInviteLinksResponse 0a150a02733111ba56000001001001180a208480808020
GetInviteLinkInfoRequest 0a027331
GetInviteLinkInfoResponse 19ba56000001001001200b308680808020425d08818080802012027332180a200b2885808080203216088180808020120273321801280c300d3a027337400f380e420273384810501150135a030102036014688d80808020708e80808020708f80808020880118aa0103733231b001014a2808818080802010091a140881808080201009180a20032802300d3801400f20032802300d3801400150115801
DeleteInviteLinkRequest 08818080802012027332
DeleteInviteLinkResponse
KickChatUserRequest 08818080802011ba560000010010011806
KickChatUserResponse
BanChatUserRequest 08818080802011ba560000010010011801
BanChatUserResponse
MuteChatUserRequest 08818080802011ba560000010010011806
MuteChatUserResponse
DeleteChatMessage 08081009
DeleteChatMessagesRequest 0881808080201082808080201a0408081009
DeleteChatMessagesResponse
ChatReactionRequest 088180808020108280808020180a200b2801320273363801
ChatReactionResponse 080a
ChatReactionNotice 088180808020108280808020180a200b29ba5600000100100130013a0273374001
InviteFriendToChatGroupRequest 08818080802011ba560000010010011883808080202001
InviteFriendToChatGroupResponse
ChatRoleActions 08818080802010011801200128013001380140014801500158016001
SetTaglineRequest 08818080802012027332
SetTaglineResponse
UserChatGroupPreferences 0803100218012001
SetUserPreferencesRequest 08818080802012080803100218012001
SetUserPreferencesResponse
CreateRoleRequest 08818080802012027332
CreateRoleResponse 12080881808080201801
RenameRoleRequest 0881808080201082808080201a027333
RenameRoleResponse
DeleteRoleRequest 088180808020108280808020
DeleteRoleResponse
GetRoleActionsRequest 088180808020108280808020
GetRoleActionsResponse 0a080881808080203801
ReplaceRoleActionsRequest 088180808020108280808020220a08828080802020013801
ReplaceRoleActionsResponse
AddRoleToUserRequest 08818080802018838080802021ba56000001001001
AddRoleToUserResponse
DeleteRoleFromUserRequest 08818080802018838080802021ba56000001001001
DeleteRoleFromUserResponse
GetBanListRequest 088180808020
BanInfo 08071008180922027334
GetBanListResponse 0a0a08071008180922027334
SetAvatarRequest 0881808080201203010203
SetAvatarResponse
ChatRoomNoticeNotification 08818080802011ba5600000100100118012001
DismissChatRoomNotice 08818080802011ba560000010010011801
ChatRoomMemberNotice 08071001
ChatRoomGroupMemberNotices 088180808020120408071001
FriendGroup 0807120752616964657273
FriendGroupMembership 09ba560000010010011007
ClientFriendsGroupsList 080110011a0b08071207526169646572731a0808081204576f726b220b09ba560000010010011007220b0901000000010010011008
ClientCreateFriendsGroup 09ba5600000100100112075261696465727319ba56000001001001190100000001001001
ClientCreateFriendsGroupResponse 08011007
ClientDeleteFriendsGroup 09ba560000010010011007
ClientDeleteFriendsGroupResponse 0801
ClientManageFriendsGroup 080712075261696465727319ba56000001001001210100000001001001
ClientManageFriendsGroupResponse 0801
ClientAddFriendToGroup 080711ba56000001001001
ClientAddFriendToGroupResponse 0801
ClientRemoveFriendFromGroup 080711ba56000001001001
ClientRemoveFriendFromGroupResponse 0801
ClientAddFriend 09ba560000010010011207736f6d656f6e65
ClientAddFriendResponse 081d1101000000010010011a08536f6d65204f6e65
ClientRemoveFriend 09ba56000001001001
ClientFriendMsg 09ba5600000100100110021a06747970696e6725813bb16a2801
ClientFriendMsgIncoming 09ba5600000100100110011801220568656c6c6f2d823bb16a
FriendMessagesSendRequest 09ba5600000100100110011a0f6869205b625d74686572655b2f625d20012801300142076162632d313233
FriendMessagesSendResponse 0a0f6869205b625d74686572655b2f625d1083f7c4d506180222086869207468657265
FriendMessagesIncoming 09ba56000001001001100118012202796f2d843bb16a300338014202796f4801
GetRecentMessagesRequest 09ba56000001001001110100000001001001183220012d00f15365300138034080f7c4d5064804
PastReaction 0801120c3a737465616d68617070793a18baad011864
PastMessage 08baad011085f7c4d5061a0d66726f6d20746865207061737420022a120801120c3a737465616d68617070793a1864
GetRecentMessagesResponse 0a0f08baad011085f7c4d5061a016120010a0d08641086f7c4d5061a016220002001
GetActiveSessionsRequest 0880f7c4d5061001
MessageSession 08641087f7c4d5061883f7c4d50620042801
GetActiveSessionsResponse 0a1008641087f7c4d5061883f7c4d50620041088f7c4d506
AckMessage 09ba560000010010011089f7c4d506
GetEmoticonListRequest
EmoticonInfo 0a0a737465616d68617070791003188af7c4d50620092880e2cfaa0630da05
GetEmoticonListResponse 0a0e0a0a737465616d686170707910010a0e0a08737465616d73616410022004
UpdateMessageReactionRequest 09ba56000001001001108bf7c4d506180220012a0a737465616d68617070793001
UpdateMessageReactionResponse 08baad010864
MessageReactionNotice 09ba56000001001001108cf7c4d50618012101000000010010012802320b43617453757270726973653801
SessionNoticeNotification 09ba5600000100100110011801
DismissSessionNotice 0901000000010010011001
FriendProfileInfoRequest 09ba56000001001001
FriendProfileInfoResponse 08011101000000010010011880daf1eb042208536f6d65204f6e652a0842656c6c65767565320257413a0d556e6974656420537461746573420548656c6c6f4a0841626f7574206d65
FriendsSteamLevelsRequest 08baad010864
FriendLevel 08baad01102a
FriendsSteamLevelsResponse 0a0608baad01102a0a0408641007
GetPerFriendPreferencesRequest
PerFriendPreferences 0dba560000120542756464791801200228023001380240014802
GetPerFriendPreferencesResponse 0a0e0dba560000120542756464792802
SetPerFriendPreferencesRequest 0a0a0dba560000120350616c
SetPerFriendPreferencesResponse
PerFriendPreferencesChanged 0dba560000120c0dba560000120350616c2801
FriendNicknameChanged 0dba560000120350616c1801
ClientGetEmoticonList
EmoticonOwned 0a0a737465616d68617070791003188af7c4d50620092880e2cfaa0630da05
StickerOwned 0a0b436174537572707269736510021881e2cfaa0620b803288bf7c4d5063005
EffectOwned 0a0946697265776f726b7310011882e2cfaa06200128ba04
ClientEmoticonList 0a0e0a0a737465616d68617070791001120f0a0b436174537572707269736510021a0d0a0946697265776f726b732001
IgnoreFriendRequest 09ba560000010010011001
IgnoreFriendResponse 0805
GetPasswordRsaPublicKeyRequest 0a07736f6d656f6e65
GetPasswordRsaPublicKeyResponse 0a3043304646454543304646454543304646454543304646454543304646454543304646454543304646454543304646454512063031303030311888ffc1a28c34
DeviceDetails 0a046d6f686f100318cb012001280232030908073802
AllowedConfirmation 08031210612a2a2a406578616d706c652e636f6d
BeginAuthViaCredentialsRequest 0a046d6f686f1207736f6d656f6e651a045a6d39762088ffc1a28c342801300338004206436c69656e744a080a046d6f686f10035205677561726458016003
BeginAuthViaCredentialsResponse 08838080808080800212030a0b0c1d0000a040220208032205080412017828baad8180908080880132047765616b3a0968747470733a2f2f7842046d6f7265
BeginAuthViaQrRequest 0a046d6f686f10031a060a046d6f686f2206436c69656e74
BeginAuthViaQrResponse 088480808080808002121468747470733a2f2f732e7465616d2f712f312f321a02010225000020402a0208023001
PollAuthSessionRequest 08838080808080800212030a0b0c190000000000010000
PollAuthSessionResponse 088580808080808002121468747470733a2f2f732e7465616d2f712f6e65771a0772656672657368220661636365737328013207736f6d656f6e653a086e65776775617264420968747470733a2f2f61
UpdateAuthWithGuardCodeRequest 08838080808080800211ba560000010010011a0541423132432003
UpdateAuthWithGuardCodeResponse 3a0968747470733a2f2f61
GenerateAccessTokenRequest 0a077265667265736811ba560000010010011801
GenerateAccessTokenResponse 0a0661636365737312087265667265736832
IpAddress 1210000102030405060708090a0b0c0d0e0f
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""Makes golden.txt: every message in proto.rs, as Valve's own definitions
encode it.
proto.rs is written by hand, and a wrong field number or type in a hand-written
message is silent: the field just never arrives. This asks the real
definitions (the .proto files extracted from the official client) to encode a
fully populated message of each kind, and the tests in proto.rs decode those
bytes with the hand-written structs and require the very same bytes back. A
field the struct has wrong is dropped or fails to decode, and the bytes differ.
Run it when proto.rs gains a message or Valve changes one:
python3 make_golden.py /path/to/steam/decomp/protos > golden.txt
It needs `protoc` and the Python `protobuf` package. It also checks that every
field of each struct in proto.rs is filled in here, so adding a field to the
Rust and forgetting it here fails loudly instead of leaving it untested.
"""
import importlib
import os
import re
import subprocess
import sys
import tempfile
PROTO_DIR = sys.argv[1] if len(sys.argv) > 1 else "/1tb/git/steam/decomp/protos"
HERE = os.path.dirname(os.path.abspath(__file__))
FILES = [
"steammessages_base.proto",
"steammessages_clientserver_login.proto",
"steammessages_clientserver_friends.proto",
"steammessages_friendmessages.steamclient.proto",
"steammessages_auth.steamclient.proto",
"steammessages_player.steamclient.proto",
"steammessages_clientserver_2.proto",
"steammessages_parental.steamclient.proto",
"steammessages_chat.steamclient.proto",
"steammessages_parental_objects.proto",
# What those import.
"steammessages_unified_base.steamclient.proto",
"enums.proto",
]
out = tempfile.mkdtemp()
subprocess.run(["protoc", "-I", PROTO_DIR, f"--python_out={out}", *FILES], check=True, capture_output=True)
sys.path.insert(0, out)
base = importlib.import_module("steammessages_base_pb2")
login = importlib.import_module("steammessages_clientserver_login_pb2")
friends = importlib.import_module("steammessages_clientserver_friends_pb2")
fmsg = importlib.import_module("steammessages_friendmessages.steamclient_pb2")
auth = importlib.import_module("steammessages_auth.steamclient_pb2")
player = importlib.import_module("steammessages_player.steamclient_pb2")
cs2 = importlib.import_module("steammessages_clientserver_2_pb2")
parental = importlib.import_module("steammessages_parental.steamclient_pb2")
parental_objects = importlib.import_module("steammessages_parental_objects_pb2")
chat = importlib.import_module("steammessages_chat.steamclient_pb2")
ID = 76561197960287930 # an account's 64-bit id
ID2 = 76561197960265729
# Rust struct name -> (the real message class, the values to give it).
# Every value is something other than the field's default, or it would not be
# written to the wire and would prove nothing.
CASES = {
"Header": (base.CMsgProtoBufHeader, dict(
steamid=ID, client_sessionid=-1234, routing_appid=730, jobid_source=17, jobid_target=18,
target_job_name="FriendMessagesClient.IncomingMessage#1", eresult=1, error_message="no", realm=1)),
"Multi": (base.CMsgMulti, dict(size_unzipped=4096, message_body=b"\x1f\x8b\x08\x00zipped")),
"ClientHello": (login.CMsgClientHello, dict(protocol_version=65581)),
"ClientHeartBeat": (login.CMsgClientHeartBeat, dict(send_reply=True)),
"ClientLogon": (login.CMsgClientLogon, dict(
protocol_version=65581, cell_id=9, client_package_version=1771, client_language="english",
client_os_type=203, should_remember_password=True, obfuscated_private_ip=dict(v4=0xdeadbeef),
qos_level=2, machine_id=b"\x01\x02\x03machine", ui_mode=4, chat_mode=2, account_name="someone",
steamguard_dont_remember_computer=True, machine_name="HOST", client_instance_id=2**40 + 5,
supports_rate_limit_response=True, access_token="eyJ.refresh.token", gaming_device_type=1)),
"ClientLogonResponse": (login.CMsgClientLogonResponse, dict(
eresult=1, heartbeat_seconds=27, rtime32_server_time=1790000000, account_flags=3, cell_id=11,
eresult_extended=5, vanity_url="someone", client_supplied_steamid=ID, ip_country_code="GB",
client_instance_id=2**41 + 7, token_id=2**50 + 9, family_group_id=2**33)),
"ClientLogOff": (login.CMsgClientLogOff, dict()),
"ClientLoggedOff": (login.CMsgClientLoggedOff, dict(eresult=6)),
"ClientAccountInfo": (login.CMsgClientAccountInfo, dict(
persona_name="Some One", ip_country="GB", count_authed_computers=3, account_flags=8, two_factor_state=1)),
"FriendsListEntry": (friends.CMsgClientFriendsList.Friend, dict(ulfriendid=ID, efriendrelationship=3)),
"ClientFriendsList": (friends.CMsgClientFriendsList, dict(
bincremental=True, friends=[dict(ulfriendid=ID, efriendrelationship=3), dict(ulfriendid=ID2, efriendrelationship=2)],
max_friend_count=250, active_friend_count=2, friends_limit_hit=True)),
"KeyValue": (friends.CMsgClientPersonaState.Friend.KV, dict(key="status", value="In a match")),
"PersonaFriend": (friends.CMsgClientPersonaState.Friend, dict(
friendid=ID, persona_state=3, game_played_app_id=730, persona_state_flags=512, persona_set_by_user=True,
player_name="Some One", avatar_hash=bytes(range(20)), last_logoff=1700000000, last_logon=1700000100,
last_seen_online=1700000200, game_name="Counter-Strike 2", gameid=730,
rich_presence=[dict(key="steam_display", value="#Playing"), dict(key="status", value="Dust II")],
on_steam_deck=True, gaming_device_type=2)),
"ClientPersonaState": (friends.CMsgClientPersonaState, dict(
status_flags=1106, friends=[dict(friendid=ID, persona_state=1, player_name="A"), dict(friendid=ID2, persona_state=7, player_name="B")])),
"ClientRequestFriendData": (friends.CMsgClientRequestFriendData, dict(persona_state_requested=1106, friends=[ID, ID2])),
"ClientChangeStatus": (friends.CMsgClientChangeStatus, dict(
persona_state=7, player_name="Some One", is_auto_generated_name=True, high_priority=True,
persona_set_by_user=True, persona_state_flags=1, need_persona_response=True, is_client_idle=True)),
"GetCommunityPreferencesRequest": (player.CPlayer_GetCommunityPreferences_Request, dict()),
"CommunityPreferences": (player.CPlayer_CommunityPreferences, dict(
timestamp_updated=1790000000, parenthesize_nicknames=True, text_filter_setting=2, text_filter_ignore_friends=False, text_filter_words_revision=7)),
"GetCommunityPreferencesResponse": (player.CPlayer_GetCommunityPreferences_Response, dict(preferences=dict(text_filter_setting=1, text_filter_ignore_friends=True))),
"GetTextFilterWordsRequest": (player.CPlayer_GetTextFilterWords_Request, dict()),
"TextFilterWords": (player.CPlayer_TextFilterWords, dict(text_filter_custom_banned_words=["a", "b"], text_filter_custom_clean_words=["c"], text_filter_words_revision=3)),
"GetTextFilterWordsResponse": (player.CPlayer_GetTextFilterWords_Response, dict(words=dict(text_filter_custom_banned_words=["x"]))),
"GetPrivacySettingsRequest": (player.CPlayer_GetPrivacySettings_Request, dict()),
"PrivacySettings": (player.CPrivacySettings, dict(
privacy_state=3, privacy_state_inventory=2, privacy_state_gifts=1, privacy_state_ownedgames=3, privacy_state_playtime=2, privacy_state_friendslist=1)),
"GetPrivacySettingsResponse": (player.CPlayer_GetPrivacySettings_Response, dict(privacy_settings=dict(privacy_state=2))),
"GetParentalSettingsRequest": (parental.CParental_GetParentalSettings_Request, dict(steamid=ID)),
"ParentalSettings": (parental_objects.ParentalSettings, dict(steamid=ID, is_enabled=True, enabled_features=15, temporary_enabled_features=3)),
"GetParentalSettingsResponse": (parental.CParental_GetParentalSettings_Response, dict(settings=dict(is_enabled=True, enabled_features=8))),
"EnumerateTokensRequest": (auth.CAuthentication_RefreshToken_Enumerate_Request, dict(include_revoked=True)),
"TokenUsageEvent": (auth.CAuthentication_RefreshToken_Enumerate_Response.TokenUsageEvent, dict(time=1790000001, country="GB", state="ENG", city="London")),
"RefreshTokenDescription": (auth.CAuthentication_RefreshToken_Enumerate_Response.RefreshTokenDescription, dict(
token_id=2**40 + 3, token_description="moho on host", time_updated=1790000002, platform_type=3, logged_in=True,
first_seen=dict(time=1, country="GB"), last_seen=dict(time=2, city="London"))),
"EnumerateTokensResponse": (auth.CAuthentication_RefreshToken_Enumerate_Response, dict(
refresh_tokens=[dict(token_id=5, token_description="phone", platform_type=3)], requesting_token=5)),
"RevokeRefreshTokenRequest": (auth.CAuthentication_RefreshToken_Revoke_Request, dict(token_id=2**40 + 3, steamid=ID)),
"RevokeRefreshTokenResponse": (auth.CAuthentication_RefreshToken_Revoke_Response, dict()),
"ChatRoomState": (chat.CChatRoomState, dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, members_in_voice=[7, 9], time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)),
"ChatRoomMember": (chat.CChatRoomMember, dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])),
"ChatRole": (chat.CChatRole, dict(role_id=8589934593, name='s2', ordinal=10)),
"ChatRoomGroupHeaderState": (chat.CChatRoomGroupHeaderState, dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True)),
"ChatRoomGroupState": (chat.CChatRoomGroupState, dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])])),
"UserChatRoomState": (chat.CUserChatRoomState, dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)),
"UserChatRoomGroupState": (chat.CUserChatRoomGroupState, dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True)),
"ChatGroupSummary": (chat.CChatRoom_GetChatRoomGroupSummary_Response, dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True)),
"ChatRoomSummaryPair": (chat.CChatRoomSummaryPair, dict(user_chat_group_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), group_summary=dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True))),
"GetMyChatRoomGroupsRequest": (chat.CChatRoom_GetMyChatRoomGroups_Request, dict()),
"GetMyChatRoomGroupsResponse": (chat.CChatRoom_GetMyChatRoomGroups_Response, dict(chat_room_groups=[dict(user_chat_group_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), group_summary=dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True))])),
"GetChatRoomGroupStateRequest": (chat.CChatRoom_GetChatRoomGroupState_Request, dict(chat_group_id=8589934593)),
"GetChatRoomGroupStateResponse": (chat.CChatRoom_GetChatRoomGroupState_Response, dict(state=dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])]))),
"SetSessionActiveGroupsRequest": (chat.CChatRoom_SetSessionActiveChatRoomGroups_Request, dict(chat_group_ids=[8589934593, 8589934594], chat_groups_data_requested=[8589934594, 8589934595], virtualize_members_threshold=6)),
"SetSessionActiveGroupsResponse": (chat.CChatRoom_SetSessionActiveChatRoomGroups_Response, dict(chat_group_notices=[dict(chat_group_id=8589934593, member_notices=[dict(accountid=7, notice_type=1)])], chat_states=[dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])])], virtualize_members_chat_group_ids=[8589934594, 8589934595])),
"ChatSendRequest": (chat.CChatRoom_SendChatMessage_Request, dict(chat_group_id=8589934593, chat_id=8589934594, message='text', echo_to_sender=True)),
"ChatSendResponse": (chat.CChatRoom_SendChatMessage_Response, dict(modified_message='s1', server_timestamp=9, ordinal=10, message_without_bb_code='s4')),
"ChatHistoryRequest": (chat.CChatRoom_GetMessageHistory_Request, dict(chat_group_id=8589934593, chat_id=8589934594, last_time=10, last_ordinal=11, start_time=12, start_ordinal=13, max_count=14)),
"ServerMessage": (chat.ServerMessage, dict(message=2, string_param='s2', accountid_param=10)),
"ChatMessageReaction": (chat.CChatRoom_GetMessageHistory_Response.ChatMessage.MessageReaction, dict(reaction_type=1, reaction='s2', num_reactors=10, has_user_reacted=True)),
"ChatHistoryMessage": (chat.CChatRoom_GetMessageHistory_Response.ChatMessage, dict(sender=8, server_timestamp=9, message='text', ordinal=11, server_message=dict(message=2, string_param='s2', accountid_param=10), deleted=True, reactions=[dict(reaction_type=1, reaction='s2', num_reactors=10, has_user_reacted=True)])),
"ChatHistoryResponse": (chat.CChatRoom_GetMessageHistory_Response, dict(messages=[dict(sender=8, server_timestamp=9, message='text', ordinal=11, server_message=dict(message=2, string_param='s2', accountid_param=10), deleted=True, reactions=[dict(reaction_type=1, reaction='s2', num_reactors=10, has_user_reacted=True)])], more_available=True)),
"ChatMentions": (chat.CChatMentions, dict(mention_all=True, mention_here=True, mention_accountids=[10, 12])),
"ChatIncoming": (chat.CChatRoom_IncomingChatMessage_Notification, dict(chat_group_id=8589934593, chat_id=8589934594, steamid_sender=ID, message='text', timestamp=12, mentions=dict(mention_all=True, mention_here=True, mention_accountids=[10, 12]), ordinal=14, server_message=dict(message=2, string_param='s2', accountid_param=10), message_no_bbcode='s9', chat_name='s10', notification_key='s11')),
"ChatAck": (chat.CChatRoom_AckChatMessage_Notification, dict(chat_group_id=8589934593, chat_id=8589934594, timestamp=10)),
"ChatModifiedMessage": (chat.CChatRoom_ChatMessageModified_Notification.ChatMessage, dict(server_timestamp=8, ordinal=9, deleted=True)),
"ChatModified": (chat.CChatRoom_ChatMessageModified_Notification, dict(chat_group_id=8589934593, chat_id=8589934594, messages=[dict(server_timestamp=8, ordinal=9, deleted=True)])),
"ChatMemberChange": (chat.CChatRoom_MemberStateChange_Notification, dict(chat_group_id=8589934593, member=dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600]), change=1)),
"ChatHeaderChange": (chat.CChatRoom_ChatRoomHeaderState_Notification, dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True))),
"ChatRoomsChange": (chat.CChatRoom_ChatRoomGroupRoomsChange_Notification, dict(chat_group_id=8589934593, default_chat_id=8589934594, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)])),
"ChatUserStateChange": (chat.ChatRoomClient_NotifyChatGroupUserStateChanged_Notification, dict(chat_group_id=8589934593, user_chat_group_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), group_summary=dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True), user_action=2)),
"CreateChatGroupRequest": (chat.CChatRoom_CreateChatRoomGroup_Request, dict(steamid_partner=ID, steamid_invited=ID, name='s3', steamid_invitees=[ID, ID2])),
"CreateChatGroupResponse": (chat.CChatRoom_CreateChatRoomGroup_Response, dict(chat_group_id=8589934593, state=dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])]), user_chat_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True))),
"JoinChatGroupRequest": (chat.CChatRoom_JoinChatRoomGroup_Request, dict(chat_group_id=8589934593, invite_code='s2', chat_id=8589934595)),
"JoinChatGroupResponse": (chat.CChatRoom_JoinChatRoomGroup_Response, dict(state=dict(header_state=dict(chat_group_id=8589934593, chat_name='s2', clanid=20, accountid_owner=21, tagline='s15', avatar_sha=b'\x01\x02\x03', default_role_id=8589934609, roles=[dict(role_id=8589934593, name='s2', ordinal=10)], role_actions=[dict(role_id=8589934593, can_chat=True)], avatar_ugc_url='s25', disabled=True), members=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])], default_chat_id=8589934596, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], kicked=[dict(accountid=8, state=2, rank=20, time_kick_expire=13, role_ids=[8589934599, 8589934600])]), user_chat_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), join_chat_id=8589934596, time_expire=12)),
"LeaveChatGroupRequest": (chat.CChatRoom_LeaveChatRoomGroup_Request, dict(chat_group_id=8589934593)),
"LeaveChatGroupResponse": (chat.CChatRoom_LeaveChatRoomGroup_Response, dict()),
"CreateChatRoomRequest": (chat.CChatRoom_CreateChatRoom_Request, dict(chat_group_id=8589934593, name='s2', allow_voice=True)),
"CreateChatRoomResponse": (chat.CChatRoom_CreateChatRoom_Response, dict(chat_room=dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15))),
"DeleteChatRoomRequest": (chat.CChatRoom_DeleteChatRoom_Request, dict(chat_group_id=8589934593, chat_id=8589934594)),
"DeleteChatRoomResponse": (chat.CChatRoom_DeleteChatRoom_Response, dict()),
"RenameChatRoomRequest": (chat.CChatRoom_RenameChatRoom_Request, dict(chat_group_id=8589934593, chat_id=8589934594, name='s3')),
"RenameChatRoomResponse": (chat.CChatRoom_RenameChatRoom_Response, dict()),
"RenameChatGroupRequest": (chat.CChatRoom_RenameChatRoomGroup_Request, dict(chat_group_id=8589934593, name='s2')),
"RenameChatGroupResponse": (chat.CChatRoom_RenameChatRoomGroup_Response, dict()),
"CreateInviteLinkRequest": (chat.CChatRoom_CreateInviteLink_Request, dict(chat_group_id=8589934593, seconds_valid=9, chat_id=8589934595)),
"CreateInviteLinkResponse": (chat.CChatRoom_CreateInviteLink_Response, dict(invite_code='s1', seconds_valid=9)),
"GetInviteLinksRequest": (chat.CChatRoom_GetInviteLinksForGroup_Request, dict(chat_group_id=8589934593)),
"InviteLinkInfo": (chat.CChatRoom_GetInviteLinksForGroup_Response.LinkInfo, dict(invite_code='s1', steamid_creator=ID, time_expires=10, chat_id=8589934596)),
"GetInviteLinksResponse": (chat.CChatRoom_GetInviteLinksForGroup_Response, dict(invite_links=[dict(invite_code='s1', steamid_creator=ID, time_expires=10, chat_id=8589934596)])),
"GetInviteLinkInfoRequest": (chat.CChatRoom_GetInviteLinkInfo_Request, dict(invite_code='s1')),
"GetInviteLinkInfoResponse": (chat.CChatRoom_GetInviteLinkInfo_Response, dict(steamid_sender=ID, time_expires=11, chat_id=8589934598, group_summary=dict(chat_group_id=8589934593, chat_group_name='s2', active_member_count=10, active_voice_member_count=11, default_chat_id=8589934597, chat_rooms=[dict(chat_id=8589934593, chat_name='s2', voice_allowed=True, time_last_message=12, sort_order=13, last_message='s7', accountid_last_message=15)], clanid=14, chat_group_tagline='s8', accountid_owner=16, top_members=[17, 19], chat_group_avatar_sha=b'\x01\x02\x03', rank=20, default_role_id=8589934605, role_ids=[8589934606, 8589934607], appid=24, avatar_ugc_url='s21', disabled=True), user_chat_group_state=dict(chat_group_id=8589934593, time_joined=9, user_chat_room_state=[dict(chat_id=8589934593, time_joined=9, time_last_ack=10, desktop_notification_level=3, mobile_notification_level=2, time_last_mention=13, unread_indicator_muted=True, time_first_unread=15)], desktop_notification_level=3, mobile_notification_level=2, time_last_group_ack=13, unread_indicator_muted=True, direct_messages_allowed=True), time_kick_expire=17, banned=True)),
"DeleteInviteLinkRequest": (chat.CChatRoom_DeleteInviteLink_Request, dict(chat_group_id=8589934593, invite_code='s2')),
"DeleteInviteLinkResponse": (chat.CChatRoom_DeleteInviteLink_Response, dict()),
"KickChatUserRequest": (chat.CChatRoom_KickUser_Request, dict(chat_group_id=8589934593, steamid=ID, expiration=6)),
"KickChatUserResponse": (chat.CChatRoom_KickUser_Response, dict()),
"BanChatUserRequest": (chat.CChatRoom_SetUserBanState_Request, dict(chat_group_id=8589934593, steamid=ID, ban_state=True)),
"BanChatUserResponse": (chat.CChatRoom_SetUserBanState_Response, dict()),
"MuteChatUserRequest": (chat.CChatRoom_MuteUser_Request, dict(chat_group_id=8589934593, steamid=ID, expiration=6)),
"MuteChatUserResponse": (chat.CChatRoom_MuteUser_Response, dict()),
"DeleteChatMessage": (chat.CChatRoom_DeleteChatMessages_Request.Message, dict(server_timestamp=8, ordinal=9)),
"DeleteChatMessagesRequest": (chat.CChatRoom_DeleteChatMessages_Request, dict(chat_group_id=8589934593, chat_id=8589934594, messages=[dict(server_timestamp=8, ordinal=9)])),
"DeleteChatMessagesResponse": (chat.CChatRoom_DeleteChatMessages_Response, dict()),
"ChatReactionRequest": (chat.CChatRoom_UpdateMessageReaction_Request, dict(chat_group_id=8589934593, chat_id=8589934594, server_timestamp=10, ordinal=11, reaction_type=1, reaction='s6', is_add=True)),
"ChatReactionResponse": (chat.CChatRoom_UpdateMessageReaction_Response, dict(num_reactors=10)),
"ChatReactionNotice": (chat.CChatRoom_MessageReaction_Notification, dict(chat_group_id=8589934593, chat_id=8589934594, server_timestamp=10, ordinal=11, reactor=ID, reaction_type=1, reaction='s7', is_add=True)),
"InviteFriendToChatGroupRequest": (chat.CChatRoom_InviteFriendToChatRoomGroup_Request, dict(chat_group_id=8589934593, steamid=ID, chat_id=8589934595, skip_friendsui_check=True)),
"InviteFriendToChatGroupResponse": (chat.CChatRoom_InviteFriendToChatRoomGroup_Response, dict()),
"ChatRoleActions": (chat.CChatRoleActions, dict(role_id=8589934593, can_create_rename_delete_channel=True, can_kick=True, can_ban=True, can_invite=True, can_change_tagline_avatar_name=True, can_chat=True, can_view_history=True, can_change_group_roles=True, can_change_user_roles=True, can_mention_all=True, can_set_watching_broadcast=True)),
"SetTaglineRequest": (chat.CChatRoom_SetChatRoomGroupTagline_Request, dict(chat_group_id=8589934593, tagline='s2')),
"SetTaglineResponse": (chat.CChatRoom_SetChatRoomGroupTagline_Response, dict()),
"UserChatGroupPreferences": (chat.CChatRoom_SetUserChatGroupPreferences_Request.ChatGroupPreferences, dict(desktop_notification_level=3, mobile_notification_level=2, unread_indicator_muted=True, direct_messages_allowed=True)),
"SetUserPreferencesRequest": (chat.CChatRoom_SetUserChatGroupPreferences_Request, dict(chat_group_id=8589934593, chat_group_preferences=dict(desktop_notification_level=3, mobile_notification_level=2, unread_indicator_muted=True, direct_messages_allowed=True))),
"SetUserPreferencesResponse": (chat.CChatRoom_SetUserChatGroupPreferences_Response, dict()),
"CreateRoleRequest": (chat.CChatRoom_CreateRole_Request, dict(chat_group_id=8589934593, name='s2')),
"CreateRoleResponse": (chat.CChatRoom_CreateRole_Response, dict(actions=dict(role_id=8589934593, can_kick=True))),
"RenameRoleRequest": (chat.CChatRoom_RenameRole_Request, dict(chat_group_id=8589934593, role_id=8589934594, name='s3')),
"RenameRoleResponse": (chat.CChatRoom_RenameRole_Response, dict()),
"DeleteRoleRequest": (chat.CChatRoom_DeleteRole_Request, dict(chat_group_id=8589934593, role_id=8589934594)),
"DeleteRoleResponse": (chat.CChatRoom_DeleteRole_Response, dict()),
"GetRoleActionsRequest": (chat.CChatRoom_GetRoleActions_Request, dict(chat_group_id=8589934593, role_id=8589934594)),
"GetRoleActionsResponse": (chat.CChatRoom_GetRoleActions_Response, dict(actions=[dict(role_id=8589934593, can_chat=True)])),
"ReplaceRoleActionsRequest": (chat.CChatRoom_ReplaceRoleActions_Request, dict(chat_group_id=8589934593, role_id=8589934594, actions=dict(role_id=8589934594, can_chat=True, can_ban=True))),
"ReplaceRoleActionsResponse": (chat.CChatRoom_ReplaceRoleActions_Response, dict()),
"AddRoleToUserRequest": (chat.CChatRoom_AddRoleToUser_Request, dict(chat_group_id=8589934593, role_id=8589934595, steamid=ID)),
"AddRoleToUserResponse": (chat.CChatRoom_AddRoleToUser_Response, dict()),
"DeleteRoleFromUserRequest": (chat.CChatRoom_DeleteRoleFromUser_Request, dict(chat_group_id=8589934593, role_id=8589934595, steamid=ID)),
"DeleteRoleFromUserResponse": (chat.CChatRoom_DeleteRoleFromUser_Response, dict()),
"GetBanListRequest": (chat.CChatRoom_GetBanList_Request, dict(chat_group_id=8589934593)),
"BanInfo": (chat.CChatRoom_GetBanList_Response.BanInfo, dict(accountid=7, accountid_actor=8, time_banned=9, ban_reason='s4')),
"GetBanListResponse": (chat.CChatRoom_GetBanList_Response, dict(bans=[dict(accountid=7, accountid_actor=8, time_banned=9, ban_reason='s4')])),
"SetAvatarRequest": (chat.CChatRoom_SetChatRoomGroupAvatar_Request, dict(chat_group_id=8589934593, avatar_sha=b'\x01\x02\x03')),
"SetAvatarResponse": (chat.CChatRoom_SetChatRoomGroupAvatar_Response, dict()),
"ChatRoomNoticeNotification": (chat.CChatRoom_ChatRoomNotice_Notification, dict(chat_group_id=8589934593, steamid_sender=ID, notice_type=1, active=True)),
"DismissChatRoomNotice": (chat.CChatRoom_DismissChatRoomNotice_Notification, dict(chat_group_id=8589934593, steamid_sender=ID, notice_type=1)),
"ChatRoomMemberNotice": (chat.CChatRoomMemberNotice, dict(accountid=7, notice_type=1)),
"ChatRoomGroupMemberNotices": (chat.CChatRoomGroupMemberNotices, dict(chat_group_id=8589934593, member_notices=[dict(accountid=7, notice_type=1)])),
"FriendGroup": (friends.CMsgClientFriendsGroupsList.FriendGroup, dict(nGroupID=7, strGroupName="Raiders")),
"FriendGroupMembership": (friends.CMsgClientFriendsGroupsList.FriendGroupsMembership, dict(ulSteamID=ID, nGroupID=7)),
"ClientFriendsGroupsList": (friends.CMsgClientFriendsGroupsList, dict(
bremoval=True, bincremental=True, friendGroups=[dict(nGroupID=7, strGroupName="Raiders"), dict(nGroupID=8, strGroupName="Work")],
memberships=[dict(ulSteamID=ID, nGroupID=7), dict(ulSteamID=ID2, nGroupID=8)])),
"ClientCreateFriendsGroup": (friends.CMsgClientCreateFriendsGroup, dict(steamid=ID, groupname="Raiders", steamid_friends=[ID, ID2])),
"ClientCreateFriendsGroupResponse": (friends.CMsgClientCreateFriendsGroupResponse, dict(eresult=1, groupid=7)),
"ClientDeleteFriendsGroup": (friends.CMsgClientDeleteFriendsGroup, dict(steamid=ID, groupid=7)),
"ClientDeleteFriendsGroupResponse": (friends.CMsgClientDeleteFriendsGroupResponse, dict(eresult=1)),
"ClientManageFriendsGroup": (friends.CMsgClientManageFriendsGroup, dict(groupid=7, groupname="Raiders", steamid_friends_added=[ID], steamid_friends_removed=[ID2])),
"ClientManageFriendsGroupResponse": (friends.CMsgClientManageFriendsGroupResponse, dict(eresult=1)),
"ClientAddFriendToGroup": (friends.CMsgClientAddFriendToGroup, dict(groupid=7, steamiduser=ID)),
"ClientAddFriendToGroupResponse": (friends.CMsgClientAddFriendToGroupResponse, dict(eresult=1)),
"ClientRemoveFriendFromGroup": (friends.CMsgClientRemoveFriendFromGroup, dict(groupid=7, steamiduser=ID)),
"ClientRemoveFriendFromGroupResponse": (friends.CMsgClientRemoveFriendFromGroupResponse, dict(eresult=1)),
"ClientAddFriend": (friends.CMsgClientAddFriend, dict(steamid_to_add=ID, accountname_or_email_to_add="someone")),
"ClientAddFriendResponse": (friends.CMsgClientAddFriendResponse, dict(eresult=29, steam_id_added=ID2, persona_name_added="Some One")),
"ClientRemoveFriend": (friends.CMsgClientRemoveFriend, dict(friendid=ID)),
"ClientFriendMsg": (friends.CMsgClientFriendMsg, dict(
steamid=ID, chat_entry_type=2, message=b"typing", rtime32_server_timestamp=1790000001, echo_to_sender=True)),
"ClientFriendMsgIncoming": (friends.CMsgClientFriendMsgIncoming, dict(
steamid_from=ID, chat_entry_type=1, from_limited_account=True, message=b"hello", rtime32_server_timestamp=1790000002)),
"FriendMessagesSendRequest": (fmsg.CFriendMessages_SendMessage_Request, dict(
steamid=ID, chat_entry_type=1, message="hi [b]there[/b]", contains_bbcode=True, echo_to_sender=True,
low_priority=True, client_message_id="abc-123")),
"FriendMessagesSendResponse": (fmsg.CFriendMessages_SendMessage_Response, dict(
modified_message="hi [b]there[/b]", server_timestamp=1790000003, ordinal=2, message_without_bb_code="hi there")),
"FriendMessagesIncoming": (fmsg.CFriendMessages_IncomingMessage_Notification, dict(
steamid_friend=ID, chat_entry_type=1, from_limited_account=True, message="yo", rtime32_server_timestamp=1790000004,
ordinal=3, local_echo=True, message_no_bbcode="yo", low_priority=True)),
"GetRecentMessagesRequest": (fmsg.CFriendMessages_GetRecentMessages_Request, dict(
steamid1=ID, steamid2=ID2, count=50, most_recent_conversation=True, rtime32_start_time=1700000000,
bbcode_format=True, start_ordinal=3, time_last=1790000000, ordinal_last=4)),
"PastReaction": (fmsg.CFriendMessages_GetRecentMessages_Response.FriendMessage.MessageReaction, dict(reaction_type=1, reaction=":steamhappy:", reactors=[22202, 100])),
"PastMessage": (fmsg.CFriendMessages_GetRecentMessages_Response.FriendMessage, dict(
accountid=22202, timestamp=1790000005, message="from the past", ordinal=2,
reactions=[dict(reaction_type=1, reaction=":steamhappy:", reactors=[100])])),
"GetRecentMessagesResponse": (fmsg.CFriendMessages_GetRecentMessages_Response, dict(
messages=[dict(accountid=22202, timestamp=1790000005, message="a", ordinal=1), dict(accountid=100, timestamp=1790000006, message="b", ordinal=0)],
more_available=True)),
"GetActiveSessionsRequest": (fmsg.CFriendsMessages_GetActiveMessageSessions_Request, dict(lastmessage_since=1790000000, only_sessions_with_messages=True)),
"MessageSession": (fmsg.CFriendsMessages_GetActiveMessageSessions_Response.FriendMessageSession, dict(
accountid_friend=100, last_message=1790000007, last_view=1790000003, unread_message_count=4, notices=[1])),
"GetActiveSessionsResponse": (fmsg.CFriendsMessages_GetActiveMessageSessions_Response, dict(
message_sessions=[dict(accountid_friend=100, last_message=1790000007, last_view=1790000003, unread_message_count=4)],
timestamp=1790000008)),
"AckMessage": (fmsg.CFriendMessages_AckMessage_Notification, dict(steamid_partner=ID, timestamp=1790000009)),
"GetEmoticonListRequest": (player.CPlayer_GetEmoticonList_Request, dict()),
"EmoticonInfo": (player.CPlayer_GetEmoticonList_Response.Emoticon, dict(
name="steamhappy", count=3, time_last_used=1790000010, use_count=9, time_received=1700000000, appid=730)),
"GetEmoticonListResponse": (player.CPlayer_GetEmoticonList_Response, dict(
emoticons=[dict(name="steamhappy", count=1), dict(name="steamsad", count=2, use_count=4)])),
"UpdateMessageReactionRequest": (fmsg.CFriendMessages_UpdateMessageReaction_Request, dict(
steamid=ID, server_timestamp=1790000011, ordinal=2, reaction_type=1, reaction="steamhappy", is_add=True)),
"UpdateMessageReactionResponse": (fmsg.CFriendMessages_UpdateMessageReaction_Response, dict(reactors=[22202, 100])),
"MessageReactionNotice": (fmsg.CFriendMessages_MessageReaction_Notification, dict(
steamid_friend=ID, server_timestamp=1790000012, ordinal=1, reactor=ID2, reaction_type=2, reaction="CatSurprise", is_add=True)),
"SessionNoticeNotification": (fmsg.CFriendMessages_SessionNotice_Notification, dict(steamid_friend=ID, notice_type=1, active=True)),
"DismissSessionNotice": (fmsg.CFriendMessages_DismissSessionNotice_Notification, dict(steamid_friend=ID2, notice_type=1)),
"FriendProfileInfoRequest": (friends.CMsgClientFriendProfileInfo, dict(steamid_friend=ID)),
"FriendProfileInfoResponse": (friends.CMsgClientFriendProfileInfoResponse, dict(
eresult=1, steamid_friend=ID2, time_created=1300000000, real_name="Some One", city_name="Bellevue",
state_name="WA", country_name="United States", headline="Hello", summary="About me")),
"FriendsSteamLevelsRequest": (cs2.CMsgClientFSGetFriendsSteamLevels, dict(accountids=[22202, 100])),
"FriendLevel": (cs2.CMsgClientFSGetFriendsSteamLevelsResponse.Friend, dict(accountid=22202, level=42)),
"FriendsSteamLevelsResponse": (cs2.CMsgClientFSGetFriendsSteamLevelsResponse, dict(friends=[dict(accountid=22202, level=42), dict(accountid=100, level=7)])),
"GetPerFriendPreferencesRequest": (player.CPlayer_GetPerFriendPreferences_Request, dict()),
"PerFriendPreferences": (player.PerFriendPreferences, dict(
accountid=22202, nickname="Buddy", notifications_showingame=1, notifications_showonline=2, notifications_showmessages=2,
sounds_showingame=1, sounds_showonline=2, sounds_showmessages=1, notifications_sendmobile=2)),
"GetPerFriendPreferencesResponse": (player.CPlayer_GetPerFriendPreferences_Response, dict(preferences=[dict(accountid=22202, nickname="Buddy", notifications_showmessages=2)])),
"SetPerFriendPreferencesRequest": (player.CPlayer_SetPerFriendPreferences_Request, dict(preferences=dict(accountid=22202, nickname="Pal"))),
"SetPerFriendPreferencesResponse": (player.CPlayer_SetPerFriendPreferences_Response, dict()),
"PerFriendPreferencesChanged": (player.CPlayer_PerFriendPreferencesChanged_Notification, dict(accountid=22202, preferences=dict(accountid=22202, nickname="Pal", notifications_showmessages=1))),
"FriendNicknameChanged": (player.CPlayer_FriendNicknameChanged_Notification, dict(accountid=22202, nickname="Pal", is_echo_to_self=True)),
"ClientGetEmoticonList": (friends.CMsgClientGetEmoticonList, dict()),
"EmoticonOwned": (friends.CMsgClientEmoticonList.Emoticon, dict(name="steamhappy", count=3, time_last_used=1790000010, use_count=9, time_received=1700000000, appid=730)),
"StickerOwned": (friends.CMsgClientEmoticonList.Sticker, dict(name="CatSurprise", count=2, time_received=1700000001, appid=440, time_last_used=1790000011, use_count=5)),
"EffectOwned": (friends.CMsgClientEmoticonList.Effect, dict(name="Fireworks", count=1, time_received=1700000002, infinite_use=True, appid=570)),
"ClientEmoticonList": (friends.CMsgClientEmoticonList, dict(
emoticons=[dict(name="steamhappy", count=1)], stickers=[dict(name="CatSurprise", count=2)], effects=[dict(name="Fireworks", infinite_use=True)])),
"IgnoreFriendRequest": (player.CPlayer_IgnoreFriend_Request, dict(steamid=ID, unignore=True)),
"IgnoreFriendResponse": (player.CPlayer_IgnoreFriend_Response, dict(friend_relationship=5)),
"GetPasswordRsaPublicKeyRequest": (auth.CAuthentication_GetPasswordRSAPublicKey_Request, dict(account_name="someone")),
"GetPasswordRsaPublicKeyResponse": (auth.CAuthentication_GetPasswordRSAPublicKey_Response, dict(
publickey_mod="C0FFEE" * 8, publickey_exp="010001", timestamp=1790000005000)),
"DeviceDetails": (auth.CAuthentication_DeviceDetails, dict(
device_friendly_name="moho", platform_type=3, os_type=203, gaming_device_type=1, client_count=2,
machine_id=b"\x09\x08\x07", app_type=2)),
"AllowedConfirmation": (auth.CAuthentication_AllowedConfirmation, dict(confirmation_type=3, associated_message="a***@example.com")),
"BeginAuthViaCredentialsRequest": (auth.CAuthentication_BeginAuthSessionViaCredentials_Request, dict(
device_friendly_name="moho", account_name="someone", encrypted_password="Zm9v", encryption_timestamp=1790000005000,
remember_login=True, platform_type=3, persistence=0, website_id="Client",
device_details=dict(device_friendly_name="moho", platform_type=3), guard_data="guard", language=1, qos_level=3)),
"BeginAuthViaCredentialsResponse": (auth.CAuthentication_BeginAuthSessionViaCredentials_Response, dict(
client_id=2**50 + 3, request_id=b"\x0a\x0b\x0c", interval=5.0,
allowed_confirmations=[dict(confirmation_type=3), dict(confirmation_type=4, associated_message="x")],
steamid=ID, weak_token="weak", agreement_session_url="https://x", extended_error_message="more")),
"BeginAuthViaQrRequest": (auth.CAuthentication_BeginAuthSessionViaQR_Request, dict(
device_friendly_name="moho", platform_type=3, device_details=dict(device_friendly_name="moho"), website_id="Client")),
"BeginAuthViaQrResponse": (auth.CAuthentication_BeginAuthSessionViaQR_Response, dict(
client_id=2**50 + 4, challenge_url="https://s.team/q/1/2", request_id=b"\x01\x02", interval=2.5,
allowed_confirmations=[dict(confirmation_type=2)], version=1)),
"PollAuthSessionRequest": (auth.CAuthentication_PollAuthSessionStatus_Request, dict(
client_id=2**50 + 3, request_id=b"\x0a\x0b\x0c", token_to_revoke=2**40)),
"PollAuthSessionResponse": (auth.CAuthentication_PollAuthSessionStatus_Response, dict(
new_client_id=2**50 + 5, new_challenge_url="https://s.team/q/new", refresh_token="refresh", access_token="access",
had_remote_interaction=True, account_name="someone", new_guard_data="newguard", agreement_session_url="https://a")),
"UpdateAuthWithGuardCodeRequest": (auth.CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request, dict(
client_id=2**50 + 3, steamid=ID, code="AB12C", code_type=3)),
"UpdateAuthWithGuardCodeResponse": (auth.CAuthentication_UpdateAuthSessionWithSteamGuardCode_Response, dict(agreement_session_url="https://a")),
"GenerateAccessTokenRequest": (auth.CAuthentication_AccessToken_GenerateForApp_Request, dict(
refresh_token="refresh", steamid=ID, renewal_type=1)),
"GenerateAccessTokenResponse": (auth.CAuthentication_AccessToken_GenerateForApp_Response, dict(access_token="access", refresh_token="refresh2")),
"IpAddress": (base.CMsgIPAddress, dict(v6=bytes(range(16)))),
}
def fill(msg, values):
for name, value in values.items():
field = msg.DESCRIPTOR.fields_by_name[name]
if field.message_type is not None and field.is_repeated:
for item in value:
fill(getattr(msg, name).add(), item)
elif field.message_type is not None:
fill(getattr(msg, name), value)
elif field.is_repeated:
getattr(msg, name).extend(value)
else:
setattr(msg, name, value)
return msg
def rust_fields():
"""struct name -> its field names, read out of proto.rs."""
text = open(os.path.join(HERE, "..", "proto.rs")).read()
structs = {}
for chunk in text.split("pub struct ")[1:]:
name = chunk.split(" ", 1)[0]
# An empty message is written `{}` on one line; the rest end at a
# closing brace at the start of a line.
body = "" if chunk.startswith(f"{name} {{}}") else chunk.split("\n}", 1)[0]
structs[name] = set(re.findall(r"pub (\w+):", body))
return structs
rust = rust_fields()
problems = []
for name, (cls, values) in CASES.items():
# The oneof in IpAddress is one Rust field standing for two real ones.
if name == "IpAddress":
continue
if name not in rust:
problems.append(f"{name}: no such struct in proto.rs")
continue
if set(values) != rust[name]:
problems.append(f"{name}: filled {sorted(set(values) ^ rust[name])} differently from the struct")
missing = sorted(set(rust) - set(CASES))
if missing:
problems.append(f"structs with no case here: {missing}")
if problems:
sys.exit("\n".join(problems))
for name, (cls, values) in CASES.items():
print(name, fill(cls(), values).SerializeToString().hex())
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Writes voicewire.rs and voice_golden.txt from the Steam client's own JavaScript.
Steam's voice and WebRTC calls are not in the .proto files extracted from
steamclient.so: they are only in the web client, as generated message classes
with their field numbers and types. This reads those tables out of the
prettified UI bundle and writes, from the one table:
- ../voicewire.rs: a prost struct for each message
- voice_golden.txt: each message encoded by the reference protobuf library from
a descriptor built out of the same table, which the tests in voicewire.rs
require the structs to reproduce byte for byte
python3 make_voice.py /path/to/steam/decomp/steamui/chunk~2dcc5aaf7.js
The bundle is the source of truth; nothing here is typed in by hand.
"""
import os
import re
import sys
from google.protobuf import descriptor_pb2, descriptor_pool, message_factory
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = open(sys.argv[1]).read()
WANT = re.compile(r"^C(VoiceChat|WebRTCClient|WebRTC)_|^CChatRoom_(JoinVoiceChat|LeaveVoiceChat|NotifyShouldRejoinChatRoomVoiceChat)")
KINDS = {
"readFixed64String": ("fixed64", "u64", descriptor_pb2.FieldDescriptorProto.TYPE_FIXED64),
"readUint64String": ("uint64", "u64", descriptor_pb2.FieldDescriptorProto.TYPE_UINT64),
"readUint32": ("uint32", "u32", descriptor_pb2.FieldDescriptorProto.TYPE_UINT32),
"readInt32": ("int32", "i32", descriptor_pb2.FieldDescriptorProto.TYPE_INT32),
"readBool": ("bool", "bool", descriptor_pb2.FieldDescriptorProto.TYPE_BOOL),
"readString": ("string", "String", descriptor_pb2.FieldDescriptorProto.TYPE_STRING),
"readBytes": ('bytes = "vec"', "Vec<u8>", descriptor_pb2.FieldDescriptorProto.TYPE_BYTES),
"readEnum": ("int32", "i32", descriptor_pb2.FieldDescriptorProto.TYPE_INT32),
"readFloat": ("float", "f32", descriptor_pb2.FieldDescriptorProto.TYPE_FLOAT),
}
# JS class variable -> wire name, for nested message fields.
# Class variables are reused from one module of the bundle to the next, so a name
# is resolved to the nearest definition before the place it is used.
defs = [] # (position, var, wire name, body)
for m in re.finditer(r"class (\w+) extends d\.Message \{(.*?)getClassName\(\) \{\s*return \"(\w+)\";", SRC, re.S):
var, body, name = m.groups()
defs.append((m.start(), var, name, body))
def rust_name(wire):
n = wire[1:] if wire.startswith("C") else wire
n = n.replace("CSSRCToAccountIDMapping", "SsrcAccount")
parts = n.split("_")
if parts[0] in ("VoiceChat", "WebRTCClient", "WebRTC"):
parts = parts[1:]
elif parts[0] == "ChatRoom":
# Both services have a LeaveVoiceChat; the room's is told apart.
parts = ["Room"] + parts[1:]
return "".join(p[0].upper() + p[1:] for p in parts)
def resolve(var, before):
same = [d for d in defs if d[1] == var]
earlier = [d for d in same if d[0] < before]
# A message can refer to one defined just after it in the same module.
return (earlier[-1] if earlier else [d for d in same if d[0] > before][0])[2]
messages = {} # wire name -> (rust name, [(field, number, kind, rust type, repeated, submsg)])
order = []
for pos, var, name, body in defs:
if not WANT.search(name):
continue
fields = []
for fm in re.finditer(r"(\w+): \{ n: (\d+), (?:br: m\.qM\.(\w+)|c: (\w+), r: !0)", body):
fname, num, read, sub = fm.groups()
if read:
fields.append((fname, int(num), read, None))
else:
fields.append((fname, int(num), None, resolve(sub, pos)))
messages[name] = (rust_name(name), fields)
order.append(name)
def sub_wire(wire):
return wire
# --- Rust ---
rs = ['//! Steam voice: the messages of the VoiceChat and WebRTCClient services and the voice\n',
'//! calls of ChatRoom, written out by `golden/make_voice.py` from the Steam client\'s own\n',
'//! JavaScript. Do not edit by hand: change the generator and run it again.\n\n',
'#![allow(dead_code)]\n\nuse prost::Message;\n\n']
for name in order:
rname, fields = messages[name]
rs.append("#[derive(Clone, PartialEq, Message)]\n")
rs.append(f"pub struct {rname} {{\n")
for fname, num, read, sub in fields:
if read:
attr, ty, _ = KINDS[read]
rs.append(f' #[prost({attr}, optional, tag = "{num}")]\n pub {fname}: Option<{ty}>,\n')
else:
inner = messages[sub_wire(sub)][0]
rs.append(f' #[prost(message, repeated, tag = "{num}")]\n pub {fname}: Vec<{inner}>,\n')
rs.append("}\n\n")
names = ", ".join(messages[n][0] for n in order)
rs.append("#[cfg(test)]\nmod tests {\n use super::*;\n\n const GOLDEN: &str = include_str!(\"golden/voice_golden.txt\");\n\n")
rs.append(" fn unhex(s: &str) -> Vec<u8> {\n (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()\n }\n\n")
rs.append(" fn round_trips<M: Message + Default>(name: &str, bytes: &[u8]) {\n let decoded = M::decode(bytes).unwrap_or_else(|e| panic!(\"{name}: {e}\"));\n assert_eq!(decoded.encode_to_vec(), bytes, \"{name} does not encode to what the reference library wrote\");\n }\n\n")
rs.append(" #[test]\n fn every_voice_message_matches_the_reference_encoding() {\n let mut seen = 0;\n for line in GOLDEN.lines().filter(|l| !l.trim().is_empty()) {\n let (name, hex) = line.split_once(' ').expect(\"name and bytes\");\n let bytes = unhex(hex);\n macro_rules! cases {\n ($($t:ident),* $(,)?) => {\n match name {\n $( stringify!($t) => round_trips::<$t>(name, &bytes), )*\n other => panic!(\"voice_golden.txt has {other}, which voicewire.rs does not\"),\n }\n };\n }\n")
rs.append(f" cases!({names});\n seen += 1;\n }}\n assert_eq!(seen, {len(order)});\n }}\n}}\n")
open(os.path.join(HERE, "..", "voicewire.rs"), "w").write("".join(rs))
# --- reference encoding ---
fdp = descriptor_pb2.FileDescriptorProto(name="voice.proto", package="v", syntax="proto2")
for name in order:
rname, fields = messages[name]
msg = fdp.message_type.add(name=rname)
for fname, num, read, sub in fields:
f = msg.field.add(name=fname, number=num, label=descriptor_pb2.FieldDescriptorProto.LABEL_OPTIONAL)
if read:
f.type = KINDS[read][2]
else:
f.label = descriptor_pb2.FieldDescriptorProto.LABEL_REPEATED
f.type = descriptor_pb2.FieldDescriptorProto.TYPE_MESSAGE
f.type_name = ".v." + messages[sub_wire(sub)][0]
pool = descriptor_pool.DescriptorPool()
pool.Add(fdp)
def sample(desc, depth=0):
values = {}
for f in desc.fields:
T = f.type
if T == f.TYPE_MESSAGE:
if depth < 2:
values[f.name] = [sample(f.message_type, depth + 1)]
elif T == f.TYPE_BOOL:
values[f.name] = True
elif T == f.TYPE_STRING:
values[f.name] = f"s{f.number}"
elif T == f.TYPE_BYTES:
values[f.name] = b"\x01\x02\x03"
elif T == f.TYPE_FLOAT:
values[f.name] = 1.5
elif T in (f.TYPE_FIXED64, f.TYPE_UINT64):
values[f.name] = 2**33 + f.number
else:
values[f.name] = 7 + f.number
return values
def fill(msg, values):
for k, v in values.items():
f = msg.DESCRIPTOR.fields_by_name[k]
if f.type == f.TYPE_MESSAGE:
for item in v:
fill(getattr(msg, k).add(), item)
else:
setattr(msg, k, v)
return msg
lines = []
for name in order:
rname = messages[name][0]
cls = message_factory.GetMessageClass(pool.FindMessageTypeByName("v." + rname))
lines.append(f"{rname} {fill(cls(), sample(cls.DESCRIPTOR)).SerializeToString().hex()}")
open(os.path.join(HERE, "voice_golden.txt"), "w").write("\n".join(lines) + "\n")
print(len(order), "messages")
+37
View File
@@ -0,0 +1,37 @@
RequestOneOnOneChatRequest 090100000002000000
RequestOneOnOneChatResponse 090100000002000000
OneOnOneChatRequestedNotification 090100000002000000110200000002000000
AnswerOneOnOneChatRequest 0901000000020000001102000000020000001801
AnswerOneOnOneChatResponse
OneOnOneChatRequestResponseNotification 0901000000020000001102000000020000001801
EndOneOnOneChatRequest 090100000002000000
EndOneOnOneChatResponse
LeaveOneOnOneChatRequest 090100000002000000110200000002000000
LeaveOneOnOneChatResponse
UserJoinedVoiceChatNotification 090100000002000000110200000002000000188380808020210400000002000000290500000002000000308680808020380e
UserVoiceStatusNotification 090100000002000000110200000002000000180120012801300d3801
AllMembersStatusNotification 090100000002000000121c090100000002000000110200000002000000180120012801300d3801
UpdateVoiceChatWebRTCDataRequest 0901000000020000001009180a200b280c300d3a0273374001480150015801
UpdateVoiceChatWebRTCDataResponse 0801
UpdateUserVideoStatusRequest 0901000000020000001102000000020000001801
UpdateUserVideoStatusResponse
UploadClientVoiceChatLogsRequest 09010000000200000012027332
UploadClientVoiceChatLogsResponse
LeaveVoiceChatRequest 090100000002000000
LeaveVoiceChatResponse
UserLeftVoiceChatNotification 090100000002000000110200000002000000188380808020210400000002000000290500000002000000308680808020380e
VoiceChatEndedNotification 090100000002000000110200000002000000190300000002000000208480808020288580808020
InitiateWebRTCConnectionRequest 0a027331120273321a027333
InitiateWebRTCConnectionResponse 0a027331
UpdateWebRTCConnectionRequest 08081009180a200b2a027335
UpdateWebRTCConnectionResponse 0a027331
WebRTCSessionConnectedNotification 08081009180a200b280c
WebRTCUpdateRemoteDescriptionNotification 0a0273311082808080201a0408081009
WebRTCUpdateRemoteDescriptionNotificationSsrcAccount 08081009
AcknowledgeUpdatedRemoteDescriptionRequest 08081009180a200b288580808020
AcknowledgeUpdatedRemoteDescriptionResponse
RoomJoinVoiceChatRequest 088180808020108280808020
RoomJoinVoiceChatResponse 088180808020
RoomLeaveVoiceChatRequest 088180808020108280808020
RoomLeaveVoiceChatResponse
RoomNotifyShouldRejoinChatRoomVoiceChatNotification 088180808020108280808020
+320
View File
@@ -0,0 +1,320 @@
//! The groups a Steam account has sorted its friends into.
//!
//! Steam keeps them for the account - "Raiders", "Work" - and tells every
//! client of it, so a friend filed in one place is filed in all. A friend can be
//! in several. The window files each conversation under the one heading its own
//! category system has for it and mirrors changes both ways; this is the
//! daemon's half: what Steam says, and the calls that change it.
use super::link::Link;
use super::proto::*;
use super::steamid;
use super::wire::emsg;
use crate::state::AppState;
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{LazyLock, Mutex};
/// The groups and who is in each.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Groups {
names: BTreeMap<i32, String>,
members: BTreeMap<i32, BTreeSet<u64>>,
}
impl Groups {
/// Takes in what Steam said: all of it, or what changed. A list marked as a
/// removal names things taken away. Returns whether anything is different.
pub fn apply(&mut self, list: &ClientFriendsGroupsList) -> bool {
let before = self.clone();
let incremental = list.bincremental.unwrap_or(false);
let removal = list.bremoval.unwrap_or(false);
if !incremental {
self.names.clear();
self.members.clear();
}
for group in &list.friendGroups {
let Some(id) = group.nGroupID else { continue };
if removal {
self.names.remove(&id);
self.members.remove(&id);
} else {
self.names.insert(id, group.strGroupName.clone().unwrap_or_default());
self.members.entry(id).or_default();
}
}
for membership in &list.memberships {
let (Some(friend), Some(id)) = (membership.ulSteamID, membership.nGroupID) else { continue };
if removal {
if let Some(members) = self.members.get_mut(&id) {
members.remove(&friend);
}
} else if self.names.contains_key(&id) {
self.members.entry(id).or_default().insert(friend);
}
}
*self != before
}
/// The groups by number and name: for the live check, which has to find its own.
#[cfg(test)]
pub fn names_for_probe(&self) -> Vec<(i32, String)> {
self.names.iter().map(|(i, n)| (*i, n.clone())).collect()
}
pub fn put_member(&mut self, group: i32, friend: u64, present: bool) {
if !self.names.contains_key(&group) {
return;
}
let members = self.members.entry(group).or_default();
if present {
members.insert(friend);
} else {
members.remove(&friend);
}
}
pub fn put_group(&mut self, group: i32, name: &str, friends: &[u64]) {
self.names.insert(group, name.to_string());
self.members.insert(group, friends.iter().copied().collect());
}
pub fn rename(&mut self, group: i32, name: &str) {
if let Some(held) = self.names.get_mut(&group) {
*held = name.to_string();
}
}
pub fn remove(&mut self, group: i32) {
self.names.remove(&group);
self.members.remove(&group);
}
/// As the window reads it: each group with its name and the friends in it
/// by their 64-bit ids, which is how a conversation names who it is with.
pub fn to_json(&self) -> Value {
Value::Array(
self.names
.iter()
.map(|(id, name)| json!({ "id": id, "name": name, "members": self.members.get(id).map(|m| m.iter().map(|f| f.to_string()).collect::<Vec<_>>()).unwrap_or_default() }))
.collect(),
)
}
}
/// What each signed-in account's groups are now, for the window to ask for.
static HELD: LazyLock<Mutex<HashMap<String, Groups>>> = LazyLock::new(Default::default);
pub fn held(account_id: &str) -> Groups {
HELD.lock().unwrap().get(account_id).cloned().unwrap_or_default()
}
pub fn clear(account_id: &str) {
HELD.lock().unwrap().remove(account_id);
}
/// Keeps what Steam says and tells the window, if it is different.
pub fn heard(state: &AppState, account_id: &str, list: &ClientFriendsGroupsList) {
let groups = {
let mut all = HELD.lock().unwrap();
let groups = all.entry(account_id.to_string()).or_default();
if !groups.apply(list) {
return;
}
groups.clone()
};
tell(state, account_id, &groups);
unfile_conversations(state, account_id);
}
/// Takes every friend's conversation out of any heading it was given, and keeps it out. A
/// heading on a conversation is a service's own grouping and cannot be moved, renamed or removed
/// in the window; a Steam folder is not that - the Steam account's own page draws the folders
/// itself and the direct-messages page's headings are the person's - and one filed here once
/// (a heading restored from an earlier run among them) was a heading nobody could do anything with.
pub fn unfile_conversations(state: &AppState, account_id: &str) {
for buffer in state.runtime.list_buffers().into_iter().filter(|b| b.account_id == account_id && b.kind == "dm" && b.category.is_some()) {
state.runtime.set_buffer_category(state, &buffer.id, None, 0);
}
}
/// The same for one conversation, for when it has only just been made or restored.
pub fn unfile_one(state: &AppState, buffer_id: &str) {
state.runtime.set_buffer_category(state, buffer_id, None, 0);
}
fn tell(state: &AppState, account_id: &str, groups: &Groups) {
state.events.emit("steamFriendGroups", json!({ "accountId": account_id, "groups": groups.to_json() }));
}
/// What a change is, for the one call that makes it.
#[derive(Debug, PartialEq)]
pub enum Change {
Add { group: i32, friend: u64 },
Remove { group: i32, friend: u64 },
Create { name: String, friends: Vec<u64> },
Rename { group: i32, name: String },
Delete { group: i32 },
}
/// Asks Steam to make a change, and keeps what it answers. Returns the number
/// of the group made, for a creation.
pub async fn change(state: &AppState, link: &Link, account_id: &str, own: u64, what: Change) -> Result<Option<i32>, String> {
let refused = |code: Option<u32>| match code.unwrap_or(2) {
1 => None,
other => Some(super::eresult::describe(other as i32)),
};
let asked = |e: super::link::CallError| super::chat::refusal(&e);
let mut made = None;
match &what {
Change::Add { group, friend } => {
let answer: ClientAddFriendToGroupResponse = link.ask(emsg::CLIENT_ADD_FRIEND_TO_GROUP, &ClientAddFriendToGroup { groupid: Some(*group), steamiduser: Some(*friend) }).await.map_err(asked)?;
if let Some(why) = refused(answer.eresult) {
return Err(why);
}
}
Change::Remove { group, friend } => {
let answer: ClientRemoveFriendFromGroupResponse = link.ask(emsg::CLIENT_REMOVE_FRIEND_FROM_GROUP, &ClientRemoveFriendFromGroup { groupid: Some(*group), steamiduser: Some(*friend) }).await.map_err(asked)?;
if let Some(why) = refused(answer.eresult) {
return Err(why);
}
}
Change::Create { name, friends } => {
let answer: ClientCreateFriendsGroupResponse = link
.ask(emsg::CLIENT_CREATE_FRIENDS_GROUP, &ClientCreateFriendsGroup { steamid: Some(own), groupname: Some(name.clone()), steamid_friends: friends.clone() })
.await
.map_err(asked)?;
if let Some(why) = refused(answer.eresult) {
return Err(why);
}
made = answer.groupid;
}
Change::Rename { group, name } => {
let answer: ClientManageFriendsGroupResponse =
link.ask(emsg::CLIENT_MANAGE_FRIENDS_GROUP, &ClientManageFriendsGroup { groupid: Some(*group), groupname: Some(name.clone()), ..Default::default() }).await.map_err(asked)?;
if let Some(why) = refused(answer.eresult) {
return Err(why);
}
}
Change::Delete { group } => {
let answer: ClientDeleteFriendsGroupResponse = link.ask(emsg::CLIENT_DELETE_FRIENDS_GROUP, &ClientDeleteFriendsGroup { steamid: Some(own), groupid: Some(*group) }).await.map_err(asked)?;
if let Some(why) = refused(answer.eresult) {
return Err(why);
}
}
}
// What Steam has just agreed to, held now; if it also says so, it changes nothing.
let groups = {
let mut all = HELD.lock().unwrap();
let groups = all.entry(account_id.to_string()).or_default();
match what {
Change::Add { group, friend } => groups.put_member(group, friend, true),
Change::Remove { group, friend } => groups.put_member(group, friend, false),
Change::Create { name, friends } => {
if let Some(id) = made {
groups.put_group(id, &name, &friends);
}
}
Change::Rename { group, name } => groups.rename(group, &name),
Change::Delete { group } => groups.remove(group),
}
groups.clone()
};
tell(state, account_id, &groups);
unfile_conversations(state, account_id);
Ok(made)
}
/// The friend a window names by their id as text.
pub fn friend_of(text: &str) -> Option<u64> {
text.parse::<u64>().ok().filter(|id| steamid::account_of(*id).is_some())
}
#[cfg(test)]
mod tests {
use super::*;
const A: u64 = 76561197960287930;
const B: u64 = 76561197960265729;
fn list(groups: &[(i32, &str)], members: &[(u64, i32)], incremental: bool, removal: bool) -> ClientFriendsGroupsList {
ClientFriendsGroupsList {
bremoval: Some(removal),
bincremental: Some(incremental),
friendGroups: groups.iter().map(|(id, name)| FriendGroup { nGroupID: Some(*id), strGroupName: Some(name.to_string()) }).collect(),
memberships: members.iter().map(|(f, g)| FriendGroupMembership { ulSteamID: Some(*f), nGroupID: Some(*g) }).collect(),
}
}
#[test]
fn a_conversation_filed_under_a_heading_is_taken_out_of_it() {
let state = crate::backend::kick::testkit::simulated_daemon("steam-groups-unfile");
let account = "steam:unfile";
let buffer = state.runtime.ensure_buffer(&state, account, "Gabe", "dm");
state.runtime.set_buffer_category(&state, &buffer.id, Some("QC"), 0);
assert_eq!(state.runtime.get_buffer(&buffer.id).unwrap().category.as_deref(), Some("QC"));
unfile_conversations(&state, account);
assert_eq!(state.runtime.get_buffer(&buffer.id).unwrap().category, None, "a heading nobody can move, rename or remove is not left on it");
}
#[test]
fn what_steam_says_is_the_groups_and_who_is_in_them() {
let mut groups = Groups::default();
assert!(groups.apply(&list(&[(7, "Raiders"), (8, "Work")], &[(A, 7), (B, 7), (B, 8)], false, false)));
assert_eq!(
groups.to_json(),
json!([{ "id": 7, "name": "Raiders", "members": [B.to_string(), A.to_string()] }, { "id": 8, "name": "Work", "members": [B.to_string()] }])
);
assert!(!groups.apply(&list(&[(7, "Raiders")], &[(A, 7)], true, false)), "the same said again changes nothing");
}
#[test]
fn a_change_is_one_thing_and_a_full_list_is_everything() {
let mut groups = Groups::default();
groups.apply(&list(&[(7, "Raiders"), (8, "Work")], &[(A, 7), (B, 8)], false, false));
// Incremental: a rename, somebody moved.
assert!(groups.apply(&list(&[(7, "Raid Team")], &[(B, 7)], true, false)));
assert_eq!(groups.names[&7], "Raid Team");
assert!(groups.members[&7].contains(&B) && groups.members[&7].contains(&A));
// A removal of a person from a group, and of a group.
assert!(groups.apply(&list(&[], &[(A, 7)], true, true)));
assert!(!groups.members[&7].contains(&A));
assert!(groups.apply(&list(&[(8, "")], &[], true, true)));
assert!(!groups.names.contains_key(&8) && !groups.members.contains_key(&8));
// A full list replaces what was held.
assert!(groups.apply(&list(&[(9, "New")], &[], false, false)));
assert_eq!(groups.names.keys().copied().collect::<Vec<_>>(), vec![9]);
// Somebody in a group nobody has named is in no group.
assert!(!groups.apply(&list(&[], &[(A, 55)], true, false)));
}
#[tokio::test]
async fn changes_are_asked_of_steam_and_kept_when_it_agrees() {
use crate::backend::steam::link::{self, fake::*};
use prost::Message;
let state = crate::backend::kick::testkit::simulated_daemon("steam-groups-change");
let url = serve(Box::new(|p| match p.emsg {
emsg::CLIENT_CREATE_FRIENDS_GROUP => {
let ask = ClientCreateFriendsGroup::decode(p.body.as_slice()).unwrap();
assert_eq!((ask.steamid, ask.groupname.as_deref(), ask.steamid_friends.clone()), (Some(A), Some("Raiders"), vec![B]));
Some(vec![answer(&p, 1, ClientCreateFriendsGroupResponse { eresult: Some(1), groupid: Some(7) }.encode_to_vec())])
}
emsg::CLIENT_ADD_FRIEND_TO_GROUP => Some(vec![answer(&p, 1, ClientAddFriendToGroupResponse { eresult: Some(1) }.encode_to_vec())]),
emsg::CLIENT_REMOVE_FRIEND_FROM_GROUP => Some(vec![answer(&p, 1, ClientRemoveFriendFromGroupResponse { eresult: Some(15) }.encode_to_vec())]),
_ => Some(vec![]),
}))
.await;
let (link, _told) = link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
let account = "steam:groups-test";
let made = change(&state, &link, account, A, Change::Create { name: "Raiders".into(), friends: vec![B] }).await.unwrap();
assert_eq!(made, Some(7));
assert_eq!(held(account).to_json(), json!([{ "id": 7, "name": "Raiders", "members": [B.to_string()] }]));
change(&state, &link, account, A, Change::Add { group: 7, friend: A }).await.unwrap();
assert!(held(account).members[&7].contains(&A));
// Steam refusing leaves what was held as it was, and says why.
assert!(change(&state, &link, account, A, Change::Remove { group: 7, friend: A }).await.is_err());
assert!(held(account).members[&7].contains(&A));
clear(account);
assert_eq!(held(account), Groups::default());
}
}
+505
View File
@@ -0,0 +1,505 @@
//! What was said before, and what has been read.
//!
//! Steam keeps every conversation, so nothing here is moho's to lose: opening
//! a conversation asks for its recent end, scrolling up asks for the part
//! before, and signing in asks what arrived while nobody was listening. What
//! comes back is stored under the same id a live message gets - the second
//! Steam stamped it and where it fell within that second - so hearing a
//! message twice, once live and once in history, is hearing it once.
//!
//! Reading is shared the other way too: looking at a conversation here tells
//! Steam it is read, so a phone stops buzzing about it, and Steam tells us
//! when it was read there.
use super::chat::message_id;
use super::friends::Roster;
use super::publish::Conversations;
use super::link::{CallError, Link};
use super::proto::{AckMessage, GetActiveSessionsRequest, GetActiveSessionsResponse, GetRecentMessagesRequest, GetRecentMessagesResponse, PastMessage};
use super::steamid;
use crate::model::Buffer;
use crate::state::AppState;
/// How many conversations a catch-up reads, most recent first.
const CATCH_UP_CONVERSATIONS: usize = 30;
/// How many messages one request asks for. Steam's own client loads fifty at
/// a time as you scroll up.
pub const PAGE: u32 = 50;
/// One message out of the past, oldest-first once it leaves `fetch`.
#[derive(Debug, PartialEq)]
pub struct Past {
pub account: u32,
pub timestamp: u32,
pub ordinal: u32,
pub text: String,
/// Who has reacted with what, as held here; empty in a test that has none.
pub reactions: Vec<crate::model::Reaction>,
/// The web pages Steam's client described in it, which are cards.
pub embeds: Vec<crate::model::Embed>,
/// An invitation to a group chat, if that is what it is.
pub invite: Option<(u64, Option<u64>, String)>,
}
impl Past {
pub fn id(&self) -> String {
message_id(self.timestamp, self.ordinal)
}
}
fn read(message: &PastMessage, own: u32) -> Option<Past> {
let account = message.accountid.filter(|a| *a != 0)?;
// Asked for with its tags, and read into what moho draws.
let rendered = super::bbcode::render_message(message.message.as_deref().unwrap_or_default());
let (text, embeds) = (rendered.text, rendered.cards);
if text.trim().is_empty() {
return None;
}
let invite = super::chatgroups::invite_in(message.message.as_deref().unwrap_or_default());
Some(Past { account, timestamp: message.timestamp.unwrap_or_default(), ordinal: message.ordinal.unwrap_or_default(), text, reactions: super::reactions::from_history(own, &message.reactions), embeds, invite })
}
/// The conversations Steam has said have nothing before what is held: asking again for the part
/// before would get the same answer, and it is asked every time the top is scrolled to.
static AT_START: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> = std::sync::LazyLock::new(Default::default);
/// Whether this conversation is known to be held back to its first message.
pub fn at_start(buffer_id: &str) -> bool {
AT_START.lock().unwrap().contains(buffer_id)
}
/// Remembers that there is nothing before what is held, or that there is after all.
pub fn note_start(buffer_id: &str, reached: bool) {
let mut held = AT_START.lock().unwrap();
if reached {
held.insert(buffer_id.to_string());
} else {
held.remove(buffer_id);
}
}
/// Asks for up to `count` messages of a conversation that are older than
/// `older_than` - a second and an ordinal within it - or the newest when that
/// is absent. Oldest first, and whether there are more before them.
pub async fn fetch(link: &Link, own: u64, friend: u64, older_than: Option<(u32, u32)>, count: u32) -> Result<(Vec<Past>, bool), String> {
let (time_last, ordinal_last) = older_than.unwrap_or((u32::MAX, 0));
let request = GetRecentMessagesRequest {
steamid1: Some(own),
steamid2: Some(friend),
count: Some(count),
rtime32_start_time: Some(0),
start_ordinal: Some(0),
time_last: Some(time_last),
ordinal_last: Some(ordinal_last),
bbcode_format: Some(true),
..Default::default()
};
let answer: GetRecentMessagesResponse = link.call("FriendMessages.GetRecentMessages#1", &request, true).await.map_err(|e| super::chat::refusal(&e))?;
let own_number = steamid::account_of(own).unwrap_or_default();
let mut messages: Vec<Past> = answer.messages.iter().filter_map(|m| read(m, own_number)).collect();
// Steam answers newest first.
messages.reverse();
Ok((messages, answer.more_available.unwrap_or(false)))
}
/// Everything Steam holds a session for: who it has had messages with, when it
/// was last looked at, and how many are unread.
pub async fn sessions(link: &Link, since: u32) -> Result<GetActiveSessionsResponse, CallError> {
link.call("FriendMessages.GetActiveMessageSessions#1", &GetActiveSessionsRequest { lastmessage_since: Some(since), only_sessions_with_messages: Some(true) }, true).await
}
/// Says a conversation has been read up to a moment.
pub fn ack(link: &Link, friend: u64, timestamp: u32) -> Result<(), CallError> {
link.notify("FriendMessages.AckMessage#1", &AckMessage { steamid_partner: Some(friend), timestamp: Some(timestamp) })
}
/// The friend's 64-bit id from the conversation, and our own number.
fn people(state: &AppState, buffer: &Buffer) -> Result<(u64, u64), String> {
let friend: u64 = buffer.remote_id.as_deref().and_then(|id| id.parse().ok()).filter(|id| steamid::account_of(*id).is_some()).ok_or("this conversation is not with a Steam account")?;
let own = state.accounts.get_steam(&buffer.account_id).map(|c| c.steamid).ok_or("this Steam account is not set up")?;
Ok((own, friend))
}
/// Puts messages in a conversation's log without announcing them - they are
/// old, and a window told about each as news would treat them as unread.
/// Returns how many were new to the log.
pub fn store_quietly(state: &AppState, buffer: &Buffer, own: u32, past: &[Past]) -> usize {
let own_name = state.runtime.own_identity(&buffer.account_id).unwrap_or_default();
let mut added = 0;
for p in past {
let is_own = p.account == own;
let from = if is_own { own_name.as_str() } else { buffer.name.as_str() };
let sender = (!is_own).then(|| steamid::text_of(p.account));
let text = if is_own { p.text.clone() } else { super::account::filtered(&buffer.account_id, true, &p.text) };
let stored = state.store.append_message(&buffer.id, &p.id(), from, &text, i64::from(p.timestamp), false, false, "chat", None, &p.reactions, is_own, None, &p.embeds, &[], sender.as_deref(), None, "dm", None, &[]);
if matches!(stored, Ok(true)) {
added += 1;
} else if !p.reactions.is_empty() {
// Already held, but reacted to since: Steam's list is the truth.
let _ = state.store.set_reactions(&buffer.id, &p.id(), &p.reactions);
}
}
added
}
/// The recent end of a conversation, stored: what opening it asks for.
pub async fn open(state: &AppState, buffer: &Buffer) -> Result<usize, String> {
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
let (own, friend) = people(state, buffer)?;
let (past, _) = fetch(&link, own, friend, None, PAGE).await?;
Ok(store_quietly(state, buffer, steamid::account_of(own).unwrap_or_default(), &past))
}
/// The part before what is shown, stored: what scrolling to the top asks for.
/// `before` is the second of the oldest message on screen.
pub async fn older(state: &AppState, buffer: &Buffer, before: i64) -> Result<usize, String> {
// Steam has already said there is nothing before this: no question to ask again.
if at_start(&buffer.id) {
return Ok(0);
}
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
let (own, friend) = people(state, buffer)?;
let before = u32::try_from(before).map_err(|_| "that is not a time Steam knows".to_string())?;
// Up to and including the second asked about, whichever ordinal: what is
// already stored is recognised and not stored again.
let (past, more) = fetch(&link, own, friend, Some((before, u32::MAX)), PAGE).await?;
note_start(&buffer.id, !more);
Ok(store_quietly(state, buffer, steamid::account_of(own).unwrap_or_default(), &past))
}
/// Tells Steam a conversation has been read, up to its newest message.
pub fn mark_read(state: &AppState, buffer: &Buffer) -> Result<(), String> {
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
let (_, friend) = people(state, buffer)?;
let newest = state.store.get_backlog(&buffer.id, 0, 1).ok().and_then(|m| m.last().map(|m| m.ts)).filter(|t| *t > 0);
let at = newest.and_then(|t| u32::try_from(t).ok()).unwrap_or_else(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs() as u32);
ack(&link, friend, at).map_err(|e| e.to_string())
}
/// Catches up on what arrived while nobody was listening: for each
/// conversation Steam says has something this log does not, the newest page of
/// it. What was sent to us and not yet looked at anywhere is announced as news,
/// so it counts as unread and is notified; everything else - what we sent from
/// another client, what was read there - is only put in the log.
///
/// Returns how many unread conversations had no friend named yet to be read
/// into, so the caller can try again as the names arrive.
///
/// Run after a connection settles, and so on every reconnect as well as every
/// start: whatever the gap was, this is what fills it. A conversation whose log
/// is already up to date is left alone, and one with no log at all is left for
/// opening it, which asks for the same.
pub async fn catch_up(state: &AppState, link: &Link, account_id: &str, own: u32, roster: &Roster, shown: &Conversations) -> usize {
let answer = match sessions(link, 0).await {
Ok(a) => a,
Err(e) => {
tracing::info!("steam[{account_id}]: could not ask what was missed: {e}");
return 0;
}
};
let mut unnamed = 0;
let own_id = steamid::steamid_of(own);
// The recently active first, and only so many: a start must not spend its
// first minute reading what was said to somebody years ago. What is left
// is read when the conversation is opened, which asks for the same.
let mut sessions = answer.message_sessions;
sessions.sort_by_key(|s| std::cmp::Reverse(s.last_message.unwrap_or(0)));
tracing::debug!("steam[{account_id}]: {} conversation(s) with messages, {} unread", sessions.len(), sessions.iter().filter(|s| s.unread_message_count.unwrap_or(0) > 0).count());
sessions.truncate(CATCH_UP_CONVERSATIONS);
for session in sessions {
let Some(friend) = session.accountid_friend else { continue };
let Some(name) = shown.name_of(friend) else {
if session.unread_message_count.unwrap_or(0) > 0 {
unnamed += 1;
}
continue;
};
let buffer_id = crate::model::buffer_id(account_id, name);
let Some(buffer) = state.runtime.get_buffer(&buffer_id) else {
tracing::debug!("steam[{account_id}]: no conversation {buffer_id} to read what was missed into");
continue;
};
let newest_held = state.store.get_backlog(&buffer_id, 0, 1).ok().and_then(|m| m.last().map(|m| m.ts)).unwrap_or(0);
let unread = session.unread_message_count.unwrap_or(0);
let behind = newest_held > 0 && i64::from(session.last_message.unwrap_or(0)) > newest_held;
tracing::debug!("steam[{account_id}]: {name}: {unread} unread, last message {}, held up to {newest_held}, behind: {behind}", session.last_message.unwrap_or(0));
if unread == 0 && !behind {
continue;
}
let (past, _) = match fetch(link, own_id, steamid::steamid_of(friend), None, PAGE).await {
Ok(page) => page,
Err(e) => {
tracing::info!("steam[{account_id}]: could not catch up on {name}: {e}");
continue;
}
};
let last_view = i64::from(session.last_view.unwrap_or(0));
let friend_name = roster.persona(friend).and_then(|p| p.name.clone()).unwrap_or_else(|| name.to_string());
let (news, old): (Vec<Past>, Vec<Past>) = past
.into_iter()
.filter(|p| i64::from(p.timestamp) > newest_held)
.partition(|p| p.account != own && i64::from(p.timestamp) > last_view);
store_quietly(state, &buffer, own, &old);
for p in &news {
state.runtime.record_message_at(
state,
account_id,
name,
"dm",
&friend_name,
&super::account::filtered(account_id, true, &p.text),
false,
"chat",
None,
Some(p.id()),
false,
roster.persona(friend).and_then(|x| x.avatar.clone()),
p.embeds.clone(),
Vec::new(),
Some(steamid::text_of(p.account)),
Some(i64::from(p.timestamp)),
None,
None,
);
if let Some(found) = p.invite.clone().filter(|_| p.account != own) {
super::chatgroups::note_invite(state, account_id, &friend_name, found);
}
}
// Read elsewhere, so whatever this window counted is not waiting.
if unread == 0 {
state.events.emit("bufferRead", serde_json::json!({ "accountId": account_id, "bufferId": buffer_id }));
}
}
unnamed
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit::simulated_daemon;
use crate::backend::steam::friends::{Roster, REL_FRIEND};
use crate::backend::steam::link::{self, fake::*};
use crate::backend::steam::proto::*;
use crate::backend::steam::wire::Packet;
use prost::Message;
use std::sync::{Arc, Mutex};
use std::time::Duration;
const ACCOUNT: &str = "steam:tester";
const ME: u32 = 22202;
const FRIEND: u32 = 100;
fn past(account: u32, ts: u32, ord: u32, text: &str) -> PastMessage {
PastMessage { accountid: Some(account), timestamp: Some(ts), message: Some(text.into()), ordinal: Some(ord), reactions: vec![] }
}
/// A Steam holding a conversation, newest first as it answers, which also
/// remembers what it was asked and what it was told.
fn steam(history: Vec<PastMessage>, sessions: Vec<MessageSession>, seen: Arc<Mutex<Vec<Packet>>>) -> Script {
Box::new(move |p| {
seen.lock().unwrap().push(p.clone());
let method = p.header.target_job_name.clone().unwrap_or_default();
if method.contains("GetRecentMessages") {
let ask = GetRecentMessagesRequest::decode(p.body.as_slice()).unwrap();
// Older than the boundary, newest first, as many as asked for.
let (t, o) = (ask.time_last.unwrap_or(u32::MAX), ask.ordinal_last.unwrap_or(0));
let mut older: Vec<PastMessage> = history.iter().filter(|m| (m.timestamp.unwrap_or(0), m.ordinal.unwrap_or(0)) < (t, o)).cloned().collect();
older.sort_by_key(|m| std::cmp::Reverse((m.timestamp.unwrap_or(0), m.ordinal.unwrap_or(0))));
let count = ask.count.unwrap_or(50) as usize;
let more = older.len() > count;
older.truncate(count);
Some(vec![answer(&p, 1, GetRecentMessagesResponse { messages: older, more_available: Some(more) }.encode_to_vec())])
} else if method.contains("GetActiveMessageSessions") {
Some(vec![answer(&p, 1, GetActiveSessionsResponse { message_sessions: sessions.clone(), timestamp: Some(1) }.encode_to_vec())])
} else {
Some(vec![])
}
})
}
async fn link_to(script: Script) -> Link {
let url = serve(script).await;
link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0
}
fn friends_list() -> (Roster, Conversations) {
let mut roster = Roster::default();
roster.apply_friends_list(&ClientFriendsList {
bincremental: Some(false),
friends: vec![FriendsListEntry { ulfriendid: Some(steamid::steamid_of(FRIEND)), efriendrelationship: Some(REL_FRIEND) }],
..Default::default()
});
roster.apply_persona(&ClientPersonaState { friends: vec![PersonaFriend { friendid: Some(steamid::steamid_of(FRIEND)), player_name: Some("Gabe".into()), persona_state: Some(1), ..Default::default() }], ..Default::default() });
(roster, Conversations::new(ACCOUNT))
}
fn held(state: &AppState) -> Vec<(String, String, bool)> {
state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 200).unwrap().into_iter().map(|m| (m.id, m.body, m.is_own)).collect()
}
fn five() -> Vec<PastMessage> {
vec![past(FRIEND, 100, 0, "one"), past(ME, 110, 0, "two"), past(FRIEND, 120, 0, "three"), past(FRIEND, 120, 1, "same second"), past(ME, 130, 0, "five")]
}
#[tokio::test]
async fn history_comes_back_oldest_first_and_asks_the_way_steams_own_client_does() {
let seen = Arc::new(Mutex::new(Vec::new()));
let link = link_to(steam(five(), vec![], seen.clone())).await;
let (newest, more) = fetch(&link, steamid::steamid_of(ME), steamid::steamid_of(FRIEND), None, 3).await.unwrap();
assert_eq!(newest.iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["three", "same second", "five"], "the newest three, oldest first");
assert!(more);
let ask = GetRecentMessagesRequest::decode(seen.lock().unwrap()[0].body.as_slice()).unwrap();
assert_eq!((ask.steamid1, ask.steamid2, ask.count), (Some(steamid::steamid_of(ME)), Some(steamid::steamid_of(FRIEND)), Some(3)));
assert_eq!((ask.rtime32_start_time, ask.time_last, ask.ordinal_last), (Some(0), Some(u32::MAX), Some(0)), "no lower bound, no upper one");
// Paging: strictly older than a second and an ordinal within it.
let (before, more) = fetch(&link, steamid::steamid_of(ME), steamid::steamid_of(FRIEND), Some((120, 0)), 3).await.unwrap();
assert_eq!(before.iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["one", "two"]);
assert!(!more, "that was the start of it");
}
#[tokio::test]
async fn a_message_with_nothing_in_it_or_nobody_behind_it_is_not_history() {
let blank = vec![past(FRIEND, 1, 0, " "), past(0, 2, 0, "nobody"), PastMessage { accountid: Some(FRIEND), timestamp: Some(3), message: None, ordinal: None, reactions: vec![] }, past(FRIEND, 4, 0, "kept ")];
let link = link_to(steam(blank, vec![], Arc::default())).await;
let (got, _) = fetch(&link, steamid::steamid_of(ME), steamid::steamid_of(FRIEND), None, 50).await.unwrap();
assert_eq!(got, vec![Past { account: FRIEND, timestamp: 4, ordinal: 0, text: "kept".into(), reactions: vec![], embeds: vec![], invite: None }], "and trailing space is dropped");
}
#[tokio::test]
async fn stored_history_is_the_same_message_as_one_heard_live() {
let state = simulated_daemon("steam-history-dedupe");
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
state.runtime.set_own_identity(ACCOUNT, "Me");
let (roster, mut shown) = friends_list();
shown.publish(&state, &roster);
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap();
let link = link_to(steam(five(), vec![], Arc::default())).await;
state.runtime.set_steam_link(ACCOUNT, link);
// One of them was heard live first, under the id a live message gets.
crate::backend::steam::chat::record_incoming(
&state, ACCOUNT, &mut friends_list().0, &mut shown, "Me",
&crate::backend::steam::chat::Incoming { account: FRIEND, text: "three".into(), timestamp: 120, id: message_id(120, 0), from_us: false, embeds: Vec::new(), invite: None },
);
assert_eq!(held(&state).len(), 1);
let added = open(&state, &buffer).await.unwrap();
assert_eq!(added, 4, "the four it did not have, not the one it did");
let all = held(&state);
assert_eq!(all.len(), 5);
assert_eq!(all.iter().map(|m| m.1.as_str()).collect::<Vec<_>>().len(), 5);
let mine: Vec<&str> = all.iter().filter(|m| m.2).map(|m| m.1.as_str()).collect();
assert_eq!(mine.len(), 2, "what we sent is ours: {mine:?}");
assert_eq!(open(&state, &buffer).await.unwrap(), 0, "and asking again adds nothing");
}
#[tokio::test]
async fn scrolling_up_asks_for_what_is_older_and_not_for_what_is_there() {
let state = simulated_daemon("steam-history-older");
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
let (roster, mut shown) = friends_list();
shown.publish(&state, &roster);
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap();
note_start(&buffer.id, false);
let seen = Arc::new(Mutex::new(Vec::new()));
state.runtime.set_steam_link(ACCOUNT, link_to(steam(five(), vec![], seen.clone())).await);
assert!(older(&state, &buffer, -5).await.is_err());
// Everything from the second of 120 on is on screen.
assert_eq!(older(&state, &buffer, 120).await.unwrap(), 4, "everything up to and including that second is stored, whichever of it was shown");
let texts: Vec<String> = held(&state).into_iter().map(|m| m.1).collect();
assert_eq!(texts, ["one", "two", "three", "same second"]);
// Steam said there was nothing before that, so scrolling to the top again asks nothing.
let asked = seen.lock().unwrap().len();
assert!(at_start(&buffer.id));
assert_eq!(older(&state, &buffer, 20).await.unwrap(), 0);
assert_eq!(older(&state, &buffer, 20).await.unwrap(), 0);
assert_eq!(seen.lock().unwrap().len(), asked, "no question was sent");
note_start(&buffer.id, false);
}
#[tokio::test]
async fn reading_is_told_to_steam_up_to_the_newest_message_held() {
let state = simulated_daemon("steam-history-ack");
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
let (roster, mut shown) = friends_list();
shown.publish(&state, &roster);
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
state.runtime.set_steam_link(ACCOUNT, link_to(steam(five(), vec![], seen.clone())).await);
open(&state, &buffer).await.unwrap();
mark_read(&state, &buffer).unwrap();
for _ in 0..100 {
if seen.lock().unwrap().iter().any(|p| p.header.target_job_name.as_deref() == Some("FriendMessages.AckMessage#1")) {
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
let seen = seen.lock().unwrap();
let ack = seen.iter().find(|p| p.header.target_job_name.as_deref() == Some("FriendMessages.AckMessage#1")).expect("an ack was sent");
let body = AckMessage::decode(ack.body.as_slice()).unwrap();
assert_eq!((body.steamid_partner, body.timestamp), (Some(steamid::steamid_of(FRIEND)), Some(130)));
assert_eq!(ack.header.jobid_source, None, "a notification, which nothing waits on");
}
fn session(unread: u32, last_message: u32, last_view: u32) -> MessageSession {
MessageSession { accountid_friend: Some(FRIEND), last_message: Some(last_message), last_view: Some(last_view), unread_message_count: Some(unread), notices: vec![] }
}
async fn caught_up(name: &str, held_before: Vec<PastMessage>, session: MessageSession) -> (AppState, Vec<serde_json::Value>, Vec<serde_json::Value>) {
let state = simulated_daemon(name);
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
state.runtime.set_own_identity(ACCOUNT, "Me");
let (roster, mut shown) = friends_list();
shown.publish(&state, &roster);
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap();
let link = link_to(steam(five(), vec![session], Arc::default())).await;
store_quietly(&state, &buffer, ME, &held_before.iter().filter_map(|m| read(m, ME)).collect::<Vec<_>>());
let mut rx = state.events.subscribe();
let _ = catch_up(&state, &link, ACCOUNT, ME, &roster, &shown).await;
let (mut said, mut read_events) = (vec![], vec![]);
while let Ok(e) = rx.try_recv() {
match e.name {
"message" => said.push(e.data),
"bufferRead" => read_events.push(e.data),
_ => {}
}
}
(state, said, read_events)
}
#[tokio::test]
async fn what_arrived_while_away_and_was_not_read_anywhere_is_news() {
// Held up to 110; Steam says three since, unread, nothing viewed since 110.
let (state, said, read) = caught_up("steam-catchup-news", five()[..2].to_vec(), session(3, 130, 110)).await;
let heard: Vec<&str> = said.iter().map(|m| m["body"].as_str().unwrap()).collect();
assert_eq!(heard, ["three", "same second"], "what the friend sent after 110 is news; what we sent ('five') is not");
assert!(read.is_empty(), "something is still unread");
let all: Vec<String> = held(&state).into_iter().map(|m| m.1).collect();
assert_eq!(all, ["one", "two", "three", "same second", "five"], "and all of it is in the log, in order");
}
#[tokio::test]
async fn what_was_read_on_another_client_is_logged_and_clears_the_unread_count() {
let (state, said, read) = caught_up("steam-catchup-read", five()[..2].to_vec(), session(0, 130, 130)).await;
assert!(said.is_empty(), "read on the phone, so nothing to announce: {said:?}");
assert_eq!(held(&state).len(), 5, "but in the log");
assert_eq!(read.len(), 1, "and what this window counted is cleared");
assert_eq!(read[0]["bufferId"], crate::model::buffer_id(ACCOUNT, "Gabe"));
}
#[tokio::test]
async fn a_conversation_that_is_up_to_date_is_left_alone() {
let (state, said, read) = caught_up("steam-catchup-current", five(), session(0, 130, 130)).await;
assert!(said.is_empty() && read.is_empty());
assert_eq!(held(&state).len(), 5);
}
#[tokio::test]
async fn a_conversation_with_no_log_and_nothing_unread_waits_to_be_opened() {
let (state, said, _) = caught_up("steam-catchup-unopened", vec![], session(0, 130, 130)).await;
assert!(said.is_empty());
assert!(held(&state).is_empty(), "years of history are not fetched for every friend at every start");
}
}
+186
View File
@@ -0,0 +1,186 @@
//! Friend invite links: the one an account gives out, and the ones it is given.
//!
//! Steam's `s.team/p/<code>/<token>`. The code is the account's number written
//! in a way that is hard to misread - its hexadecimal digits mapped onto sixteen
//! letters that cannot be mistaken for each other or for a number - with a
//! hyphen in the middle. The token is a link of the account's own making: it
//! lets whoever has it add the account even if it takes no requests from
//! strangers, lasts a month, and can be revoked.
//!
//! The links are made, listed and revoked through Steam's website, as the
//! official client does; a link received is read for the account it is from and
//! turned into the ordinary request to be friends.
use super::link::Link;
use super::web;
use serde_json::Value;
/// The sixteen letters, in order of the hexadecimal digit each stands for.
const LETTERS: &[u8; 16] = b"bcdfghjkmnpqrtvw";
/// The code an account is known by in a link.
pub fn code_of(account: u32) -> String {
let code: String = format!("{account:x}").bytes().map(|d| LETTERS[(d as char).to_digit(16).unwrap_or(0) as usize] as char).collect();
if code.len() > 3 {
let (a, b) = code.split_at(code.len() / 2);
format!("{a}-{b}")
} else {
code
}
}
/// The account a code is for, or nothing for text that is not one.
pub fn account_in(code: &str) -> Option<u32> {
let letters: String = code.chars().filter(|c| *c != '-').collect::<String>().to_ascii_lowercase();
if letters.is_empty() || letters.len() > 8 {
return None;
}
let mut value: u64 = 0;
for c in letters.bytes() {
let digit = LETTERS.iter().position(|l| *l == c)? as u64;
value = value * 16 + digit;
}
u32::try_from(value).ok().filter(|v| *v > 0)
}
/// The account a link or a bare code is from: `s.team/p/<code>[/<token>]`, a
/// profile address in the code's form, or the code on its own.
pub fn account_of_link(text: &str) -> Option<u32> {
let text = text.trim().trim_end_matches('/');
let rest = text.split_once("/p/").map(|(_, r)| r).or_else(|| text.split_once("/user/").map(|(_, r)| r)).unwrap_or(text);
let code = rest.split(['/', '?', '#']).next()?;
account_in(code)
}
/// A link an account has given out.
#[derive(Debug, PartialEq)]
pub struct InviteLink {
pub token: String,
pub link: String,
pub valid: bool,
pub created: u64,
pub lasts_seconds: u64,
}
fn read_link(own_code: &str, entry: &Value) -> Option<InviteLink> {
let token = entry["invite_token"].as_str().filter(|t| !t.is_empty())?.to_string();
Some(InviteLink {
link: format!("https://s.team/p/{own_code}/{token}"),
valid: entry["valid"].as_i64().unwrap_or(0) != 0,
created: entry["time_created"].as_u64().unwrap_or(0),
lasts_seconds: entry["invite_duration"].as_str().and_then(|d| d.parse().ok()).or_else(|| entry["invite_duration"].as_u64()).unwrap_or(0),
token,
})
}
/// Reads the list the website gives: the ones still good, newest first.
pub fn read_links(own_account: u32, answer: &Value) -> Vec<InviteLink> {
let code = code_of(own_account);
let mut links: Vec<InviteLink> = answer["tokens"].as_array().into_iter().flatten().filter_map(|t| read_link(&code, t)).filter(|l| l.valid).collect();
links.sort_by_key(|l| std::cmp::Reverse(l.created));
links
}
async fn website(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<(reqwest::Client, String, String), String> {
web::login(link, key, steamid, refresh_token).await
}
fn plain_json(raw: &[u8]) -> Value {
serde_json::from_str(&web::plain(raw)).unwrap_or(Value::Null)
}
/// The account's own links that are still good.
pub async fn list(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<Vec<InviteLink>, String> {
let (http, cookie, session) = website(link, key, steamid, refresh_token).await?;
let response = http
.get("https://steamcommunity.com/invites/ajaxgetall")
.query(&[("sessionid", session.as_str())])
.header("Cookie", cookie)
.send()
.await
.map_err(|e| format!("could not reach Steam's website: {e}"))?;
let status = response.status();
let answer = plain_json(&response.bytes().await.map_err(|e| e.to_string())?);
if !status.is_success() {
return Err(web::explain(status, &answer));
}
Ok(read_links(super::steamid::account_of(steamid).unwrap_or_default(), &answer))
}
/// Makes a new link, good for a month.
pub async fn create(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<InviteLink, String> {
let (http, cookie, session) = website(link, key, steamid, refresh_token).await?;
let form = reqwest::multipart::Form::new().text("sessionid", session).text("steamid_user", steamid.to_string()).text("duration", "2592000");
let response = http.post("https://steamcommunity.com/invites/ajaxcreate").header("Cookie", cookie).multipart(form).send().await.map_err(|e| format!("could not reach Steam's website: {e}"))?;
let status = response.status();
let answer = plain_json(&response.bytes().await.map_err(|e| e.to_string())?);
if !status.is_success() {
return Err(web::explain(status, &answer));
}
read_link(&code_of(super::steamid::account_of(steamid).unwrap_or_default()), &answer["invite"]).ok_or_else(|| "Steam did not make a link".to_string())
}
/// Takes a link back, so that it is no use to whoever has it.
pub async fn revoke(link: &Link, key: &str, steamid: u64, refresh_token: &str, token: &str) -> Result<(), String> {
if token.is_empty() || !token.chars().all(|c| c.is_ascii_alphanumeric()) {
return Err("that is not an invite link".to_string());
}
let (http, cookie, session) = website(link, key, steamid, refresh_token).await?;
let form = reqwest::multipart::Form::new().text("sessionid", session).text("invite_token", token.to_string()).text("steamid_user", steamid.to_string());
let response = http.post("https://steamcommunity.com/invites/ajaxrevoke").header("Cookie", cookie).multipart(form).send().await.map_err(|e| format!("could not reach Steam's website: {e}"))?;
let status = response.status();
let answer = plain_json(&response.bytes().await.map_err(|e| e.to_string())?);
if status.is_success() && answer["success"].as_i64() == Some(1) {
Ok(())
} else if answer["success"].as_i64().is_some_and(|c| c > 1) || !status.is_success() {
Err(web::explain(status, &answer))
} else {
Err("Steam would not revoke that link".to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn a_code_is_the_account_number_in_letters() {
// Checked against Steam itself: these two are the codes of two real accounts.
assert_eq!(code_of(664476208), "dknq-cpfb");
assert_eq!(code_of(17167656), "cbh-whdm");
assert_eq!(account_in("dknq-cpfb"), Some(664476208));
assert_eq!(account_in("CBH-WHDM"), Some(17167656));
assert_eq!(account_in("dknqcpfb"), Some(664476208), "the hyphen is for reading");
for account in [1u32, 255, 4095, 22202, 664476208, u32::MAX] {
assert_eq!(account_in(&code_of(account)), Some(account), "{account}");
}
assert_eq!(account_in(""), None);
assert_eq!(account_in("abcdefgh"), None, "a, e and i are not among the letters");
assert_eq!(account_in("bcdfghjkmnpq-rtvw1"), None, "too long to be a number of this kind");
}
#[test]
fn a_link_is_read_for_the_account_it_is_from() {
assert_eq!(account_of_link("https://s.team/p/dknq-cpfb/QWERTYUI"), Some(664476208));
assert_eq!(account_of_link("https://s.team/p/dknq-cpfb"), Some(664476208));
assert_eq!(account_of_link("http://s.team/p/dknq-cpfb/QWERTYUI/"), Some(664476208));
assert_eq!(account_of_link("https://steamcommunity.com/user/cbh-whdm/"), Some(17167656));
assert_eq!(account_of_link("dknq-cpfb"), Some(664476208));
assert_eq!(account_of_link("https://example.com/not/this"), None);
}
#[test]
fn the_links_an_account_has_are_read_newest_first_and_only_the_good_ones() {
let answer = json!({ "success": 1, "tokens": [
{ "invite_token": "AAAAAAAA", "valid": 1, "time_created": 100, "invite_duration": "2592000" },
{ "invite_token": "BBBBBBBB", "valid": 0, "time_created": 300, "invite_duration": "2592000" },
{ "invite_token": "CCCCCCCC", "valid": 1, "time_created": 200, "invite_duration": "2592000" },
{ "valid": 1 }
] });
let links = read_links(664476208, &answer);
assert_eq!(links.iter().map(|l| l.token.as_str()).collect::<Vec<_>>(), ["CCCCCCCC", "AAAAAAAA"]);
assert_eq!(links[0].link, "https://s.team/p/dknq-cpfb/CCCCCCCC");
assert_eq!(links[0].lasts_seconds, 2592000);
}
}
+563
View File
@@ -0,0 +1,563 @@
//! One WebSocket to a connection manager, and the two ways of using it.
//!
//! **Asking.** Most of what a client does is a question: who is this, may I
//! sign in, send this. Each is a packet that names a job, and the answer is a
//! packet that names the same job; [`Link::call`] sends one and waits for its
//! answer, however many others arrive in between.
//!
//! **Being told.** The rest is things the server says unprompted - a friend
//! came online, a message arrived, you were signed out. Those come out of the
//! channel [`open`] returns, in the order they arrived, with the batches
//! already taken apart.
//!
//! A `Link` is a cheap handle: cloning it gives another way to ask, and the
//! connection closes when the last one is dropped.
use super::eresult;
use super::proto::Header;
use super::wire::{self, emsg, Packet, NO_JOB};
use anyhow::{Context, Result};
use futures::{SinkExt, StreamExt};
use prost::Message;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::{mpsc, oneshot};
use tokio_tungstenite::tungstenite::Message as Ws;
/// How long to wait for an answer before saying there is none.
pub const CALL_TIMEOUT: Duration = Duration::from_secs(30);
/// Who this connection is signed in as: stamped on everything sent once it is.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Identity {
pub steamid: u64,
pub session: i32,
}
/// Why a question got no answer, or a bad one.
#[derive(Debug)]
pub enum CallError {
/// Steam answered, and the answer was no. The code and, where Steam said,
/// its own words.
Refused(i32, Option<String>),
/// The connection ended before the answer came.
Closed,
/// No answer arrived in time.
TimedOut,
/// An answer arrived that could not be read.
Unreadable(String),
}
impl std::fmt::Display for CallError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CallError::Refused(code, said) => {
let words = eresult::describe(*code);
// Steam's own words go after ours when they add anything.
match said.as_deref().filter(|m| !m.eq_ignore_ascii_case(&words)) {
Some(said) => write!(f, "{words} ({said})"),
None => write!(f, "{words}"),
}
}
CallError::Closed => write!(f, "the connection to Steam ended"),
CallError::TimedOut => write!(f, "Steam did not answer in time"),
CallError::Unreadable(why) => write!(f, "an answer from Steam could not be read: {why}"),
}
}
}
impl std::error::Error for CallError {}
/// The questions waiting for an answer, and whether there is still a
/// connection to answer them.
///
/// The two are one thing behind one lock so that a question cannot be added
/// after the connection ended and before anybody noticed: that question would
/// wait out its whole timeout for an answer that cannot come.
#[derive(Default)]
struct Waiting {
ended: bool,
by_job: HashMap<u64, oneshot::Sender<Packet>>,
}
type Jobs = Arc<Mutex<Waiting>>;
struct Inner {
out: mpsc::UnboundedSender<Vec<u8>>,
jobs: Jobs,
next_job: AtomicU64,
identity: Mutex<Identity>,
timeout: Duration,
tasks: [tokio::task::AbortHandle; 2],
}
impl Drop for Inner {
fn drop(&mut self) {
for task in &self.tasks {
task.abort();
}
}
}
#[derive(Clone)]
pub struct Link(Arc<Inner>);
/// Opens the connection, routed as `key` is, and starts reading it.
pub async fn open(key: &str, url: &str) -> Result<(Link, mpsc::UnboundedReceiver<Packet>)> {
open_with(key, url, CALL_TIMEOUT).await
}
pub async fn open_with(key: &str, url: &str, timeout: Duration) -> Result<(Link, mpsc::UnboundedReceiver<Packet>)> {
let (socket, _) = crate::net::route::websocket(key, url).await.with_context(|| format!("connecting to {url}"))?;
let (mut sink, mut stream) = socket.split();
let (out, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (told, told_rx) = mpsc::unbounded_channel::<Packet>();
let jobs: Jobs = Arc::new(Mutex::new(Waiting::default()));
let writer = tokio::spawn(async move {
while let Some(bytes) = out_rx.recv().await {
if sink.send(Ws::Binary(bytes)).await.is_err() {
break;
}
}
let _ = sink.close().await;
});
let reader = tokio::spawn({
let jobs = jobs.clone();
let key = key.to_string();
async move {
let mut why = "the stream ended".to_string();
loop {
let frame = match stream.next().await {
None => break,
Some(Err(e)) => {
why = format!("a read failed: {e}");
break;
}
Some(Ok(frame)) => frame,
};
let bytes = match frame {
Ws::Binary(bytes) => bytes,
Ws::Close(close) => {
why = format!("Steam closed it: {}", close.map(|c| format!("{} {}", c.code, c.reason)).unwrap_or_else(|| "no reason given".to_string()));
break;
}
// Pings are answered by the library; text is not a thing
// this server sends.
_ => continue,
};
let packet = match Packet::decode(&bytes) {
Ok(p) => p,
Err(e) => {
tracing::debug!("steam: a packet that could not be read: {e:#}");
continue;
}
};
let packets = if packet.emsg == emsg::MULTI {
match wire::unpack_multi(&packet.body) {
Ok(inside) => inside,
Err(e) => {
tracing::debug!("steam: a batch that could not be read: {e:#}");
continue;
}
}
} else {
vec![packet]
};
for packet in packets {
// An answer goes to whoever asked; anything else is news.
let waiting = match packet.job_target() {
NO_JOB => None,
job => jobs.lock().unwrap().by_job.remove(&job),
};
match waiting {
Some(waiter) => {
let _ = waiter.send(packet);
}
None => {
if told.send(packet).is_err() {
return;
}
}
}
}
}
tracing::info!("steam[{key}]: the connection ended: {why}");
// Nobody is going to be answered now. Dropping the waiters is how
// each of them finds out, and dropping `told` is how the owner of
// the connection does. Marked ended in the same breath, so a
// question asked from here on fails at once.
let mut waiting = jobs.lock().unwrap();
waiting.ended = true;
waiting.by_job.clear();
}
});
let link = Link(Arc::new(Inner {
out,
jobs,
next_job: AtomicU64::new(1),
identity: Mutex::new(Identity::default()),
timeout,
tasks: [writer.abort_handle(), reader.abort_handle()],
}));
Ok((link, told_rx))
}
impl Link {
/// Who this connection says it is from now on.
pub fn set_identity(&self, identity: Identity) {
*self.0.identity.lock().unwrap() = identity;
}
pub fn identity(&self) -> Identity {
*self.0.identity.lock().unwrap()
}
fn header(&self, signed_in: bool) -> Header {
let id = self.identity();
Header {
steamid: (signed_in && id.steamid != 0).then_some(id.steamid),
client_sessionid: (id.session != 0).then_some(id.session),
..Header::default()
}
}
/// Whether the connection is still there to send on.
pub fn is_open(&self) -> bool {
!self.0.out.is_closed() && !self.0.jobs.lock().unwrap().ended
}
/// Sends a message that expects no answer.
pub fn send<M: Message>(&self, emsg: u32, message: &M) -> Result<(), CallError> {
let packet = Packet::new(emsg, self.header(true), message.encode_to_vec());
self.0.out.send(packet.encode()).map_err(|_| CallError::Closed)
}
/// A service method that is said and not answered: Steam's `SendNotification`,
/// as against a call. Nothing waits for anything.
pub fn notify<Q: Message>(&self, method: &str, request: &Q) -> Result<(), CallError> {
let mut header = self.header(true);
header.target_job_name = Some(method.to_string());
header.realm = Some(1);
let packet = Packet::new(emsg::SERVICE_METHOD_CALL_FROM_CLIENT, header, request.encode_to_vec());
self.0.out.send(packet.encode()).map_err(|_| CallError::Closed)
}
/// A question to Steam in one of its older messages, and the answer.
///
/// These are not service methods: there is no method name, and the
/// result code is inside the answer rather than in its header, so it is
/// the caller's to read. What matches the answer to the question is the
/// same job id.
pub async fn ask<Q: Message, A: Message + Default>(&self, emsg: u32, request: &Q) -> Result<A, CallError> {
let job = self.0.next_job.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
{
let mut waiting = self.0.jobs.lock().unwrap();
if waiting.ended {
return Err(CallError::Closed);
}
waiting.by_job.insert(job, tx);
}
let mut header = self.header(true);
header.jobid_source = Some(job);
let packet = Packet::new(emsg, header, request.encode_to_vec());
if self.0.out.send(packet.encode()).is_err() {
self.0.jobs.lock().unwrap().by_job.remove(&job);
return Err(CallError::Closed);
}
let answer = match tokio::time::timeout(self.0.timeout, rx).await {
Ok(Ok(answer)) => answer,
Ok(Err(_)) => return Err(CallError::Closed),
Err(_) => {
self.0.jobs.lock().unwrap().by_job.remove(&job);
return Err(CallError::TimedOut);
}
};
A::decode(answer.body.as_slice()).map_err(|e| CallError::Unreadable(e.to_string()))
}
/// A question to one of Steam's services, and its answer.
///
/// `method` is the service and method as Steam names them, `Service.Method#1`.
/// `signed_in` says whether to speak as the account: the calls that make
/// signing in possible are made before there is one.
pub async fn call<Q: Message, A: Message + Default>(&self, method: &str, request: &Q, signed_in: bool) -> Result<A, CallError> {
let job = self.0.next_job.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = oneshot::channel();
{
let mut waiting = self.0.jobs.lock().unwrap();
if waiting.ended {
return Err(CallError::Closed);
}
waiting.by_job.insert(job, tx);
}
let mut header = self.header(signed_in);
header.jobid_source = Some(job);
header.target_job_name = Some(method.to_string());
header.realm = Some(1);
let kind = if signed_in { emsg::SERVICE_METHOD_CALL_FROM_CLIENT } else { emsg::SERVICE_METHOD_CALL_FROM_CLIENT_NON_AUTHED };
let packet = Packet::new(kind, header, request.encode_to_vec());
if self.0.out.send(packet.encode()).is_err() {
self.0.jobs.lock().unwrap().by_job.remove(&job);
return Err(CallError::Closed);
}
let answer = match tokio::time::timeout(self.0.timeout, rx).await {
Ok(Ok(answer)) => answer,
Ok(Err(_)) => return Err(CallError::Closed),
Err(_) => {
self.0.jobs.lock().unwrap().by_job.remove(&job);
return Err(CallError::TimedOut);
}
};
let code = answer.eresult();
if code != eresult::OK {
// Steam asking for its agreement to be accepted puts the page for it in the answer, and
// that page is the whole of what can be done about it.
let said = answer.header.error_message.filter(|m| !m.is_empty()).or_else(|| (code == eresult::MUST_AGREE_TO_SSA).then(|| agreement_page(&answer.body)).flatten());
return Err(CallError::Refused(code, said));
}
A::decode(answer.body.as_slice()).map_err(|e| CallError::Unreadable(e.to_string()))
}
}
/// The address Steam gives for the agreement it wants accepted: the sign-in answers carry it as
/// field 7 and the polling answer as field 8.
fn agreement_page(body: &[u8]) -> Option<String> {
#[derive(Message)]
struct Page {
#[prost(string, optional, tag = "7")]
sign_in: Option<String>,
#[prost(string, optional, tag = "8")]
poll: Option<String>,
}
let page = Page::decode(body).ok()?;
page.sign_in.or(page.poll).filter(|u| u.starts_with("https://"))
}
#[cfg(test)]
mod agreement_tests {
use super::*;
#[test]
fn the_page_for_the_agreement_steam_wants_is_found_in_either_kind_of_answer() {
#[derive(Message)]
struct Begin {
#[prost(string, optional, tag = "7")]
url: Option<String>,
}
#[derive(Message)]
struct Poll {
#[prost(string, optional, tag = "8")]
url: Option<String>,
}
assert_eq!(agreement_page(&Begin { url: Some("https://login.steampowered.com/a".into()) }.encode_to_vec()).as_deref(), Some("https://login.steampowered.com/a"));
assert_eq!(agreement_page(&Poll { url: Some("https://login.steampowered.com/b".into()) }.encode_to_vec()).as_deref(), Some("https://login.steampowered.com/b"));
assert_eq!(agreement_page(&Poll { url: Some("javascript:alert(1)".into()) }.encode_to_vec()), None, "only an https address is passed on");
assert_eq!(agreement_page(&[0xff, 0xff]), None);
}
}
/// A connection manager that is not one: speaks the framing, over a plain
/// WebSocket on the loopback, as a script says. For the tests of everything
/// built on a connection.
#[cfg(test)]
pub(crate) mod fake {
use super::*;
use tokio::net::TcpListener;
/// What a scripted server does with each packet it is sent: the packets to
/// send back, in order. Returning `None` closes the connection.
pub type Script = Box<dyn FnMut(Packet) -> Option<Vec<Packet>> + Send>;
/// A server on a free port; the `ws://` address to open.
pub async fn serve(mut script: Script) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let Ok((tcp, _)) = listener.accept().await else { return };
let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
while let Some(Ok(frame)) = ws.next().await {
let Ws::Binary(bytes) = frame else { continue };
let packet = Packet::decode(&bytes).expect("a packet from the client");
match script(packet) {
Some(replies) => {
for reply in replies {
if ws.send(Ws::Binary(reply.encode())).await.is_err() {
return;
}
}
}
None => {
let _ = ws.close(None).await;
return;
}
}
}
});
format!("ws://{addr}")
}
/// The answer to a call: the request's job as its target, a result, a body.
pub fn answer(request: &Packet, code: i32, body: Vec<u8>) -> Packet {
Packet::new(
emsg::SERVICE_METHOD_RESPONSE,
Header { jobid_target: request.header.jobid_source, eresult: Some(code), ..Header::default() },
body,
)
}
/// Something the server says unprompted.
pub fn tell(emsg: u32, body: Vec<u8>) -> Packet {
Packet::new(emsg, Header::default(), body)
}
}
#[cfg(test)]
mod tests {
use super::fake::*;
use super::*;
use crate::backend::steam::proto::*;
const KEY: &str = "steam:test";
#[tokio::test]
async fn a_question_gets_its_own_answer_whatever_arrives_between() {
let url = serve(Box::new(|req| {
assert_eq!(req.header.target_job_name.as_deref(), Some("Authentication.GetPasswordRSAPublicKey#1"));
let ask = GetPasswordRsaPublicKeyRequest::decode(req.body.as_slice()).unwrap();
assert_eq!(ask.account_name.as_deref(), Some("someone"));
// News first, to be sure the answer is matched by its job and not
// by being the next thing to arrive.
Some(vec![
tell(emsg::CLIENT_ACCOUNT_INFO, ClientAccountInfo { persona_name: Some("Some One".into()), ..Default::default() }.encode_to_vec()),
answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some("AB".into()), publickey_exp: Some("010001".into()), timestamp: Some(7) }.encode_to_vec()),
])
}))
.await;
let (link, mut told) = open(KEY, &url).await.unwrap();
let got: GetPasswordRsaPublicKeyResponse = link
.call("Authentication.GetPasswordRSAPublicKey#1", &GetPasswordRsaPublicKeyRequest { account_name: Some("someone".into()) }, false)
.await
.unwrap();
assert_eq!((got.publickey_mod.as_deref(), got.timestamp), (Some("AB"), Some(7)));
let news = told.recv().await.unwrap();
assert_eq!(news.emsg, emsg::CLIENT_ACCOUNT_INFO);
}
#[tokio::test]
async fn the_question_says_who_is_asking_only_once_there_is_somebody() {
let seen = Arc::new(Mutex::new(Vec::<Packet>::new()));
let url = serve({
let seen = seen.clone();
Box::new(move |req| {
seen.lock().unwrap().push(req.clone());
Some(vec![answer(&req, 1, Vec::new())])
})
})
.await;
let (link, _told) = open(KEY, &url).await.unwrap();
let _: ClientLogOff = link.call("A.B#1", &ClientLogOff {}, false).await.unwrap();
link.set_identity(Identity { steamid: 76561197960287930, session: 55 });
let _: ClientLogOff = link.call("A.B#1", &ClientLogOff {}, true).await.unwrap();
let seen = seen.lock().unwrap();
assert_eq!(seen[0].emsg, emsg::SERVICE_METHOD_CALL_FROM_CLIENT_NON_AUTHED);
assert_eq!(seen[0].header.steamid, None);
assert_eq!(seen[0].header.realm, Some(1));
assert_eq!(seen[1].emsg, emsg::SERVICE_METHOD_CALL_FROM_CLIENT);
assert_eq!(seen[1].header.steamid, Some(76561197960287930));
assert_eq!(seen[1].header.client_sessionid, Some(55));
assert_ne!(seen[0].header.jobid_source, seen[1].header.jobid_source, "each question is its own job");
}
#[tokio::test]
async fn a_no_is_an_error_carrying_the_code_and_steams_own_words() {
let url = serve(Box::new(|req| {
let mut no = answer(&req, eresult::RATE_LIMIT_EXCEEDED, Vec::new());
no.header.error_message = Some("slow down".into());
Some(vec![no])
}))
.await;
let (link, _told) = open(KEY, &url).await.unwrap();
let err = link.call::<_, ClientLogOff>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
assert!(matches!(&err, CallError::Refused(84, Some(m)) if m == "slow down"));
assert!(err.to_string().contains("limiting"));
assert!(err.to_string().contains("(slow down)"), "Steam's own words are kept: {err}");
}
#[tokio::test]
async fn a_batch_is_taken_apart_and_its_news_arrives_in_order() {
let url = serve(Box::new(|_| {
let inside = [
tell(emsg::CLIENT_FRIENDS_LIST, ClientFriendsList { bincremental: Some(false), ..Default::default() }.encode_to_vec()),
tell(emsg::CLIENT_PERSONA_STATE, ClientPersonaState::default().encode_to_vec()),
];
Some(vec![tell(emsg::MULTI, wire::pack_multi(&inside, true))])
}))
.await;
let (link, mut told) = open(KEY, &url).await.unwrap();
link.send(emsg::CLIENT_HEART_BEAT, &ClientHeartBeat::default()).unwrap();
assert_eq!(told.recv().await.unwrap().emsg, emsg::CLIENT_FRIENDS_LIST);
assert_eq!(told.recv().await.unwrap().emsg, emsg::CLIENT_PERSONA_STATE);
}
#[tokio::test]
async fn a_connection_that_ends_fails_the_questions_waiting_and_closes_the_news() {
let url = serve(Box::new(|_| None)).await;
let (link, mut told) = open(KEY, &url).await.unwrap();
let err = link.call::<_, ClientLogOff>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
assert!(matches!(err, CallError::Closed), "{err:?}");
assert!(told.recv().await.is_none(), "the news stops when the connection does");
// And asking after it has gone fails at once rather than waiting.
let err = link.call::<_, ClientLogOff>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
assert!(matches!(err, CallError::Closed), "{err:?}");
}
#[tokio::test]
async fn no_answer_is_a_timeout_and_the_job_is_forgotten() {
let url = serve(Box::new(|_| Some(Vec::new()))).await;
let (link, _told) = open_with(KEY, &url, Duration::from_millis(150)).await.unwrap();
let err = link.call::<_, ClientLogOff>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
assert!(matches!(err, CallError::TimedOut), "{err:?}");
assert!(link.0.jobs.lock().unwrap().by_job.is_empty(), "a question nobody is waiting on is not kept");
}
#[tokio::test]
async fn an_answer_that_is_not_what_was_asked_for_is_unreadable_not_a_panic() {
let url = serve(Box::new(|req| Some(vec![answer(&req, 1, vec![0xff, 0xff, 0xff])]))).await;
let (link, _told) = open(KEY, &url).await.unwrap();
let err = link.call::<_, ClientFriendsList>("A.B#1", &ClientLogOff {}, false).await.unwrap_err();
assert!(matches!(err, CallError::Unreadable(_)), "{err:?}");
}
#[tokio::test]
async fn dropping_the_last_handle_closes_the_connection() {
let closed = Arc::new(tokio::sync::Notify::new());
let url = {
let closed = closed.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
while ws.next().await.is_some() {}
closed.notify_one();
});
format!("ws://{addr}")
};
let (link, told) = open(KEY, &url).await.unwrap();
let other = link.clone();
drop(link);
assert!(other.is_open());
drop(other);
drop(told);
tokio::time::timeout(Duration::from_secs(5), closed.notified()).await.expect("the server saw the connection close");
}
}
+557
View File
@@ -0,0 +1,557 @@
//! Adding an account: the sign-in a person takes part in.
//!
//! The password is typed once, here, and this is where it ends: it is
//! encrypted for Steam, sent, and dropped when the sign-in finishes. What the
//! account keeps afterwards is the token Steam hands back.
//!
//! Signing in is a conversation that can take minutes, because Steam may
//! want something from a person on a phone. So it runs as its own task and
//! talks to the window through events rather than answering one request:
//!
//! - `steamLoginStatus` - what it is doing, for a line of text
//! - `steamLoginNeeds` - what Steam is asking for: a code from the
//! authenticator, a code it emailed, an approval in the Steam app, or
//! several of those (any one will do)
//! - `steamLoginResult` - done, with the account or the reason it failed
//!
//! and the window answers a code with `submitSteamGuardCode`.
use super::auth::{self, Begun, Guard};
use super::connect;
use super::link::{self, Identity};
use super::proto::ClientHello;
use super::wire::emsg;
use super::cm;
use crate::accounts::SteamAccountConfig;
use crate::state::AppState;
use anyhow::{anyhow, bail, Context, Result};
use serde_json::json;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::mpsc;
/// How long a sign-in may wait for a person. Steam gives up on a session after
/// about this long, and a form left open overnight should not leave a task.
const PATIENCE: Duration = Duration::from_secs(5 * 60);
/// The sign-ins waiting for a code, by id, and the way to give each one.
fn waiting() -> &'static Mutex<HashMap<String, mpsc::UnboundedSender<String>>> {
static WAITING: std::sync::OnceLock<Mutex<HashMap<String, mpsc::UnboundedSender<String>>>> = std::sync::OnceLock::new();
WAITING.get_or_init(Default::default)
}
/// Gives a sign-in the code it asked for.
pub fn submit_code(login_id: &str, code: &str) -> Result<()> {
let code = code.trim();
if code.is_empty() {
bail!("type the code first");
}
let sender = waiting().lock().unwrap().get(login_id).cloned().ok_or_else(|| anyhow!("that sign-in is not waiting for a code any more"))?;
sender.send(code.to_string()).map_err(|_| anyhow!("that sign-in has ended"))
}
/// Stops a sign-in that is waiting, because the form was closed.
pub fn cancel(login_id: &str) {
waiting().lock().unwrap().remove(login_id);
}
/// What the window is asked for, from what Steam allows.
///
/// Each option is a way in; any one of them will do. Ones this client has no
/// answer to are left out, and if nothing is left the sign-in cannot go on.
pub fn options_of(begun: &Begun) -> Vec<serde_json::Value> {
begun
.confirmations
.iter()
.filter_map(|c| match &c.guard {
Guard::DeviceCode => Some(json!({ "kind": "deviceCode" })),
Guard::EmailCode => Some(json!({ "kind": "emailCode", "hint": c.hint })),
Guard::DeviceConfirmation => Some(json!({ "kind": "approval", "hint": c.hint })),
Guard::EmailConfirmation => Some(json!({ "kind": "emailApproval", "hint": c.hint })),
Guard::None | Guard::Other(_) => None,
})
.collect()
}
/// Which kind of code to say a typed one is: the authenticator's if that is
/// on offer, otherwise the emailed one.
pub fn guard_for_code(begun: &Begun) -> Option<Guard> {
[Guard::DeviceCode, Guard::EmailCode].into_iter().find(|g| begun.confirmations.iter().any(|c| &c.guard == g))
}
/// Starts a sign-in. Answers at once; the rest is events.
pub fn start(state: AppState, login_id: String, username: String, password: String) {
let (code_tx, code_rx) = mpsc::unbounded_channel();
waiting().lock().unwrap().insert(login_id.clone(), code_tx);
tokio::spawn(async move {
let outcome = async {
let key = crate::net::route::pending_key("steam");
let servers = cm::discover(&key, 0).await?;
let urls: Vec<String> = servers.iter().take(4).map(|s| s.url()).collect();
flow(&state, &login_id, &username, &password, &urls, code_rx).await
}
.await;
waiting().lock().unwrap().remove(&login_id);
match outcome {
Ok(Some(saved)) => {
let account = finish(&saved);
connect::spawn(state.clone(), saved);
state.events.emit("steamLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
}
// The form was closed; nobody is waiting for an answer.
Ok(None) => {}
Err(e) => {
let error = format!("{e:#}");
tracing::info!("steam: sign-in {login_id}: {error}");
state.events.emit("steamLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
}
}
});
}
/// Starts a sign-in by scanning a code with the Steam app on a phone: no name
/// and no password are typed here at all. Answers at once; the code to draw
/// and the end are events - `steamLoginQr` carries the picture, and the rest is
/// as for a typed sign-in.
pub fn start_qr(state: AppState, login_id: String) {
let (code_tx, code_rx) = mpsc::unbounded_channel();
waiting().lock().unwrap().insert(login_id.clone(), code_tx);
tokio::spawn(async move {
let outcome = async {
let key = crate::net::route::pending_key("steam");
let servers = cm::discover(&key, 0).await?;
let urls: Vec<String> = servers.iter().take(4).map(|s| s.url()).collect();
flow_qr(&state, &login_id, &urls, code_rx).await
}
.await;
waiting().lock().unwrap().remove(&login_id);
let _ = std::fs::remove_file(qr_path(&login_id));
match outcome {
Ok(Some(saved)) => {
let account = finish(&saved);
connect::spawn(state.clone(), saved);
state.events.emit("steamLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
}
Ok(None) => {}
Err(e) => {
let error = format!("{e:#}");
tracing::info!("steam: sign-in {login_id}: {error}");
state.events.emit("steamLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
}
}
});
}
fn qr_path(login_id: &str) -> std::path::PathBuf {
crate::media_cache::transient_dir().join(format!("moho-steam-qr-{}.png", login_id.replace(|c: char| !c.is_ascii_alphanumeric(), "-")))
}
/// The address as a picture a phone can scan. The code is a credential for as
/// long as it is shown: whoever scans and approves it is signed in as them.
pub fn write_qr(address: &str, path: &std::path::Path) -> Result<()> {
let code = qrcode::QrCode::new(address.as_bytes())?;
let image = code.render::<image::Luma<u8>>().min_dimensions(320, 320).build();
image.save_with_format(path, image::ImageFormat::Png).context("encoding the QR code as PNG")?;
let _ = crate::secure::restrict_file_to_owner(path);
Ok(())
}
/// The scan-to-sign-in itself, on the first of `urls` that answers.
pub async fn flow_qr(state: &AppState, login_id: &str, urls: &[String], mut cancelled: mpsc::UnboundedReceiver<String>) -> Result<Option<SteamAccountConfig>> {
let status = |detail: &str| state.events.emit("steamLoginStatus", json!({ "loginId": login_id, "detail": detail }));
let key = crate::net::route::pending_key("steam");
status("connecting to Steam");
let mut opened = None;
let mut last = anyhow!("no Steam server to connect to");
for url in urls {
match link::open(&key, url).await {
Ok(ok) => {
opened = Some(ok);
break;
}
Err(e) => last = e,
}
}
let (link, _told) = opened.ok_or(last).context("reaching Steam")?;
link.send(emsg::CLIENT_HELLO, &ClientHello { protocol_version: Some(connect::PROTOCOL_VERSION) })?;
let (mut begun, address) = auth::begin_qr(&link).await.map_err(|e| anyhow!("{e}"))?;
let path = qr_path(login_id);
write_qr(&address, &path)?;
state.events.emit("steamLoginQr", json!({ "loginId": login_id, "path": path.to_string_lossy() }));
status("waiting for the code to be scanned");
let deadline = tokio::time::Instant::now() + PATIENCE;
let tokens = loop {
tokio::select! {
// The form was closed (or the sender dropped): nobody is waiting.
_ = cancelled.recv() => return Ok(None),
_ = tokio::time::sleep(begun.interval) => {
if tokio::time::Instant::now() >= deadline {
bail!("the code was not scanned in time; start again");
}
let polled = auth::poll_session(&link, &mut begun).await.map_err(|e| anyhow!("{}", auth::waiting_failure(e)))?;
// A code runs out and is replaced: the picture follows it.
if let Some(next) = polled.new_challenge {
write_qr(&next, &path)?;
state.events.emit("steamLoginQr", json!({ "loginId": login_id, "path": path.to_string_lossy(), "renewed": true }));
}
if let Some(tokens) = polled.tokens {
break tokens;
}
}
}
};
status("signed in");
let steamid = auth::steamid_of_token(&tokens.refresh_token).ok_or_else(|| anyhow!("Steam's token did not say whose it is"))?;
let username = tokens.account_name.clone().map(|n| n.to_lowercase()).ok_or_else(|| anyhow!("Steam did not say the account's name"))?;
let config = SteamAccountConfig {
username,
steamid,
display_name: None,
refresh_token: tokens.refresh_token,
guard_data: tokens.guard_data,
session: "chat".to_string(),
use_tor: crate::net::route::router().wanted(&key),
strict_route: false,
};
let saved = state.accounts.add_steam(config).context("saving the account")?;
Ok(Some(saved))
}
/// What a finished sign-in is worth to the window: the account as it lists,
/// and the route the account will be reached by remembered for it.
pub fn finish(saved: &SteamAccountConfig) -> serde_json::Value {
crate::net::route::router().mark(&saved.account_id(), saved.use_tor);
serde_json::to_value(crate::accounts::steam_account_to_json(saved, "connecting", None)).expect("an account serialises")
}
/// The sign-in itself, on the first of `urls` that answers.
pub async fn flow(
state: &AppState,
login_id: &str,
username: &str,
password: &str,
urls: &[String],
mut codes: mpsc::UnboundedReceiver<String>,
) -> Result<Option<SteamAccountConfig>> {
let status = |detail: &str| state.events.emit("steamLoginStatus", json!({ "loginId": login_id, "detail": detail }));
let key = crate::net::route::pending_key("steam");
let username = username.trim().to_lowercase();
if username.is_empty() || password.is_empty() {
bail!("type the account name and the password");
}
status("connecting to Steam");
let mut opened = None;
let mut last = anyhow!("no Steam server to connect to");
for url in urls {
match link::open(&key, url).await {
Ok(ok) => {
opened = Some(ok);
break;
}
Err(e) => last = e,
}
}
let (link, _told) = opened.ok_or(last).context("reaching Steam")?;
link.send(emsg::CLIENT_HELLO, &ClientHello { protocol_version: Some(connect::PROTOCOL_VERSION) })?;
status("signing in");
// The note Steam gave last time this machine signed this account in, if there
// is one: sent so that Steam knows the machine and does not ask for a Guard
// code again.
let remembered = state.accounts.get_steam(&format!("steam:{username}")).and_then(|a| a.guard_data).filter(|g| !g.is_empty());
let mut begun = auth::begin(&link, &username, password, remembered.as_deref()).await.map_err(|e| anyhow!("{e}"))?;
let steamid = begun.steamid;
let options = options_of(&begun);
if !begun.needs_nothing() {
if options.is_empty() {
bail!("Steam asked for a kind of confirmation this version of moho cannot give");
}
state.events.emit("steamLoginNeeds", json!({ "loginId": login_id, "options": options }));
}
let deadline = tokio::time::Instant::now() + PATIENCE;
let tokens = loop {
tokio::select! {
code = codes.recv() => {
// The form was closed: nobody is waiting.
let Some(code) = code else { return Ok(None) };
let Some(guard) = guard_for_code(&begun) else { continue };
if let Err(e) = auth::send_code(&link, &begun, &guard, &code).await {
// Said and asked again: a mistyped code is the ordinary case.
state.events.emit("steamLoginNeeds", json!({ "loginId": login_id, "options": options, "error": e.to_string() }));
continue;
}
status("checking the code");
}
_ = tokio::time::sleep(begun.interval) => {
if tokio::time::Instant::now() >= deadline {
bail!("Steam was left waiting too long; start again");
}
if let Some(tokens) = auth::poll(&link, &mut begun).await.map_err(|e| anyhow!("{}", auth::waiting_failure(e)))? {
break tokens;
}
}
}
};
let config = SteamAccountConfig {
username: tokens.account_name.clone().map(|n| n.to_lowercase()).unwrap_or(username),
steamid,
display_name: None,
refresh_token: tokens.refresh_token,
guard_data: tokens.guard_data,
// The kind is no longer chosen; kept so older files still read.
session: "chat".to_string(),
use_tor: crate::net::route::router().wanted(&key),
strict_route: false,
};
if steamid != 0 {
link.set_identity(Identity { steamid, session: 0 });
}
let saved = state.accounts.add_steam(config).context("saving the account")?;
Ok(Some(saved))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit::simulated_daemon;
use crate::backend::steam::auth::Confirmation;
use crate::backend::steam::eresult;
use crate::backend::steam::link::fake::*;
use crate::backend::steam::proto::*;
use crate::backend::steam::wire::Packet;
use prost::Message;
use rsa::traits::PublicKeyParts;
use rsa::RsaPrivateKey;
use std::sync::Arc;
fn begun(guards: &[(Guard, Option<&str>)]) -> Begun {
Begun {
client_id: 1,
request_id: vec![],
interval: Duration::from_millis(50),
steamid: 76561197960287930,
confirmations: guards.iter().map(|(g, h)| Confirmation { guard: g.clone(), hint: h.map(str::to_string) }).collect(),
}
}
#[test]
fn the_window_is_offered_every_way_in_that_this_client_can_do() {
let b = begun(&[(Guard::DeviceCode, None), (Guard::DeviceConfirmation, Some("Steam app")), (Guard::EmailCode, Some("a***@x.com")), (Guard::Other(6), None)]);
let kinds: Vec<_> = options_of(&b).iter().map(|o| o["kind"].as_str().unwrap().to_string()).collect();
assert_eq!(kinds, ["deviceCode", "approval", "emailCode"], "a way this client cannot answer is not offered");
assert_eq!(options_of(&b)[2]["hint"], "a***@x.com");
assert!(options_of(&begun(&[(Guard::None, None)])).is_empty());
}
#[test]
fn a_typed_code_is_the_authenticators_when_that_is_offered_and_otherwise_the_emailed_one() {
assert_eq!(guard_for_code(&begun(&[(Guard::EmailCode, None), (Guard::DeviceCode, None)])), Some(Guard::DeviceCode));
assert_eq!(guard_for_code(&begun(&[(Guard::EmailCode, None)])), Some(Guard::EmailCode));
assert_eq!(guard_for_code(&begun(&[(Guard::DeviceConfirmation, None)])), None, "an approval takes no code");
}
#[test]
fn a_code_for_a_sign_in_that_is_not_waiting_is_refused_with_a_reason() {
assert!(submit_code("nobody", "ABCDE").unwrap_err().to_string().contains("not waiting"));
assert!(submit_code("nobody", " ").unwrap_err().to_string().contains("type the code"));
let (tx, mut rx) = mpsc::unbounded_channel();
waiting().lock().unwrap().insert("login-x".into(), tx);
submit_code("login-x", " ab12c ").unwrap();
assert_eq!(rx.try_recv().unwrap(), "ab12c");
cancel("login-x");
assert!(submit_code("login-x", "x").is_err(), "closing the form ends the wait");
}
/// A Steam that wants the authenticator's code (and would take a phone
/// approval), accepts only the right code, and then issues tokens.
fn steam(private_n: String, private_e: String, right_code: &'static str, seen: Arc<Mutex<Vec<Packet>>>) -> Script {
let mut approved = false;
Box::new(move |req| {
seen.lock().unwrap().push(req.clone());
let method = req.header.target_job_name.clone().unwrap_or_default();
let out = match method.as_str() {
"Authentication.GetPasswordRSAPublicKey#1" => answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(private_n.clone()), publickey_exp: Some(private_e.clone()), timestamp: Some(1) }.encode_to_vec()),
"Authentication.BeginAuthSessionViaCredentials#1" => answer(
&req,
1,
BeginAuthViaCredentialsResponse {
client_id: Some(99),
request_id: Some(vec![1]),
interval: Some(0.5),
steamid: Some(76561197960287930),
allowed_confirmations: vec![
AllowedConfirmation { confirmation_type: Some(3), associated_message: None },
AllowedConfirmation { confirmation_type: Some(4), associated_message: Some("Steam app".into()) },
],
..Default::default()
}
.encode_to_vec(),
),
"Authentication.UpdateAuthSessionWithSteamGuardCode#1" => {
let sent = UpdateAuthWithGuardCodeRequest::decode(req.body.as_slice()).unwrap();
if sent.code.as_deref() == Some(right_code) {
approved = true;
answer(&req, 1, Vec::new())
} else {
answer(&req, eresult::TWO_FACTOR_CODE_MISMATCH, Vec::new())
}
}
"Authentication.PollAuthSessionStatus#1" if approved => answer(
&req,
1,
PollAuthSessionResponse { refresh_token: Some("refresh".into()), access_token: Some("access".into()), account_name: Some("Someone".into()), new_guard_data: Some("remember".into()), ..Default::default() }.encode_to_vec(),
),
"Authentication.PollAuthSessionStatus#1" => answer(&req, 1, Vec::new()),
_ => answer(&req, 1, Vec::new()),
};
Some(vec![out])
})
}
fn keys() -> (String, String) {
let private = RsaPrivateKey::new(&mut rand::thread_rng(), 1024).unwrap();
let public = private.to_public_key();
(public.n().to_str_radix(16), public.e().to_str_radix(16))
}
async fn next_event(rx: &mut tokio::sync::broadcast::Receiver<crate::events::Event>, name: &str) -> serde_json::Value {
tokio::time::timeout(Duration::from_secs(10), async {
loop {
let e = rx.recv().await.unwrap();
if e.name == name {
return e.data;
}
}
})
.await
.unwrap_or_else(|_| panic!("no {name} event"))
}
#[tokio::test]
async fn a_person_signs_in_mistypes_the_code_and_then_gets_it_right() {
let state = simulated_daemon("steam-login-whole");
let mut rx = state.events.subscribe();
let (n, e) = keys();
let seen = Arc::new(Mutex::new(Vec::new()));
let url = serve(steam(n, e, "RIGHT", seen.clone())).await;
let (code_tx, code_rx) = mpsc::unbounded_channel();
let task = tokio::spawn({
let state = state.clone();
async move { flow(&state, "login-1", " Someone ", "hunter2", &[url], code_rx).await }
});
// Steam asks, offering the authenticator and a phone approval.
let needs = next_event(&mut rx, "steamLoginNeeds").await;
assert_eq!(needs["loginId"], "login-1");
let kinds: Vec<_> = needs["options"].as_array().unwrap().iter().map(|o| o["kind"].as_str().unwrap()).collect();
assert_eq!(kinds, ["deviceCode", "approval"]);
assert!(needs.get("error").is_none());
// A wrong code is said, and asked for again, and the sign-in carries on.
code_tx.send("WRONG".into()).unwrap();
let again = next_event(&mut rx, "steamLoginNeeds").await;
assert!(again["error"].as_str().unwrap().contains("Steam Guard"), "{again}");
code_tx.send("RIGHT".into()).unwrap();
let saved = task.await.unwrap().unwrap().expect("an account");
let account = finish(&saved);
assert_eq!(account["id"], "steam:someone");
assert_eq!(account["service"], "steam");
assert_eq!(account["hasPassword"], true);
// What was kept: the token and the machine note, never the password.
let saved = state.accounts.get_steam("steam:someone").expect("saved under the lower-case name Steam gave");
assert_eq!((saved.refresh_token.as_str(), saved.guard_data.as_deref(), saved.steamid), ("refresh", Some("remember"), 76561197960287930));
assert_eq!(saved.session, "chat");
let file = std::fs::read_to_string(state.accounts.path_for_tests()).unwrap();
assert!(file.contains("refresh") && !file.contains("hunter2"), "the token is on disk and the password is not: {file}");
// Everything Steam was sent about the password was encrypted.
let begin = seen.lock().unwrap().iter().find(|p| p.header.target_job_name.as_deref() == Some("Authentication.BeginAuthSessionViaCredentials#1")).cloned().unwrap();
assert!(!String::from_utf8_lossy(&begin.body).contains("hunter2"));
}
#[tokio::test]
async fn a_machine_that_signed_in_before_says_so() {
let state = simulated_daemon("steam-login-remembered");
let _ = state.accounts.add_steam(SteamAccountConfig { username: "someone".into(), steamid: 76561197960287930, refresh_token: "old".into(), guard_data: Some("remember-me".into()), session: "chat".into(), ..Default::default() });
let (n, e) = keys();
let begin_seen = Arc::new(Mutex::new(None::<BeginAuthViaCredentialsRequest>));
let url = serve({
let seen = begin_seen.clone();
Box::new(move |req| {
let method = req.header.target_job_name.clone().unwrap_or_default();
Some(vec![match method.as_str() {
"Authentication.GetPasswordRSAPublicKey#1" => answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1) }.encode_to_vec()),
"Authentication.BeginAuthSessionViaCredentials#1" => {
*seen.lock().unwrap() = Some(BeginAuthViaCredentialsRequest::decode(req.body.as_slice()).unwrap());
// Steam knows the machine: nothing more is asked, and the next poll goes through.
answer(&req, 1, BeginAuthViaCredentialsResponse { client_id: Some(9), request_id: Some(vec![1]), interval: Some(0.5), steamid: Some(76561197960287930), ..Default::default() }.encode_to_vec())
}
"Authentication.PollAuthSessionStatus#1" => answer(&req, 1, PollAuthSessionResponse { refresh_token: Some("new".into()), account_name: Some("someone".into()), new_guard_data: Some("remember-me-too".into()), ..Default::default() }.encode_to_vec()),
_ => answer(&req, 1, Vec::new()),
}])
})
})
.await;
let (_tx, rx) = mpsc::unbounded_channel();
let saved = flow(&state, "login-r", "someone", "hunter2", &[url], rx).await.unwrap().expect("signed in");
assert_eq!(begin_seen.lock().unwrap().as_ref().and_then(|b| b.guard_data.as_deref()), Some("remember-me"), "the note from last time went with the sign-in");
assert_eq!(saved.guard_data.as_deref(), Some("remember-me-too"), "and the new one is kept");
}
#[tokio::test]
async fn a_wrong_password_is_reported_by_the_form_in_steams_words() {
let state = simulated_daemon("steam-login-badpass");
let (n, e) = keys();
let url = serve(Box::new(move |req| {
let method = req.header.target_job_name.clone().unwrap_or_default();
Some(vec![if method.contains("GetPasswordRSAPublicKey") {
answer(&req, 1, GetPasswordRsaPublicKeyResponse { publickey_mod: Some(n.clone()), publickey_exp: Some(e.clone()), timestamp: Some(1) }.encode_to_vec())
} else {
answer(&req, eresult::INVALID_PASSWORD, Vec::new())
}])
}))
.await;
let (_tx, rx) = mpsc::unbounded_channel();
let err = flow(&state, "login-2", "someone", "wrong", &[url], rx).await.unwrap_err();
assert!(err.to_string().contains("password"), "{err:#}");
assert!(state.accounts.get_steam("steam:someone").is_none(), "nothing is saved for a sign-in that failed");
}
#[tokio::test]
async fn an_empty_form_and_an_unreachable_steam_are_said_plainly() {
let state = simulated_daemon("steam-login-bad");
let (_tx, rx) = mpsc::unbounded_channel();
assert!(flow(&state, "l", "", "x", &[], rx).await.unwrap_err().to_string().contains("account name"));
let (_tx, rx) = mpsc::unbounded_channel();
assert!(flow(&state, "l", "x", "", &[], rx).await.unwrap_err().to_string().contains("password"));
let (_tx, rx) = mpsc::unbounded_channel();
let err = flow(&state, "l", "someone", "x", &["ws://127.0.0.1:1".to_string()], rx).await.unwrap_err();
assert!(format!("{err:#}").contains("reaching Steam"), "{err:#}");
}
#[tokio::test]
async fn closing_the_form_ends_the_wait_without_saving_anything() {
let state = simulated_daemon("steam-login-closed");
let (n, e) = keys();
let url = serve(steam(n, e, "RIGHT", Arc::new(Mutex::new(Vec::new())))).await;
let (code_tx, code_rx) = mpsc::unbounded_channel();
let task = tokio::spawn({
let state = state.clone();
async move { flow(&state, "login-3", "someone", "x", &[url], code_rx).await }
});
tokio::time::sleep(Duration::from_millis(300)).await;
drop(code_tx);
assert!(tokio::time::timeout(Duration::from_secs(5), task).await.expect("it ends").unwrap().unwrap().is_none());
assert!(state.accounts.get_steam("steam:someone").is_none());
}
}
+173
View File
@@ -0,0 +1,173 @@
//! Steam - the friends list and direct messages of a Steam account, over the
//! same connection managers the official client uses.
//!
//! Steam is a directory of servers ("connection managers") that a client opens
//! a WebSocket to and speaks protobuf over. Everything a chat client does is
//! a message on that one connection: signing in, the friends list, who is
//! online, a message sent, a message received. There is no separate chat
//! server; the same socket that tells you a friend started a game delivers the
//! message they sent.
//!
//! # How this folder is arranged
//!
//! - `proto` - the messages, written out from Valve's own definitions
//! - `wire` - how a packet is laid out, and the batches that hold packets
//! - `steamid` - the two forms of an account's identity
//! - `eresult` - the result codes, said in words
//! - `auth` - signing in with a name and password, through Steam Guard, to a token
//! - `presence` - Steam's states and moho's
//! - `friends` - who the friends are, how they are doing, what each conversation is called
//! - `chat` - direct messages, in and out
//! - `login` - adding an account: the sign-in a person takes part in
//! - `connect` - an account's connection: sign in with the token, listen, reconnect
//! - `publish` - the roster as the window sees it: a conversation each, named, with a dot
//! - `cm` - finding a connection manager to connect to
//! - `link` - one connection: asking it things, and hearing what it says
pub mod auth;
pub mod bbcode;
pub mod chat;
pub mod chatgroups;
pub mod cm;
pub mod connect;
pub mod emoticons;
pub mod eresult;
pub mod friends;
pub mod friendship;
pub mod groups;
pub mod invites;
pub mod history;
pub mod link;
pub mod login;
pub mod account;
pub mod apps;
pub mod blocked;
pub mod notices;
pub mod prefs;
pub mod presence;
pub mod profile;
#[cfg(test)]
mod probe;
pub mod proto;
pub mod publish;
pub mod reactions;
pub mod steamid;
pub mod web;
pub mod voice;
pub mod voicewire;
pub mod wire;
pub use connect::spawn;
pub use presence::apply_status;
use crate::state::AppState;
/// Tells Steam this session is being left, so it is not shown as signed in
/// for a while afterwards as one that was merely dropped. Written to the
/// connection; the caller keeps it open long enough for that to be sent.
pub fn say_goodbye(link: &link::Link) {
let _ = link.send(wire::emsg::CLIENT_LOG_OFF, &proto::ClientLogOff {});
}
/// Ends a session politely: says goodbye, and lets go of the connection a
/// moment later, which is how long the goodbye takes to be written.
pub fn sign_off(link: link::Link) {
say_goodbye(&link);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
drop(link);
});
}
/// Says why a refusal may be what it is, when the account is one Steam holds back.
pub fn explained(account_id: &str, why: String) -> String {
let facts = account::held(account_id);
if facts.family_view.is_some_and(|(_, holds_friends)| holds_friends) {
return format!("{why} (Family View is on for this account and holds friends and chat back; unlock it in Steam to chat)");
}
why
}
/// Sends a direct message in the conversation `buffer` is, and stores it.
pub async fn send_message(state: &AppState, buffer: &crate::model::Buffer, body: &str) -> Result<(), String> {
// A room of a chat group is spoken in as a group, not to a person.
if chatgroups::is_room(buffer) {
return chatgroups::send(state, buffer, body).await;
}
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
let recipient = buffer.remote_id.clone().ok_or("this conversation is not with a Steam account")?;
let sent = chat::send(&link, &recipient, body).await.map_err(|why| {
tracing::info!("steam[{}]: a message to {recipient} was not sent: {why}", buffer.account_id);
explained(&buffer.account_id, why)
})?;
tracing::info!("steam[{}]: sent a message to {recipient} as {}", buffer.account_id, sent.id);
let own = state.runtime.own_identity(&buffer.account_id).unwrap_or_default();
state.runtime.record_message_at(
state,
&buffer.account_id,
&buffer.name,
"dm",
&own,
&sent.shown,
false,
"chat",
None,
Some(sent.id),
false,
None,
sent.embeds,
Vec::new(),
None,
Some(i64::from(sent.timestamp)).filter(|t| *t > 0),
None,
None,
);
Ok(())
}
/// Sends files to a friend or into a room of a group, and the words that came with them.
///
/// Steam has no caption: the words go first as a message of their own and each
/// file is a message after it, which is how its own client sends the two. The
/// messages are written into the conversation by Steam and come back to this
/// session as the echo of what another session sent, so nothing is recorded
/// here. Stops at the first file Steam refuses and says why.
pub async fn send_files(
state: &AppState,
buffer: &crate::model::Buffer,
caption: &str,
files: &[(String, bool)],
progress: Option<&crate::upload::Progress>,
) -> Result<(), String> {
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
// A room of a group, or a friend.
let room = chatgroups::ids_of(buffer).filter(|_| chatgroups::is_room(buffer));
let friend = match room {
Some(_) => String::new(),
None => buffer.remote_id.clone().ok_or("this conversation is not with a Steam account")?,
};
let target = match room {
Some((group, room)) => web::FileTarget::Room { group, room },
None => web::FileTarget::Friend(&friend),
};
let config = state.accounts.get_steam(&buffer.account_id).ok_or("this Steam account is not connected")?;
// Refused before anything is sent, so a bad file does not leave the words
// sent and the pictures not.
for (path, _) in files {
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or(path);
let size = std::fs::metadata(path).map(|m| m.len()).map_err(|e| format!("reading {name}: {e}"))?;
if let Some(why) = web::refusal(name, size) {
return Err(why);
}
}
if !caption.trim().is_empty() {
send_message(state, buffer, caption).await?;
}
for (path, spoiler) in files {
let url = web::send_file(&link, &buffer.account_id, config.steamid, &config.refresh_token, target, path, *spoiler, progress)
.await
.inspect_err(|why| tracing::info!("steam[{}]: a file to {} was not sent: {why}", buffer.account_id, buffer.name))?;
tracing::info!("steam[{}]: sent a file to {}: {url}", buffer.account_id, buffer.name);
}
Ok(())
}
+234
View File
@@ -0,0 +1,234 @@
//! Steam's warning about a conversation.
//!
//! Steam can mark a conversation as suspicious - the person may not be who they
//! appear to be, a common shape for a scam - and tells the account as a notice.
//! It is said twice, so that it is not missed: once in the conversation, in
//! words, as a warning line that stays in its place in the log, and again as a
//! banner over the conversation that stays until the person dismisses it.
//! Dismissing tells Steam, which does not raise it again; if Steam does raise a
//! notice again - a new one - the banner comes back. The line is the same fact
//! each time Steam raises it, so it is stored under an id of its own and said
//! no more than once.
use super::proto::{DismissSessionNotice, SessionNoticeNotification};
use crate::model::BufferNotice;
use super::steamid;
use crate::state::AppState;
/// `EChatSessionNotice`.
pub const SUSPICIOUS: i32 = 1;
/// What to tell somebody about a notice, or nothing for one moho does not know.
pub fn words(notice_type: i32) -> Option<&'static str> {
match notice_type {
SUSPICIOUS => Some("Steam warns that this person may not be who they appear to be. Be careful with links, offers and requests for anything - and do not give out your account details."),
_ => None,
}
}
/// Says the notice in the conversation, once. `friend` is who it is about.
pub fn say(state: &AppState, account_id: &str, conversation: &str, friend: u32, notice_type: i32) {
let Some(text) = words(notice_type) else { return };
state.runtime.record_message_at(
state,
account_id,
conversation,
"dm",
"Steam",
text,
false,
// A kind of its own, which the window draws in red so it is not scrolled past.
"warning",
None,
Some(notice_id(friend, notice_type)),
false,
None,
Vec::new(),
Vec::new(),
None,
None,
None,
None,
);
}
/// The id a notice is kept under: the same in the log and in what says whether
/// it has been dismissed.
pub fn notice_id(friend: u32, notice_type: i32) -> String {
format!("notice:{friend}:{notice_type}")
}
/// Puts back the banners that were up when moho last ran and have not been
/// dismissed. Steam does not say a notice again at every sign-in, so what was
/// not seen would otherwise be gone with the restart. A warning that was said in
/// the log before anything recorded it is one nobody dismissed, and is shown.
pub fn restore(state: &AppState, account_id: &str) {
show_pending(state, account_id, None);
}
/// The same for one conversation, for the moment it is made or renamed: a
/// conversation known by its number at the start is renamed when Steam says
/// what it is called, and the new one has nothing on it until this is said again.
pub fn restore_buffer(state: &AppState, account_id: &str, buffer_id: &str) {
show_pending(state, account_id, Some(buffer_id));
}
fn show_pending(state: &AppState, account_id: &str, only: Option<&str>) {
let Ok(held) = state.store.account_notices(account_id) else { return };
for (buffer, id, recorded) in held {
if only.is_some_and(|only| only != buffer) {
continue;
}
let kind = id.rsplit(':').next().and_then(|t| t.parse::<i32>().ok()).unwrap_or(SUSPICIOUS);
let Some(text) = words(kind) else { continue };
match recorded.as_deref() {
Some("dismissed") => {}
other => {
if other.is_none() {
let _ = state.store.set_notice_state(&buffer, &id, "pending");
}
state.runtime.set_buffer_notice(state, &buffer, Some(BufferNotice { kind: "suspicious".to_string(), text: text.to_string() }));
}
}
}
}
/// Dismissed: the banner is taken off, and it stays off across a restart until
/// Steam raises a notice again.
pub fn dismissed(state: &AppState, buffer_id: &str, friend: u32, notice_type: i32) {
let _ = state.store.set_notice_state(buffer_id, &notice_id(friend, notice_type), "dismissed");
state.runtime.set_buffer_notice(state, buffer_id, None);
}
/// A notice that arrived live, for a conversation that exists.
pub fn heard(state: &AppState, account_id: &str, conversation: &str, notice: &SessionNoticeNotification) {
let (Some(friend), Some(kind)) = (notice.steamid_friend.and_then(steamid::account_of), notice.notice_type) else { return };
let buffer = crate::model::buffer_id(account_id, conversation);
// Raised: said in the log, and held over the conversation until dismissed.
// Lifted: the banner goes, and the line stays where it was said.
if notice.active.unwrap_or(true) {
say(state, account_id, conversation, friend, kind);
if let Some(text) = words(kind) {
// Raised afresh, so not dismissed any more, whatever came before.
let _ = state.store.set_notice_state(&buffer, &notice_id(friend, kind), "pending");
state.runtime.set_buffer_notice(state, &buffer, Some(BufferNotice { kind: "suspicious".to_string(), text: text.to_string() }));
}
} else if words(kind).is_some() {
dismissed(state, &buffer, friend, kind);
}
}
/// Tells Steam a notice has been seen, so it is not raised again.
pub fn dismiss(link: &super::link::Link, friend: u64, notice_type: i32) -> Result<(), String> {
link.notify("FriendMessages.DismissSessionNotice#1", &DismissSessionNotice { steamid_friend: Some(friend), notice_type: Some(notice_type) }).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit::simulated_daemon;
const ACCOUNT: &str = "steam:tester";
fn said(state: &AppState) -> Vec<(String, String, bool)> {
state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "Gabe"), 0, 20).unwrap().into_iter().map(|m| (m.kind, m.body, m.is_own)).collect()
}
#[test]
fn a_suspicious_notice_is_a_line_in_the_conversation_said_once() {
let state = simulated_daemon("steam-notice-once");
let notice = SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(SUSPICIOUS), active: Some(true) };
heard(&state, ACCOUNT, "Gabe", &notice);
heard(&state, ACCOUNT, "Gabe", &notice);
let got = said(&state);
assert_eq!(got.len(), 1, "raised twice, said once: {got:?}");
assert_eq!(got[0].0, "warning");
let held = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).unwrap().notice;
assert_eq!(held.as_ref().map(|n| n.kind.as_str()), Some("suspicious"), "and held over the conversation");
assert!(got[0].1.contains("may not be who they appear to be"));
}
#[test]
fn the_banner_is_taken_off_when_dismissed_and_comes_back_when_steam_says_it_again() {
let state = simulated_daemon("steam-notice-banner");
let raised = SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(SUSPICIOUS), active: Some(true) };
heard(&state, ACCOUNT, "Gabe", &raised);
let id = crate::model::buffer_id(ACCOUNT, "Gabe");
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_some());
// Dismissed: the banner goes, and the line in the log stays.
dismissed(&state, &id, 100, SUSPICIOUS);
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none());
assert_eq!(said(&state).len(), 1);
// Steam raising it again is a new notice.
heard(&state, ACCOUNT, "Gabe", &raised);
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_some(), "a new notice is shown");
assert_eq!(said(&state).len(), 1, "though the log is not told twice");
// Steam lifting it takes the banner off.
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { active: Some(false), ..raised });
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none());
}
#[test]
fn a_banner_not_dismissed_is_there_after_a_restart_and_one_dismissed_is_not() {
let state = simulated_daemon("steam-notice-restart");
let id = crate::model::buffer_id(ACCOUNT, "Gabe");
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(SUSPICIOUS), active: Some(true) });
// The window and the daemon are restarted: nothing is held but what was kept.
state.runtime.set_buffer_notice(&state, &id, None);
restore(&state, ACCOUNT);
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_some(), "not dismissed, so still there");
dismissed(&state, &id, 100, SUSPICIOUS);
restore(&state, ACCOUNT);
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none(), "dismissed, so not brought back");
}
#[test]
fn a_warning_said_in_the_log_before_anything_recorded_it_is_shown_once() {
let state = simulated_daemon("steam-notice-legacy");
let id = crate::model::buffer_id(ACCOUNT, "Gabe");
// As it was stored before there was a banner: a system line, nothing more.
state.runtime.record_message_at(&state, ACCOUNT, "Gabe", "dm", "Steam", "Steam warns that this person may not be who they appear to be.", false, "system", None, Some(notice_id(100, SUSPICIOUS)), false, None, vec![], vec![], None, None, None, None);
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none());
restore(&state, ACCOUNT);
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_some(), "nobody dismissed it, so it is up");
dismissed(&state, &id, 100, SUSPICIOUS);
restore(&state, ACCOUNT);
assert!(state.runtime.get_buffer(&id).unwrap().notice.is_none());
}
#[test]
fn a_notice_that_is_lifted_or_not_known_says_nothing() {
let state = simulated_daemon("steam-notice-quiet");
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(SUSPICIOUS), active: Some(false) });
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { steamid_friend: Some(steamid::steamid_of(100)), notice_type: Some(99), active: Some(true) });
heard(&state, ACCOUNT, "Gabe", &SessionNoticeNotification { steamid_friend: None, notice_type: Some(SUSPICIOUS), active: Some(true) });
assert!(said(&state).is_empty());
}
#[tokio::test]
async fn dismissing_tells_steam_which_notice_about_whom() {
use crate::backend::steam::link::{self, fake::*};
use prost::Message;
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let url = serve({
let seen = seen.clone();
Box::new(move |p| {
seen.lock().unwrap().push(p);
Some(vec![])
})
})
.await;
let (link, _told) = link::open_with("test", &url, std::time::Duration::from_secs(5)).await.unwrap();
dismiss(&link, steamid::steamid_of(100), SUSPICIOUS).unwrap();
for _ in 0..100 {
if !seen.lock().unwrap().is_empty() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let seen = seen.lock().unwrap();
assert_eq!(seen[0].header.target_job_name.as_deref(), Some("FriendMessages.DismissSessionNotice#1"));
let body = DismissSessionNotice::decode(seen[0].body.as_slice()).unwrap();
assert_eq!((body.steamid_friend, body.notice_type), (Some(steamid::steamid_of(100)), Some(1)));
}
}
+126
View File
@@ -0,0 +1,126 @@
//! What an account keeps about each friend: the name it calls them, and whether
//! their messages notify it.
//!
//! Steam holds these for the account, so they follow it to every client: a
//! nickname set here is the name on the phone, and one set there is the name
//! here. They are read when the account signs in, and changes - made anywhere -
//! arrive as notifications. Setting one asks for what the account has now,
//! changes the one thing, and sends it back whole, because the message that
//! sets them replaces what was held and would otherwise forget the rest.
use super::friends::NOTIFY_NEVER;
use super::link::Link;
use super::proto::{GetPerFriendPreferencesRequest, GetPerFriendPreferencesResponse, IgnoreFriendRequest, IgnoreFriendResponse, PerFriendPreferences, SetPerFriendPreferencesRequest, SetPerFriendPreferencesResponse};
/// Everything the account keeps about its friends.
pub async fn load(link: &Link) -> Result<Vec<PerFriendPreferences>, String> {
let answer: GetPerFriendPreferencesResponse = link.call("Player.GetPerFriendPreferences#1", &GetPerFriendPreferencesRequest {}, true).await.map_err(|e| super::chat::refusal(&e))?;
Ok(answer.preferences)
}
/// What is held about one friend, or nothing yet.
fn held_about(all: Vec<PerFriendPreferences>, account: u32) -> PerFriendPreferences {
all.into_iter().find(|p| p.accountid == Some(account)).unwrap_or(PerFriendPreferences { accountid: Some(account), ..Default::default() })
}
async fn change(link: &Link, account: u32, edit: impl FnOnce(&mut PerFriendPreferences)) -> Result<(), String> {
let mut prefs = held_about(load(link).await?, account);
edit(&mut prefs);
let _: SetPerFriendPreferencesResponse = link
.call("Player.SetPerFriendPreferences#1", &SetPerFriendPreferencesRequest { preferences: Some(prefs) }, true)
.await
.map_err(|e| super::chat::refusal(&e))?;
Ok(())
}
/// Sets what the account calls a friend; empty takes the name back.
pub async fn set_nickname(link: &Link, account: u32, nickname: &str) -> Result<(), String> {
let nickname = nickname.trim().chars().take(64).collect::<String>();
change(link, account, |p| p.nickname = (!nickname.is_empty()).then_some(nickname)).await
}
/// Whether a friend's messages notify the account: never, or as everybody's do.
pub async fn set_muted(link: &Link, account: u32, muted: bool) -> Result<(), String> {
change(link, account, |p| p.notifications_showmessages = muted.then_some(NOTIFY_NEVER)).await
}
/// Blocks a friend, or lifts it. Blocking somebody takes them off the friends
/// list and stops them writing to the account.
pub async fn block(link: &Link, friend: u64, block: bool) -> Result<(), String> {
let _: IgnoreFriendResponse = link.call("Player.IgnoreFriend#1", &IgnoreFriendRequest { steamid: Some(friend), unignore: Some(!block) }, true).await.map_err(|e| super::chat::refusal(&e))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::steam::link::{self, fake::*};
use prost::Message;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// What a scripted Steam was asked, in order: the method and the body.
type Seen = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// A Steam holding preferences for one friend, which keeps what it is sent.
fn steam(held: Vec<PerFriendPreferences>, seen: Seen) -> Script {
Box::new(move |p| {
let method = p.header.target_job_name.clone().unwrap_or_default();
seen.lock().unwrap().push((method.clone(), p.body.clone()));
let body = match method.as_str() {
"Player.GetPerFriendPreferences#1" => GetPerFriendPreferencesResponse { preferences: held.clone() }.encode_to_vec(),
"Player.SetPerFriendPreferences#1" => SetPerFriendPreferencesResponse {}.encode_to_vec(),
_ => IgnoreFriendResponse { friend_relationship: Some(5) }.encode_to_vec(),
};
Some(vec![answer(&p, 1, body)])
})
}
async fn link_to(script: Script) -> Link {
let url = serve(script).await;
link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0
}
fn sent(seen: &Seen) -> PerFriendPreferences {
let seen = seen.lock().unwrap();
let (_, body) = seen.iter().rev().find(|(m, _)| m == "Player.SetPerFriendPreferences#1").expect("a set");
SetPerFriendPreferencesRequest::decode(body.as_slice()).unwrap().preferences.unwrap()
}
#[tokio::test]
async fn a_nickname_is_set_without_forgetting_the_rest_of_what_is_held() {
let held = vec![PerFriendPreferences { accountid: Some(100), nickname: Some("Old".into()), notifications_showmessages: Some(2), sounds_showmessages: Some(1), ..Default::default() }];
let seen = Arc::new(Mutex::new(Vec::new()));
let link = link_to(steam(held, seen.clone())).await;
set_nickname(&link, 100, " Buddy ").await.unwrap();
let first = sent(&seen);
assert_eq!((first.accountid, first.nickname.as_deref()), (Some(100), Some("Buddy")));
assert_eq!((first.notifications_showmessages, first.sounds_showmessages), (Some(2), Some(1)), "what else is held goes back as it was");
set_nickname(&link, 100, " ").await.unwrap();
assert_eq!(sent(&seen).nickname, None, "empty takes the name back");
}
#[tokio::test]
async fn muting_a_friend_is_the_never_setting_and_unmuting_returns_it_to_the_default() {
let seen = Arc::new(Mutex::new(Vec::new()));
let link = link_to(steam(vec![PerFriendPreferences { accountid: Some(100), nickname: Some("Keep".into()), ..Default::default() }], seen.clone())).await;
set_muted(&link, 100, true).await.unwrap();
assert_eq!((sent(&seen).notifications_showmessages, sent(&seen).nickname.as_deref()), (Some(NOTIFY_NEVER), Some("Keep")));
set_muted(&link, 100, false).await.unwrap();
assert_eq!(sent(&seen).notifications_showmessages, None);
// A friend nothing is held about starts from nothing.
set_muted(&link, 7, true).await.unwrap();
assert_eq!((sent(&seen).accountid, sent(&seen).nickname), (Some(7), None));
}
#[tokio::test]
async fn blocking_names_the_person_and_unblocking_says_so() {
let seen = Arc::new(Mutex::new(Vec::new()));
let link = link_to(steam(vec![], seen.clone())).await;
block(&link, 76561197960265829, true).await.unwrap();
block(&link, 76561197960265829, false).await.unwrap();
let seen = seen.lock().unwrap();
let asks: Vec<IgnoreFriendRequest> = seen.iter().filter(|(m, _)| m == "Player.IgnoreFriend#1").map(|(_, b)| IgnoreFriendRequest::decode(b.as_slice()).unwrap()).collect();
assert_eq!(asks.iter().map(|a| (a.steamid, a.unignore)).collect::<Vec<_>>(), [(Some(76561197960265829), Some(false)), (Some(76561197960265829), Some(true))]);
}
}
+227
View File
@@ -0,0 +1,227 @@
//! Presence: the words moho uses for how a person is showing, and Steam's.
//!
//! Steam has eight states and moho's window draws four. The mapping is the
//! only place that decides which is which, in both directions, so a friend's
//! dot and our own plaque cannot disagree about what "Snooze" is.
//!
//! Steam's states are numbers: Offline 0, Online 1, Busy 2, Away 3, Snooze 4,
//! Looking to Trade 5, Looking to Play 6, Invisible 7.
use super::link::{CallError, Link};
use super::proto::ClientChangeStatus;
use super::wire::emsg;
pub const ONLINE: u32 = 1;
pub const BUSY: u32 = 2;
pub const AWAY: u32 = 3;
pub const SNOOZE: u32 = 4;
pub const LOOKING_TO_TRADE: u32 = 5;
pub const LOOKING_TO_PLAY: u32 = 6;
pub const INVISIBLE: u32 = 7;
/// What to tell Steam for a status chosen in moho.
///
/// Do not disturb is moho's own switch for its own notifications and stays
/// Online to Steam (as it does for IRC and Matrix); Steam's "Busy" is a
/// different thing, a state friends see, and it is not set on anyone's behalf.
pub fn persona_state_for(status: &str) -> u32 {
match status {
"idle" => AWAY,
"invisible" => INVISIBLE,
_ => ONLINE,
}
}
/// How a state Steam reported is drawn. Invisible is never seen on somebody
/// else - Steam shows them as offline - but it is understood on ourselves.
pub fn status_of(persona_state: u32) -> &'static str {
match persona_state {
ONLINE | LOOKING_TO_TRADE | LOOKING_TO_PLAY => "online",
BUSY => "dnd",
AWAY | SNOOZE => "idle",
_ => "offline",
}
}
/// What a friend is doing, for the line under their name: the game they are
/// in, or a state the four colours cannot tell apart (Busy and Do not disturb
/// are one red, Away and Snooze one amber, and "looking to trade" is just
/// online). Nothing for plainly online or offline, which the dot says; and
/// nothing for a game when they are offline, since that is stale.
///
/// The kind decides the picture in front of it: a game has a gamepad, and a
/// state has none, being words.
pub fn activity(persona_state: u32, game: Option<&str>) -> Option<(&'static str, String)> {
if status_of(persona_state) == "offline" {
return None;
}
if let Some(game) = game.filter(|g| !g.is_empty()) {
return Some(("game", game.to_string()));
}
let words = match persona_state {
BUSY => "Busy",
AWAY => "Away",
SNOOZE => "Snoozing",
LOOKING_TO_TRADE => "Looking to trade",
LOOKING_TO_PLAY => "Looking to play",
_ => return None,
};
Some(("custom", words.to_string()))
}
/// Which kind of client somebody is on, for their card: from the flags on their
/// state, and whether it is a Deck. Nothing for somebody who is not here.
///
/// `EPersonaStateFlag`: 256 web, 512 mobile, 1024 Big Picture, 2048 VR.
pub fn client_of(persona_state: u32, flags: u32, on_deck: bool) -> Option<&'static str> {
if status_of(persona_state) == "offline" {
return None;
}
Some(if on_deck {
"Steam Deck"
} else if flags & 2048 != 0 {
"VR"
} else if flags & 1024 != 0 {
"Big Picture"
} else if flags & 512 != 0 {
"Mobile"
} else if flags & 256 != 0 {
"Web"
} else {
"Desktop"
})
}
/// Tells Steam how this account is showing.
///
/// `by_user` says it was a choice, as against something done on their behalf
/// (stepping away from the machine): Steam keeps the two apart, and does not
/// move a state somebody picked.
pub fn send_status(link: &Link, status: &str, by_user: bool) -> Result<(), CallError> {
link.send(
emsg::CLIENT_CHANGE_STATUS,
&ClientChangeStatus {
persona_state: Some(persona_state_for(status)),
persona_set_by_user: Some(by_user),
need_persona_response: Some(false),
..ClientChangeStatus::default()
},
)
}
/// Changes how a signed-in account is showing. Whether it could: an account
/// that is not connected has nothing to tell, and keeps the choice for the
/// next time it is.
pub fn apply_status(state: &crate::state::AppState, account_id: &str, status: &str, by_user: bool) -> bool {
match state.runtime.steam_link(account_id) {
Some(link) => send_status(&link, status, by_user).is_ok(),
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn what_the_dot_cannot_say_is_said_under_the_name() {
let words = |state, game| activity(state, game).map(|(k, t)| format!("{k}:{t}"));
assert_eq!(words(ONLINE, None), None, "online is the dot");
assert_eq!(words(0, None), None, "offline is the dot");
assert_eq!(words(BUSY, None).as_deref(), Some("custom:Busy"));
assert_eq!(words(AWAY, None).as_deref(), Some("custom:Away"));
assert_eq!(words(SNOOZE, None).as_deref(), Some("custom:Snoozing"));
assert_eq!(words(LOOKING_TO_TRADE, None).as_deref(), Some("custom:Looking to trade"));
assert_eq!(words(LOOKING_TO_PLAY, None).as_deref(), Some("custom:Looking to play"));
// A game outranks the state, is named bare - the gamepad says what it is
// - and an empty name is no game.
assert_eq!(words(ONLINE, Some("Counter-Strike 2")).as_deref(), Some("game:Counter-Strike 2"));
assert_eq!(words(BUSY, Some("Dota 2")).as_deref(), Some("game:Dota 2"));
assert_eq!(words(ONLINE, Some("")), None);
// Somebody offline is not playing anything, whatever was last said.
assert_eq!(words(0, Some("Counter-Strike 2")), None);
// Invisible is never seen on somebody else, and reads as offline.
assert_eq!(words(INVISIBLE, Some("x")), None);
}
#[test]
fn the_kind_of_client_is_read_from_the_flags_and_is_nothing_for_somebody_away() {
assert_eq!(client_of(ONLINE, 0, false), Some("Desktop"));
assert_eq!(client_of(ONLINE, 512, false), Some("Mobile"));
assert_eq!(client_of(ONLINE, 256 | 1, false), Some("Web"));
assert_eq!(client_of(AWAY, 1024, false), Some("Big Picture"));
assert_eq!(client_of(ONLINE, 2048 | 512, false), Some("VR"));
assert_eq!(client_of(ONLINE, 512, true), Some("Steam Deck"), "a Deck is a Deck whatever else is set");
assert_eq!(client_of(0, 512, false), None);
assert_eq!(client_of(INVISIBLE, 0, false), None);
}
#[test]
fn what_moho_offers_is_what_steam_is_told() {
assert_eq!(persona_state_for("online"), 1);
assert_eq!(persona_state_for("idle"), 3, "idle is Steam's Away");
assert_eq!(persona_state_for("invisible"), 7);
assert_eq!(persona_state_for("dnd"), 1, "do not disturb is local; Steam still sees an online person");
assert_eq!(persona_state_for("nonsense"), 1);
}
#[test]
fn every_state_steam_reports_is_drawn_as_one_of_the_four() {
assert_eq!(status_of(0), "offline");
assert_eq!(status_of(1), "online");
assert_eq!(status_of(2), "dnd");
assert_eq!(status_of(3), "idle");
assert_eq!(status_of(4), "idle");
assert_eq!(status_of(5), "online");
assert_eq!(status_of(6), "online");
assert_eq!(status_of(7), "offline");
assert_eq!(status_of(99), "offline", "a state from a newer Steam is not a guess in the direction that shows");
}
#[test]
fn what_we_set_is_read_back_the_way_we_set_it() {
for status in ["online", "idle"] {
assert_eq!(status_of(persona_state_for(status)), status);
}
}
#[tokio::test]
async fn the_status_chosen_is_what_steam_is_told_and_by_whom() {
use crate::backend::steam::link::{self, fake::*};
use crate::backend::steam::wire::Packet;
use prost::Message;
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<Packet>::new()));
let url = serve({
let seen = seen.clone();
Box::new(move |p| {
seen.lock().unwrap().push(p);
Some(Vec::new())
})
})
.await;
let (link, _told) = link::open("steam:test", &url).await.unwrap();
link.set_identity(link::Identity { steamid: 76561197960287930, session: 3 });
send_status(&link, "invisible", true).unwrap();
send_status(&link, "idle", false).unwrap();
for _ in 0..100 {
if seen.lock().unwrap().len() == 2 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let seen = seen.lock().unwrap();
assert_eq!(seen.len(), 2);
assert!(seen.iter().all(|p| p.emsg == emsg::CLIENT_CHANGE_STATUS));
let first = ClientChangeStatus::decode(seen[0].body.as_slice()).unwrap();
assert_eq!((first.persona_state, first.persona_set_by_user), (Some(INVISIBLE), Some(true)));
let second = ClientChangeStatus::decode(seen[1].body.as_slice()).unwrap();
assert_eq!((second.persona_state, second.persona_set_by_user), (Some(AWAY), Some(false)), "stepping away is not a choice");
assert_eq!(seen[0].header.steamid, Some(76561197960287930), "as the account");
}
#[test]
fn an_account_that_is_not_connected_has_nobody_to_tell() {
let state = crate::backend::kick::testkit::simulated_daemon("steam-presence-offline");
assert!(!apply_status(&state, "steam:nobody", "idle", true));
}
}
File diff suppressed because it is too large Load Diff
+231
View File
@@ -0,0 +1,231 @@
//! A friend's card: what they wrote on their profile, their level, and what
//! Steam knows of how they are showing.
//!
//! The facts that arrive with their presence - the kind of client, the last
//! time they were seen, what their game says - are already held in the
//! conversation's one-person roster, so the card reads them from there. What
//! needs asking is the profile text and the level.
use super::link::Link;
use super::proto::{FriendProfileInfoRequest, FriendProfileInfoResponse, FriendsSteamLevelsRequest, FriendsSteamLevelsResponse};
use super::steamid;
use super::wire::emsg;
use crate::profile;
use crate::state::AppState;
use serde_json::{json, Value};
/// What somebody wrote about where they are, as one line.
fn place(p: &FriendProfileInfoResponse) -> String {
[&p.city_name, &p.state_name, &p.country_name].iter().filter_map(|s| s.as_deref()).map(str::trim).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(", ")
}
/// A profile's summary, which is written with Steam's tags, as plain words.
fn summary(text: &str) -> String {
let mut plain = super::bbcode::to_markup(text).replace('\u{200b}', "");
// The card shows words, not the marks the log draws bold and struck with.
for mark in ["**", "~~", "||", "[u]", "[/u]"] {
plain = plain.replace(mark, "");
}
let plain = pictures_as_words(&plain);
plain.chars().take(600).collect::<String>().trim().to_string()
}
/// `[steam:emote:name]` as `:name:`, which is how it is read in words.
fn pictures_as_words(text: &str) -> String {
let mut out = String::new();
let mut rest = text;
while let Some(at) = rest.find("[steam:") {
out.push_str(&rest[..at]);
let tail = &rest[at..];
match tail.find(']').and_then(|end| tail[7..end].split_once(':').map(|(_, name)| (end, name))) {
Some((end, name)) => {
out.push_str(&format!(":{name}:"));
rest = &tail[end + 1..];
}
None => {
out.push_str("[steam:");
rest = &tail[7..];
}
}
}
out.push_str(rest);
out
}
/// Adds what the two answers say.
fn fill(card: &mut Value, info: Option<&FriendProfileInfoResponse>, level: Option<u32>) {
if let Some(level) = level.filter(|l| *l > 0) {
profile::note(card, "Steam level", level.to_string());
}
let Some(info) = info else { return };
profile::set(card, "createdTs", info.time_created.filter(|t| *t > 0).map(|t| json!(t)));
profile::note(card, "Real name", info.real_name.clone().unwrap_or_default());
profile::note(card, "Location", place(info));
profile::note(card, "Headline", info.headline.clone().unwrap_or_default());
profile::note(card, "Summary", summary(info.summary.as_deref().unwrap_or_default()));
}
/// Adds the names they have had, newest first: the one they have now is the one
/// the card is headed by, so it is not listed again.
fn fill_names(card: &mut Value, names: &[(String, String)]) {
let now = card["name"].as_str().unwrap_or_default().to_string();
let history: Vec<Value> = names.iter().filter(|(n, _)| *n != now).map(|(name, when)| json!({ "name": name, "when": when })).collect();
if !history.is_empty() {
card["nameHistory"] = json!(history);
}
}
/// A length of time in the units a person says it in.
fn how_long(seconds: u64) -> String {
match seconds {
0..=59 => "under a minute".to_string(),
60..=3599 => format!("{} min", seconds / 60),
_ => format!("{}h {:02}m", seconds / 3600, (seconds % 3600) / 60),
}
}
/// Adds what is already known from how they are showing.
fn fill_from_presence(card: &mut Value, member: &Value) {
profile::set(card, "status", member["status"].as_str().map(|s| json!(s)));
profile::set(card, "avatarUrl", member["avatarUrl"].as_str().map(|s| json!(s)));
profile::set(card, "activity", Some(member["activity"].clone()).filter(|a| !a.is_null()));
profile::note(card, "On", member["client"].as_str().unwrap_or_default());
// Only as long as moho has seen it: Steam does not say how long anybody has been in a game.
if let Some(since) = member["playingSince"].as_u64().filter(|s| *s > 0) {
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
if now > since {
profile::note(card, "Seen playing for", how_long(now - since));
}
}
profile::note(card, "Doing", member["rich"].as_str().unwrap_or_default());
// Only for somebody who is not here: for somebody who is, it is now.
if member["status"].as_str() == Some("offline") {
profile::set(card, "lastActiveTs", member["lastSeen"].as_u64().filter(|t| *t > 0).map(|t| json!(t)));
}
}
async fn ask_profile(link: &Link, friend: u64) -> Option<FriendProfileInfoResponse> {
let answer: FriendProfileInfoResponse = link.ask(emsg::CLIENT_FRIEND_PROFILE_INFO, &FriendProfileInfoRequest { steamid_friend: Some(friend) }).await.ok()?;
(answer.eresult.unwrap_or(super::eresult::FAIL) == super::eresult::OK).then_some(answer)
}
async fn ask_level(link: &Link, friend: u32) -> Option<u32> {
let answer: FriendsSteamLevelsResponse = link.ask(emsg::CLIENT_FS_GET_FRIENDS_STEAM_LEVELS, &FriendsSteamLevelsRequest { accountids: vec![friend] }).await.ok()?;
answer.friends.iter().find(|f| f.accountid == Some(friend)).and_then(|f| f.level)
}
/// The card for the person a conversation is with.
pub async fn build(state: &AppState, account_id: &str, buffer_id: &str, who: &str, user_id: &str) -> Value {
let mut card = profile::pending("steam", account_id, who);
card["pending"] = json!(false);
let buffer = state.runtime.get_buffer(buffer_id);
// A conversation is with one person; a room of a group is with many, and the
// member who was asked about is named.
let in_room = buffer.as_ref().is_some_and(super::chatgroups::is_room);
let friend = if in_room { user_id.parse::<u64>().ok() } else { buffer.as_ref().and_then(|b| b.remote_id.as_deref()).and_then(|id| id.parse::<u64>().ok()) }.filter(|id| steamid::account_of(*id).is_some());
let Some(friend) = friend else { return card };
card["id"] = json!(friend.to_string());
let listed = state.runtime.get_presence(buffer_id).and_then(|m| m.as_array().cloned()).unwrap_or_default();
let member = if in_room { listed.into_iter().find(|m| m["userId"].as_str() == Some(&friend.to_string())) } else { listed.into_iter().next() };
if let Some(member) = member {
fill_from_presence(&mut card, &member);
if in_room {
if let Some(roles) = member["roles"].as_array().filter(|r| !r.is_empty()) {
card["roles"] = json!(roles);
card["isModerator"] = json!(member["powerLevel"].as_i64().unwrap_or(0) >= super::chatgroups::RANK_MODERATOR as i64);
}
}
}
if let Some(link) = state.runtime.steam_link(account_id) {
let number = steamid::account_of(friend).unwrap_or_default();
let config = state.accounts.get_steam(account_id);
let names = async {
match &config {
Some(c) => super::web::aliases(&link, account_id, c.steamid, &c.refresh_token, friend).await.unwrap_or_default(),
None => Vec::new(),
}
};
let (info, level, names) = tokio::join!(ask_profile(&link, friend), ask_level(&link, number), names);
fill(&mut card, info.as_ref(), level);
fill_names(&mut card, &names);
}
card
}
#[cfg(test)]
mod tests {
use super::*;
fn labels(card: &Value) -> Vec<(String, String)> {
card["extra"].as_array().map(|a| a.iter().map(|e| (e["label"].as_str().unwrap().to_string(), e["value"].as_str().unwrap().to_string())).collect()).unwrap_or_default()
}
#[test]
fn how_long_they_have_been_seen_playing_is_said_in_a_person_s_units() {
assert_eq!(how_long(20), "under a minute");
assert_eq!(how_long(600), "10 min");
assert_eq!(how_long(3600 + 5 * 60), "1h 05m");
let mut card = profile::pending("steam", "steam:me", "Gabe");
let since = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() - 4000;
fill_from_presence(&mut card, &json!({ "status": "online", "activity": { "kind": "game", "text": "X" }, "playingSince": since }));
assert_eq!(labels(&card), [("Seen playing for".to_string(), "1h 06m".to_string())]);
}
#[test]
fn the_names_they_have_had_are_listed_except_the_one_they_have() {
let mut card = profile::pending("steam", "steam:me", "Gabe");
fill_names(&mut card, &[("Gabe".into(), "1 Jan, 2024 @ 1:00pm".into()), ("Gabe Newell".into(), "3 Mar, 2019 @ 1:00am".into())]);
assert_eq!(card["nameHistory"], json!([{ "name": "Gabe Newell", "when": "3 Mar, 2019 @ 1:00am" }]));
let mut none = profile::pending("steam", "steam:me", "Gabe");
fill_names(&mut none, &[("Gabe".into(), "x".into())]);
assert!(none.get("nameHistory").is_none(), "only the present name: nothing to list");
}
#[test]
fn what_they_wrote_is_on_the_card_in_words() {
let mut card = profile::pending("steam", "steam:me", "Gabe");
let info = FriendProfileInfoResponse {
eresult: Some(1),
time_created: Some(1300000000),
real_name: Some("Gabe N".into()),
city_name: Some("Bellevue".into()),
state_name: Some("".into()),
country_name: Some("United States".into()),
headline: Some("Hello".into()),
summary: Some("About [b]me[/b] and [emoticon]steamhappy[/emoticon] \\[x]".into()),
..Default::default()
};
fill(&mut card, Some(&info), Some(42));
assert_eq!(card["createdTs"], 1300000000);
assert_eq!(
labels(&card),
[("Steam level", "42"), ("Real name", "Gabe N"), ("Location", "Bellevue, United States"), ("Headline", "Hello"), ("Summary", "About me and :steamhappy: [x]")]
.map(|(a, b)| (a.to_string(), b.to_string())),
"an empty part of a place is left out, and the summary is words"
);
}
#[test]
fn nothing_known_is_nothing_shown() {
let mut card = profile::pending("steam", "steam:me", "Gabe");
fill(&mut card, None, None);
fill(&mut card, Some(&FriendProfileInfoResponse::default()), Some(0));
assert!(labels(&card).is_empty());
assert!(card.get("createdTs").is_none());
}
#[test]
fn how_they_are_showing_comes_from_their_presence() {
let mut card = profile::pending("steam", "steam:me", "Gabe");
fill_from_presence(&mut card, &json!({ "status": "online", "client": "Mobile", "rich": "In a match", "avatarUrl": "https://a/b.jpg", "activity": { "kind": "game", "text": "Dota 2" }, "lastSeen": 100 }));
assert_eq!(card["status"], "online");
assert_eq!(card["activity"]["text"], "Dota 2");
assert!(card.get("lastActiveTs").is_none(), "last seen is for somebody who is away");
assert_eq!(labels(&card), [("On".to_string(), "Mobile".to_string()), ("Doing".to_string(), "In a match".to_string())]);
let mut gone = profile::pending("steam", "steam:me", "Gabe");
fill_from_presence(&mut gone, &json!({ "status": "offline", "lastSeen": 1790000000 }));
assert_eq!(gone["lastActiveTs"], 1790000000u64);
assert!(labels(&gone).is_empty(), "nothing to say of a client that is not there");
}
}
File diff suppressed because it is too large Load Diff
+444
View File
@@ -0,0 +1,444 @@
//! What the window sees of the roster: a conversation for each person, a
//! name that follows them, and how they are doing.
//!
//! A friend is a direct-message conversation. Its name is their name, so it
//! changes when they rename themselves - and then the conversation is moved,
//! history and all, rather than a second one begun. Its presence is a
//! one-person member list, which is what the window reads a direct message's
//! dot from.
//!
//! This runs after every batch of updates and so is written to say nothing
//! when nothing changed. Signing in brings hundreds of persona updates in a
//! few seconds, and a window told "this friend is online" four times for each
//! of them would spend its first moments redrawing.
use super::friends::Roster;
use super::{presence, steamid};
use crate::state::AppState;
use serde_json::json;
use std::collections::HashMap;
/// What the window was last told about one person.
#[derive(Clone, Debug, PartialEq)]
struct Shown {
name: String,
status: &'static str,
/// What is said under the name: the game, or a state the colour cannot give.
activity: Option<(&'static str, String)>,
avatar: Option<String>,
/// Which kind of client, when they were last seen, and what their game says: for their card.
detail: (Option<&'static str>, Option<u32>, Option<String>),
}
/// Which kind of device a person is on, in words: a Steam Machine is said by what Steam calls the
/// device, the rest by what their client says.
fn client_name(p: &super::friends::Persona) -> Option<&'static str> {
let said = presence::client_of(p.state, p.flags, p.on_deck)?;
Some(if p.device == super::friends::DEVICE_STEAM_MACHINE && !p.on_deck { "Steam Machine" } else { said })
}
pub struct Conversations {
account_id: String,
shown: HashMap<u32, Shown>,
}
impl Conversations {
pub fn new(account_id: &str) -> Self {
Self { account_id: account_id.to_string(), shown: HashMap::new() }
}
/// Takes up the conversations that already exist for this account, as the
/// daemon restored them, so that a friend who was renamed while moho was
/// closed has their conversation moved rather than a second one made.
/// A conversation says who it is with in its `remote_id`.
pub fn adopt(&mut self, state: &AppState, account_id: &str) {
for buffer in state.runtime.list_buffers() {
if buffer.account_id != account_id || buffer.kind != "dm" {
continue;
}
let Some(account) = buffer.remote_id.as_deref().and_then(|id| id.parse::<u64>().ok()).and_then(steamid::account_of) else { continue };
self.shown.entry(account).or_insert(Shown { name: buffer.name, status: "offline", activity: None, avatar: None, detail: (None, None, None) });
}
}
/// Follows the account's mutes - set in another client, or here - onto the
/// conversations: muted on Steam is silent here, and the row says so.
pub fn apply_mutes(&self, state: &AppState, roster: &Roster) {
for (account, shown) in &self.shown {
state.runtime.set_silenced(state, &crate::model::buffer_id(&self.account_id, &shown.name), roster.muted(*account));
}
}
/// The name of the conversation with this person, as it is on screen now.
pub fn name_of(&self, account: u32) -> Option<&str> {
self.shown.get(&account).map(|s| s.name.as_str())
}
/// Brings the conversations in line with the roster, telling the window
/// only about what is different from last time.
pub fn publish(&mut self, state: &AppState, roster: &Roster) {
let names = roster.conversation_names();
for account in roster.participants() {
let Some(mut name) = names.get(&account).cloned() else { continue };
// A session that has only just begun knows its friends by number
// until their names arrive. A conversation already called something
// keeps that: renamed to a number and back, a friend list would
// flicker into digits at every reconnect, and a connection that
// keeps dropping would leave it that way.
if !roster.has_name(account) {
if let Some(held) = self.shown.get(&account).map(|s| s.name.clone()).filter(|n| *n != steamid::text_of(account)) {
name = held;
}
}
let persona = roster.persona(account);
let status = persona.map_or("offline", |p| presence::status_of(p.state));
let avatar = persona.and_then(|p| p.avatar.clone());
let activity = persona.and_then(|p| presence::activity(p.state, roster.game_of(account).as_deref()));
let detail = persona.map_or((None, None, None), |p| (client_name(p), p.last_seen, p.rich.clone()));
let next = Shown { name: name.clone(), status, activity: activity.clone(), avatar: avatar.clone(), detail: detail.clone() };
let before = self.shown.get(&account);
if before == Some(&next) {
continue;
}
// A person who changed their name keeps their conversation.
if let Some(old) = before.map(|b| b.name.clone()).filter(|old| *old != name) {
state.runtime.rename_buffer(state, &self.account_id, &old, &name, "dm");
}
let buffer = state.runtime.ensure_buffer(state, &self.account_id, &name, "dm");
// Made new, or renamed from its number: a warning on it comes back.
super::notices::restore_buffer(state, &self.account_id, &buffer.id);
// And whether the account has muted them, wherever it did.
state.runtime.set_silenced(state, &buffer.id, roster.muted(account));
state.runtime.set_buffer_remote_id(state, &buffer.id, &steamid::text_of(account));
// Under no heading of a service's: see groups::unfile_conversations.
super::groups::unfile_one(state, &buffer.id);
if let Some(url) = &avatar {
state.runtime.set_buffer_avatar(state, &buffer.id, url);
}
// The window reads a direct message's dot from its member list;
// for a one-to-one that is the other person alone.
let member = json!([{
"nick": persona.and_then(|p| p.name.clone()).unwrap_or_else(|| name.clone()),
"userId": steamid::text_of(account),
"status": status,
"activity": activity.map(|(kind, text)| json!({ "kind": kind, "text": text })),
"playingSince": persona.filter(|p| p.app.is_some() || p.game.is_some()).and_then(|p| p.playing_since),
"client": detail.0,
"lastSeen": detail.1,
"rich": detail.2,
"away": status == "idle",
"avatarUrl": avatar,
}]);
state.runtime.set_presence(&buffer.id, member.clone());
state.events.emit("presenceChange", json!({ "bufferId": buffer.id, "members": member }));
self.shown.insert(account, next);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit::simulated_daemon;
use crate::backend::steam::friends::{REL_FRIEND, Roster};
use crate::backend::steam::proto::*;
const ACCOUNT: &str = "steam:tester";
fn friends(accounts: &[u32]) -> ClientFriendsList {
ClientFriendsList {
bincremental: Some(false),
friends: accounts.iter().map(|a| FriendsListEntry { ulfriendid: Some(steamid::steamid_of(*a)), efriendrelationship: Some(REL_FRIEND) }).collect(),
..Default::default()
}
}
fn who(account: u32, name: &str, state: u32) -> PersonaFriend {
PersonaFriend { friendid: Some(steamid::steamid_of(account)), player_name: Some(name.into()), persona_state: Some(state), avatar_hash: Some(vec![7; 20]), ..Default::default() }
}
fn update(people: Vec<PersonaFriend>) -> ClientPersonaState {
ClientPersonaState { friends: people, ..Default::default() }
}
fn events(rx: &mut tokio::sync::broadcast::Receiver<crate::events::Event>, name: &str) -> Vec<serde_json::Value> {
let mut out = Vec::new();
while let Ok(e) = rx.try_recv() {
if e.name == name {
out.push(e.data);
}
}
out
}
#[test]
fn a_friend_becomes_a_direct_message_with_a_face_and_a_dot() {
let state = simulated_daemon("steam-publish-basic");
let mut rx = state.events.subscribe();
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[22202]));
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
let mut shown = Conversations::new(ACCOUNT);
shown.publish(&state, &roster);
let buffer = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).expect("a conversation for the friend");
assert_eq!(buffer.kind, "dm");
assert_eq!(buffer.remote_id.as_deref(), Some("76561197960287930"), "the person, not the name, is what identifies them");
assert!(buffer.avatar_url.as_deref().is_some_and(|u| u.starts_with("https://avatars.steamstatic.com/07070707")));
let presence = events(&mut rx, "presenceChange");
assert_eq!(presence.len(), 1);
let members = &presence[0]["members"];
assert_eq!(members.as_array().unwrap().len(), 1, "a direct message's list is the other person alone");
assert_eq!((members[0]["nick"].as_str(), members[0]["status"].as_str(), members[0]["away"].as_bool()), (Some("Gabe"), Some("online"), Some(false)));
assert_eq!(shown.name_of(22202), Some("Gabe"));
}
#[test]
fn a_reconnect_does_not_turn_the_friends_back_into_numbers() {
let state = simulated_daemon("steam-publish-reconnect");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[22202]));
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
Conversations::new(ACCOUNT).publish(&state, &roster);
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).is_some());
// A new session: the conversations as they were, and a roster that has
// the friends list and nobody's name yet.
let mut shown = Conversations::new(ACCOUNT);
shown.adopt(&state, ACCOUNT);
let mut fresh = Roster::default();
fresh.apply_friends_list(&friends(&[22202]));
shown.publish(&state, &fresh);
assert_eq!(shown.name_of(22202), Some("Gabe"), "still called what they were called");
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "76561197960287930")).is_none(), "and no conversation made under the number");
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).is_some());
// When the name does arrive, a changed one is honoured.
fresh.apply_persona(&update(vec![who(22202, "Gabriel", 1)]));
shown.publish(&state, &fresh);
assert_eq!(shown.name_of(22202), Some("Gabriel"));
}
#[test]
fn a_warning_survives_the_conversation_being_renamed_from_its_number() {
let state = simulated_daemon("steam-publish-warning-rename");
// As the last run left it: known by its number, with a warning said in it.
let number = steamid::text_of(22202);
state.runtime.record_message_at(&state, ACCOUNT, &number, "dm", "Steam", "Steam warns that this person may not be who they appear to be.", false, "system", None, Some(crate::backend::steam::notices::notice_id(22202, 1)), false, None, vec![], vec![], None, None, None, None);
let mut shown = Conversations::new(ACCOUNT);
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[22202]));
shown.adopt(&state, ACCOUNT);
shown.publish(&state, &roster);
crate::backend::steam::notices::restore(&state, ACCOUNT);
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, &number)).unwrap().notice.is_some());
// Steam says who it is: the conversation becomes Gabe, and the banner is on it.
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
shown.publish(&state, &roster);
let gabe = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Gabe")).expect("renamed");
assert!(gabe.notice.is_some(), "the banner is still up on the renamed conversation");
}
#[test]
fn a_game_on_steam_is_in_game_until_its_name_is_known_and_then_its_name() {
let state = simulated_daemon("steam-publish-game-by-number");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[22202, 22203]));
// Steam names no game of its own: the number alone, as it sends it.
let playing = |account: u32, app: u64| PersonaFriend { friendid: Some(steamid::steamid_of(account)), player_name: Some(format!("P{account}")), persona_state: Some(1), gameid: Some(app), game_played_app_id: Some(app as u32), ..Default::default() };
roster.apply_persona(&update(vec![playing(22202, 730), playing(22203, 730)]));
assert_eq!(roster.unnamed_apps(), vec![730], "one question for two people in the same game");
let mut shown = Conversations::new(ACCOUNT);
let mut rx = state.events.subscribe();
shown.publish(&state, &roster);
let said = events(&mut rx, "presenceChange");
let text = |said: &[serde_json::Value], who: &str| said.iter().rev().find(|e| e["bufferId"].as_str().is_some_and(|b| b.ends_with(who))).and_then(|e| e["members"][0]["activity"].clone().as_object().cloned());
assert_eq!(text(&said, "P22202").unwrap()["text"], "In game");
roster.set_app_name(730, "Counter-Strike 2".into());
assert!(roster.unnamed_apps().is_empty());
shown.publish(&state, &roster);
let said = events(&mut rx, "presenceChange");
assert_eq!(text(&said, "P22202").unwrap()["text"], "Counter-Strike 2", "named, and said again");
assert_eq!(text(&said, "P22203").unwrap()["kind"], "game");
// A game that has a name from Steam is not asked about, and stopping is stopping.
roster.apply_persona(&update(vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), game_name: Some("My Mod".into()), gameid: Some(1 << 40), ..Default::default() }]));
assert_eq!(roster.game_of(22202).as_deref(), Some("My Mod"));
roster.apply_persona(&update(vec![PersonaFriend { friendid: Some(steamid::steamid_of(22202)), gameid: Some(0), ..Default::default() }]));
assert_eq!(roster.game_of(22202), None);
}
#[test]
fn a_mute_set_on_steam_silences_the_conversation_here_and_lifting_it_unsilences() {
let state = simulated_daemon("steam-publish-mute");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[22202]));
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
let mut shown = Conversations::new(ACCOUNT);
shown.publish(&state, &roster);
let id = crate::model::buffer_id(ACCOUNT, "Gabe");
assert!(!state.runtime.get_buffer(&id).unwrap().server_muted);
// Muted in another client: it arrives as the account's preferences for them.
roster.set_preferences(&PerFriendPreferences { accountid: Some(22202), notifications_showmessages: Some(crate::backend::steam::friends::NOTIFY_NEVER), ..Default::default() });
shown.apply_mutes(&state, &roster);
assert!(state.runtime.get_buffer(&id).unwrap().server_muted, "the row says it is muted");
roster.set_preferences(&PerFriendPreferences { accountid: Some(22202), ..Default::default() });
shown.apply_mutes(&state, &roster);
assert!(!state.runtime.get_buffer(&id).unwrap().server_muted);
}
#[test]
fn nothing_is_said_again_when_nothing_changed() {
let state = simulated_daemon("steam-publish-quiet");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[1, 2]));
roster.apply_persona(&update(vec![who(1, "One", 1), who(2, "Two", 3)]));
let mut shown = Conversations::new(ACCOUNT);
shown.publish(&state, &roster);
let mut rx = state.events.subscribe();
shown.publish(&state, &roster);
assert!(events(&mut rx, "presenceChange").is_empty());
assert!(events(&mut rx, "bufferListChange").is_empty());
// One of them changes: only that one is said.
roster.apply_persona(&update(vec![who(2, "Two", 1)]));
shown.publish(&state, &roster);
let said = events(&mut rx, "presenceChange");
assert_eq!(said.len(), 1);
assert_eq!(said[0]["members"][0]["nick"], "Two");
assert_eq!(said[0]["members"][0]["status"], "online");
}
#[test]
fn a_game_and_the_states_the_colours_share_are_said_beside_the_dot_and_change_when_they_do() {
let state = simulated_daemon("steam-publish-text");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[1, 2, 3]));
roster.apply_persona(&update(vec![who(1, "Plain", 1), who(2, "Snoozer", 4), who(3, "Gamer", 1)]));
let game = |name: &str| PersonaFriend { friendid: Some(steamid::steamid_of(3)), gameid: Some(730), game_name: Some(name.into()), ..Default::default() };
roster.apply_persona(&update(vec![game("Counter-Strike 2")]));
let mut shown = Conversations::new(ACCOUNT);
let mut rx = state.events.subscribe();
shown.publish(&state, &roster);
let text = |said: &[serde_json::Value], nick: &str| said.iter().find(|e| e["members"][0]["nick"] == nick).map(|e| e["members"][0]["activity"].clone());
let said = events(&mut rx, "presenceChange");
assert_eq!(text(&said, "Plain"), Some(serde_json::Value::Null), "online says nothing under the name");
assert_eq!(text(&said, "Snoozer"), Some(serde_json::json!({ "kind": "custom", "text": "Snoozing" })));
assert_eq!(text(&said, "Gamer"), Some(serde_json::json!({ "kind": "game", "text": "Counter-Strike 2" })));
// The dot is still what it was for each: a game does not change it.
assert_eq!(said.iter().find(|e| e["members"][0]["nick"] == "Gamer").unwrap()["members"][0]["status"], "online");
// They stop playing: said once, for that person alone.
roster.apply_persona(&update(vec![PersonaFriend { friendid: Some(steamid::steamid_of(3)), gameid: Some(0), ..Default::default() }]));
shown.publish(&state, &roster);
let said = events(&mut rx, "presenceChange");
assert_eq!(said.len(), 1);
assert_eq!(said[0]["members"][0]["nick"], "Gamer");
assert!(said[0]["members"][0]["activity"].is_null());
}
#[test]
fn away_is_idle_and_offline_is_offline_and_a_game_does_not_change_either() {
let state = simulated_daemon("steam-publish-states");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[1, 2, 3, 4]));
roster.apply_persona(&update(vec![who(1, "Away", 3), who(2, "Busy", 2), who(3, "Gone", 0), who(4, "Hidden", 7)]));
let mut rx = state.events.subscribe();
Conversations::new(ACCOUNT).publish(&state, &roster);
let said = events(&mut rx, "presenceChange");
let by_name: HashMap<String, (String, bool)> = said
.iter()
.map(|e| (e["members"][0]["nick"].as_str().unwrap().to_string(), (e["members"][0]["status"].as_str().unwrap().to_string(), e["members"][0]["away"].as_bool().unwrap())))
.collect();
assert_eq!(by_name["Away"], ("idle".to_string(), true));
assert_eq!(by_name["Busy"], ("dnd".to_string(), false));
assert_eq!(by_name["Gone"], ("offline".to_string(), false));
assert_eq!(by_name["Hidden"].0, "offline", "invisible reads as gone, as it does in Steam");
}
#[test]
fn a_friend_who_renames_keeps_their_conversation_and_its_history() {
let state = simulated_daemon("steam-publish-rename");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[5]));
roster.apply_persona(&update(vec![who(5, "Old Name", 1)]));
let mut shown = Conversations::new(ACCOUNT);
shown.publish(&state, &roster);
state.runtime.record_message(&state, ACCOUNT, "Old Name", "dm", "Old Name", "hello there", false, "chat", None, Some("1:1".into()), false, None, Vec::new(), Vec::new(), None);
roster.apply_persona(&update(vec![who(5, "New Name", 1)]));
shown.publish(&state, &roster);
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Old Name")).is_none(), "not left behind as a second conversation");
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "New Name")).is_some());
let history = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "New Name"), 0, 10).unwrap();
assert_eq!(history.iter().map(|m| m.body.as_str()).collect::<Vec<_>>(), ["hello there"], "the history moved with them");
}
#[test]
fn two_friends_who_share_a_name_get_two_conversations_and_stay_two() {
let state = simulated_daemon("steam-publish-shared");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[10, 20]));
roster.apply_persona(&update(vec![who(10, "Alex", 1)]));
let mut shown = Conversations::new(ACCOUNT);
shown.publish(&state, &roster);
assert_eq!(shown.name_of(10), Some("Alex"));
roster.apply_persona(&update(vec![who(20, "Alex", 1)]));
shown.publish(&state, &roster);
assert_eq!((shown.name_of(10), shown.name_of(20)), (Some("Alex (10)"), Some("Alex (20)")));
let one = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Alex (10)")).unwrap();
let two = state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Alex (20)")).unwrap();
assert_ne!(one.remote_id, two.remote_id);
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Alex")).is_none());
}
#[test]
fn a_friend_renamed_while_moho_was_closed_keeps_their_conversation() {
let state = simulated_daemon("steam-publish-adopt");
// What a previous run left: a conversation with its person recorded.
let buffer = state.runtime.ensure_buffer(&state, ACCOUNT, "Before", "dm");
state.runtime.set_buffer_remote_id(&state, &buffer.id, &steamid::text_of(9));
state.runtime.record_message(&state, ACCOUNT, "Before", "dm", "Before", "an old message", false, "chat", None, Some("1:0".into()), false, None, Vec::new(), Vec::new(), None);
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[9]));
roster.apply_persona(&update(vec![who(9, "After", 1)]));
let mut shown = Conversations::new(ACCOUNT);
shown.adopt(&state, ACCOUNT);
shown.publish(&state, &roster);
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "Before")).is_none());
let moved = state.store.get_backlog(&crate::model::buffer_id(ACCOUNT, "After"), 0, 10).unwrap();
assert_eq!(moved.iter().map(|m| m.body.as_str()).collect::<Vec<_>>(), ["an old message"]);
}
#[test]
fn another_accounts_conversations_are_not_adopted() {
let state = simulated_daemon("steam-publish-adopt-other");
let theirs = state.runtime.ensure_buffer(&state, "steam:someone-else", "Friend", "dm");
state.runtime.set_buffer_remote_id(&state, &theirs.id, &steamid::text_of(9));
let mut shown = Conversations::new(ACCOUNT);
shown.adopt(&state, ACCOUNT);
assert_eq!(shown.name_of(9), None);
}
#[test]
fn somebody_who_has_not_been_named_yet_is_called_by_their_id_and_renamed_when_known() {
let state = simulated_daemon("steam-publish-unnamed");
let mut roster = Roster::default();
roster.apply_friends_list(&friends(&[22202]));
let mut shown = Conversations::new(ACCOUNT);
shown.publish(&state, &roster);
assert_eq!(shown.name_of(22202), Some("76561197960287930"), "until Steam says who they are, their id");
roster.apply_persona(&update(vec![who(22202, "Gabe", 1)]));
shown.publish(&state, &roster);
assert_eq!(shown.name_of(22202), Some("Gabe"));
assert!(state.runtime.get_buffer(&crate::model::buffer_id(ACCOUNT, "76561197960287930")).is_none());
}
}
+234
View File
@@ -0,0 +1,234 @@
//! Reacting to a message with an emoticon or a sticker, and seeing others do.
//!
//! A reaction on Steam is an emoticon or sticker named by a word, held by a list
//! of people. moho keeps them as it keeps everyone's: a count, and whether it
//! is ours, under a token - `[steam:emote:steamhappy]` - that the window draws
//! as the picture. The message is found by the id it is stored under, the
//! second Steam stamped it and its place in that second, which is also how
//! Steam names it.
use super::proto::{MessageReactionNotice, PastReaction, UpdateMessageReactionRequest, UpdateMessageReactionResponse};
use super::steamid;
use crate::model::{Buffer, Reaction};
use crate::state::AppState;
use serde_json::json;
/// `EMessageReactionType`.
pub const EMOTICON: i32 = 1;
pub const STICKER: i32 = 2;
fn valid_name(kind: i32, name: &str) -> bool {
if kind == STICKER {
return super::emoticons::valid_sticker(name);
}
!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
/// How a reaction is held here: the token the window draws as a picture.
pub fn token(kind: i32, name: &str) -> Option<String> {
let name = name.trim().trim_matches(':');
valid_name(kind, name).then(|| format!("[steam:{}:{name}]", if kind == STICKER { "sticker" } else { "emote" }))
}
/// What a token held here is, as Steam names it: its kind and its word.
pub fn parse_token(emoji: &str) -> Option<(i32, String)> {
let inner = emoji.strip_prefix("[steam:")?.strip_suffix(']')?;
let (kind, name) = inner.split_once(':')?;
let kind = match kind {
"emote" => EMOTICON,
"sticker" => STICKER,
_ => return None,
};
valid_name(kind, name).then(|| (kind, name.to_string()))
}
/// How Steam spells a reaction: an emoticon between colons, as in a message, and a
/// sticker by its bare name. (Found in Steam's own client, and refused as
/// anything else.)
fn on_the_wire(kind: i32, name: &str) -> String {
if kind == EMOTICON { format!(":{name}:") } else { name.to_string() }
}
/// A message's id here, as the second and ordinal Steam names it by.
pub fn parse_message_id(id: &str) -> Option<(u32, u32)> {
let (ts, ord) = id.split_once(':')?;
Some((ts.parse().ok()?, ord.parse().ok()?))
}
/// The reactions out of a history entry, as held here.
pub fn from_history(own: u32, list: &[PastReaction]) -> Vec<Reaction> {
list.iter()
.filter_map(|r| {
let emoji = token(r.reaction_type.unwrap_or(EMOTICON), r.reaction.as_deref()?)?;
(!r.reactors.is_empty()).then(|| Reaction { emoji, count: r.reactors.len() as i64, me: r.reactors.contains(&own), animated: false })
})
.collect()
}
/// Puts the list of people who hold one reaction on a message, replacing what
/// was held: Steam's answer to a change is who holds it now, which is truer
/// than adding one to a count.
fn set_reactors(state: &AppState, buffer_id: &str, msg_id: &str, emoji: &str, reactors: &[u32], own: u32) -> Result<(), String> {
let message = state.store.get_message(buffer_id, msg_id).map_err(|e| e.to_string())?.ok_or("that message is not in the log")?;
let mut held: Vec<Reaction> = message.reactions;
held.retain(|r| r.emoji != emoji);
if !reactors.is_empty() {
held.push(Reaction { emoji: emoji.to_string(), count: reactors.len() as i64, me: reactors.contains(&own), animated: false });
}
state.store.set_reactions(buffer_id, msg_id, &held).map_err(|e| e.to_string())?;
state.events.emit("reactionsChanged", json!({ "bufferId": buffer_id, "id": msg_id, "reactions": held }));
Ok(())
}
/// Adds or takes off our reaction on a message.
pub async fn toggle(state: &AppState, buffer: &Buffer, msg_id: &str, emoji: &str, add: bool) -> Result<(), String> {
let link = state.runtime.steam_link(&buffer.account_id).ok_or("this Steam account is not connected")?;
let (kind, name) = parse_token(emoji).ok_or("Steam reactions are its own emoticons and stickers")?;
let (timestamp, ordinal) = parse_message_id(msg_id).ok_or("that message has no place in Steam's log")?;
let friend: u64 = buffer.remote_id.as_deref().and_then(|id| id.parse().ok()).filter(|id| steamid::account_of(*id).is_some()).ok_or("this conversation is not with a Steam account")?;
let own = state.accounts.get_steam(&buffer.account_id).and_then(|c| steamid::account_of(c.steamid)).ok_or("this Steam account is not set up")?;
let request = UpdateMessageReactionRequest { steamid: Some(friend), server_timestamp: Some(timestamp), ordinal: Some(ordinal), reaction_type: Some(kind), reaction: Some(on_the_wire(kind, &name)), is_add: Some(add) };
let answer: UpdateMessageReactionResponse = link.call("FriendMessages.UpdateMessageReaction#1", &request, true).await.map_err(|e| super::chat::refusal(&e))?;
set_reactors(state, &buffer.id, msg_id, emoji, &answer.reactors, own)
}
/// Somebody reacted, or took it back, in a conversation that exists.
pub fn heard(state: &AppState, buffer_id: &str, own: u32, notice: &MessageReactionNotice) {
let (Some(ts), Some(ord)) = (notice.server_timestamp, notice.ordinal) else { return };
let Some(emoji) = token(notice.reaction_type.unwrap_or(EMOTICON), notice.reaction.as_deref().unwrap_or_default()) else { return };
let by_us = notice.reactor.and_then(steamid::account_of) == Some(own);
state.runtime.update_reaction(state, buffer_id, &super::chat::message_id(ts, ord), &emoji, by_us, notice.is_add.unwrap_or(true));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit::simulated_daemon;
use crate::backend::steam::link::{self, fake::*};
use prost::Message;
use std::time::Duration;
const ACCOUNT: &str = "steam:tester";
const ME: u32 = 22202;
const FRIEND: u32 = 100;
#[test]
fn a_reaction_is_a_token_for_a_picture_and_back() {
assert_eq!(token(EMOTICON, "steamhappy").as_deref(), Some("[steam:emote:steamhappy]"));
assert_eq!(token(EMOTICON, ":steamhappy:").as_deref(), Some("[steam:emote:steamhappy]"), "colons are not part of it");
assert_eq!(token(STICKER, "CatSurprise").as_deref(), Some("[steam:sticker:CatSurprise]"));
assert_eq!(token(EMOTICON, "a b"), None);
assert_eq!(token(EMOTICON, ""), None);
assert_eq!(parse_token("[steam:emote:steamhappy]"), Some((EMOTICON, "steamhappy".to_string())));
assert_eq!(parse_token("[steam:sticker:CatSurprise]"), Some((STICKER, "CatSurprise".to_string())));
assert_eq!(token(STICKER, "Nyan GLaDOS").as_deref(), Some("[steam:sticker:Nyan GLaDOS]"), "a sticker's name has spaces in it");
assert_eq!(parse_token("[steam:sticker:Nyan GLaDOS]"), Some((STICKER, "Nyan GLaDOS".to_string())));
for not in ["👍", "<:name:123>", "[steam:other:x]", "[steam:emote:a b]", "[steam:emote:]", "steam:emote:x"] {
assert_eq!(parse_token(not), None, "{not}");
}
}
#[test]
fn steam_spells_an_emoticon_with_colons_and_a_sticker_without() {
assert_eq!(on_the_wire(EMOTICON, "steamhappy"), ":steamhappy:");
assert_eq!(on_the_wire(STICKER, "CatSurprise"), "CatSurprise");
}
#[test]
fn a_message_is_named_by_its_second_and_ordinal() {
assert_eq!(parse_message_id("1790000000:3"), Some((1790000000, 3)));
for not in ["", "1790000000", "a:b", "1:", ":1", "-1:0", "1:2:3"] {
assert_eq!(parse_message_id(not), None, "{not:?}");
}
}
#[test]
fn history_reactions_count_who_and_whether_it_is_ours() {
let list = vec![
PastReaction { reaction_type: Some(1), reaction: Some("steamhappy".into()), reactors: vec![FRIEND, ME] },
PastReaction { reaction_type: Some(2), reaction: Some("CatSurprise".into()), reactors: vec![FRIEND] },
PastReaction { reaction_type: Some(1), reaction: Some("empty".into()), reactors: vec![] },
PastReaction { reaction_type: Some(1), reaction: Some("bad name".into()), reactors: vec![FRIEND] },
];
let got = from_history(ME, &list);
assert_eq!(got.iter().map(|r| (r.emoji.as_str(), r.count, r.me)).collect::<Vec<_>>(), [("[steam:emote:steamhappy]", 2, true), ("[steam:sticker:CatSurprise]", 1, false)]);
}
fn conversation(name: &str) -> (AppState, Buffer, String) {
let state = simulated_daemon(name);
state.accounts.add_steam(crate::accounts::SteamAccountConfig { username: "tester".into(), steamid: steamid::steamid_of(ME), ..Default::default() }).unwrap();
let buffer = state.runtime.ensure_buffer(&state, ACCOUNT, "Gabe", "dm");
state.runtime.set_buffer_remote_id(&state, &buffer.id, &steamid::text_of(FRIEND));
let buffer = state.runtime.get_buffer(&buffer.id).unwrap();
state.runtime.record_message_at(&state, ACCOUNT, "Gabe", "dm", "Gabe", "hello", false, "chat", None, Some("1790000000:1".into()), false, None, vec![], vec![], None, Some(1790000000), None, None);
(state, buffer, "1790000000:1".to_string())
}
#[tokio::test]
async fn reacting_asks_steam_and_holds_who_it_says_now_has_it() {
let (state, buffer, msg) = conversation("steam-react-toggle");
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let url = serve({
let seen = seen.clone();
Box::new(move |p| {
seen.lock().unwrap().push(UpdateMessageReactionRequest::decode(p.body.as_slice()).unwrap());
let is_add = seen.lock().unwrap().last().unwrap().is_add == Some(true);
// Steam answers who holds it now: the friend always, and us while added.
let reactors = if is_add { vec![FRIEND, ME] } else { vec![FRIEND] };
Some(vec![answer(&p, 1, UpdateMessageReactionResponse { reactors }.encode_to_vec())])
})
})
.await;
state.runtime.set_steam_link(ACCOUNT, link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0);
let mut events = state.events.subscribe();
toggle(&state, &buffer, &msg, "[steam:emote:steamhappy]", true).await.unwrap();
let held = state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions;
assert_eq!(held.iter().map(|r| (r.emoji.as_str(), r.count, r.me)).collect::<Vec<_>>(), [("[steam:emote:steamhappy]", 2, true)]);
let sent = seen.lock().unwrap()[0].clone();
assert_eq!((sent.steamid, sent.server_timestamp, sent.ordinal, sent.reaction_type, sent.reaction.as_deref(), sent.is_add), (Some(steamid::steamid_of(FRIEND)), Some(1790000000), Some(1), Some(1), Some(":steamhappy:"), Some(true)));
assert!(std::iter::from_fn(|| events.try_recv().ok()).any(|e| e.name == "reactionsChanged"), "the window is told");
toggle(&state, &buffer, &msg, "[steam:emote:steamhappy]", false).await.unwrap();
let held = state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions;
assert_eq!(held.iter().map(|r| (r.count, r.me)).collect::<Vec<_>>(), [(1, false)], "ours is off, theirs stays");
}
#[tokio::test]
async fn what_is_not_one_of_steams_is_refused_before_it_is_asked() {
let (state, buffer, msg) = conversation("steam-react-refused");
let url = serve(Box::new(|_| panic!("nothing should be asked"))).await;
state.runtime.set_steam_link(ACCOUNT, link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0);
assert!(toggle(&state, &buffer, &msg, "👍", true).await.unwrap_err().contains("emoticons"));
assert!(toggle(&state, &buffer, "nonsense", "[steam:emote:x]", true).await.is_err());
// A message that is not in the log has nothing to put it on.
let url = serve(Box::new(|p| Some(vec![answer(&p, 1, UpdateMessageReactionResponse { reactors: vec![ME] }.encode_to_vec())]))).await;
state.runtime.set_steam_link(ACCOUNT, link::open_with("test", &url, Duration::from_secs(5)).await.unwrap().0);
assert!(toggle(&state, &buffer, "5:5", "[steam:emote:x]", true).await.unwrap_err().contains("not in the log"));
}
#[test]
fn somebody_else_reacting_adds_to_the_count_and_taking_it_back_removes_it() {
let (state, buffer, msg) = conversation("steam-react-heard");
let notice = |reactor: u32, add: bool| MessageReactionNotice {
steamid_friend: Some(steamid::steamid_of(FRIEND)),
server_timestamp: Some(1790000000),
ordinal: Some(1),
reactor: Some(steamid::steamid_of(reactor)),
reaction_type: Some(EMOTICON),
reaction: Some("steamhappy".into()),
is_add: Some(add),
};
heard(&state, &buffer.id, ME, &notice(FRIEND, true));
heard(&state, &buffer.id, ME, &notice(ME, true));
let held = state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions;
assert_eq!(held.iter().map(|r| (r.count, r.me)).collect::<Vec<_>>(), [(2, true)], "a friend's and ours, from another client");
heard(&state, &buffer.id, ME, &notice(FRIEND, false));
heard(&state, &buffer.id, ME, &notice(ME, false));
assert!(state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions.is_empty(), "none left, none shown");
// Nonsense is nothing.
heard(&state, &buffer.id, ME, &MessageReactionNotice { reaction: Some("bad name".into()), ..notice(FRIEND, true) });
heard(&state, &buffer.id, ME, &MessageReactionNotice { server_timestamp: None, ..notice(FRIEND, true) });
assert!(state.store.get_message(&buffer.id, &msg).unwrap().unwrap().reactions.is_empty());
}
}
+59
View File
@@ -0,0 +1,59 @@
//! A Steam account's identity: the 32-bit account number a friends list and a
//! chat message use, and the 64-bit SteamID the connection and the profile
//! pages use.
//!
//! A SteamID64 is that account number with three more things packed in beside
//! it: the universe (1, the public one), the type of account (1, an
//! individual - groups and games have their own) and an instance (1, the
//! desktop one, which every ordinary person's account has). So for a person it
//! is always the account number added to one constant, and these two functions
//! are the whole conversion.
/// Universe 1, type 1, instance 1, account 0: what a person's id starts from.
pub const INDIVIDUAL_BASE: u64 = (1 << 56) | (1 << 52) | (1 << 32);
/// The 64-bit id of the person with this account number.
pub fn steamid_of(account: u32) -> u64 {
INDIVIDUAL_BASE + u64::from(account)
}
/// The account number in a person's 64-bit id, or `None` for an id that is not
/// a person's (a group's, a game server's, or nonsense).
pub fn account_of(steamid: u64) -> Option<u32> {
// The universe, type and instance must be exactly an individual's; what is
// left is the account number, which is 32 bits.
let rest = steamid.checked_sub(INDIVIDUAL_BASE)?;
u32::try_from(rest).ok()
}
/// The id the way it is written in a profile address.
pub fn text_of(account: u32) -> String {
steamid_of(account).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
/// Gabe Newell's account, whose id is the most photographed in existence:
/// account number 22202 is 76561197960287930.
#[test]
fn the_two_forms_of_an_id_convert_both_ways() {
assert_eq!(steamid_of(22202), 76561197960287930);
assert_eq!(account_of(76561197960287930), Some(22202));
assert_eq!(INDIVIDUAL_BASE, 76561197960265728);
assert_eq!(account_of(steamid_of(u32::MAX)), Some(u32::MAX));
assert_eq!(account_of(steamid_of(0)), Some(0));
}
#[test]
fn something_that_is_not_a_person_is_not_converted() {
// A group's id has type 7 where a person's has 1.
let group = (1u64 << 56) | (7 << 52) | (1 << 32) | 12345;
assert_eq!(account_of(group), None);
assert_eq!(account_of(0), None);
assert_eq!(account_of(INDIVIDUAL_BASE - 1), None);
// Too large to be an account number added to the base.
assert_eq!(account_of(INDIVIDUAL_BASE + u64::from(u32::MAX) + 1), None);
}
}
+333
View File
@@ -0,0 +1,333 @@
//! Steam voice: calls to a friend and voice in a chat group's room.
//!
//! Steam's voice is WebRTC with Valve's own server as the one peer: the client
//! offers, the server answers with an ICE-lite description of a relay it holds
//! for the session, media goes there, and the server mixes and forwards it. A
//! browser speaks all of that already, so the media lives in the window - the
//! same division `lib/webrtc.ts` draws for Matrix - and this carries what is
//! the daemon's: the signalling, which is calls and notifications on the
//! account's own connection.
//!
//! None of it is in the definitions extracted from the client binary. It is in
//! the Steam client's own JavaScript, which is where `voicewire.rs` and the
//! order of calls below were read from: offer, then `InitiateWebRTCConnection`;
//! the server says the session is connected; then either `JoinVoiceChat` for a
//! room or `RequestOneOnOneChat` for a friend; then `UpdateVoiceChatWebRTCData`
//! tells the chat which stream is ours. While it runs the server renegotiates
//! as people come and go, and says who is who by stream number.
//!
//! The window sends a call as `{ op, ... }` and hears everything the server says
//! as a `steamVoice` event. Every 64-bit number crosses as text, since the
//! window cannot hold one.
use super::chat::refusal;
use super::chatgroups;
use super::link::Link;
use super::steamid;
use super::voicewire::*;
use crate::state::AppState;
use prost::Message;
use serde_json::{json, Value};
fn id_of(v: &Value, key: &str) -> Option<u64> {
match &v[key] {
Value::String(s) => s.parse().ok(),
Value::Number(n) => n.as_u64(),
_ => None,
}
}
fn num(v: &Value, key: &str) -> Option<u32> {
v[key].as_u64().and_then(|n| u32::try_from(n).ok())
}
fn text(v: &Value, key: &str) -> Option<String> {
v[key].as_str().map(str::to_string)
}
/// A call that is only said, with no answer to wait for.
fn say<Q: Message>(link: &Link, method: &str, request: &Q) -> Result<(), String> {
link.notify(method, request).map_err(|e| e.to_string())
}
async fn ask<Q: Message, A: Message + Default>(link: &Link, method: &str, request: &Q) -> Result<A, String> {
link.call(method, request, true).await.map_err(|e| voice_refusal(&e, method))
}
/// What the window reads at the front of a failure that may be gone in a moment, as the Steam client
/// reads them: it tries a call again, after a little longer each time, for up to a minute.
pub const AGAIN: &str = "[again] ";
/// Why a voice call was refused, in the words that fit it. A failure that is the server's and
/// passing is marked [`AGAIN`] for the window to try again; being refused for good is said.
fn voice_refusal(error: &super::link::CallError, method: &str) -> String {
use super::link::CallError;
use super::eresult as e;
let transient = match error {
CallError::TimedOut => true,
CallError::Refused(code, _) => matches!(*code, e::FAIL | e::BUSY | e::TIMEOUT | e::SERVICE_UNAVAILABLE | 32 | 33 | 37 | 55),
_ => false,
};
if transient {
return format!("{AGAIN}{}", refusal(error));
}
match error {
CallError::Refused(e::ACCESS_DENIED, _) if method.starts_with("ChatRoom.JoinVoiceChat") => "you may not join voice in this room".to_string(),
CallError::Refused(e::ACCESS_DENIED, _) if method.starts_with("VoiceChat.RequestOneOnOneChat") => "this person is not taking calls from you".to_string(),
CallError::Refused(e::BLOCKED, _) | CallError::Refused(e::IGNORED, _) => "this person cannot be called".to_string(),
CallError::Refused(e::LIMIT_EXCEEDED, _) => "the voice chat is full".to_string(),
CallError::Refused(e::NO_MATCH | e::FILE_NOT_FOUND | e::INVALID_PARAM | e::INVALID_STATE, _) if method.starts_with("ChatRoom.JoinVoiceChat") => "this room has no voice".to_string(),
CallError::Refused(e::NO_MATCH | e::FILE_NOT_FOUND | e::INVALID_STATE, _) => "that call is over".to_string(),
other => refusal(other),
}
}
/// One thing the window asks of the voice, and what Steam answers.
pub async fn act(state: &AppState, account_id: &str, params: &Value) -> Result<Value, String> {
let link = state.runtime.steam_link(account_id).ok_or("this Steam account is not connected")?;
let op = params["op"].as_str().ok_or("steamVoice needs an \"op\"")?;
match op {
// The offer, and the answer from the server that will carry the session.
"initiate" => {
let request = InitiateWebRTCConnectionRequest { sdp: text(params, "sdp"), browser_name: text(params, "browserName"), browser_version: text(params, "browserVersion") };
let answer: InitiateWebRTCConnectionResponse = ask(&link, "WebRTCClient.InitiateWebRTCConnection#1", &request).await?;
Ok(json!({ "remoteDescription": answer.remote_description }))
}
// A new offer on a session that is up (the browser's own renegotiation).
"update" => {
let request = UpdateWebRTCConnectionRequest {
ip_webrtc_server: num(params, "serverIp"),
port_webrtc_server: num(params, "serverPort"),
ip_webrtc_session_client: num(params, "clientIp"),
port_webrtc_session_client: num(params, "clientPort"),
sdp: text(params, "sdp"),
};
let answer: UpdateWebRTCConnectionResponse = ask(&link, "WebRTCClient.UpdateWebRTCConnection#1", &request).await?;
Ok(json!({ "remoteDescription": answer.remote_description }))
}
// The window has taken the server's new offer and answered it.
"acknowledge" => {
let request = AcknowledgeUpdatedRemoteDescriptionRequest {
ip_webrtc_server: num(params, "serverIp"),
port_webrtc_server: num(params, "serverPort"),
ip_webrtc_session_client: num(params, "clientIp"),
port_webrtc_session_client: num(params, "clientPort"),
remote_description_version: id_of(params, "version"),
};
let _: AcknowledgeUpdatedRemoteDescriptionResponse = ask(&link, "WebRTCClient.AcknowledgeUpdatedRemoteDescription#1", &request).await?;
Ok(json!({}))
}
"joinRoom" => {
let (Some(group), Some(room)) = (id_of(params, "groupId"), id_of(params, "roomId")) else { return Err("joinRoom needs a group and a room".into()) };
let answer: RoomJoinVoiceChatResponse = ask(&link, "ChatRoom.JoinVoiceChat#1", &RoomJoinVoiceChatRequest { chat_group_id: Some(group), chat_id: Some(room) }).await?;
Ok(json!({ "voiceChatId": answer.voice_chatid.map(|v| v.to_string()) }))
}
"leaveRoom" => {
let (Some(group), Some(room)) = (id_of(params, "groupId"), id_of(params, "roomId")) else { return Err("leaveRoom needs a group and a room".into()) };
let _: RoomLeaveVoiceChatResponse = ask(&link, "ChatRoom.LeaveVoiceChat#1", &RoomLeaveVoiceChatRequest { chat_group_id: Some(group), chat_id: Some(room) }).await?;
Ok(json!({}))
}
// Which stream is ours, once the session is connected: what makes the chat
// count this connection as being in it.
"data" => {
let request = UpdateVoiceChatWebRTCDataRequest {
voice_chatid: id_of(params, "voiceChatId"),
ip_webrtc_server: num(params, "serverIp"),
port_webrtc_server: num(params, "serverPort"),
ip_webrtc_client: num(params, "clientIp"),
port_webrtc_client: num(params, "clientPort"),
ssrc_my_sending_stream: num(params, "ssrc"),
user_agent: text(params, "userAgent"),
has_audio_worklets_support: Some(params["worklets"].as_bool().unwrap_or(false)),
has_webrtc_data_channel: Some(params["dataChannel"].as_bool().unwrap_or(false)),
accepts_webrtc_video: Some(false),
sends_webrtc_video: Some(false),
};
let answer: UpdateVoiceChatWebRTCDataResponse = ask(&link, "VoiceChat.UpdateVoiceChatWebRTCData#1", &request).await?;
Ok(json!({ "sendLogs": answer.send_client_voice_logs.unwrap_or(false) }))
}
// How we are showing to the others in the call: muted, deafened, no microphone.
"status" => {
let own = state.accounts.get_steam(account_id).map(|c| c.steamid).ok_or("this Steam account is not set up")?;
let request = UserVoiceStatusNotification {
voice_chatid: id_of(params, "voiceChatId"),
user_steamid: Some(own),
user_muted_mic_locally: Some(params["micMuted"].as_bool().unwrap_or(false)),
user_muted_output_locally: Some(params["outputMuted"].as_bool().unwrap_or(false)),
user_has_no_mic_for_session: Some(params["noMic"].as_bool().unwrap_or(false)),
user_webaudio_sample_rate: params["sampleRate"].as_i64().and_then(|n| i32::try_from(n).ok()),
user_sends_video: Some(false),
};
say(&link, "VoiceChat.NotifyUserVoiceStatus#1", &request)?;
Ok(json!({}))
}
"requestCall" => {
let partner = id_of(params, "steamid").ok_or("requestCall needs a person")?;
let answer: RequestOneOnOneChatResponse = ask(&link, "VoiceChat.RequestOneOnOneChat#1", &RequestOneOnOneChatRequest { steamid_partner: Some(partner) }).await?;
Ok(json!({ "voiceChatId": answer.voice_chatid.map(|v| v.to_string()) }))
}
"answerCall" => {
let partner = id_of(params, "steamid").ok_or("answerCall needs a person")?;
let request = AnswerOneOnOneChatRequest { voice_chatid: id_of(params, "voiceChatId"), steamid_partner: Some(partner), accepted_request: Some(params["accept"].as_bool().unwrap_or(true)) };
let _: AnswerOneOnOneChatResponse = ask(&link, "VoiceChat.AnswerOneOnOneChat#1", &request).await?;
Ok(json!({}))
}
// Hanging up on them: it ends the call for both.
"endCall" => {
let partner = id_of(params, "steamid").ok_or("endCall needs a person")?;
let _: EndOneOnOneChatResponse = ask(&link, "VoiceChat.EndOneOnOneChat#1", &EndOneOnOneChatRequest { steamid_partner: Some(partner) }).await?;
Ok(json!({}))
}
// Leaving it running for them, where they can come back to it.
"leaveCall" => {
let partner = id_of(params, "steamid").ok_or("leaveCall needs a person")?;
let request = LeaveOneOnOneChatRequest { steamid_partner: Some(partner), voice_chatid: id_of(params, "voiceChatId") };
let _: LeaveOneOnOneChatResponse = ask(&link, "VoiceChat.LeaveOneOnOneChat#1", &request).await?;
Ok(json!({}))
}
other => Err(format!("{other} is not something Steam voice can be asked to do")),
}
}
fn who(account: u64) -> String {
steamid::text_of(steamid::account_of(account).unwrap_or(0))
}
fn status_json(s: &UserVoiceStatusNotification) -> Value {
json!({
"voiceChatId": s.voice_chatid.map(|v| v.to_string()),
"steamid": s.user_steamid.map(who),
"micMuted": s.user_muted_mic_locally.unwrap_or(false),
"outputMuted": s.user_muted_output_locally.unwrap_or(false),
"noMic": s.user_has_no_mic_for_session.unwrap_or(false),
})
}
/// Tells the window what the server said about the call, if this is one of its notifications.
/// Returns whether it was.
pub fn heard(state: &AppState, account_id: &str, method: &str, body: &[u8]) -> bool {
let tell = |kind: &str, data: Value| {
state.events.emit("steamVoice", json!({ "accountId": account_id, "kind": kind, "data": data }));
};
match method {
"WebRTCClientNotifications.NotifyWebRTCSessionConnected#1" => {
let Ok(n) = WebRTCSessionConnectedNotification::decode(body) else { return true };
tell("sessionConnected", json!({ "ssrc": n.ssrc, "clientIp": n.client_ip, "clientPort": n.client_port, "serverIp": n.server_ip, "serverPort": n.server_port }));
}
"WebRTCClientNotifications.NotifyWebRTCUpdateRemoteDescription#1" => {
let Ok(n) = WebRTCUpdateRemoteDescriptionNotification::decode(body) else { return true };
tell(
"remoteDescription",
json!({
"sdp": n.remote_description,
"version": n.remote_description_version.map(|v| v.to_string()),
"ssrcs": n.ssrcs_to_accountids.iter().map(|m| json!({ "ssrc": m.ssrc, "steamid": m.accountid.map(steamid::text_of) })).collect::<Vec<_>>(),
}),
);
}
"VoiceChatClient.NotifyUserJoinedVoiceChat#1" | "VoiceChatClient.NotifyUserLeftVoiceChat#1" => {
let joined = method.contains("Joined");
let Ok(n) = UserJoinedVoiceChatNotification::decode(body) else { return true };
let (group, room) = (n.chat_group_id.filter(|g| *g != 0), n.chatid.filter(|c| *c != 0));
if let (Some(group), Some(room), Some(user)) = (group, room, n.user_steamid.and_then(steamid::account_of)) {
chatgroups::voice_changed(state, account_id, group, room, user, joined);
}
tell(if joined { "userJoined" } else { "userLeft" }, json!({ "voiceChatId": n.voice_chatid.map(|v| v.to_string()), "steamid": n.user_steamid.map(who), "groupId": group.map(|g| g.to_string()), "roomId": room.map(|r| r.to_string()) }));
}
"VoiceChatClient.NotifyVoiceChatEnded#1" => {
let Ok(n) = VoiceChatEndedNotification::decode(body) else { return true };
tell("ended", json!({ "voiceChatId": n.voice_chatid.map(|v| v.to_string()), "groupId": n.chat_group_id.map(|g| g.to_string()), "roomId": n.chatid.map(|r| r.to_string()) }));
}
"VoiceChatClient.NotifyUserVoiceStatus#1" => {
let Ok(n) = UserVoiceStatusNotification::decode(body) else { return true };
tell("status", status_json(&n));
}
"VoiceChatClient.NotifyAllUsersVoiceStatus#1" => {
let Ok(n) = AllMembersStatusNotification::decode(body) else { return true };
tell("allStatus", json!({ "voiceChatId": n.voice_chatid.map(|v| v.to_string()), "users": n.users.iter().map(status_json).collect::<Vec<_>>() }));
}
"VoiceChatClient.NotifyOneOnOneChatRequested#1" => {
let Ok(n) = OneOnOneChatRequestedNotification::decode(body) else { return true };
tell("callRequested", json!({ "voiceChatId": n.voice_chatid.map(|v| v.to_string()), "steamid": n.steamid_partner.map(who) }));
}
"VoiceChatClient.NotifyOneOnOneChatResponse#1" => {
let Ok(n) = OneOnOneChatRequestResponseNotification::decode(body) else { return true };
tell("callResponse", json!({ "voiceChatId": n.voicechat_id.map(|v| v.to_string()), "steamid": n.steamid_partner.map(who), "accepted": n.accepted_request.unwrap_or(false) }));
}
"ChatRoomClient.NotifyShouldRejoinChatRoomVoiceChat#1" => {
let Ok(n) = RoomNotifyShouldRejoinChatRoomVoiceChatNotification::decode(body) else { return true };
tell("shouldRejoin", json!({ "groupId": n.chat_group_id.map(|g| g.to_string()), "roomId": n.chat_id.map(|r| r.to_string()) }));
}
_ => return false,
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit::simulated_daemon;
/// What the window is told when the server says something, as `(kind, data)` of the next event.
fn told(state: &AppState, method: &str, body: Vec<u8>) -> (bool, Option<Value>) {
let mut rx = state.events.subscribe();
let known = heard(state, "steam:voice-test", method, &body);
let event = loop {
match rx.try_recv() {
Ok(e) if e.name == "steamVoice" => break Some(e.data),
Ok(_) => continue,
Err(_) => break None,
}
};
(known, event)
}
#[tokio::test]
async fn what_the_server_says_of_the_session_reaches_the_window_in_the_window_s_words() {
let state = simulated_daemon("steam-voice-heard");
let connected = WebRTCSessionConnectedNotification { ssrc: Some(5), client_ip: Some(1), client_port: Some(2), server_ip: Some(3), server_port: Some(4) };
let (known, event) = told(&state, "WebRTCClientNotifications.NotifyWebRTCSessionConnected#1", connected.encode_to_vec());
assert!(known);
let event = event.unwrap();
assert_eq!((event["kind"].as_str(), event["data"]["ssrc"].as_u64(), event["data"]["serverPort"].as_u64()), (Some("sessionConnected"), Some(5), Some(4)));
let offer = WebRTCUpdateRemoteDescriptionNotification {
remote_description: Some("{}".into()),
remote_description_version: Some(u64::MAX - 1),
ssrcs_to_accountids: vec![WebRTCUpdateRemoteDescriptionNotificationSsrcAccount { ssrc: Some(9), accountid: Some(100) }],
};
let (_, event) = told(&state, "WebRTCClientNotifications.NotifyWebRTCUpdateRemoteDescription#1", offer.encode_to_vec());
let data = event.unwrap()["data"].clone();
assert_eq!(data["version"], (u64::MAX - 1).to_string(), "a 64-bit number crosses as text, since the window cannot hold it");
assert_eq!((data["ssrcs"][0]["ssrc"].as_u64(), data["ssrcs"][0]["steamid"].as_str()), (Some(9), Some("76561197960265828")));
let call = OneOnOneChatRequestedNotification { voice_chatid: Some(u64::MAX), steamid_partner: Some(steamid::steamid_of(100)) };
let (_, event) = told(&state, "VoiceChatClient.NotifyOneOnOneChatRequested#1", call.encode_to_vec());
let data = event.unwrap()["data"].clone();
assert_eq!((data["voiceChatId"].as_str(), data["steamid"].as_str()), (Some("18446744073709551615"), Some("76561197960265828")));
let (known, event) = told(&state, "VoiceChatClient.NotifyNothingInParticular#1", Vec::new());
assert!(!known && event.is_none(), "a method this does not know is not told");
}
#[test]
fn a_passing_failure_is_marked_for_trying_again_and_being_refused_for_good_is_said() {
use crate::backend::steam::link::CallError;
assert!(voice_refusal(&CallError::Refused(2, None), "ChatRoom.JoinVoiceChat#1").starts_with(AGAIN));
assert!(voice_refusal(&CallError::TimedOut, "WebRTCClient.InitiateWebRTCConnection#1").starts_with(AGAIN));
assert_eq!(voice_refusal(&CallError::Refused(15, None), "ChatRoom.JoinVoiceChat#1"), "you may not join voice in this room");
assert_eq!(voice_refusal(&CallError::Refused(15, None), "VoiceChat.RequestOneOnOneChat#1"), "this person is not taking calls from you");
assert_eq!(voice_refusal(&CallError::Refused(40, None), "VoiceChat.RequestOneOnOneChat#1"), "this person cannot be called");
assert_eq!(voice_refusal(&CallError::Refused(25, None), "ChatRoom.JoinVoiceChat#1"), "the voice chat is full");
assert_eq!(voice_refusal(&CallError::Refused(42, None), "ChatRoom.JoinVoiceChat#1"), "this room has no voice");
assert!(!voice_refusal(&CallError::Closed, "VoiceChat.EndOneOnOneChat#1").starts_with(AGAIN));
}
#[test]
fn numbers_cross_as_text_and_a_number_too_big_for_its_place_is_not_one() {
assert_eq!(id_of(&json!({ "a": "18446744073709551615" }), "a"), Some(u64::MAX));
assert_eq!(id_of(&json!({ "a": 5 }), "a"), Some(5));
assert_eq!(id_of(&json!({}), "a"), None);
assert_eq!(num(&json!({ "n": 4294967296u64 }), "n"), None, "a number too big for 32 bits is not one");
}
}
+373
View File
@@ -0,0 +1,373 @@
//! Steam voice: the messages of the VoiceChat and WebRTCClient services and the voice
//! calls of ChatRoom, written out by `golden/make_voice.py` from the Steam client's own
//! JavaScript. Do not edit by hand: change the generator and run it again.
#![allow(dead_code)]
use prost::Message;
#[derive(Clone, PartialEq, Message)]
pub struct RequestOneOnOneChatRequest {
#[prost(fixed64, optional, tag = "1")]
pub steamid_partner: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct RequestOneOnOneChatResponse {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct OneOnOneChatRequestedNotification {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub steamid_partner: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct AnswerOneOnOneChatRequest {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub steamid_partner: Option<u64>,
#[prost(bool, optional, tag = "3")]
pub accepted_request: Option<bool>,
}
#[derive(Clone, PartialEq, Message)]
pub struct AnswerOneOnOneChatResponse {
}
#[derive(Clone, PartialEq, Message)]
pub struct OneOnOneChatRequestResponseNotification {
#[prost(fixed64, optional, tag = "1")]
pub voicechat_id: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub steamid_partner: Option<u64>,
#[prost(bool, optional, tag = "3")]
pub accepted_request: Option<bool>,
}
#[derive(Clone, PartialEq, Message)]
pub struct EndOneOnOneChatRequest {
#[prost(fixed64, optional, tag = "1")]
pub steamid_partner: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct EndOneOnOneChatResponse {
}
#[derive(Clone, PartialEq, Message)]
pub struct LeaveOneOnOneChatRequest {
#[prost(fixed64, optional, tag = "1")]
pub steamid_partner: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub voice_chatid: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct LeaveOneOnOneChatResponse {
}
#[derive(Clone, PartialEq, Message)]
pub struct UserJoinedVoiceChatNotification {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub user_steamid: Option<u64>,
#[prost(uint64, optional, tag = "3")]
pub chatid: Option<u64>,
#[prost(fixed64, optional, tag = "4")]
pub one_on_one_steamid_lower: Option<u64>,
#[prost(fixed64, optional, tag = "5")]
pub one_on_one_steamid_higher: Option<u64>,
#[prost(uint64, optional, tag = "6")]
pub chat_group_id: Option<u64>,
#[prost(uint32, optional, tag = "7")]
pub user_sessionid: Option<u32>,
}
#[derive(Clone, PartialEq, Message)]
pub struct UserVoiceStatusNotification {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub user_steamid: Option<u64>,
#[prost(bool, optional, tag = "3")]
pub user_muted_mic_locally: Option<bool>,
#[prost(bool, optional, tag = "4")]
pub user_muted_output_locally: Option<bool>,
#[prost(bool, optional, tag = "5")]
pub user_has_no_mic_for_session: Option<bool>,
#[prost(int32, optional, tag = "6")]
pub user_webaudio_sample_rate: Option<i32>,
#[prost(bool, optional, tag = "7")]
pub user_sends_video: Option<bool>,
}
#[derive(Clone, PartialEq, Message)]
pub struct AllMembersStatusNotification {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(message, repeated, tag = "2")]
pub users: Vec<UserVoiceStatusNotification>,
}
#[derive(Clone, PartialEq, Message)]
pub struct UpdateVoiceChatWebRTCDataRequest {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(uint32, optional, tag = "2")]
pub ip_webrtc_server: Option<u32>,
#[prost(uint32, optional, tag = "3")]
pub port_webrtc_server: Option<u32>,
#[prost(uint32, optional, tag = "4")]
pub ip_webrtc_client: Option<u32>,
#[prost(uint32, optional, tag = "5")]
pub port_webrtc_client: Option<u32>,
#[prost(uint32, optional, tag = "6")]
pub ssrc_my_sending_stream: Option<u32>,
#[prost(string, optional, tag = "7")]
pub user_agent: Option<String>,
#[prost(bool, optional, tag = "8")]
pub has_audio_worklets_support: Option<bool>,
#[prost(bool, optional, tag = "9")]
pub has_webrtc_data_channel: Option<bool>,
#[prost(bool, optional, tag = "10")]
pub accepts_webrtc_video: Option<bool>,
#[prost(bool, optional, tag = "11")]
pub sends_webrtc_video: Option<bool>,
}
#[derive(Clone, PartialEq, Message)]
pub struct UpdateVoiceChatWebRTCDataResponse {
#[prost(bool, optional, tag = "1")]
pub send_client_voice_logs: Option<bool>,
}
#[derive(Clone, PartialEq, Message)]
pub struct UpdateUserVideoStatusRequest {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub user_steamid: Option<u64>,
#[prost(bool, optional, tag = "3")]
pub accept_webrtc_video: Option<bool>,
}
#[derive(Clone, PartialEq, Message)]
pub struct UpdateUserVideoStatusResponse {
}
#[derive(Clone, PartialEq, Message)]
pub struct UploadClientVoiceChatLogsRequest {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(string, optional, tag = "2")]
pub client_voice_logs_new_lines: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
pub struct UploadClientVoiceChatLogsResponse {
}
#[derive(Clone, PartialEq, Message)]
pub struct LeaveVoiceChatRequest {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct LeaveVoiceChatResponse {
}
#[derive(Clone, PartialEq, Message)]
pub struct UserLeftVoiceChatNotification {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub user_steamid: Option<u64>,
#[prost(uint64, optional, tag = "3")]
pub chatid: Option<u64>,
#[prost(fixed64, optional, tag = "4")]
pub one_on_one_steamid_lower: Option<u64>,
#[prost(fixed64, optional, tag = "5")]
pub one_on_one_steamid_higher: Option<u64>,
#[prost(uint64, optional, tag = "6")]
pub chat_group_id: Option<u64>,
#[prost(uint32, optional, tag = "7")]
pub user_sessionid: Option<u32>,
}
#[derive(Clone, PartialEq, Message)]
pub struct VoiceChatEndedNotification {
#[prost(fixed64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
#[prost(fixed64, optional, tag = "2")]
pub one_on_one_steamid_lower: Option<u64>,
#[prost(fixed64, optional, tag = "3")]
pub one_on_one_steamid_higher: Option<u64>,
#[prost(uint64, optional, tag = "4")]
pub chatid: Option<u64>,
#[prost(uint64, optional, tag = "5")]
pub chat_group_id: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct InitiateWebRTCConnectionRequest {
#[prost(string, optional, tag = "1")]
pub sdp: Option<String>,
#[prost(string, optional, tag = "2")]
pub browser_name: Option<String>,
#[prost(string, optional, tag = "3")]
pub browser_version: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
pub struct InitiateWebRTCConnectionResponse {
#[prost(string, optional, tag = "1")]
pub remote_description: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
pub struct UpdateWebRTCConnectionRequest {
#[prost(uint32, optional, tag = "1")]
pub ip_webrtc_server: Option<u32>,
#[prost(uint32, optional, tag = "2")]
pub port_webrtc_server: Option<u32>,
#[prost(uint32, optional, tag = "3")]
pub ip_webrtc_session_client: Option<u32>,
#[prost(uint32, optional, tag = "4")]
pub port_webrtc_session_client: Option<u32>,
#[prost(string, optional, tag = "5")]
pub sdp: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
pub struct UpdateWebRTCConnectionResponse {
#[prost(string, optional, tag = "1")]
pub remote_description: Option<String>,
}
#[derive(Clone, PartialEq, Message)]
pub struct WebRTCSessionConnectedNotification {
#[prost(uint32, optional, tag = "1")]
pub ssrc: Option<u32>,
#[prost(uint32, optional, tag = "2")]
pub client_ip: Option<u32>,
#[prost(uint32, optional, tag = "3")]
pub client_port: Option<u32>,
#[prost(uint32, optional, tag = "4")]
pub server_ip: Option<u32>,
#[prost(uint32, optional, tag = "5")]
pub server_port: Option<u32>,
}
#[derive(Clone, PartialEq, Message)]
pub struct WebRTCUpdateRemoteDescriptionNotification {
#[prost(string, optional, tag = "1")]
pub remote_description: Option<String>,
#[prost(uint64, optional, tag = "2")]
pub remote_description_version: Option<u64>,
#[prost(message, repeated, tag = "3")]
pub ssrcs_to_accountids: Vec<WebRTCUpdateRemoteDescriptionNotificationSsrcAccount>,
}
#[derive(Clone, PartialEq, Message)]
pub struct WebRTCUpdateRemoteDescriptionNotificationSsrcAccount {
#[prost(uint32, optional, tag = "1")]
pub ssrc: Option<u32>,
#[prost(uint32, optional, tag = "2")]
pub accountid: Option<u32>,
}
#[derive(Clone, PartialEq, Message)]
pub struct AcknowledgeUpdatedRemoteDescriptionRequest {
#[prost(uint32, optional, tag = "1")]
pub ip_webrtc_server: Option<u32>,
#[prost(uint32, optional, tag = "2")]
pub port_webrtc_server: Option<u32>,
#[prost(uint32, optional, tag = "3")]
pub ip_webrtc_session_client: Option<u32>,
#[prost(uint32, optional, tag = "4")]
pub port_webrtc_session_client: Option<u32>,
#[prost(uint64, optional, tag = "5")]
pub remote_description_version: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct AcknowledgeUpdatedRemoteDescriptionResponse {
}
#[derive(Clone, PartialEq, Message)]
pub struct RoomJoinVoiceChatRequest {
#[prost(uint64, optional, tag = "1")]
pub chat_group_id: Option<u64>,
#[prost(uint64, optional, tag = "2")]
pub chat_id: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct RoomJoinVoiceChatResponse {
#[prost(uint64, optional, tag = "1")]
pub voice_chatid: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct RoomLeaveVoiceChatRequest {
#[prost(uint64, optional, tag = "1")]
pub chat_group_id: Option<u64>,
#[prost(uint64, optional, tag = "2")]
pub chat_id: Option<u64>,
}
#[derive(Clone, PartialEq, Message)]
pub struct RoomLeaveVoiceChatResponse {
}
#[derive(Clone, PartialEq, Message)]
pub struct RoomNotifyShouldRejoinChatRoomVoiceChatNotification {
#[prost(uint64, optional, tag = "1")]
pub chat_id: Option<u64>,
#[prost(uint64, optional, tag = "2")]
pub chat_group_id: Option<u64>,
}
#[cfg(test)]
mod tests {
use super::*;
const GOLDEN: &str = include_str!("golden/voice_golden.txt");
fn unhex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
fn round_trips<M: Message + Default>(name: &str, bytes: &[u8]) {
let decoded = M::decode(bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(decoded.encode_to_vec(), bytes, "{name} does not encode to what the reference library wrote");
}
#[test]
fn every_voice_message_matches_the_reference_encoding() {
let mut seen = 0;
for line in GOLDEN.lines().filter(|l| !l.trim().is_empty()) {
let (name, hex) = line.split_once(' ').expect("name and bytes");
let bytes = unhex(hex);
macro_rules! cases {
($($t:ident),* $(,)?) => {
match name {
$( stringify!($t) => round_trips::<$t>(name, &bytes), )*
other => panic!("voice_golden.txt has {other}, which voicewire.rs does not"),
}
};
}
cases!(RequestOneOnOneChatRequest, RequestOneOnOneChatResponse, OneOnOneChatRequestedNotification, AnswerOneOnOneChatRequest, AnswerOneOnOneChatResponse, OneOnOneChatRequestResponseNotification, EndOneOnOneChatRequest, EndOneOnOneChatResponse, LeaveOneOnOneChatRequest, LeaveOneOnOneChatResponse, UserJoinedVoiceChatNotification, UserVoiceStatusNotification, AllMembersStatusNotification, UpdateVoiceChatWebRTCDataRequest, UpdateVoiceChatWebRTCDataResponse, UpdateUserVideoStatusRequest, UpdateUserVideoStatusResponse, UploadClientVoiceChatLogsRequest, UploadClientVoiceChatLogsResponse, LeaveVoiceChatRequest, LeaveVoiceChatResponse, UserLeftVoiceChatNotification, VoiceChatEndedNotification, InitiateWebRTCConnectionRequest, InitiateWebRTCConnectionResponse, UpdateWebRTCConnectionRequest, UpdateWebRTCConnectionResponse, WebRTCSessionConnectedNotification, WebRTCUpdateRemoteDescriptionNotification, WebRTCUpdateRemoteDescriptionNotificationSsrcAccount, AcknowledgeUpdatedRemoteDescriptionRequest, AcknowledgeUpdatedRemoteDescriptionResponse, RoomJoinVoiceChatRequest, RoomJoinVoiceChatResponse, RoomLeaveVoiceChatRequest, RoomLeaveVoiceChatResponse, RoomNotifyShouldRejoinChatRoomVoiceChatNotification);
seen += 1;
}
assert_eq!(seen, 37);
}
}
+503
View File
@@ -0,0 +1,503 @@
//! Files in a conversation: pictures and short videos.
//!
//! Steam's chat takes a file through its website, not the connection that
//! carries the messages. The official client does the same three steps, and so
//! does this:
//!
//! 1. Tell `steamcommunity.com/chat/beginfileupload` what is coming - size,
//! name, type, picture size. It answers with where to put it.
//! 2. `PUT` the file there.
//! 3. Tell `steamcommunity.com/chat/commitfileupload` it arrived, and to whom.
//! This is what sends the message: Steam writes it into the conversation
//! itself, and the other side - and our own other sessions, this one
//! included - hear it the way they hear any message. The spoiler flag is a
//! field here, so a picture is hidden by Steam and not by anything moho
//! wraps around it.
//!
//! The website knows an account by cookies, which a refresh token makes: it
//! can be exchanged, over the same connection, for a short-lived access token,
//! and the site accepts that as the login cookie.
//!
//! The types and the size are what the official client checks before it will
//! start, read out of the client: a file Steam will not take is refused here
//! with the same words, rather than after the upload.
use super::link::Link;
use super::proto::{GenerateAccessTokenRequest, GenerateAccessTokenResponse};
use crate::upload::{Phase, Progress};
use rand::Rng;
use serde::Deserialize;
use serde_json::Value;
const SITE: &str = "https://steamcommunity.com";
/// The most a file in chat may be, in bytes: the client's own 30 MB.
pub const MAX_BYTES: u64 = 30 * 1024 * 1024;
/// What the client accepts: its extensions, and the type it tells the site.
pub const TYPES: &[(&str, &str)] = &[
("jpg", "image/jpeg"),
("jpeg", "image/jpeg"),
("png", "image/png"),
("gif", "image/gif"),
("webp", "image/webp"),
("avif", "image/avif"),
("mp4", "video/mp4"),
("webm", "video/webm"),
("mpg", "video/mpeg"),
("mpeg", "video/mpeg"),
("ogv", "video/ogg"),
];
/// The file's type if Steam takes it, going by its extension as the client does.
pub fn type_of(file_name: &str) -> Option<&'static str> {
let extension = file_name.rsplit_once('.')?.1.to_ascii_lowercase();
TYPES.iter().find(|(e, _)| *e == extension).map(|(_, t)| *t)
}
/// Why this file will not go, or nothing if it will.
pub fn refusal(file_name: &str, size: u64) -> Option<String> {
if size == 0 {
return Some(format!("{file_name} is empty"));
}
if type_of(file_name).is_none() {
let extension = file_name.rsplit_once('.').map(|(_, e)| e).unwrap_or(file_name);
let known = TYPES.iter().map(|(e, _)| *e).collect::<Vec<_>>().join(", ");
return Some(format!("Steam does not take .{extension} files in chat - it takes {known}"));
}
if size > MAX_BYTES {
return Some(format!("{file_name} is over the {} MB Steam allows in chat", MAX_BYTES / 1024 / 1024));
}
None
}
/// The picture's size in pixels, or nothing for a file that is not one the
/// reader knows (a video) - which Steam is told as zero, as the client does.
fn dimensions(bytes: &[u8]) -> (usize, usize) {
imagesize::blob_size(bytes).map(|s| (s.width, s.height)).unwrap_or((0, 0))
}
/// What is shown of a failure: Steam's own words where it gave any.
#[derive(Deserialize, Default)]
struct Answer {
success: Option<i64>,
message: Option<String>,
hmac: Option<String>,
timestamp: Option<i64>,
result: Option<serde_json::Value>,
/// What an avatar upload is known by afterwards.
sha: Option<String>,
}
#[derive(Deserialize)]
struct Begun {
ugcid: serde_json::Value,
url_host: String,
url_path: String,
/// A number on the wire (`1`), where the client reads it as a truth value.
#[serde(default, deserialize_with = "truthy")]
use_https: bool,
#[serde(default)]
request_headers: Vec<Header>,
}
fn truthy<'de, D: serde::Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
Ok(match serde_json::Value::deserialize(d)? {
serde_json::Value::Bool(b) => b,
serde_json::Value::Number(n) => n.as_i64().unwrap_or(0) != 0,
serde_json::Value::String(s) => s == "1" || s.eq_ignore_ascii_case("true"),
_ => false,
})
}
#[derive(Deserialize)]
struct Header {
name: String,
value: String,
}
/// The cookies the site reads a login from, for `steamid`.
fn cookies(steamid: &str, access_token: &str, session_id: &str) -> String {
format!("sessionid={session_id}; steamLoginSecure={steamid}%7C%7C{access_token}")
}
/// A short-lived token for the website, made from the refresh token.
pub async fn access_token(link: &Link, steamid: u64, refresh_token: &str) -> Result<String, String> {
let request = GenerateAccessTokenRequest { refresh_token: Some(refresh_token.to_string()), steamid: Some(steamid), renewal_type: Some(0) };
let answer: GenerateAccessTokenResponse = link
.call("Authentication.GenerateAccessTokenForApp#1", &request, true)
.await
.map_err(|e| format!("Steam would not give this account a web session: {e}"))?;
answer.access_token.filter(|t| !t.is_empty()).ok_or_else(|| "Steam gave no web session for this account".to_string())
}
/// The website as this account: a client, the cookie it signs in with, and the
/// session id the forms repeat.
pub(super) async fn login(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<(reqwest::Client, String, String), String> {
let router = crate::net::route::router();
let token = access_token(link, steamid, refresh_token).await?;
let session_id = {
let mut rng = rand::thread_rng();
(0..24).map(|_| format!("{:x}", rng.gen_range(0..16))).collect::<String>()
};
let cookie = cookies(&steamid.to_string(), &token, &session_id);
let http = router.client_if("steam", router.routed(key), |b| {
b.timeout(std::time::Duration::from_secs(600)).user_agent(concat!("moho/", env!("CARGO_PKG_VERSION")))
});
Ok((http, cookie, session_id))
}
/// Asks Steam whether this account may upload a picture, without sending
/// anything: the first step is a question, and nothing exists until the file is
/// put where the answer says. For the live check.
#[cfg(test)]
pub async fn may_upload(link: &Link, key: &str, steamid: u64, refresh_token: &str) -> Result<String, String> {
let (http, cookie, session_id) = login(link, key, steamid, refresh_token).await?;
let form = reqwest::multipart::Form::new()
.text("sessionid", session_id)
.text("l", "english")
.text("file_size", "1000")
.text("file_name", format!("{}_probe_check.png", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or_default()))
.text("file_sha", (0..40).map(|_| format!("{:x}", rand::thread_rng().gen_range(0..16))).collect::<String>())
.text("file_image_width", "10")
.text("file_image_height", "10")
.text("file_type", "image/png");
let answer = site(&http, &cookie, &format!("{SITE}/chat/beginfileupload/?l=english"), form).await?;
let shape = answer.result.as_ref().map(|r| match r {
serde_json::Value::Object(o) => o.iter().map(|(k, v)| format!("{k}:{}", match v { serde_json::Value::String(_) => "string", serde_json::Value::Number(_) => "number", serde_json::Value::Bool(_) => "bool", serde_json::Value::Array(_) => "array", serde_json::Value::Object(_) => "object", serde_json::Value::Null => "null" })).collect::<Vec<_>>().join(", "),
_ => "not an object".to_string(),
});
Ok(format!("answer keys: {shape:?}; top-level hmac {} timestamp {}", answer.hmac.is_some(), answer.timestamp.is_some()))
}
/// Sends one file to a friend, hidden behind a spoiler if asked. Returns
/// where Steam keeps it.
///
/// `key` is the account's id, which decides whether the traffic is routed.
#[allow(clippy::too_many_arguments)]
/// Who a file is for: a friend, or a room of a chat group. What the commit names is all that differs.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FileTarget<'a> {
Friend(&'a str),
Room { group: u64, room: u64 },
}
#[allow(clippy::too_many_arguments)]
pub async fn send_file(
link: &Link,
key: &str,
steamid: u64,
refresh_token: &str,
target: FileTarget<'_>,
path: &str,
spoiler: bool,
progress: Option<&Progress>,
) -> Result<String, String> {
let file_name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("file").to_string();
let size = tokio::fs::metadata(path).await.map_err(|e| format!("reading {file_name}: {e}"))?.len();
if let Some(why) = refusal(&file_name, size) {
return Err(why);
}
let mime = type_of(&file_name).unwrap_or("application/octet-stream");
if let Some(p) = progress {
p.at(Phase::Preparing, size as usize, "steam");
}
let router = crate::net::route::router();
if router.routed(key) {
router.ready_for_general().await.map_err(|e| format!("reaching Steam by the route chosen: {e:#}"))?;
}
let bytes = tokio::fs::read(path).await.map_err(|e| format!("reading {file_name}: {e}"))?;
let (width, height) = dimensions(&bytes);
// Random, as the official client sends it. Not the file's own hash, for
// all that the field is called one: Steam refuses a second upload that
// names a hash it has already been given, so the same picture sent twice
// would be turned away the second time.
let sha = (0..40).map(|_| format!("{:x}", rand::thread_rng().gen_range(0..16))).collect::<String>();
let upload_name = format!("{}_{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or_default(), file_name);
let (http, cookie, session_id) = login(link, key, steamid, refresh_token).await?;
let begin = reqwest::multipart::Form::new()
.text("sessionid", session_id.clone())
.text("l", "english")
.text("file_size", size.to_string())
.text("file_name", upload_name.clone())
.text("file_sha", sha.clone())
.text("file_image_width", width.to_string())
.text("file_image_height", height.to_string())
.text("file_type", mime.to_string());
let answer = site(&http, &cookie, &format!("{SITE}/chat/beginfileupload/?l=english"), begin).await?;
let (hmac, timestamp) = (answer.hmac.clone().unwrap_or_default(), answer.timestamp.unwrap_or_default());
let begun: Begun = serde_json::from_value(answer.result.clone().unwrap_or_default()).ok()
.filter(|b: &Begun| !b.url_host.is_empty())
.ok_or_else(|| "Steam would not take the file just now - it did not say where to put it".to_string())?;
if let Some(p) = progress {
p.at(Phase::Sending, bytes.len(), "steam");
}
let url = format!("{}://{}{}", if begun.use_https { "https" } else { "http" }, begun.url_host, begun.url_path);
let mut put = http.put(&url).body(bytes);
for h in begun.request_headers.iter().filter(|h| !h.name.eq_ignore_ascii_case("content-length") && !h.name.eq_ignore_ascii_case("host")) {
put = put.header(h.name.as_str(), h.value.as_str());
}
let stored = put.send().await.map_err(|e| format!("sending {file_name} to Steam: {e}"))?;
if !stored.status().is_success() {
return Err(format!("Steam's file server refused {file_name} ({})", stored.status()));
}
if let Some(p) = progress {
p.at(Phase::Waiting, size as usize, "steam");
}
let ugcid = match &begun.ugcid {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
let mut commit = reqwest::multipart::Form::new()
.text("sessionid", session_id)
.text("l", "english")
.text("file_name", upload_name)
.text("file_sha", sha)
.text("success", "1")
.text("ugcid", ugcid)
.text("file_type", mime.to_string())
.text("file_image_width", width.to_string())
.text("file_image_height", height.to_string())
.text("timestamp", timestamp.to_string())
.text("hmac", hmac)
.text("spoiler", if spoiler { "1" } else { "0" });
// A friend is named by their id, a room by its group and its own number: as the client's own
// two kinds of conversation name theirs.
commit = match target {
FileTarget::Friend(friend) => commit.text("friend_steamid", friend.to_string()),
FileTarget::Room { group, room } => commit.text("chat_group_id", group.to_string()).text("chat_id", room.to_string()),
};
let done = site(&http, &cookie, &format!("{SITE}/chat/commitfileupload/"), commit).await?;
let result = done.result.unwrap_or_default();
// Valve's own nesting: the call succeeded, and inside it there is a second
// verdict on whether the file did.
if result.get("success").and_then(|v| v.as_i64()).is_some_and(|v| v != 1) {
return Err(done.message.unwrap_or_else(|| "Steam did not send the file".to_string()));
}
Ok(result.pointer("/details/url").and_then(|v| v.as_str()).unwrap_or_default().to_string())
}
/// What a group's picture may be, as the client's own picker says.
pub const AVATAR_TYPES: &[&str] = &["jpg", "jpeg", "png", "gif"];
/// Sends a picture for a chat group to the site, which keeps it and answers with what it is
/// known by; the group is then told to use that (`SetChatRoomGroupAvatar`).
pub async fn upload_avatar(link: &Link, key: &str, steamid: u64, refresh_token: &str, path: &str) -> Result<Vec<u8>, String> {
let file_name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("avatar").to_string();
let extension = file_name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
let mime = match extension.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"gif" => "image/gif",
_ => return Err(format!("a group's picture is one of {}", AVATAR_TYPES.join(", "))),
};
let bytes = tokio::fs::read(path).await.map_err(|e| format!("reading {file_name}: {e}"))?;
if bytes.is_empty() {
return Err(format!("{file_name} is empty"));
}
let (http, cookie, session_id) = login(link, key, steamid, refresh_token).await?;
let part = reqwest::multipart::Part::bytes(bytes).file_name(file_name).mime_str(mime).map_err(|e| e.to_string())?;
let form = reqwest::multipart::Form::new().text("sessionid", session_id).part("avatar", part);
let answer = site_coded(&http, &cookie, &format!("{SITE}/chat/avatarfileupload/"), form).await.map_err(|(code, said)| avatar_refusal(code, said))?;
let sha = answer.sha.filter(|s| s.len() == 40).ok_or("Steam took the picture and did not say what it is called")?;
(0..sha.len()).step_by(2).map(|i| u8::from_str_radix(&sha[i..i + 2], 16).map_err(|_| "Steam named the picture in a way that is not understood".to_string())).collect()
}
/// An answer's text. The site compresses what it likes whatever it is asked,
/// and a refusal's reason is in the body, so it is unpacked here.
pub(super) fn plain(raw: &[u8]) -> String {
use std::io::Read;
if raw.starts_with(&[0x1f, 0x8b]) {
let mut out = String::new();
if flate2::read::GzDecoder::new(raw).read_to_string(&mut out).is_ok() {
return out;
}
}
String::from_utf8_lossy(raw).into_owned()
}
/// The names somebody has had, newest first, each with when Steam says they
/// took it - in Steam's own words for the moment, which are not a date a
/// program can rely on ("12 Oct, 2025 @ 6:03pm") and are shown as given.
pub async fn aliases(link: &Link, key: &str, steamid: u64, refresh_token: &str, friend: u64) -> Result<Vec<(String, String)>, String> {
let (http, cookie, _) = login(link, key, steamid, refresh_token).await?;
let response = http
.get(format!("{SITE}/profiles/{friend}/ajaxaliases?l=english"))
.header("Cookie", cookie)
.header("Referer", format!("{SITE}/profiles/{friend}"))
.send()
.await
.map_err(|e| format!("could not reach Steam's website: {e}"))?;
let status = response.status();
let raw = response.bytes().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(explain(status, &serde_json::from_str(&plain(&raw)).unwrap_or_default()));
}
Ok(read_aliases(&plain(&raw)))
}
/// Reads the answer: a list of `{newname, timechanged}`.
pub fn read_aliases(text: &str) -> Vec<(String, String)> {
let Ok(Value::Array(list)) = serde_json::from_str::<Value>(text) else { return Vec::new() };
list.iter()
.filter_map(|entry| {
let name = entry["newname"].as_str()?.trim();
let when = entry["timechanged"].as_str().unwrap_or_default().trim();
(!name.is_empty()).then(|| (name.chars().take(64).collect(), when.chars().take(40).collect()))
})
.collect()
}
/// One call to the site, read as the client reads it: the body is JSON even
/// when the status is an error, and its `message` is what to show.
async fn site(http: &reqwest::Client, cookie: &str, url: &str, form: reqwest::multipart::Form) -> Result<Answer, String> {
site_coded(http, cookie, url, form).await.map_err(|(_, why)| why)
}
/// The same, with the result code the site gave, for a caller that has words of its own for some.
async fn site_coded(http: &reqwest::Client, cookie: &str, url: &str, form: reqwest::multipart::Form) -> Result<Answer, (Option<i64>, String)> {
let response = http
.post(url)
.header("Cookie", cookie)
.header("Referer", format!("{SITE}/chat/"))
.multipart(form)
.send()
.await
.map_err(|e| (None, format!("could not reach Steam's website: {e}")))?;
let status = response.status();
let raw = response.bytes().await.unwrap_or_default();
let text = plain(&raw);
let answer: Answer = serde_json::from_str(&text).unwrap_or_default();
// Never the body: it carries the signed address the file is sent to.
tracing::debug!("steam web: {} -> {status}, success {:?}", url.split('?').next().unwrap_or(url), answer.success);
if !status.is_success() || answer.success.is_some_and(|s| s != 1) {
let why = answer.message.clone().filter(|m| !m.is_empty()).unwrap_or_else(|| match (status.as_u16(), answer.success) {
// The site answers in Steam's own result codes, which say why far better than the status.
(_, Some(code)) if code > 1 => format!("Steam refused it: {}", super::eresult::describe(code as i32)),
(401 | 403, _) => "Steam's website did not accept this sign-in".to_string(),
_ => format!("Steam's website refused it ({status})"),
});
return Err((answer.success, why));
}
Ok(answer)
}
/// Why the site refused something, from its status and what it said: its own result code in the
/// words of the general table when it gave one, and otherwise what the status means.
pub(super) fn explain(status: reqwest::StatusCode, answer: &serde_json::Value) -> String {
if let Some(message) = answer["message"].as_str().filter(|m| !m.is_empty()) {
return message.to_string();
}
match (status.as_u16(), answer["success"].as_i64()) {
(_, Some(code)) if code > 1 => format!("Steam refused it: {}", super::eresult::describe(code as i32)),
(401 | 403, _) => "Steam's website did not accept this sign-in; try again in a moment".to_string(),
(429, _) => "Steam's website is limiting how often this can be done; wait a while".to_string(),
(500..=599, _) => "Steam's website is having trouble just now; try again in a moment".to_string(),
_ => format!("Steam's website refused it ({status})"),
}
}
/// Why the site refused a group's picture, in the words the client has for each: not signed in, a
/// limited account, not allowed, failed to upload, too large, not a usable picture, a server error.
fn avatar_refusal(code: Option<i64>, said: String) -> String {
match code {
Some(21) => "Steam's website says this account is not signed in; try again in a moment".to_string(),
Some(112) => "a limited account cannot change a group's picture; Steam asks for a purchase first".to_string(),
Some(15) => "you may not change this group's picture".to_string(),
Some(2) => "Steam could not take the picture; try again".to_string(),
Some(25) => "that picture is too large for Steam".to_string(),
Some(8) => "Steam cannot use that as a picture".to_string(),
Some(37) => "Steam had a problem on its side taking the picture; try again in a moment".to_string(),
_ => said,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn what_the_site_refuses_is_said_by_its_own_code_and_then_by_its_status() {
let said = |status: u16, body: serde_json::Value| explain(reqwest::StatusCode::from_u16(status).unwrap(), &body);
assert_eq!(said(400, serde_json::json!({ "message": "No." })), "No.");
assert!(said(401, serde_json::json!({ "success": 112 })).contains("limited account"));
assert!(said(401, serde_json::json!({})).contains("sign-in"));
assert!(said(429, serde_json::json!({})).contains("limiting"));
assert!(said(503, serde_json::json!({})).contains("trouble"));
assert!(said(418, serde_json::json!({})).contains("418"));
}
#[test]
fn a_refused_picture_is_told_in_the_words_the_client_has_for_each_reason() {
assert!(avatar_refusal(Some(112), String::new()).contains("limited account"));
assert!(avatar_refusal(Some(25), String::new()).contains("too large"));
assert!(avatar_refusal(Some(8), String::new()).contains("cannot use"));
assert!(avatar_refusal(Some(15), String::new()).contains("may not"));
assert_eq!(avatar_refusal(Some(99), "Steam said this".into()), "Steam said this", "what Steam says of the rest is kept");
}
#[test]
fn the_types_are_the_ones_the_client_takes() {
for name in ["a.jpg", "a.JPEG", "a.png", "a.gif", "a.webm", "a.mpg", "a.mp4", "a.mpeg", "a.ogv", "a.webp", "a.avif"] {
assert!(type_of(name).is_some(), "{name}");
}
assert_eq!(type_of("clip.MP4"), Some("video/mp4"));
assert_eq!(type_of("photo.jpg"), Some("image/jpeg"));
assert_eq!(type_of("paper.pdf"), None);
assert_eq!(type_of("noextension"), None);
}
#[test]
fn a_file_steam_will_not_take_is_refused_in_its_own_terms() {
assert!(refusal("a.png", 100).is_none());
assert!(refusal("a.png", MAX_BYTES).is_none());
assert!(refusal("a.png", MAX_BYTES + 1).unwrap().contains("30 MB"));
assert!(refusal("a.pdf", 10).unwrap().contains(".pdf"));
assert!(refusal("a.png", 0).unwrap().contains("empty"));
}
#[test]
fn steams_answer_to_the_first_step_is_read_as_it_is_sent() {
// As Steam sends it: the id a string, and "use https" a number.
let said = serde_json::json!({
"storage_system": 1, "ugcid": "13438947538884666122", "timestamp": 1791849742,
"url_host": "files.example.test", "url_path": "/a/b", "use_https": 1,
"request_headers": [{ "name": "x-goog-meta", "value": "v" }], "success": 1
});
let begun: Begun = serde_json::from_value(said).expect("what Steam sends");
assert!(begun.use_https);
assert_eq!((begun.url_host.as_str(), begun.url_path.as_str(), begun.request_headers.len()), ("files.example.test", "/a/b", 1));
let no: Result<Begun, _> = serde_json::from_value(serde_json::json!({ "url_host": null, "url_path": null, "ugcid": null, "request_headers": [], "success": 10 }));
assert!(no.is_err(), "a refusal has no place to put anything");
let plain: Begun = serde_json::from_value(serde_json::json!({ "ugcid": 5, "url_host": "h", "url_path": "/p", "use_https": false })).unwrap();
assert!(!plain.use_https);
}
#[test]
fn a_name_history_is_read_as_names_and_the_moments_they_were_taken() {
let said = r#"[{"newname":"Gabe","timechanged":"12 Oct, 2025 @ 6:03pm"},{"newname":" ","timechanged":"x"},{"newname":"Old Name","timechanged":"3 Mar, 2019 @ 1:00am"},{"newname":"No Time"}]"#;
assert_eq!(
read_aliases(said),
vec![("Gabe".to_string(), "12 Oct, 2025 @ 6:03pm".to_string()), ("Old Name".to_string(), "3 Mar, 2019 @ 1:00am".to_string()), ("No Time".to_string(), String::new())]
);
assert!(read_aliases("not json").is_empty());
assert!(read_aliases("{}").is_empty());
}
#[test]
fn the_login_is_two_cookies() {
assert_eq!(cookies("7656", "tok", "abc"), "sessionid=abc; steamLoginSecure=7656%7C%7Ctok");
}
#[test]
fn a_picture_has_a_size_and_anything_else_has_none() {
let png = image::RgbaImage::new(3, 2);
let mut bytes = Vec::new();
png.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
assert_eq!(dimensions(&bytes), (3, 2));
assert_eq!(dimensions(b"not a picture"), (0, 0));
}
}
+350
View File
@@ -0,0 +1,350 @@
//! How a packet is laid out on Steam's WebSocket, and nothing about what is in
//! one.
//!
//! One WebSocket binary message is one packet:
//!
//! ```text
//! u32 LE message type, with the top bit set when the body is protobuf
//! u32 LE length of the header
//! header a `Header` protobuf (jobs, the account, the session, a result)
//! body the message's own protobuf
//! ```
//!
//! A handful of older messages use a fixed binary header instead of a
//! protobuf one; they are read so that one arriving does not end the
//! connection, but nothing here sends one.
//!
//! One kind of packet holds others: a `Multi`, whose body is a run of
//! `u32 LE length, packet`, gzip-compressed when it says how long it is once
//! unzipped. A server batches what it has to say after a sign-in this way, so
//! the whole friends list and every name in it commonly arrive inside one.
use super::proto::{Header, Multi};
use anyhow::{anyhow, bail, Context, Result};
use prost::Message;
use std::io::Read;
/// Set on the message type of a packet whose body (and header) is protobuf.
pub const PROTO_MASK: u32 = 0x8000_0000;
/// No job: what a packet that neither starts nor answers a request carries.
pub const NO_JOB: u64 = u64::MAX;
/// The message types this client sends or looks for. Numbers from the
/// official client's own table (`enums_clientserver.proto`).
pub mod emsg {
pub const MULTI: u32 = 1;
#[cfg(test)]
pub const SERVICE_METHOD_RESPONSE: u32 = 147;
/// What the server sends the client unprompted: a notification such as
/// `FriendMessagesClient.IncomingMessage#1`. 152 is the same shape and is
/// accepted wherever this is, since both are seen.
pub const SERVICE_METHOD: u32 = 146;
pub const SERVICE_METHOD_CALL_FROM_CLIENT: u32 = 151;
pub const SERVICE_METHOD_SEND_TO_CLIENT: u32 = 152;
pub const CLIENT_HEART_BEAT: u32 = 703;
pub const CLIENT_LOG_OFF: u32 = 706;
pub const CLIENT_REMOVE_FRIEND: u32 = 714;
pub const CLIENT_CHANGE_STATUS: u32 = 716;
pub const CLIENT_LOG_ON_RESPONSE: u32 = 751;
pub const CLIENT_LOGGED_OFF: u32 = 757;
pub const CLIENT_PERSONA_STATE: u32 = 766;
pub const CLIENT_FRIENDS_LIST: u32 = 767;
pub const CLIENT_ACCOUNT_INFO: u32 = 768;
pub const CLIENT_FRIEND_PROFILE_INFO: u32 = 5535;
pub const CLIENT_FS_GET_FRIENDS_STEAM_LEVELS: u32 = 7528;
pub const CLIENT_GET_EMOTICON_LIST: u32 = 9330;
pub const CLIENT_EMOTICON_LIST: u32 = 9331;
pub const CLIENT_ADD_FRIEND: u32 = 791;
/// Only the tests build one: the connection matches it to its question by job id, not by kind.
#[cfg(test)]
pub const CLIENT_ADD_FRIEND_RESPONSE: u32 = 792;
pub const CLIENT_REQUEST_FRIEND_DATA: u32 = 815;
pub const CLIENT_LOGON: u32 = 5514;
pub const CLIENT_FRIENDS_GROUPS_LIST: u32 = 5553;
pub const CLIENT_CREATE_FRIENDS_GROUP: u32 = 5560;
pub const CLIENT_DELETE_FRIENDS_GROUP: u32 = 5562;
pub const CLIENT_MANAGE_FRIENDS_GROUP: u32 = 5564;
pub const CLIENT_ADD_FRIEND_TO_GROUP: u32 = 5566;
pub const CLIENT_REMOVE_FRIEND_FROM_GROUP: u32 = 5568;
pub const SERVICE_METHOD_CALL_FROM_CLIENT_NON_AUTHED: u32 = 9804;
pub const CLIENT_HELLO: u32 = 9805;
}
/// The most a compressed batch may grow to. A real one is a few hundred
/// kilobytes; this is a stop against a body that claims to be small and is not.
const MAX_UNZIPPED: usize = 64 * 1024 * 1024;
/// One packet, with its header read and its body left as bytes.
#[derive(Clone, Debug, PartialEq)]
pub struct Packet {
/// The message type, without the protobuf bit.
pub emsg: u32,
/// Whether the body is protobuf; false for the old fixed-header packets.
pub protobuf: bool,
pub header: Header,
pub body: Vec<u8>,
}
impl Packet {
pub fn new(emsg: u32, header: Header, body: Vec<u8>) -> Self {
Self { emsg, protobuf: true, header, body }
}
/// The request this answers, or `NO_JOB`.
pub fn job_target(&self) -> u64 {
self.header.jobid_target.unwrap_or(NO_JOB)
}
/// How it went. Absent reads as the schema's default, a failure: success
/// is always written down.
pub fn eresult(&self) -> i32 {
self.header.eresult.unwrap_or(super::eresult::FAIL)
}
/// The bytes to put on the wire.
pub fn encode(&self) -> Vec<u8> {
debug_assert!(self.protobuf, "only protobuf packets are written");
let header = self.header.encode_to_vec();
let mut out = Vec::with_capacity(8 + header.len() + self.body.len());
out.extend_from_slice(&(self.emsg | PROTO_MASK).to_le_bytes());
out.extend_from_slice(&(header.len() as u32).to_le_bytes());
out.extend_from_slice(&header);
out.extend_from_slice(&self.body);
out
}
/// Reads one packet from a WebSocket message.
pub fn decode(bytes: &[u8]) -> Result<Packet> {
let kind = u32_at(bytes, 0).context("a packet shorter than its message type")?;
let protobuf = kind & PROTO_MASK != 0;
let emsg = kind & !PROTO_MASK;
if protobuf {
let header_len = u32_at(bytes, 4).context("a packet with no header length")? as usize;
let end = 8usize.checked_add(header_len).filter(|end| *end <= bytes.len()).ok_or_else(|| anyhow!("a header longer than its packet"))?;
let header = Header::decode(&bytes[8..end]).context("reading a packet header")?;
return Ok(Packet { emsg, protobuf, header, body: bytes[end..].to_vec() });
}
// The old layout: a one-byte header size, a two-byte version, the two
// job ids, a canary, the account and the session - 32 bytes in all
// after the type. It is read for the jobs and the session, because
// that is what the rest of this needs from it.
const LEGACY: usize = 4 + 32;
if bytes.len() < LEGACY {
bail!("an old-style packet of {} bytes", bytes.len());
}
let target = u64_at(bytes, 7).context("a legacy job id")?;
let source = u64_at(bytes, 15).context("a legacy job id")?;
let steamid = u64_at(bytes, 24).context("a legacy steam id")?;
let session = i32::from_le_bytes(bytes[32..36].try_into().expect("four bytes"));
let header = Header {
steamid: Some(steamid),
client_sessionid: Some(session),
jobid_source: Some(source),
jobid_target: Some(target),
..Header::default()
};
Ok(Packet { emsg, protobuf, header, body: bytes[LEGACY..].to_vec() })
}
}
fn u32_at(bytes: &[u8], at: usize) -> Option<u32> {
Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
}
fn u64_at(bytes: &[u8], at: usize) -> Option<u64> {
Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
}
/// The packets inside a `Multi`.
///
/// Nested batches are unpacked too, in order, so a caller sees a flat run.
pub fn unpack_multi(body: &[u8]) -> Result<Vec<Packet>> {
let multi = Multi::decode(body).context("reading a batch of packets")?;
let inner = multi.message_body.unwrap_or_default();
let bytes = match multi.size_unzipped.filter(|n| *n > 0) {
None => inner,
Some(expected) => {
let expected = expected as usize;
if expected > MAX_UNZIPPED {
bail!("a batch that claims to unzip to {expected} bytes");
}
let mut out = Vec::with_capacity(expected);
// Reading one byte past the claim is how an overlong stream is
// told from an exact one without trusting the claim.
flate2::read::GzDecoder::new(inner.as_slice())
.take(expected as u64 + 1)
.read_to_end(&mut out)
.context("unzipping a batch of packets")?;
if out.len() != expected {
bail!("a batch that said {expected} bytes and unzipped to {}", out.len());
}
out
}
};
let mut packets = Vec::new();
let mut at = 0;
while at < bytes.len() {
let len = u32_at(&bytes, at).context("a batch cut off in a length")? as usize;
at += 4;
let end = at.checked_add(len).filter(|end| *end <= bytes.len()).ok_or_else(|| anyhow!("a packet longer than its batch"))?;
let packet = Packet::decode(&bytes[at..end])?;
if packet.emsg == emsg::MULTI {
packets.extend(unpack_multi(&packet.body)?);
} else {
packets.push(packet);
}
at = end;
}
Ok(packets)
}
/// Packs packets into a gzip-compressed `Multi` body, as a server would. Only
/// the test server and the tests have a use for it.
#[cfg(test)]
pub fn pack_multi(packets: &[Packet], compress: bool) -> Vec<u8> {
use std::io::Write;
let mut run = Vec::new();
for p in packets {
let encoded = p.encode();
run.extend_from_slice(&(encoded.len() as u32).to_le_bytes());
run.extend_from_slice(&encoded);
}
let multi = if compress {
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gz.write_all(&run).unwrap();
Multi { size_unzipped: Some(run.len() as u32), message_body: Some(gz.finish().unwrap()) }
} else {
Multi { size_unzipped: None, message_body: Some(run) }
};
multi.encode_to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
fn packet(emsg: u32, body: &[u8]) -> Packet {
Packet::new(
emsg,
Header { steamid: Some(76561197960287930), client_sessionid: Some(7), jobid_source: Some(42), target_job_name: Some("A.B#1".into()), ..Header::default() },
body.to_vec(),
)
}
#[test]
fn a_packet_is_its_type_a_header_length_a_header_and_a_body() {
let p = packet(emsg::CLIENT_HEART_BEAT, &[0x08, 0x01]);
let bytes = p.encode();
// The protobuf bit rides on the type.
assert_eq!(&bytes[0..4], &(703u32 | PROTO_MASK).to_le_bytes());
let header_len = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
assert_eq!(bytes.len(), 8 + header_len + 2);
assert_eq!(&bytes[bytes.len() - 2..], &[0x08, 0x01]);
assert_eq!(Packet::decode(&bytes).unwrap(), p);
}
#[test]
fn an_answer_names_the_request_it_answers() {
let mut p = packet(emsg::SERVICE_METHOD_RESPONSE, b"");
assert_eq!(p.job_target(), NO_JOB);
p.header.jobid_target = Some(9);
assert_eq!(Packet::decode(&p.encode()).unwrap().job_target(), 9);
}
#[test]
fn a_result_nobody_wrote_is_a_failure_and_a_written_one_is_believed() {
let mut p = packet(emsg::SERVICE_METHOD_RESPONSE, b"");
assert_eq!(p.eresult(), 2);
p.header.eresult = Some(1);
assert_eq!(Packet::decode(&p.encode()).unwrap().eresult(), 1);
}
#[test]
fn packets_that_are_cut_short_are_refused_rather_than_guessed_at() {
assert!(Packet::decode(&[]).is_err());
assert!(Packet::decode(&[1, 2, 3]).is_err());
// A protobuf packet whose header claims more than there is.
let mut bytes = (703u32 | PROTO_MASK).to_le_bytes().to_vec();
bytes.extend_from_slice(&1000u32.to_le_bytes());
bytes.extend_from_slice(&[0; 10]);
assert!(Packet::decode(&bytes).is_err());
// One that claims the largest header there is must not overflow.
let mut bytes = (703u32 | PROTO_MASK).to_le_bytes().to_vec();
bytes.extend_from_slice(&u32::MAX.to_le_bytes());
assert!(Packet::decode(&bytes).is_err());
// And a header that is not a header.
let mut bytes = (703u32 | PROTO_MASK).to_le_bytes().to_vec();
bytes.extend_from_slice(&3u32.to_le_bytes());
bytes.extend_from_slice(&[0xff, 0xff, 0xff]);
assert!(Packet::decode(&bytes).is_err());
}
#[test]
fn an_old_style_packet_is_read_for_its_jobs_and_session() {
let mut bytes = 5500u32.to_le_bytes().to_vec(); // no protobuf bit
bytes.push(36); // header size
bytes.extend_from_slice(&2u16.to_le_bytes()); // version
bytes.extend_from_slice(&11u64.to_le_bytes()); // target job
bytes.extend_from_slice(&22u64.to_le_bytes()); // source job
bytes.push(239); // canary
bytes.extend_from_slice(&76561197960287930u64.to_le_bytes());
bytes.extend_from_slice(&5i32.to_le_bytes());
bytes.extend_from_slice(b"body");
let p = Packet::decode(&bytes).unwrap();
assert!(!p.protobuf);
assert_eq!(p.emsg, 5500);
assert_eq!(p.job_target(), 11);
assert_eq!(p.header.jobid_source, Some(22));
assert_eq!(p.header.steamid, Some(76561197960287930));
assert_eq!(p.header.client_sessionid, Some(5));
assert_eq!(p.body, b"body");
assert!(Packet::decode(&bytes[..20]).is_err());
}
#[test]
fn a_batch_unpacks_in_order_compressed_or_not() {
let inside = [packet(emsg::CLIENT_FRIENDS_LIST, b"one"), packet(emsg::CLIENT_PERSONA_STATE, b"two"), packet(emsg::CLIENT_ACCOUNT_INFO, b"three")];
for compress in [true, false] {
let got = unpack_multi(&pack_multi(&inside, compress)).unwrap();
assert_eq!(got, inside, "compressed: {compress}");
}
// Nothing in it is nothing out. (A zero length means "not compressed",
// as it does to every other client, so an empty batch is sent plain.)
assert!(unpack_multi(&pack_multi(&[], false)).unwrap().is_empty());
}
#[test]
fn a_batch_inside_a_batch_comes_out_flat() {
let inner = packet(emsg::CLIENT_PERSONA_STATE, b"deep");
let wrapper = packet(emsg::MULTI, &pack_multi(std::slice::from_ref(&inner), true));
let got = unpack_multi(&pack_multi(&[packet(emsg::CLIENT_FRIENDS_LIST, b"first"), wrapper], false)).unwrap();
assert_eq!(got.iter().map(|p| p.body.clone()).collect::<Vec<_>>(), vec![b"first".to_vec(), b"deep".to_vec()]);
}
#[test]
fn a_batch_that_lies_about_itself_is_refused() {
let good = pack_multi(&[packet(emsg::CLIENT_FRIENDS_LIST, b"x")], true);
// Says it unzips to a different length than it does.
let mut multi = Multi::decode(good.as_slice()).unwrap();
multi.size_unzipped = Some(multi.size_unzipped.unwrap() + 5);
assert!(unpack_multi(&multi.encode_to_vec()).is_err());
multi.size_unzipped = Some(3);
assert!(unpack_multi(&multi.encode_to_vec()).is_err(), "unzips to more than it said");
// Says it is huge.
multi.size_unzipped = Some(u32::MAX);
assert!(unpack_multi(&multi.encode_to_vec()).is_err());
// Is not gzip at all.
let junk = Multi { size_unzipped: Some(10), message_body: Some(vec![1, 2, 3, 4]) };
assert!(unpack_multi(&junk.encode_to_vec()).is_err());
// Packs a packet that runs past the end.
let mut run = 100u32.to_le_bytes().to_vec();
run.extend_from_slice(&[1, 2, 3]);
let short = Multi { size_unzipped: None, message_body: Some(run) };
assert!(unpack_multi(&short.encode_to_vec()).is_err());
// And a length cut off in the middle.
let cut = Multi { size_unzipped: None, message_body: Some(vec![1, 2]) };
assert!(unpack_multi(&cut.encode_to_vec()).is_err());
}
}
+436
View File
@@ -0,0 +1,436 @@
//! Signing in to Twitch: the device code flow, and the tokens it leaves.
//!
//! The person never types a password into moho. Moho asks Twitch for a short
//! code, shows it, and the person approves it on twitch.tv in their own
//! browser, signed in however they like; moho polls until Twitch says yes and
//! then holds two tokens:
//!
//! - an **access token**, which every call carries and which lasts four hours
//! - a **refresh token**, single use, which buys the next pair and which
//! Twitch lets go of after thirty days without one
//!
//! moho is a *public* client (it is a program on somebody's own computer, and
//! cannot keep a secret), so there is no client secret anywhere in this: the
//! device flow and the refresh both go without one, and the Client ID below
//! is not a secret either.
//!
//! The functions take the server's address, so the tests can answer them.
//! Everything here is checked against a real Twitch account in the plan's
//! round 1; what the answers look like is from the docs
//! (dev.twitch.tv/docs/authentication) and from watching them.
use anyhow::{bail, Context, Result};
use serde::Deserialize;
/// The application registered at dev.twitch.tv for moho: a *public* client.
pub const CLIENT_ID: &str = "ybpi52doogxn2gzsa7415i18bq0vlb";
/// Where Twitch's sign-in lives.
pub const ID_ROOT: &str = "https://id.twitch.tv";
/// What moho asks to be allowed, and what each is for.
///
/// No more than the features need, and said beside each in the account form.
/// Moderation and whispers are asked for when somebody turns them on, not
/// here: a scope nobody can see a use for is a reason to refuse.
pub const SCOPES: &[(&str, &str)] = &[
("chat:read", "read chat"),
("chat:edit", "send messages"),
("user:write:chat", "hear why Twitch turned a message down"),
("user:read:follows", "list the channels you follow"),
("user:read:emotes", "show the emotes you can use"),
];
/// What moderation, whispers, shoutouts, blocking and a name colour need, and what each is for.
/// Asked for in the one sign-in with the rest, so a person signs in once and has every feature: none of
/// it does anything in a channel where the account has no standing (Twitch checks that on every call), and
/// what moho offers follows what the account may do there.
pub const MODERATION_SCOPES: &[(&str, &str)] = &[
("moderator:manage:banned_users", "time people out, ban them and let them back"),
("moderator:manage:chat_messages", "delete messages"),
("moderator:manage:chat_settings", "change the room's modes (slow, followers-only and the rest)"),
("moderator:manage:announcements", "make announcements"),
("user:manage:chat_color", "change your name colour"),
("user:manage:whispers", "send whispers"),
("whispers:read", "read whispers sent to you"),
("moderator:manage:shoutouts", "give shoutouts"),
("user:manage:blocked_users", "block and unblock people"),
];
/// The scopes as Twitch wants them in a request: one string, space between.
pub fn scope_string() -> String {
SCOPES.iter().chain(MODERATION_SCOPES.iter()).map(|(s, _)| *s).collect::<Vec<_>>().join(" ")
}
/// Whether a set of scopes is the one that turned moderation on.
pub fn has_moderation(scopes: &[String]) -> bool {
scopes.iter().any(|s| s == "moderator:manage:banned_users")
}
/// The code to show, and where to enter it.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct DeviceCode {
pub device_code: String,
pub user_code: String,
/// Twitch's own page, with the code already in it.
pub verification_uri: String,
/// How long the code can be used, in seconds.
pub expires_in: u64,
/// How often to ask whether it has been approved, in seconds.
pub interval: u64,
}
/// A pair of tokens, as Twitch hands them out.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Tokens {
pub access_token: String,
pub refresh_token: String,
/// Seconds from now until the access token stops working.
pub expires_in: i64,
#[serde(default, deserialize_with = "scopes_either_way")]
pub scope: Vec<String>,
}
/// Twitch says the scopes as a list; some of its endpoints have said them as
/// one string. Both are read.
fn scopes_either_way<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<String>, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Either {
List(Vec<String>),
Text(String),
}
Ok(match Option::<Either>::deserialize(d)? {
Some(Either::List(list)) => list,
Some(Either::Text(text)) => text.split_whitespace().map(str::to_string).collect(),
None => Vec::new(),
})
}
/// What asking whether the code was approved can answer.
#[derive(Debug, Clone, PartialEq)]
pub enum Poll {
/// Not yet. Ask again after the interval.
Pending,
/// Twitch wants asking less often.
SlowDown,
/// The person said no.
Denied,
/// The code ran out before anyone approved it.
Expired,
Approved(Tokens),
}
/// Who a token belongs to and what it may do.
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct Validation {
pub client_id: String,
pub login: String,
pub user_id: String,
#[serde(default)]
pub scopes: Vec<String>,
/// Seconds the token has left.
pub expires_in: i64,
}
/// Why a token could not be renewed.
#[derive(Debug)]
pub enum Renewal {
/// Twitch will not accept it any more: revoked, used twice, or thirty
/// days old. The only answer is to sign in again.
Refused(String),
/// Anything else - the network, a server having a bad moment. The token
/// may well be fine; try again later.
Failed(anyhow::Error),
}
fn form(pairs: &[(&str, &str)]) -> String {
let mut out = url::form_urlencoded::Serializer::new(String::new());
for (k, v) in pairs {
out.append_pair(k, v);
}
out.finish()
}
async fn post_form(http: &reqwest::Client, url: &str, pairs: &[(&str, &str)]) -> Result<(u16, serde_json::Value)> {
let res = http
.post(url)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.body(form(pairs))
.send()
.await
.with_context(|| format!("reaching Twitch at {url}"))?;
let status = res.status().as_u16();
// An error answer is JSON too, and says why; one that is not is read as nothing.
let body = res.json::<serde_json::Value>().await.unwrap_or(serde_json::Value::Null);
Ok((status, body))
}
fn message_of(body: &serde_json::Value) -> String {
body.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string()
}
/// Asks Twitch for a code for somebody to approve.
pub async fn start(http: &reqwest::Client, root: &str) -> Result<DeviceCode> {
let (status, body) = post_form(http, &format!("{root}/oauth2/device"), &[("client_id", CLIENT_ID), ("scopes", &scope_string())]).await?;
if status != 200 {
bail!("Twitch would not start a sign-in ({status}): {}", message_of(&body));
}
serde_json::from_value(body).context("reading Twitch's sign-in code")
}
/// Asks whether the code has been approved.
pub async fn poll(http: &reqwest::Client, root: &str, device_code: &str) -> Result<Poll> {
let (status, body) = post_form(
http,
&format!("{root}/oauth2/token"),
&[
("client_id", CLIENT_ID),
("scopes", &scope_string()),
("device_code", device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
],
)
.await?;
if status == 200 {
return Ok(Poll::Approved(serde_json::from_value(body).context("reading the tokens Twitch gave")?));
}
// The refusals are the ordinary course of a sign-in, said in a message
// rather than a status, and spelled as the standard does.
let message = message_of(&body).to_lowercase();
Ok(match message.as_str() {
"authorization_pending" => Poll::Pending,
m if m.contains("slow_down") => Poll::SlowDown,
m if m.contains("access_denied") || m.contains("denied") => Poll::Denied,
m if m.contains("expired") || m.contains("invalid device code") || m.contains("invalid_device_code") => Poll::Expired,
_ => bail!("Twitch refused the sign-in ({status}): {}", message_of(&body)),
})
}
/// Buys a new pair of tokens with the refresh token. The old pair is spent
/// whether or not this works out, so what comes back must be kept.
pub async fn refresh(http: &reqwest::Client, root: &str, refresh_token: &str) -> std::result::Result<Tokens, Renewal> {
let (status, body) = match post_form(
http,
&format!("{root}/oauth2/token"),
&[("client_id", CLIENT_ID), ("grant_type", "refresh_token"), ("refresh_token", refresh_token)],
)
.await
{
Ok(answer) => answer,
Err(e) => return Err(Renewal::Failed(e)),
};
match status {
200 => serde_json::from_value(body).map_err(|e| Renewal::Failed(anyhow::Error::new(e).context("reading renewed tokens"))),
// Twitch says 400 "Invalid refresh token" for one that is used, revoked or old, and 401 for the same family.
400 | 401 => Err(Renewal::Refused(message_of(&body))),
other => Err(Renewal::Failed(anyhow::anyhow!("Twitch answered {other} to a renewal: {}", message_of(&body)))),
}
}
/// Who a token belongs to, or `None` when Twitch no longer accepts it.
///
/// Twitch asks apps to do this when they start and from time to time, and it
/// is how the login, the id and the scopes are read: the sign-in does not say.
pub async fn validate(http: &reqwest::Client, root: &str, access_token: &str) -> Result<Option<Validation>> {
let res = http
.get(format!("{root}/oauth2/validate"))
.header("Authorization", format!("OAuth {access_token}"))
.send()
.await
.context("reaching Twitch to check a sign-in")?;
match res.status().as_u16() {
200 => Ok(Some(res.json().await.context("reading who the token belongs to")?)),
401 => Ok(None),
other => bail!("Twitch answered {other} when checking a sign-in"),
}
}
/// Tells Twitch to forget a token, so signing out here ends it there too
/// rather than leaving it good for the rest of its four hours.
pub async fn revoke(http: &reqwest::Client, root: &str, token: &str) -> Result<()> {
let (status, body) = post_form(http, &format!("{root}/oauth2/revoke"), &[("client_id", CLIENT_ID), ("token", token)]).await?;
// 400 is a token that was already gone, which is what was wanted.
if status != 200 && status != 400 {
bail!("Twitch answered {status} when ending a sign-in: {}", message_of(&body));
}
Ok(())
}
/// When a token handed out `expires_in` seconds from `now` should be renewed:
/// a little before it stops, so a call made at the edge does not meet a dead one.
pub fn renew_at(now: i64, expires_in: i64) -> i64 {
now + (expires_in - RENEW_BEFORE).max(0)
}
/// How long before its end a token is renewed, in seconds.
pub const RENEW_BEFORE: i64 = 300;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A Twitch that answers each request with the next canned answer, and
/// remembers what it was asked: the request line and the body.
async fn twitch(answers: Vec<(u16, &'static str)>) -> (String, Arc<Mutex<Vec<(String, String)>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let root = format!("http://{}", listener.local_addr().unwrap());
let seen: Arc<Mutex<Vec<(String, String)>>> = Default::default();
let log = seen.clone();
let answers = Arc::new(Mutex::new(answers.into_iter().collect::<std::collections::VecDeque<_>>()));
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let (log, answers) = (log.clone(), answers.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let mut got = 0;
// Until the headers and as much body as it says there is.
loop {
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
if n == 0 {
break;
}
got += n;
let text = String::from_utf8_lossy(&buf[..got]).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let length = text[..end]
.lines()
.find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0)))
.unwrap_or(0);
if got >= end + 4 + length {
break;
}
}
}
let text = String::from_utf8_lossy(&buf[..got]).to_string();
let (head, body) = text.split_once("\r\n\r\n").unwrap_or((&text, ""));
log.lock().unwrap().push((head.lines().next().unwrap_or("").to_string(), body.to_string()));
let (status, answer) = answers.lock().unwrap().pop_front().unwrap_or((500, "{}"));
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{answer}", answer.len());
let _ = sock.write_all(reply.as_bytes()).await;
});
}
});
(root, seen)
}
fn http() -> reqwest::Client {
reqwest::Client::new()
}
const CODE: &str = r#"{"device_code":"dev123","expires_in":1800,"interval":5,"user_code":"ABCDEFGH","verification_uri":"https://www.twitch.tv/activate?device-code=ABCDEFGH"}"#;
const TOKENS: &str = r#"{"access_token":"acc","expires_in":14344,"refresh_token":"ref","scope":["chat:read","chat:edit"],"token_type":"bearer"}"#;
#[tokio::test]
async fn a_sign_in_asks_for_the_scopes_moho_needs_with_its_own_client_id() {
let (root, seen) = twitch(vec![(200, CODE)]).await;
let code = start(&http(), &root).await.unwrap();
assert_eq!((code.user_code.as_str(), code.interval, code.expires_in), ("ABCDEFGH", 5, 1800));
assert!(code.verification_uri.ends_with("device-code=ABCDEFGH"));
let seen = seen.lock().unwrap();
assert_eq!(seen[0].0, "POST /oauth2/device HTTP/1.1");
assert!(seen[0].1.contains(&format!("client_id={CLIENT_ID}")), "{}", seen[0].1);
// Space is a plus in a form, which is how Twitch reads a list of scopes.
assert!(seen[0].1.contains("scopes=chat%3Aread+chat%3Aedit"), "{}", seen[0].1);
// And no secret anywhere: moho is a public client.
assert!(!seen[0].1.contains("secret"));
// One sign-in has every feature: moderation, whispers, shoutouts and blocking are asked for with the rest.
for scope in ["moderator%3Amanage%3Abanned_users", "whispers%3Aread", "user%3Amanage%3Awhispers", "moderator%3Amanage%3Ashoutouts", "user%3Amanage%3Ablocked_users"] {
assert!(seen[0].1.contains(scope), "{scope} in {}", seen[0].1);
}
assert!(scope_string().contains("moderator:manage:banned_users") && scope_string().contains("whispers:read"));
assert!(has_moderation(&["chat:read".to_string(), "moderator:manage:banned_users".to_string()]) && !has_moderation(&["chat:read".to_string()]));
}
#[tokio::test]
async fn the_poll_tells_waiting_from_a_no_from_an_old_code() {
let (root, _) = twitch(vec![
(400, r#"{"status":400,"message":"authorization_pending"}"#),
(400, r#"{"status":400,"message":"slow_down"}"#),
(400, r#"{"status":400,"message":"access_denied"}"#),
(400, r#"{"status":400,"message":"invalid device code"}"#),
(200, TOKENS),
])
.await;
let client = http();
let ask = || poll(&client, &root, "dev123");
assert_eq!(ask().await.unwrap(), Poll::Pending);
assert_eq!(ask().await.unwrap(), Poll::SlowDown);
assert_eq!(ask().await.unwrap(), Poll::Denied);
assert_eq!(ask().await.unwrap(), Poll::Expired);
match ask().await.unwrap() {
Poll::Approved(t) => assert_eq!((t.access_token.as_str(), t.refresh_token.as_str(), t.scope.len()), ("acc", "ref", 2)),
other => panic!("{other:?}"),
}
}
#[tokio::test]
async fn the_poll_names_the_grant_and_the_code() {
let (root, seen) = twitch(vec![(400, r#"{"message":"authorization_pending"}"#)]).await;
let _ = poll(&http(), &root, "dev123").await;
let seen = seen.lock().unwrap();
assert!(seen[0].1.contains("device_code=dev123"), "{}", seen[0].1);
assert!(seen[0].1.contains("grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code"), "{}", seen[0].1);
}
#[tokio::test]
async fn an_answer_nobody_expected_is_an_error_with_twitchs_words() {
let (root, _) = twitch(vec![(500, r#"{"message":"the sky fell"}"#)]).await;
let e = poll(&http(), &root, "d").await.unwrap_err();
assert!(format!("{e}").contains("the sky fell"), "{e}");
}
#[tokio::test]
async fn a_refresh_returns_the_new_pair_and_a_used_one_is_refused() {
let (root, seen) = twitch(vec![(200, TOKENS), (400, r#"{"status":400,"message":"Invalid refresh token"}"#), (503, "{}")]).await;
let t = refresh(&http(), &root, "old").await.unwrap();
assert_eq!(t.access_token, "acc");
assert!(seen.lock().unwrap()[0].1.contains("grant_type=refresh_token"));
assert!(seen.lock().unwrap()[0].1.contains("refresh_token=old"));
match refresh(&http(), &root, "old").await {
Err(Renewal::Refused(why)) => assert_eq!(why, "Invalid refresh token"),
other => panic!("{other:?}"),
}
// A server having a bad moment is not a verdict on the token.
assert!(matches!(refresh(&http(), &root, "old").await, Err(Renewal::Failed(_))));
}
#[tokio::test]
async fn validating_reads_who_a_token_belongs_to_and_a_dead_one_is_none() {
let (root, seen) = twitch(vec![
(200, r#"{"client_id":"c","login":"someone","scopes":["chat:read"],"user_id":"42","expires_in":3600}"#),
(401, r#"{"status":401,"message":"invalid access token"}"#),
])
.await;
let v = validate(&http(), &root, "tok").await.unwrap().expect("accepted");
assert_eq!((v.login.as_str(), v.user_id.as_str(), v.scopes.len()), ("someone", "42", 1));
assert!(seen.lock().unwrap()[0].0.starts_with("GET /oauth2/validate"));
assert!(validate(&http(), &root, "tok").await.unwrap().is_none());
}
#[tokio::test]
async fn revoking_a_token_that_is_already_gone_is_fine() {
let (root, _) = twitch(vec![(200, "{}"), (400, r#"{"message":"Invalid token"}"#), (500, "{}")]).await;
revoke(&http(), &root, "t").await.unwrap();
revoke(&http(), &root, "t").await.unwrap();
assert!(revoke(&http(), &root, "t").await.is_err());
}
#[test]
fn scopes_are_read_as_a_list_or_as_text() {
let list: Tokens = serde_json::from_str(r#"{"access_token":"a","refresh_token":"r","expires_in":1,"scope":["a","b"]}"#).unwrap();
let text: Tokens = serde_json::from_str(r#"{"access_token":"a","refresh_token":"r","expires_in":1,"scope":"a b"}"#).unwrap();
let none: Tokens = serde_json::from_str(r#"{"access_token":"a","refresh_token":"r","expires_in":1}"#).unwrap();
assert_eq!((list.scope, text.scope, none.scope), (vec!["a".to_string(), "b".into()], vec!["a".to_string(), "b".into()], vec![]));
}
#[test]
fn a_token_is_renewed_before_it_runs_out() {
assert_eq!(renew_at(1000, 14400), 1000 + 14400 - RENEW_BEFORE);
assert_eq!(renew_at(1000, 60), 1000, "a token with less time than the margin is renewed at once");
}
}
+118
View File
@@ -0,0 +1,118 @@
//! The pictures for the badges beside a name: Twitch's global ones and each
//! channel's own (the subscriber badge is a different picture in every
//! channel, and changes with the months).
//!
//! A message's `badges` tag names each as `set/version`; what it looks like is
//! in a table Twitch publishes, asked for once for the global set and once per
//! channel when it is joined. Through Twitch's GraphQL (see `gql.rs`), because
//! the official API will answer neither without an account.
use super::gql;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
/// One badge as Twitch describes it.
#[derive(Clone, Debug, PartialEq)]
pub struct Picture {
pub title: String,
pub image: String,
}
#[derive(Default)]
struct Held {
global: Option<Arc<HashMap<String, Picture>>>,
channels: HashMap<String, Arc<HashMap<String, Picture>>>,
}
fn held() -> &'static Mutex<Held> {
static HELD: std::sync::OnceLock<Mutex<Held>> = std::sync::OnceLock::new();
HELD.get_or_init(Default::default)
}
const GLOBAL: &str = "query{ badges { setID version title imageURL(size:DOUBLE) } }";
const CHANNEL: &str = "query($login:String!){ user(login:$login){ broadcastBadges { setID version title imageURL(size:DOUBLE) } } }";
/// Reads a list of badges, whichever query it answered.
pub fn read(list: &Value) -> HashMap<String, Picture> {
let mut out = HashMap::new();
for b in list.as_array().into_iter().flatten() {
let (Some(set), Some(version), Some(image)) = (b["setID"].as_str(), b["version"].as_str(), b["imageURL"].as_str()) else { continue };
if !image.starts_with("https://") {
continue;
}
out.insert(format!("{set}/{version}"), Picture { title: b["title"].as_str().unwrap_or(set).to_string(), image: image.to_string() });
}
out
}
/// The picture for a badge in a channel: the channel's own, else the global one.
pub fn find(channel: &str, set_and_version: &str) -> Option<Picture> {
let held = held().lock().unwrap();
held.channels.get(channel).and_then(|c| c.get(set_and_version)).or_else(|| held.global.as_ref().and_then(|g| g.get(set_and_version))).cloned()
}
pub fn have_global() -> bool {
held().lock().unwrap().global.is_some()
}
pub fn forget(channel: &str) {
held().lock().unwrap().channels.remove(channel);
}
pub async fn load_global(http: &reqwest::Client, root: &str) {
if have_global() {
return;
}
let Ok(answer) = gql::call(http, root, &gql::query(GLOBAL, json!({})), &gql::Who::default()).await else { return };
let table = read(&answer["data"]["badges"]);
if !table.is_empty() {
held().lock().unwrap().global = Some(Arc::new(table));
}
}
pub async fn load_channel(http: &reqwest::Client, root: &str, login: &str) {
let Ok(answer) = gql::call(http, root, &gql::query(CHANNEL, json!({ "login": login })), &gql::Who::default()).await else { return };
let table = read(&answer["data"]["user"]["broadcastBadges"]);
held().lock().unwrap().channels.insert(login.to_string(), Arc::new(table));
}
#[cfg(test)]
pub fn keep_for_test(channel: Option<&str>, table: HashMap<String, Picture>) {
let mut held = held().lock().unwrap();
match channel {
Some(c) => {
held.channels.insert(c.to_string(), Arc::new(table));
}
None => held.global = Some(Arc::new(table)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_badge_list_is_read_by_set_and_version_and_an_address_that_is_not_https_is_refused() {
let table = read(&json!([
{ "setID": "subscriber", "version": "36", "title": "3-Year Subscriber", "imageURL": "https://static-cdn.jtvnw.net/badges/v1/a/2" },
{ "setID": "moderator", "version": "1", "title": "Moderator", "imageURL": "https://static-cdn.jtvnw.net/badges/v1/b/2" },
{ "setID": "bad", "version": "1", "title": "X", "imageURL": "file:///etc/passwd" },
{ "setID": "no-picture", "version": "1" }
]));
assert_eq!(table.len(), 2);
assert_eq!(table["subscriber/36"].title, "3-Year Subscriber");
}
#[test]
fn a_channels_own_picture_comes_before_the_global_one() {
let pic = |t: &str| Picture { title: t.into(), image: format!("https://x/{t}") };
keep_for_test(None, [("subscriber/0".to_string(), pic("global")), ("vip/1".to_string(), pic("vip"))].into_iter().collect());
keep_for_test(Some("badgechan"), [("subscriber/0".to_string(), pic("channel"))].into_iter().collect());
assert_eq!(find("badgechan", "subscriber/0").unwrap().title, "channel");
assert_eq!(find("badgechan", "vip/1").unwrap().title, "vip");
assert_eq!(find("other", "subscriber/0").unwrap().title, "global");
assert!(find("badgechan", "no/such").is_none());
forget("badgechan");
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+207
View File
@@ -0,0 +1,207 @@
//! The emotes of Twitch's own that a chat is written in: whether one is animated, when an account's own were
//! last read (they change under it: a reward unlocks one, a 24-hour unlock lapses), and how a message the
//! account sends is turned between the picture the box holds and the word Twitch reads.
//!
//! BetterTTV, FrankerFaceZ and 7TV are not here: they are browser extensions that draw over Twitch's chat, and
//! Twitch itself shows those words as words, so moho does not pretend to.
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
/// Who owns emotes an account has: found out once from Twitch and kept, since a channel's name and picture change
/// rarely and a subscription list can name dozens.
#[derive(Clone)]
pub struct Owner {
pub login: String,
pub name: String,
pub picture: String,
}
fn known_owners() -> &'static Mutex<HashMap<String, Owner>> {
static KNOWN: std::sync::OnceLock<Mutex<HashMap<String, Owner>>> = std::sync::OnceLock::new();
KNOWN.get_or_init(Default::default)
}
/// The channels these numbers belong to, by number; one whose owner Twitch would not name is not in the answer.
pub async fn owners(http: &reqwest::Client, who: &super::helix::Caller<'_>, ids: &[String]) -> HashMap<String, Owner> {
let missing: Vec<String> = {
let known = known_owners().lock().unwrap();
ids.iter().filter(|i| !known.contains_key(*i)).cloned().collect()
};
if !missing.is_empty() {
let found = super::helix::users_by_id(http, who, &missing).await;
let mut known = known_owners().lock().unwrap();
for (id, login, name, picture) in found {
known.insert(id, Owner { login, name, picture });
}
}
let known = known_owners().lock().unwrap();
ids.iter().filter_map(|i| known.get(i).map(|o| (i.clone(), o.clone()))).collect()
}
/// When an account's own emotes in a channel were last fetched, and the channel's number (for fetching them again
/// from anywhere): Twitch lets an emote lapse (a 24-hour unlock, a limited-time one) and gives no word of it, so the
/// list is read again when it is asked for and has gone stale, and the picker shows only what is there.
fn refreshed() -> &'static Mutex<HashMap<String, (std::time::Instant, String)>> {
static REFRESHED: std::sync::OnceLock<Mutex<HashMap<String, (std::time::Instant, String)>>> = std::sync::OnceLock::new();
REFRESHED.get_or_init(Default::default)
}
/// Says the account's emotes in a channel were just fetched.
pub fn note_user_emotes(account_id: &str, channel: &str, room_id: &str) {
refreshed().lock().unwrap().insert(format!("{account_id}|{channel}"), (std::time::Instant::now(), room_id.to_string()));
}
/// The channel's number, and whether the account's emotes there are older than `max_age` (or never fetched).
pub fn user_emotes_state(account_id: &str, channel: &str, max_age: std::time::Duration) -> (Option<String>, bool) {
match refreshed().lock().unwrap().get(&format!("{account_id}|{channel}")) {
Some((at, room)) => (Some(room.clone()), at.elapsed() > max_age),
None => (None, true),
}
}
/// The words an emote token stands for: `[twitch:emote:25:Kappa]` is sent as `Kappa`.
///
/// The window puts a picture in the message box where an emote was picked, and what that picture is made of is
/// this token (the same one an incoming message carries, so one piece of code draws both). Twitch reads an emote
/// as its word, so that is what leaves.
pub fn tokens_to_names(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("[twitch:") {
out.push_str(&rest[..start]);
let after = &rest[start..];
let parsed = after.find(']').and_then(|end| {
let mut parts = after[1..end].splitn(4, ':');
let (_, kind, _id, name) = (parts.next()?, parts.next()?, parts.next()?, parts.next()?);
matches!(kind, "emote" | "aemote").then_some((name, end))
});
match parsed {
Some((name, end)) => {
out.push_str(name);
rest = &after[end + 1..];
}
None => {
out.push_str("[twitch:");
rest = &after["[twitch:".len()..];
}
}
}
out.push_str(rest);
out
}
/// What the sender's own message is shown as: each word that is one of the sender's emotes becomes its token, so
/// the line the sender sees has the same pictures the others see (Twitch does not send a message back to the one
/// who sent it, so nothing else would draw them). `mine` is the sender's emotes in this channel by word, as the
/// picker holds them (the entry's id is the token); a token already in the text, from the picker, is kept.
pub fn tokens_for_own(text: &str, mine: &HashMap<String, String>) -> String {
let mut out = String::with_capacity(text.len());
let mut word = String::new();
let flush = |word: &mut String, out: &mut String| {
if word.is_empty() {
return;
}
match mine.get(word.as_str()) {
Some(token) if !word.starts_with("[twitch:") => out.push_str(token),
_ => out.push_str(word),
}
word.clear();
};
for c in text.chars() {
if c.is_whitespace() {
flush(&mut word, &mut out);
out.push(c);
} else {
word.push(c);
}
}
flush(&mut word, &mut out);
out
}
// ---------------------------------------------------------------------------
// Twitch's own, animated or not
// ---------------------------------------------------------------------------
#[derive(Default)]
struct Animated {
known: HashMap<String, bool>,
asking: HashSet<String>,
}
fn animated_cache() -> &'static Mutex<Animated> {
static CACHE: std::sync::OnceLock<Mutex<Animated>> = std::sync::OnceLock::new();
CACHE.get_or_init(Default::default)
}
/// Whether one of Twitch's own emotes is animated, if that is known yet.
///
/// Twitch's picture address has an animated form for those that are and answers
/// 404 for those that are not, and the message carries no word on which, so the
/// first time an emote is seen it is asked about, in the background, and drawn
/// still until the answer is in; every message after that draws it moving.
pub fn animated(id: &str) -> Option<bool> {
let mut cache = animated_cache().lock().unwrap();
if let Some(known) = cache.known.get(id) {
return Some(*known);
}
if cache.asking.insert(id.to_string()) {
// Only where there is something to ask with: a unit test calling this has no runtime.
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
let id = id.to_string();
runtime.spawn(async move {
let yes = match super::client() {
Ok(http) => http.head(animated_url(&id)).send().await.map(|r| r.status().is_success()).unwrap_or(false),
Err(_) => false,
};
let mut cache = animated_cache().lock().unwrap();
cache.asking.remove(&id);
// Bounded: a long session sees a great many emotes.
if cache.known.len() > 20_000 {
cache.known.clear();
}
cache.known.insert(id, yes);
});
} else {
cache.asking.remove(id);
}
}
None
}
/// Twitch's address for the moving form of one of its own emotes.
pub fn animated_url(id: &str) -> String {
format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/animated/dark/2.0")
}
/// For a test: says how an emote is, as if it had been asked.
#[cfg(test)]
pub fn say_animated(id: &str, yes: bool) {
animated_cache().lock().unwrap().known.insert(id.to_string(), yes);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_senders_own_line_shows_each_emote_as_a_picture_whether_it_was_picked_or_typed() {
let mine: HashMap<String, String> = [("Kappa".to_string(), "[twitch:emote:25:Kappa]".to_string())].into_iter().collect();
assert_eq!(tokens_for_own("hi Kappa [twitch:emote:9:Pog] plain", &mine), "hi [twitch:emote:25:Kappa] [twitch:emote:9:Pog] plain");
}
#[test]
fn a_picked_emote_leaves_as_its_word_and_nothing_else_is_touched() {
assert_eq!(tokens_to_names("hi [twitch:emote:25:Kappa] and [twitch:emote:9:Pog]!"), "hi Kappa and Pog!");
assert_eq!(tokens_to_names("[twitch:aemote:emotesv2_ab12:Wow]"), "Wow");
assert_eq!(tokens_to_names("[twitch:cheer:cheer:100] stays, [twitch:bad"), "[twitch:cheer:cheer:100] stays, [twitch:bad");
assert_eq!(tokens_to_names("plain [text] here"), "plain [text] here");
}
#[test]
fn twitchs_own_animated_form_is_a_different_address_and_an_unasked_emote_is_unknown() {
assert_eq!(animated_url("301445381"), "https://static-cdn.jtvnw.net/emoticons/v2/301445381/animated/dark/2.0");
assert_eq!(animated("never-asked-for"), None);
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Twitch's own GraphQL, the one twitch.tv's pages are built on.
//!
//! It is private - Twitch does not offer it to anybody else, and it has been
//! changed before under the clients that use it (the plan, Gitea issue #55,
//! says why moho uses it anyway and what happens if it breaks). Three things
//! are asked of it, none of which Twitch's official API will answer without an
//! account: whether a channel is live and what it is called, the signed token
//! a stream's playlist needs, and the badges a channel's chat draws.
//!
//! It is spoken as twitch.tv speaks it: the site's own Client ID, and queries
//! that are either plain GraphQL or, for the player's token, the *persisted*
//! query the player sends by hash. Answers are checked with care - a response
//! that is not what was expected is an error to say, never a crash.
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};
pub const ROOT: &str = "https://gql.twitch.tv/gql";
/// The Client ID twitch.tv's own pages send. Public (it is in every page),
/// and the only one the GraphQL endpoint accepts: it answers 400 "The
/// Client-ID header is invalid" to moho's own.
pub const SITE_CLIENT_ID: &str = "kimne78kx3ncx6brgo4mv6wki5h1ko";
/// A device id for this run, the one the site makes up for a browser: random
/// and sent with every request, so the token Twitch signs is for one device.
pub fn device_id() -> &'static str {
static ID: std::sync::OnceLock<String> = std::sync::OnceLock::new();
ID.get_or_init(|| {
let a = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(1);
let b = std::process::id() as u128;
format!("{:032x}", a.wrapping_mul(6364136223846793005).wrapping_add(b))
})
}
/// Extra things a call may carry: who is asking, and proof that it is a
/// person's browser asking.
#[derive(Default, Clone)]
pub struct Who<'a> {
/// The `auth-token` of a twitch.tv session, for a call that should be
/// answered for that person (their Turbo, their subscriptions).
pub web_token: Option<&'a str>,
/// An integrity token Twitch demanded, and the device id it was minted for.
pub integrity: Option<(&'a str, &'a str)>,
}
/// Sends one query or persisted query and answers with its JSON, or says what
/// Twitch said when it refused or reported errors.
pub async fn call(http: &reqwest::Client, root: &str, body: &Value, who: &Who<'_>) -> Result<Value> {
let mut req = http
.post(root)
.header("Client-ID", SITE_CLIENT_ID)
// The site sends its JSON as text/plain, and so does this.
.header("Content-Type", "text/plain;charset=UTF-8")
.header("X-Device-Id", who.integrity.map(|(device, _)| device).unwrap_or_else(|| device_id()))
.body(body.to_string());
if let Some(token) = who.web_token.filter(|t| !t.is_empty()) {
req = req.header("Authorization", format!("OAuth {token}"));
}
if let Some((_, token)) = who.integrity {
req = req.header("Client-Integrity", token);
}
let res = req.send().await.context("reaching Twitch")?;
let status = res.status();
let text = res.text().await.unwrap_or_default();
let value: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
if !status.is_success() {
let said = value.get("message").and_then(|m| m.as_str()).unwrap_or("");
bail!("Twitch answered {status}{}", if said.is_empty() { String::new() } else { format!(": {said}") });
}
Ok(value)
}
/// The first error message Twitch put in an answer, if it put any.
pub fn error_of(answer: &Value) -> Option<String> {
answer.get("errors")?.as_array()?.first()?.get("message")?.as_str().map(str::to_string)
}
/// A plain GraphQL query with variables.
pub fn query(text: &str, variables: Value) -> Value {
json!({ "query": text, "variables": variables })
}
+538
View File
@@ -0,0 +1,538 @@
//! Twitch's official API, Helix: only what moho uses of it.
//!
//! Sending a chat message is the first use. Over IRC a message that Twitch
//! drops (a banned word, slow mode, a link it will not have) is dropped
//! silently, or explained by a NOTICE that nothing ties to the message; Helix
//! answers each message with whether it was sent, and if not, why. It also
//! answers with the message's id, which IRC never does for one's own.
//!
//! Every call carries the account's access token and moho's own Client ID.
//! The functions take the server's address, so the tests can answer them.
use anyhow::Context;
use serde::Deserialize;
pub const API_ROOT: &str = "https://api.twitch.tv/helix";
/// What Twitch said about a message sent.
#[derive(Debug, PartialEq)]
pub struct Sent {
/// Twitch's id for the message: what a reply to it, or its removal, names.
pub message_id: String,
/// Why it was not sent, when it was not: Twitch's words.
pub dropped: Option<String>,
}
/// Why a message could not be sent by Helix at all.
#[derive(Debug)]
pub enum SendError {
/// Twitch understood and said no, in words that should reach the person.
Refused(String),
/// It never got a hearing - the network, a server error, a token or a
/// scope Twitch will not take for this call. Sending can fall back to IRC.
Unavailable(anyhow::Error),
}
#[derive(Deserialize)]
struct SendAnswer {
#[serde(default)]
data: Vec<SendResult>,
}
#[derive(Deserialize)]
struct SendResult {
#[serde(default)]
message_id: String,
#[serde(default)]
is_sent: bool,
#[serde(default)]
drop_reason: Option<DropReason>,
}
#[derive(Deserialize)]
struct DropReason {
#[serde(default)]
code: String,
#[serde(default)]
message: String,
}
/// Where Helix is and who is asking it.
pub struct Caller<'a> {
pub root: &'a str,
pub client_id: &'a str,
pub token: &'a str,
}
/// A message to send: to which channel, as whom, saying what.
pub struct Outgoing<'a> {
pub broadcaster_id: &'a str,
pub sender_id: &'a str,
pub message: &'a str,
/// The id of the message this answers.
pub reply_parent: Option<&'a str>,
}
/// Sends a message as the account, optionally as a reply to one.
pub async fn send_chat_message(http: &reqwest::Client, who: &Caller<'_>, out: &Outgoing<'_>) -> std::result::Result<Sent, SendError> {
let mut body = serde_json::json!({ "broadcaster_id": out.broadcaster_id, "sender_id": out.sender_id, "message": out.message });
if let Some(parent) = out.reply_parent {
body["reply_parent_message_id"] = serde_json::json!(parent);
}
let res = http
.post(format!("{}/chat/messages", who.root))
.header("Authorization", format!("Bearer {}", who.token))
.header("Client-Id", who.client_id)
.json(&body)
.send()
.await
.context("reaching Twitch")
.map_err(SendError::Unavailable)?;
let status = res.status().as_u16();
let text = res.text().await.unwrap_or_default();
let said = || serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("message").and_then(|m| m.as_str()).map(str::to_string)).unwrap_or_default();
match status {
200 => {
let answer: SendAnswer = serde_json::from_str(&text).context("reading Twitch's answer to a message").map_err(SendError::Unavailable)?;
let result = answer.data.into_iter().next().ok_or_else(|| SendError::Unavailable(anyhow::anyhow!("Twitch answered a message with nothing")))?;
let dropped = (!result.is_sent).then(|| match result.drop_reason {
Some(r) if !r.message.is_empty() => r.message,
Some(r) if !r.code.is_empty() => format!("Twitch did not send it ({})", r.code),
_ => "Twitch did not send it".to_string(),
});
Ok(Sent { message_id: result.message_id, dropped })
}
// Twitch understood and said no: banned, not allowed to talk, a bad id.
400 | 403 | 422 => Err(SendError::Refused(match said() {
m if m.is_empty() => format!("Twitch refused the message ({status})"),
m => m,
})),
// The token or what it may do is the problem, which is not a verdict on the message.
401 => Err(SendError::Unavailable(anyhow::anyhow!("Twitch does not accept this sign-in for sending: {}", said()))),
429 => Err(SendError::Refused("Twitch says you are sending too fast. Wait a moment.".to_string())),
other => Err(SendError::Unavailable(anyhow::anyhow!("Twitch answered {other} to a message: {}", said()))),
}
}
/// Why a call to Helix did not work.
#[derive(Debug)]
pub enum HelixError {
/// Twitch understood and said no, in words that should reach the person.
Refused(String),
/// The token or what it was allowed to do will not do for this.
Auth(String),
Failed(anyhow::Error),
}
impl std::fmt::Display for HelixError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HelixError::Refused(why) => write!(f, "{why}"),
// A reason about the account itself (no verified phone number) is not cured by signing in again.
HelixError::Auth(why) if why.to_lowercase().contains("verified") || why.to_lowercase().contains("phone") => write!(f, "Twitch does not allow this for this account ({why}). Verify the account on twitch.tv to enable it."),
HelixError::Auth(why) => write!(f, "Twitch does not allow this for this account{}. Sign in again from Accounts to refresh what it may do.", if why.is_empty() { String::new() } else { format!(" ({why})") }),
HelixError::Failed(e) => write!(f, "{e:#}"),
}
}
}
/// One call to Helix with the account's token: the answer's JSON, or why not.
async fn request(http: &reqwest::Client, who: &Caller<'_>, method: reqwest::Method, path: &str, query: &[(&str, &str)], body: Option<serde_json::Value>) -> Result<serde_json::Value, HelixError> {
let mut url = format!("{}{path}", who.root);
if !query.is_empty() {
let mut q = url::form_urlencoded::Serializer::new(String::new());
for (k, v) in query {
q.append_pair(k, v);
}
url.push('?');
url.push_str(&q.finish());
}
let mut req = http.request(method, &url).header("Authorization", format!("Bearer {}", who.token)).header("Client-Id", who.client_id);
if let Some(body) = body {
req = req.json(&body);
}
let res = req.send().await.context("reaching Twitch").map_err(HelixError::Failed)?;
let status = res.status().as_u16();
let text = res.text().await.unwrap_or_default();
let value: serde_json::Value = serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
let said = value.get("message").and_then(|m| m.as_str()).unwrap_or("").to_string();
match status {
200..=204 => Ok(value),
401 => Err(HelixError::Auth(said)),
// 403 is "you may not do that here", and is a verdict on the action when it carries Twitch's reason.
400 | 403 | 404 | 409 | 422 | 425 => Err(HelixError::Refused(if said.is_empty() { format!("Twitch refused ({status})") } else { said })),
429 => Err(HelixError::Refused("Twitch says you are doing that too fast. Wait a moment.".to_string())),
other => Err(HelixError::Failed(anyhow::anyhow!("Twitch answered {other}{}", if said.is_empty() { String::new() } else { format!(": {said}") }))),
}
}
/// Twitch's number for a login, or `None` where it has no such user.
pub async fn user_id_of(http: &reqwest::Client, who: &Caller<'_>, login: &str) -> Result<Option<String>, HelixError> {
let answer = request(http, who, reqwest::Method::GET, "/users", &[("login", login)], None).await?;
Ok(answer.pointer("/data/0/id").and_then(|i| i.as_str()).map(str::to_string))
}
/// Which channel and who is acting in it: what every moderation call carries.
pub struct Room<'a> {
pub broadcaster_id: &'a str,
pub moderator_id: &'a str,
}
/// Times somebody out for `seconds`, or bans them when there is no length.
pub async fn ban(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, user_id: &str, seconds: Option<u64>, reason: &str) -> Result<(), HelixError> {
let mut data = serde_json::json!({ "user_id": user_id });
if let Some(seconds) = seconds {
data["duration"] = serde_json::json!(seconds);
}
if !reason.is_empty() {
data["reason"] = serde_json::json!(reason);
}
request(http, who, reqwest::Method::POST, "/moderation/bans", &[("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id)], Some(serde_json::json!({ "data": data }))).await.map(|_| ())
}
/// Lets somebody back: ends a ban, or a timeout early.
pub async fn unban(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, user_id: &str) -> Result<(), HelixError> {
request(http, who, reqwest::Method::DELETE, "/moderation/bans", &[("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id), ("user_id", user_id)], None).await.map(|_| ())
}
/// Removes one message, or - with no id - everything in the chat.
pub async fn delete_chat(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, message_id: Option<&str>) -> Result<(), HelixError> {
let mut query = vec![("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id)];
if let Some(id) = message_id {
query.push(("message_id", id));
}
request(http, who, reqwest::Method::DELETE, "/moderation/chat", &query, None).await.map(|_| ())
}
/// Changes the chat's modes: only the fields given are changed.
pub async fn chat_settings(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, patch: serde_json::Value) -> Result<(), HelixError> {
request(http, who, reqwest::Method::PATCH, "/chat/settings", &[("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id)], Some(patch)).await.map(|_| ())
}
/// Makes an announcement in the channel, in one of Twitch's colours.
pub async fn announce(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, message: &str, color: &str) -> Result<(), HelixError> {
request(http, who, reqwest::Method::POST, "/chat/announcements", &[("broadcaster_id", room.broadcaster_id), ("moderator_id", room.moderator_id)], Some(serde_json::json!({ "message": message, "color": color }))).await.map(|_| ())
}
/// Changes the account's own name colour: one of Twitch's names, or a `#rrggbb` where the account may.
pub async fn set_color(http: &reqwest::Client, who: &Caller<'_>, user_id: &str, color: &str) -> Result<(), HelixError> {
request(http, who, reqwest::Method::PUT, "/chat/color", &[("user_id", user_id), ("color", color)], None).await.map(|_| ())
}
/// Blocks somebody (for the account, on Twitch's side, everywhere).
pub async fn block(http: &reqwest::Client, who: &Caller<'_>, user_id: &str) -> Result<(), HelixError> {
request(http, who, reqwest::Method::PUT, "/users/blocks", &[("target_user_id", user_id)], None).await.map(|_| ())
}
/// Lets somebody through again.
pub async fn unblock(http: &reqwest::Client, who: &Caller<'_>, user_id: &str) -> Result<(), HelixError> {
request(http, who, reqwest::Method::DELETE, "/users/blocks", &[("target_user_id", user_id)], None).await.map(|_| ())
}
/// Points the channel's viewers at another channel, in chat.
pub async fn shoutout(http: &reqwest::Client, who: &Caller<'_>, room: &Room<'_>, to_id: &str) -> Result<(), HelixError> {
request(http, who, reqwest::Method::POST, "/chat/shoutouts", &[("from_broadcaster_id", room.broadcaster_id), ("to_broadcaster_id", to_id), ("moderator_id", room.moderator_id)], None).await.map(|_| ())
}
/// Sends a whisper.
pub async fn whisper(http: &reqwest::Client, who: &Caller<'_>, from_id: &str, to_id: &str, message: &str) -> Result<(), HelixError> {
request(http, who, reqwest::Method::POST, "/whispers", &[("from_user_id", from_id), ("to_user_id", to_id)], Some(serde_json::json!({ "message": message }))).await.map(|_| ())
}
/// One emote an account can use in a channel.
#[derive(Debug, PartialEq)]
pub struct UserEmote {
/// The broadcaster who owns it: a channel's number, or empty / `twitch` for Twitch's own.
pub owner: String,
pub id: String,
pub name: String,
pub url: String,
pub animated: bool,
}
/// The emotes an account can use in a channel: Twitch's global ones, the
/// channel's, and any the account has from elsewhere (a subscription to
/// another streamer's that carries over, a follower emote). Needs the
/// `user:read:emotes` scope.
pub async fn user_emotes(http: &reqwest::Client, who: &Caller<'_>, user_id: &str, broadcaster_id: &str, cap: usize) -> anyhow::Result<Vec<UserEmote>> {
let mut out: Vec<UserEmote> = Vec::new();
let mut after = String::new();
loop {
let mut url = format!("{}/chat/emotes/user?user_id={user_id}&broadcaster_id={broadcaster_id}", who.root);
if !after.is_empty() {
url.push_str(&format!("&after={after}"));
}
let res = http
.get(&url)
.header("Authorization", format!("Bearer {}", who.token))
.header("Client-Id", who.client_id)
.send()
.await
.context("reaching Twitch for the emotes you can use")?;
let status = res.status();
let body: serde_json::Value = res.json().await.unwrap_or(serde_json::Value::Null);
if !status.is_success() {
anyhow::bail!("Twitch would not list your emotes ({status})");
}
let template = body.get("template").and_then(|t| t.as_str()).unwrap_or("https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}");
for e in body.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
let (Some(id), Some(name)) = (e.get("id").and_then(|i| i.as_str()), e.get("name").and_then(|n| n.as_str())) else { continue };
let animated = e.get("format").and_then(|f| f.as_array()).is_some_and(|f| f.iter().any(|x| x == "animated"));
// The still form at twice the size: a cell in the picker is small, and the window animates by token.
let url = template.replace("{{id}}", id).replace("{{format}}", "static").replace("{{theme_mode}}", "dark").replace("{{scale}}", "2.0");
let owner = e.get("owner_id").and_then(|o| o.as_str()).unwrap_or("").to_string();
out.push(UserEmote { owner, id: id.to_string(), name: name.to_string(), url, animated });
}
after = body.pointer("/pagination/cursor").and_then(|c| c.as_str()).unwrap_or("").to_string();
if after.is_empty() || out.len() >= cap {
break;
}
}
out.truncate(cap);
Ok(out)
}
/// Every emote a channel has of its own (subscriber tiers, Bits tiers, follower emotes), whether or not an account
/// can use them: what the picker greys out is what is in here and not in `user_emotes`.
pub async fn channel_emotes(http: &reqwest::Client, who: &Caller<'_>, broadcaster_id: &str) -> anyhow::Result<Vec<UserEmote>> {
let res = http
.get(format!("{}/chat/emotes?broadcaster_id={broadcaster_id}", who.root))
.header("Authorization", format!("Bearer {}", who.token))
.header("Client-Id", who.client_id)
.send()
.await
.context("reaching Twitch for the channel's emotes")?;
let status = res.status();
let body: serde_json::Value = res.json().await.unwrap_or(serde_json::Value::Null);
if !status.is_success() {
anyhow::bail!("Twitch would not list the channel's emotes ({status})");
}
let template = body.get("template").and_then(|t| t.as_str()).unwrap_or("https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}");
Ok(body
.get("data")
.and_then(|d| d.as_array())
.into_iter()
.flatten()
.filter_map(|e| {
let (id, name) = (e.get("id")?.as_str()?, e.get("name")?.as_str()?);
let animated = e.get("format").and_then(|f| f.as_array()).is_some_and(|f| f.iter().any(|x| x == "animated"));
let url = template.replace("{{id}}", id).replace("{{format}}", "static").replace("{{theme_mode}}", "dark").replace("{{scale}}", "2.0");
let owner = e.get("owner_id").and_then(|o| o.as_str()).unwrap_or("").to_string();
Some(UserEmote { owner, id: id.to_string(), name: name.to_string(), url, animated })
})
.collect())
}
/// Who some accounts are: (id, login, name shown, picture), as many as are found. A hundred to a request.
pub async fn users_by_id(http: &reqwest::Client, who: &Caller<'_>, ids: &[String]) -> Vec<(String, String, String, String)> {
let mut out = Vec::new();
for chunk in ids.chunks(100) {
let query: Vec<(&str, &str)> = chunk.iter().map(|i| ("id", i.as_str())).collect();
let Ok(answer) = request(http, who, reqwest::Method::GET, "/users", &query, None).await else { continue };
for u in answer.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
let text = |k: &str| u.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
out.push((text("id"), text("login"), text("display_name"), text("profile_image_url")));
}
}
out
}
/// The channels an account follows, by login: only the ones live now when
/// `live_only` (one request), otherwise all of them up to `cap`, a hundred a page.
///
/// Needs the `user:read:follows` scope. A 401 means the token will not do
/// for it and is said as such, since the likely cause is an account that
/// signed in before the scope was asked for.
pub async fn followed(http: &reqwest::Client, who: &Caller<'_>, user_id: &str, live_only: bool, cap: usize) -> anyhow::Result<Vec<String>> {
let mut logins: Vec<String> = Vec::new();
let mut after = String::new();
loop {
let mut url = format!("{}/{}?user_id={user_id}&first=100", who.root, if live_only { "streams/followed" } else { "channels/followed" });
if !after.is_empty() {
url.push_str(&format!("&after={after}"));
}
let res = http
.get(&url)
.header("Authorization", format!("Bearer {}", who.token))
.header("Client-Id", who.client_id)
.send()
.await
.context("reaching Twitch for the channels you follow")?;
let status = res.status();
let body: serde_json::Value = res.json().await.unwrap_or(serde_json::Value::Null);
if !status.is_success() {
let said = body.get("message").and_then(|m| m.as_str()).unwrap_or("");
anyhow::bail!("Twitch would not list the channels you follow ({status}){}. Signing in to Twitch again from Accounts may fix it.", if said.is_empty() { String::new() } else { format!(": {said}") });
}
for row in body.get("data").and_then(|d| d.as_array()).into_iter().flatten() {
if let Some(login) = row.get("broadcaster_login").or_else(|| row.get("user_login")).and_then(|l| l.as_str()) {
logins.push(login.to_lowercase());
}
}
after = body.pointer("/pagination/cursor").and_then(|c| c.as_str()).unwrap_or("").to_string();
// The live list is one page of a hundred, which is every channel a person follows that is on air at once.
if live_only || after.is_empty() || logins.len() >= cap {
break;
}
}
logins.truncate(cap);
Ok(logins)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn twitch(answers: Vec<(u16, &'static str)>) -> (String, Arc<Mutex<Vec<String>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let root = format!("http://{}", listener.local_addr().unwrap());
let seen: Arc<Mutex<Vec<String>>> = Default::default();
let log = seen.clone();
let answers = Arc::new(Mutex::new(answers.into_iter().collect::<std::collections::VecDeque<_>>()));
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let (log, answers) = (log.clone(), answers.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let mut got = 0;
loop {
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
if n == 0 {
break;
}
got += n;
let text = String::from_utf8_lossy(&buf[..got]).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let length = text[..end].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if got >= end + 4 + length {
break;
}
}
}
log.lock().unwrap().push(String::from_utf8_lossy(&buf[..got]).to_string());
let (status, body) = answers.lock().unwrap().pop_front().unwrap_or((500, "{}"));
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = sock.write_all(reply.as_bytes()).await;
});
}
});
(root, seen)
}
async fn send(root: &str, reply: Option<&str>) -> std::result::Result<Sent, SendError> {
let who = Caller { root, client_id: "cid", token: "tok" };
let out = Outgoing { broadcaster_id: "100", sender_id: "42", message: "hello", reply_parent: reply };
send_chat_message(&reqwest::Client::new(), &who, &out).await
}
#[tokio::test]
async fn a_sent_message_comes_back_with_its_id_and_carries_the_account_and_client() {
let (root, seen) = twitch(vec![(200, r#"{"data":[{"message_id":"abc","is_sent":true}]}"#)]).await;
let sent = send(&root, Some("parent-1")).await.unwrap();
assert_eq!(sent, Sent { message_id: "abc".into(), dropped: None });
let request = seen.lock().unwrap()[0].clone();
assert!(request.starts_with("POST /chat/messages"), "{request}");
assert!(request.to_lowercase().contains("authorization: bearer tok"));
assert!(request.to_lowercase().contains("client-id: cid"));
assert!(request.contains(r#""broadcaster_id":"100""#) && request.contains(r#""sender_id":"42""#) && request.contains(r#""reply_parent_message_id":"parent-1""#), "{request}");
}
#[tokio::test]
async fn a_message_twitch_drops_says_why() {
let (root, _) = twitch(vec![
(200, r#"{"data":[{"message_id":"","is_sent":false,"drop_reason":{"code":"msg_duplicate","message":"Your message was not sent because it is identical to the previous one you sent, less than 30 seconds ago."}}]}"#),
(200, r#"{"data":[{"message_id":"","is_sent":false,"drop_reason":{"code":"x","message":""}}]}"#),
])
.await;
assert!(send(&root, None).await.unwrap().dropped.unwrap().contains("identical to the previous one"));
assert_eq!(send(&root, None).await.unwrap().dropped.as_deref(), Some("Twitch did not send it (x)"));
}
#[tokio::test]
async fn a_no_is_a_refusal_and_a_bad_token_is_a_reason_to_try_another_way() {
let (root, _) = twitch(vec![
(403, r#"{"error":"Forbidden","status":403,"message":"The sender is not permitted to send chat messages in this channel."}"#),
(401, r#"{"message":"Missing scope: user:write:chat"}"#),
(429, "{}"),
(500, "{}"),
])
.await;
match send(&root, None).await {
Err(SendError::Refused(why)) => assert!(why.contains("not permitted"), "{why}"),
other => panic!("{other:?}"),
}
assert!(matches!(send(&root, None).await, Err(SendError::Unavailable(_))));
assert!(matches!(send(&root, None).await, Err(SendError::Refused(_))));
assert!(matches!(send(&root, None).await, Err(SendError::Unavailable(_))));
assert!(matches!(send("http://127.0.0.1:9", None).await, Err(SendError::Unavailable(_))));
}
#[tokio::test]
async fn the_channels_somebody_follows_are_listed_page_by_page_up_to_a_cap_and_the_live_ones_in_one_request() {
let (root, seen) = twitch(vec![
(200, r#"{"data":[{"broadcaster_login":"Alpha"},{"broadcaster_login":"beta"}],"pagination":{"cursor":"next-page"}}"#),
(200, r#"{"data":[{"broadcaster_login":"gamma"}],"pagination":{}}"#),
(200, r#"{"data":[{"user_login":"Live1"},{"user_login":"live2"}],"pagination":{"cursor":"ignored"}}"#),
(200, r#"{"data":[{"broadcaster_login":"a"},{"broadcaster_login":"b"},{"broadcaster_login":"c"}],"pagination":{"cursor":"more"}}"#),
(401, r#"{"message":"Missing scope: user:read:follows"}"#),
])
.await;
let who = Caller { root: &root, client_id: "cid", token: "tok" };
let client = reqwest::Client::new();
assert_eq!(followed(&client, &who, "42", false, 200).await.unwrap(), vec!["alpha", "beta", "gamma"]);
let asked = seen.lock().unwrap().clone();
assert!(asked[0].starts_with("GET /channels/followed?user_id=42&first=100 "), "{}", asked[0]);
assert!(asked[1].contains("after=next-page"), "{}", asked[1]);
assert_eq!(followed(&client, &who, "42", true, 200).await.unwrap(), vec!["live1", "live2"]);
assert!(seen.lock().unwrap()[2].starts_with("GET /streams/followed?"), "the live list is its own call, once");
assert_eq!(followed(&client, &who, "42", false, 2).await.unwrap(), vec!["a", "b"], "cut at the cap, and no further page asked for");
let e = followed(&client, &who, "42", false, 200).await.unwrap_err();
assert!(e.to_string().contains("Missing scope") && e.to_string().contains("Signing in"), "{e}");
}
#[tokio::test]
async fn the_emotes_somebody_may_use_in_a_channel_come_with_their_pictures_and_whether_they_move() {
let (root, seen) = twitch(vec![
(200, r#"{"data":[{"id":"25","name":"Kappa","emote_type":"globals","format":["static"]},{"id":"emotesv2_abc","name":"Dance","emote_type":"subscriptions","format":["static","animated"]}],"template":"https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}","pagination":{"cursor":"p2"}}"#),
(200, r#"{"data":[{"id":"9","name":"Last","format":["static"]}],"pagination":{}}"#),
(401, "{}"),
])
.await;
let who = Caller { root: &root, client_id: "cid", token: "tok" };
let client = reqwest::Client::new();
let got = user_emotes(&client, &who, "42", "100", 1000).await.unwrap();
assert_eq!(got.iter().map(|e| (e.name.as_str(), e.animated)).collect::<Vec<_>>(), vec![("Kappa", false), ("Dance", true), ("Last", false)]);
assert_eq!(got[0].url, "https://static-cdn.jtvnw.net/emoticons/v2/25/static/dark/2.0");
assert!(seen.lock().unwrap()[0].starts_with("GET /chat/emotes/user?user_id=42&broadcaster_id=100 "));
assert!(seen.lock().unwrap()[1].contains("after=p2"));
assert!(user_emotes(&client, &who, "42", "100", 1000).await.is_err());
}
#[tokio::test]
async fn the_owners_of_emotes_are_named_with_their_pictures() {
let (root, seen) = twitch(vec![(200, r#"{"data":[{"id":"7","login":"skitzy_va","display_name":"Skitzy_VA","profile_image_url":"https://img/s.png"}]}"#)]).await;
let who = Caller { root: &root, client_id: "cid", token: "tok" };
let got = users_by_id(&reqwest::Client::new(), &who, &["7".to_string(), "8".to_string()]).await;
assert_eq!(got, vec![("7".to_string(), "skitzy_va".to_string(), "Skitzy_VA".to_string(), "https://img/s.png".to_string())]);
assert!(seen.lock().unwrap()[0].starts_with("GET /users?id=7&id=8 "), "{}", seen.lock().unwrap()[0]);
}
#[tokio::test]
async fn a_channels_own_emotes_are_listed_whether_or_not_they_can_be_used() {
let (root, seen) = twitch(vec![
(200, r#"{"data":[{"id":"emotesv2_a","name":"binxSub","emote_type":"subscriptions","format":["static","animated"]},{"id":"7","name":"binxFollow","emote_type":"follower","format":["static"]}],"template":"https://static-cdn.jtvnw.net/emoticons/v2/{{id}}/{{format}}/{{theme_mode}}/{{scale}}"}"#),
(401, "{}"),
])
.await;
let who = Caller { root: &root, client_id: "cid", token: "tok" };
let client = reqwest::Client::new();
let got = channel_emotes(&client, &who, "100").await.unwrap();
assert_eq!(got.iter().map(|e| (e.name.as_str(), e.animated)).collect::<Vec<_>>(), vec![("binxSub", true), ("binxFollow", false)]);
assert_eq!(got[1].url, "https://static-cdn.jtvnw.net/emoticons/v2/7/static/dark/2.0");
assert!(seen.lock().unwrap()[0].starts_with("GET /chat/emotes?broadcaster_id=100 "));
assert!(channel_emotes(&client, &who, "100").await.is_err());
}
}
+129
View File
@@ -0,0 +1,129 @@
//! A channel's recent past.
//!
//! Twitch has no chat history: a client that joins a channel sees it from that
//! moment. The usual answer, Chatterino's among others, is a third-party
//! service that keeps the last few hundred lines of every channel it has been
//! asked about and hands them back as the raw IRC lines Twitch sent
//! (`recent-messages.robotty.de`). They go through the same reading as live
//! ones, recorded under Twitch's own message ids so that one already heard is
//! not shown twice, and timed by Twitch's own timestamps so they sort before
//! the present.
//!
//! It is somebody else's server being told which channel is being opened, so
//! it is off until the person turns it on (Settings, Twitch), and goes through
//! the account's route like every other request.
use super::chat::{self, Rooms};
use super::irc;
use crate::state::AppState;
use anyhow::{bail, Context, Result};
/// The service, and how many lines to ask for.
pub const ROOT: &str = "https://recent-messages.robotty.de/api/v2/recent-messages";
const LIMIT: usize = 400;
/// What the service answers.
#[derive(serde::Deserialize)]
struct Answer {
#[serde(default)]
messages: Vec<String>,
#[serde(default)]
error: Option<String>,
}
/// Reads a channel's recent lines out of the service's answer into the
/// scrollback. Only what was said - messages, and subscriptions and raids
/// announced - and not what the room did then (a ban, a mode, a notice),
/// which is past and would be wrong to apply now.
pub async fn load(state: &AppState, account_id: &str, channel: &str, root: &str) -> Result<usize> {
let http = super::client()?;
let res = http
.get(format!("{root}/{channel}?limit={LIMIT}&hide_moderated_messages=true&clearchat_to_notice=false"))
.send()
.await
.context("reaching the recent-messages service")?;
let status = res.status();
let answer: Answer = res.json().await.unwrap_or(Answer { messages: Vec::new(), error: None });
if !status.is_success() {
bail!("the recent-messages service answered {status}{}", answer.error.map(|e| format!(": {e}")).unwrap_or_default());
}
let cfg = state.accounts.get_twitch(account_id);
let mut rooms = Rooms::new(cfg.as_ref().map(|c| c.login.as_str()).unwrap_or(""), "");
let mut read = 0;
for raw in &answer.messages {
let Some(line) = irc::parse(raw) else { continue };
if line.channel().as_deref() != Some(channel) {
continue;
}
if matches!(line.command.as_str(), "PRIVMSG" | "USERNOTICE") {
chat::handle(state, account_id, &mut rooms, &line, true);
read += 1;
}
}
Ok(read)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn service(body: String, status: u16) -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let root = format!("http://{}", listener.local_addr().unwrap());
let seen: std::sync::Arc<std::sync::Mutex<Vec<String>>> = Default::default();
let log = seen.clone();
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let (log, body) = (log.clone(), body.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 4096];
let n = sock.read(&mut buf).await.unwrap_or(0);
log.lock().unwrap().push(String::from_utf8_lossy(&buf[..n]).lines().next().unwrap_or("").to_string());
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = sock.write_all(reply.as_bytes()).await;
});
}
});
(root, seen)
}
#[tokio::test]
async fn the_past_is_read_as_the_present_was_and_what_is_already_here_is_not_doubled() {
let state = testkit::simulated_daemon("twitch-history");
state.accounts.add_twitch(crate::accounts::TwitchAccountConfig { anonymous: true, ..Default::default() }).unwrap();
let lines = [
"@display-name=Old;id=h1;room-id=1;tmi-sent-ts=1791949000000;user-id=5;historical=1 :[email protected] PRIVMSG #xqc :said a while ago",
"@display-name=Fan;id=h2;room-id=1;tmi-sent-ts=1791949001000;user-id=6;historical=1 :[email protected] PRIVMSG #xqc :still here",
"@display-name=Fan;login=fan;msg-id=sub;room-id=1;tmi-sent-ts=1791949002000;user-id=6 :tmi.twitch.tv USERNOTICE #xqc",
"@room-id=1;target-user-id=5 :tmi.twitch.tv CLEARCHAT #xqc :old",
"@emote-only=1;room-id=1 :tmi.twitch.tv ROOMSTATE #xqc",
"@display-name=Elsewhere;id=h9;room-id=2;tmi-sent-ts=1791949003000;user-id=7 :[email protected] PRIVMSG #other :another channel",
];
let body = serde_json::json!({ "messages": lines, "error": null }).to_string();
let (root, seen) = service(body, 200).await;
// One of them was already heard live.
state.runtime.record_message_at(&state, "twitch:~anonymous", "xqc", "channel", "Fan", "still here", false, "chat", None, Some("h2".into()), false, None, vec![], vec![], None, Some(1791949001), None, None);
let read = load(&state, "twitch:~anonymous", "xqc", &root).await.unwrap();
assert_eq!(read, 3, "two messages and a subscription; not the ban, the mode or another channel");
assert!(seen.lock().unwrap()[0].contains("/xqc?limit="), "{seen:?}");
let log = state.store.get_backlog("twitch:~anonymous|xqc", 0, 20).unwrap();
let bodies: Vec<&str> = log.iter().filter(|m| m.kind == "chat").map(|m| m.body.as_str()).collect();
assert_eq!(bodies, vec!["said a while ago", "still here"], "in the order they were said, with the one heard live not repeated");
assert!(log.iter().all(|m| m.moderated.is_none()), "an old ban is not applied now");
assert!(state.runtime.get_presence("twitch:~anonymous|xqc").is_none(), "the past does not make the roster");
assert_eq!(state.runtime.get_buffer("twitch:~anonymous|xqc").unwrap().channel_modes, None, "nor the modes");
}
#[tokio::test]
async fn a_service_that_will_not_answer_is_an_error_and_nothing_else() {
let state = testkit::simulated_daemon("twitch-history-down");
let (root, _) = service(r#"{"messages":[],"error":"Channel not joined yet"}"#.to_string(), 404).await;
let e = load(&state, "twitch:~anonymous", "xqc", &root).await.unwrap_err();
assert!(format!("{e}").contains("404") && format!("{e}").contains("Channel not joined yet"), "{e}");
assert!(load(&state, "twitch:~anonymous", "xqc", "http://127.0.0.1:9").await.is_err());
}
}
+202
View File
@@ -0,0 +1,202 @@
//! Twitch's IRC, as a line: tags, a prefix, a command and its parameters.
//!
//! Twitch speaks IRC with IRCv3 message tags - `@key=value;key=value` ahead of
//! the line - and nearly everything worth knowing about a message is in them:
//! who it was, what colour, what badges, which parts are emotes, what it was
//! a reply to. This reads one line into that shape and does nothing else; what
//! the lines mean is `chat.rs`'s. Written against lines captured from live
//! channels (the tests keep some), and tolerant of what it does not know: a tag
//! it has no use for is carried along, and a line it cannot read is nothing
//! rather than an error, because one odd line must not end a connection.
use std::collections::HashMap;
/// One line from Twitch.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Line {
pub tags: HashMap<String, String>,
/// Who sent it: `nick!user@host`, or a server name.
pub prefix: Option<String>,
pub command: String,
/// Everything after the command; the trailing parameter (the one that may
/// hold spaces, after `:`) is last.
pub params: Vec<String>,
}
impl Line {
/// A tag's value, or an empty string where it is absent or empty - which
/// Twitch writes the same way (`emotes=` and no `emotes` tag are one thing).
pub fn tag(&self, key: &str) -> &str {
self.tags.get(key).map(String::as_str).unwrap_or("")
}
/// The nick the line is from, out of `nick!user@host`.
pub fn nick(&self) -> Option<&str> {
let prefix = self.prefix.as_deref()?;
Some(prefix.split('!').next().unwrap_or(prefix)).filter(|n| !n.is_empty())
}
/// The channel it is about, without the `#`, lower-cased as Twitch's are.
pub fn channel(&self) -> Option<String> {
self.params.first().and_then(|p| p.strip_prefix('#')).map(|c| c.to_lowercase())
}
/// The last parameter: what was said, for a message.
pub fn text(&self) -> &str {
if self.params.len() > 1 { self.params.last().map(String::as_str).unwrap_or("") } else { "" }
}
}
/// Reads a tag's value as the IRCv3 spec escapes it.
///
/// `\:` is `;`, `\s` a space, `\\` a backslash, `\r` and `\n` the controls; a
/// backslash before anything else is dropped and the character kept, and a
/// lone one at the end is dropped. A reply quotes the message it answers in a
/// tag, so this is what turns `hello\sthere` back into "hello there".
pub fn unescape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some(':') => out.push(';'),
Some('s') => out.push(' '),
Some('r') => out.push('\r'),
Some('n') => out.push('\n'),
Some('\\') => out.push('\\'),
Some(other) => out.push(other),
None => {}
}
}
out
}
/// Reads one line, without its line ending. `None` for a line with no command.
pub fn parse(raw: &str) -> Option<Line> {
let mut rest = raw.trim_end_matches(['\r', '\n']);
let mut line = Line::default();
if let Some(after) = rest.strip_prefix('@') {
let (tags, tail) = after.split_once(' ')?;
for tag in tags.split(';').filter(|t| !t.is_empty()) {
let (key, value) = tag.split_once('=').unwrap_or((tag, ""));
line.tags.insert(key.to_string(), unescape(value));
}
rest = tail.trim_start();
}
if let Some(after) = rest.strip_prefix(':') {
let (prefix, tail) = after.split_once(' ').unwrap_or((after, ""));
line.prefix = Some(prefix.to_string());
rest = tail.trim_start();
}
let (command, mut tail) = rest.split_once(' ').unwrap_or((rest, ""));
if command.is_empty() {
return None;
}
line.command = command.to_string();
while !tail.is_empty() {
if let Some(trailing) = tail.strip_prefix(':') {
line.params.push(trailing.to_string());
break;
}
let (param, next) = tail.split_once(' ').unwrap_or((tail, ""));
line.params.push(param.to_string());
tail = next.trim_start();
}
Some(line)
}
/// What a client sends to get in: the capabilities that put the tags on the
/// lines, then the password and the nick. In that order - Twitch wants the
/// capability request first.
pub fn greeting(login: &str, token: Option<&str>) -> Vec<String> {
// Any password does for an anonymous reader, and a `justinfan` nick marks it.
let (nick, pass) = match token {
Some(token) => (login.to_string(), format!("oauth:{token}")),
None => (format!("justinfan{}", 10_000 + (std::process::id() % 80_000)), "SCHMOOPIIE".to_string()),
};
vec!["CAP REQ :twitch.tv/tags twitch.tv/commands".to_string(), format!("PASS {pass}"), format!("NICK {nick}")]
}
#[cfg(test)]
mod tests {
use super::*;
/// A line captured from xqc's chat, tags and all.
const MESSAGE: &str = "@badge-info=subscriber/37;badges=subscriber/36,premium/1;client-nonce=0c55da2c71da4ff5af5bb3a8ee0ef46d;color=#FF69B4;display-name=Zakktheking13;emotes=301445381:0-7;first-msg=0;flags=;id=6a349c82-69f2-4ed6-ad45-30b2d3e57102;mod=0;returning-chatter=0;room-id=71092938;subscriber=1;tmi-sent-ts=1791949381404;turbo=0;user-id=494821572;user-type= :[email protected] PRIVMSG #xqc :xqcPeepo eyebrow busta";
#[test]
fn a_message_is_read_with_its_tags_prefix_channel_and_text() {
let line = parse(MESSAGE).unwrap();
assert_eq!(line.command, "PRIVMSG");
assert_eq!(line.nick(), Some("zakktheking13"));
assert_eq!(line.channel().as_deref(), Some("xqc"));
// The two spaces inside the text are the sender's, and are kept.
assert_eq!(line.text(), "xqcPeepo eyebrow busta");
assert_eq!(line.tag("display-name"), "Zakktheking13");
assert_eq!(line.tag("emotes"), "301445381:0-7");
assert_eq!(line.tag("color"), "#FF69B4");
assert_eq!(line.tag("tmi-sent-ts"), "1791949381404");
// An empty tag and an absent one are the same thing.
assert_eq!(line.tag("flags"), "");
assert_eq!(line.tag("no-such-tag"), "");
}
#[test]
fn tag_values_are_unescaped() {
assert_eq!(unescape(r"what\sdoes\sno\seyebrows\smean\sbru"), "what does no eyebrows mean bru");
assert_eq!(unescape(r"a\:b\\c\nd\re"), "a;b\\c\nd\re");
assert_eq!(unescape(r"trailing\"), "trailing");
assert_eq!(unescape(r"odd\qescape"), "oddqescape");
}
#[test]
fn a_reply_names_what_it_answers_in_its_tags() {
let line = parse("@reply-parent-display-name=deathsashes112;reply-parent-msg-body=what\\sdoes\\sno\\seyebrows\\smean\\sbru;reply-parent-msg-id=bc4a0663-4e5d-49c4-8d5b-0c78ec73d901;reply-parent-user-login=deathsashes112;reply-thread-parent-msg-id=bc4a0663-4e5d-49c4-8d5b-0c78ec73d901;id=b4d3 :a_t_m_0_s!a_t_m_0_s@a_t_m_0_s.tmi.twitch.tv PRIVMSG #xqc :@deathsashes112 it means nothing").unwrap();
assert_eq!(line.tag("reply-parent-msg-body"), "what does no eyebrows mean bru");
assert_eq!(line.tag("reply-thread-parent-msg-id"), "bc4a0663-4e5d-49c4-8d5b-0c78ec73d901");
}
#[test]
fn the_lines_with_no_tags_are_read_too() {
let line = parse(":tmi.twitch.tv 001 justinfan26983 :Welcome, GLHF!").unwrap();
assert_eq!((line.command.as_str(), line.prefix.as_deref(), line.params.clone()), ("001", Some("tmi.twitch.tv"), vec!["justinfan26983".to_string(), "Welcome, GLHF!".to_string()]));
let ping = parse("PING :tmi.twitch.tv\r\n").unwrap();
assert_eq!((ping.command.as_str(), ping.text()), ("PING", ""));
assert_eq!(ping.params, vec!["tmi.twitch.tv".to_string()]);
let cap = parse(":tmi.twitch.tv CAP * ACK :twitch.tv/tags twitch.tv/commands").unwrap();
assert_eq!((cap.command.as_str(), cap.params.len()), ("CAP", 3));
}
#[test]
fn a_roomstate_is_just_tags() {
let line = parse("@emote-only=0;followers-only=10;r9k=0;room-id=71092938;slow=0;subs-only=0 :tmi.twitch.tv ROOMSTATE #xqc").unwrap();
assert_eq!((line.command.as_str(), line.channel().as_deref(), line.tag("followers-only"), line.tag("room-id")), ("ROOMSTATE", Some("xqc"), "10", "71092938"));
}
#[test]
fn an_action_keeps_its_markers_for_the_caller_to_read() {
let line = parse(":[email protected] PRIVMSG #xqc :\u{1}ACTION waves\u{1}").unwrap();
assert_eq!(line.text(), "\u{1}ACTION waves\u{1}");
}
#[test]
fn what_is_not_a_line_is_nothing() {
assert!(parse("").is_none());
assert!(parse("@tags-and-nothing-else").is_none());
assert!(parse("\r\n").is_none());
}
#[test]
fn a_greeting_asks_for_the_tags_first_and_is_anonymous_without_a_token() {
let signed = greeting("someone", Some("abc"));
assert_eq!(signed, vec!["CAP REQ :twitch.tv/tags twitch.tv/commands", "PASS oauth:abc", "NICK someone"]);
let anon = greeting("", None);
assert!(anon[2].starts_with("NICK justinfan"), "{anon:?}");
assert!(anon[1].starts_with("PASS "));
}
}
+277
View File
@@ -0,0 +1,277 @@
//! Adding an account: the sign-in a person takes part in.
//!
//! Twitch's sign-in is a conversation held in two places. Moho asks for a
//! code and shows it; the person types it (or follows the link, which has it
//! in) on twitch.tv, signed in however they sign in, and says yes; moho,
//! meanwhile, asks every few seconds whether that has happened yet. It can
//! take minutes, so it runs as its own task and talks to the window through
//! events rather than answering one request:
//!
//! - `twitchLoginCode` - the code to show, where to enter it, and how long it
//! is good for
//! - `twitchLoginStatus` - what it is doing, for a line of text
//! - `twitchLoginResult` - done, with the account, or the reason it failed
//!
//! The window closing the form ends the wait with `cancelTwitchLogin`.
use super::auth::{self, Poll};
use crate::accounts::TwitchAccountConfig;
use crate::state::AppState;
use anyhow::{bail, Result};
use serde_json::json;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::Notify;
/// The sign-ins waiting on a person, by id, and the way to end each one.
fn waiting() -> &'static Mutex<HashMap<String, Arc<Notify>>> {
static WAITING: std::sync::OnceLock<Mutex<HashMap<String, Arc<Notify>>>> = std::sync::OnceLock::new();
WAITING.get_or_init(Default::default)
}
/// The twitch.tv session some sign-ins bring with them, by sign-in: the window that signs a person in to twitch.tv
/// and then has them approve the code is one action, and the session it saw on the way is for the account the
/// code becomes. Kept only for as long as the sign-in lasts.
fn web_tokens() -> &'static Mutex<HashMap<String, String>> {
static TOKENS: std::sync::OnceLock<Mutex<HashMap<String, String>>> = std::sync::OnceLock::new();
TOKENS.get_or_init(Default::default)
}
/// Hands a sign-in the person's twitch.tv session, to be saved with the account it makes.
pub fn set_web_token(login_id: &str, token: &str) -> bool {
if token.trim().is_empty() || !waiting().lock().unwrap().contains_key(login_id) {
return false;
}
web_tokens().lock().unwrap().insert(login_id.to_string(), token.trim().to_string());
true
}
/// Ends a sign-in that is waiting, because the form was closed.
pub fn cancel(login_id: &str) {
web_tokens().lock().unwrap().remove(login_id);
if let Some(stop) = waiting().lock().unwrap().remove(login_id) {
stop.notify_one();
}
}
/// Starts a sign-in. Answers at once; the rest is events.
pub fn start(state: AppState, login_id: String) {
start_at(state, login_id, auth::ID_ROOT.to_string(), true)
}
/// The same against a given server, and with or without starting the
/// connection once it is done - which is what lets a test do it without one.
pub fn start_at(state: AppState, login_id: String, root: String, connect: bool) {
let stop = Arc::new(Notify::new());
waiting().lock().unwrap().insert(login_id.clone(), stop.clone());
tokio::spawn(async move {
let outcome = flow(&state, &login_id, &root, stop).await;
waiting().lock().unwrap().remove(&login_id);
web_tokens().lock().unwrap().remove(&login_id);
match outcome {
Ok(Some(saved)) => {
if connect {
super::spawn(state.clone(), saved.clone());
}
let account = crate::accounts::twitch_account_to_json(&saved, if connect { "connecting" } else { "disconnected" });
state.events.emit("twitchLoginResult", json!({ "loginId": login_id, "success": true, "account": account }));
}
// The form was closed: nobody is waiting to be told.
Ok(None) => {}
Err(e) => {
let error = format!("{e:#}");
tracing::warn!("twitch sign-in {login_id}: {error}");
state.events.emit("twitchLoginResult", json!({ "loginId": login_id, "success": false, "error": error }));
}
}
});
}
/// The sign-in itself. `None` when it was cancelled.
pub async fn flow(state: &AppState, login_id: &str, root: &str, stop: Arc<Notify>) -> Result<Option<TwitchAccountConfig>> {
let status = |detail: &str| state.events.emit("twitchLoginStatus", json!({ "loginId": login_id, "detail": detail }));
let http = super::client()?;
status("Asking Twitch for a code");
let code = auth::start(&http, root).await?;
state.events.emit(
"twitchLoginCode",
json!({
"loginId": login_id,
"userCode": code.user_code,
"verificationUri": code.verification_uri,
"expiresIn": code.expires_in,
"scopes": auth::SCOPES.iter().chain(auth::MODERATION_SCOPES.iter()).map(|(s, why)| json!({ "scope": s, "why": why })).collect::<Vec<_>>(),
}),
);
status("Waiting for you to approve it on twitch.tv");
let deadline = tokio::time::Instant::now() + Duration::from_secs(code.expires_in);
let mut interval = Duration::from_secs(code.interval.max(1));
let tokens = loop {
tokio::select! {
_ = stop.notified() => return Ok(None),
_ = tokio::time::sleep(interval) => {}
}
if tokio::time::Instant::now() >= deadline {
bail!("The code ran out before it was approved. Start again for a new one.");
}
match auth::poll(&http, root, &code.device_code).await {
Ok(Poll::Pending) => {}
Ok(Poll::SlowDown) => interval += Duration::from_secs(5),
Ok(Poll::Denied) => bail!("The sign-in was refused on twitch.tv."),
Ok(Poll::Expired) => bail!("The code ran out before it was approved. Start again for a new one."),
Ok(Poll::Approved(tokens)) => break tokens,
// A network blip while waiting is not the end of a sign-in the
// person may be halfway through approving; the deadline is.
Err(e) => tracing::debug!("twitch sign-in {login_id}: {e:#}"),
}
};
status("Checking who signed in");
let Some(who) = auth::validate(&http, root, &tokens.access_token).await? else {
bail!("Twitch gave a token and then would not accept it. Try again.");
};
let now = chrono::Utc::now().timestamp();
let existing = state.accounts.get_twitch(&format!("twitch:{}", who.login.to_lowercase()));
let saved = state.accounts.add_twitch(TwitchAccountConfig {
login: who.login,
user_id: who.user_id,
display_name: existing.as_ref().and_then(|e| e.display_name.clone()),
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
expires_at: now + tokens.expires_in,
scopes: if who.scopes.is_empty() { tokens.scope } else { who.scopes },
// Signing in again keeps the channels that were open: a renewed
// sign-in is not a reason to forget where the person was.
followed_synced: existing.as_ref().map(|e| e.followed_synced).unwrap_or(false),
channels: existing.map(|e| e.channels).unwrap_or_default(),
web_token: String::new(),
anonymous: false,
use_tor: crate::net::route::router().wanted(&crate::net::route::pending_key("twitch")),
// Never strict at sign-in; the account store keeps an existing
// account's strict routing across a sign-in.
strict_route: false,
})?;
// The twitch.tv session the window signed in to on the way, if it did: video plays as the person from the start.
if let Some(token) = web_tokens().lock().unwrap().remove(login_id) {
state.accounts.mutate_twitch(&saved.account_id(), |a| a.web_token = token.clone())?;
return Ok(state.accounts.get_twitch(&saved.account_id()));
}
Ok(Some(saved))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A Twitch whose answers depend on what is asked and when: the code at
/// once, "pending" a couple of times, then the tokens, then who they are.
async fn twitch(approve_after: usize) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let root = format!("http://{}", listener.local_addr().unwrap());
let polls = Arc::new(Mutex::new(0usize));
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let polls = polls.clone();
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let n = sock.read(&mut buf).await.unwrap_or(0);
let head = String::from_utf8_lossy(&buf[..n]).to_string();
let line = head.lines().next().unwrap_or("").to_string();
let (status, body) = if line.starts_with("POST /oauth2/device") {
(200, r#"{"device_code":"dev","expires_in":60,"interval":1,"user_code":"WXYZ1234","verification_uri":"https://www.twitch.tv/activate?device-code=WXYZ1234"}"#.to_string())
} else if line.starts_with("POST /oauth2/token") {
let mut p = polls.lock().unwrap();
*p += 1;
if *p > approve_after {
(200, r#"{"access_token":"acc","refresh_token":"ref","expires_in":14000,"scope":["chat:read"],"token_type":"bearer"}"#.to_string())
} else {
(400, r#"{"status":400,"message":"authorization_pending"}"#.to_string())
}
} else if line.starts_with("GET /oauth2/validate") {
(200, r#"{"client_id":"c","login":"Someone","scopes":["chat:read"],"user_id":"42","expires_in":14000}"#.to_string())
} else {
(404, "{}".to_string())
};
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = sock.write_all(reply.as_bytes()).await;
});
}
});
root
}
async fn next_event(rx: &mut tokio::sync::broadcast::Receiver<crate::events::Event>, name: &str) -> serde_json::Value {
loop {
let event = tokio::time::timeout(Duration::from_secs(15), rx.recv()).await.expect("an event in time").expect("the bus is open");
if event.name == name {
return event.data;
}
}
}
#[tokio::test]
async fn a_person_is_shown_a_code_approves_it_and_has_an_account() {
let state = testkit::simulated_daemon("twitch-login");
let mut events = state.events.subscribe();
let root = twitch(1).await;
start_at(state.clone(), "login-1".into(), root, false);
let code = next_event(&mut events, "twitchLoginCode").await;
assert_eq!(code["userCode"], "WXYZ1234");
assert!(code["verificationUri"].as_str().unwrap().contains("device-code=WXYZ1234"));
assert!(code["scopes"].as_array().unwrap().iter().any(|s| s["scope"] == "chat:send" || s["scope"] == "chat:edit"));
let result = next_event(&mut events, "twitchLoginResult").await;
assert_eq!(result["success"], true, "{result}");
assert_eq!(result["account"]["id"], "twitch:someone", "the login is read from Twitch and lower-cased");
assert_eq!(result["account"]["service"], "twitch");
let saved = state.accounts.get_twitch("twitch:someone").expect("saved");
assert_eq!((saved.user_id.as_str(), saved.access_token.as_str(), saved.refresh_token.as_str()), ("42", "acc", "ref"));
assert!(saved.expires_at > chrono::Utc::now().timestamp() + 13000);
}
#[tokio::test]
async fn closing_the_form_ends_the_wait_without_saving_anything() {
let state = testkit::simulated_daemon("twitch-login-cancel");
let mut events = state.events.subscribe();
let root = twitch(1000).await;
start_at(state.clone(), "login-2".into(), root, false);
next_event(&mut events, "twitchLoginCode").await;
cancel("login-2");
tokio::time::sleep(Duration::from_millis(1500)).await;
assert!(state.accounts.all_twitch().is_empty());
assert!(waiting().lock().unwrap().get("login-2").is_none());
}
#[tokio::test]
async fn an_unreachable_twitch_is_said_by_the_form() {
let state = testkit::simulated_daemon("twitch-login-down");
let mut events = state.events.subscribe();
start_at(state.clone(), "login-3".into(), "http://127.0.0.1:9".into(), false);
let result = next_event(&mut events, "twitchLoginResult").await;
assert_eq!(result["success"], false);
assert!(result["error"].as_str().unwrap().to_lowercase().contains("twitch"), "{result}");
}
#[tokio::test]
async fn a_twitch_session_handed_over_while_waiting_is_saved_with_the_account_the_code_becomes() {
let state = testkit::simulated_daemon("twitch-login-web");
let mut events = state.events.subscribe();
let root = twitch(1).await;
start_at(state.clone(), "login-4".into(), root, false);
next_event(&mut events, "twitchLoginCode").await;
assert!(set_web_token("login-4", " web-session "));
assert!(!set_web_token("nobody", "x"), "a sign-in that is not waiting takes nothing");
let result = next_event(&mut events, "twitchLoginResult").await;
assert_eq!(result["success"], true, "{result}");
assert_eq!(result["account"]["webSession"], true);
assert_eq!(state.accounts.get_twitch("twitch:someone").unwrap().web_token, "web-session");
assert!(web_tokens().lock().unwrap().get("login-4").is_none(), "nothing is left lying about");
}
}
+64
View File
@@ -0,0 +1,64 @@
//! Twitch - chat and live video, over Twitch's own IRC service and the same
//! playlists its website plays.
//!
//! Twitch is the plan in Gitea issue #55, read before anything here. In short:
//! chat is IRC over a websocket (anonymously readable, signed in to send),
//! the official REST API is Helix, and the video comes from the playlist
//! service twitch.tv's own player uses, so the stream can be drawn in the
//! window's player the way Kick's is.
//!
//! # How this folder is arranged
//!
//! - `auth` - Twitch's sign-in (the device code flow) and what comes of it:
//! tokens, renewing them, finding out who they belong to
//! - `login` - adding an account: the sign-in a person takes part in
//! - `moderation` - removing messages, timeouts and bans, the room's modes, whispers: slash commands and menus alike
//! - `irc` - one line of Twitch's chat: tags, prefix, command, parameters
//! - `emotes` - whether one of Twitch's own emotes is animated, and how an account's own are turned between picture and word
//! - `badges` - the pictures for the badges beside a name
//! - `watch` - telling Twitch a channel is being watched, so its points build while the video plays
//! - `chat` - what the lines mean: messages, cards, removals, a channel's modes
//! - `gql` - Twitch's own GraphQL, which answers what its official API will not without an account
//! - `stream` - what is on air in a channel, said as the stream event the window draws
//! - `playback` - the address of a channel's video, and the integrity token Twitch sometimes wants for it
//! - `points` - channel points, the bonus, bets and votes, as the person's twitch.tv session
//! - `realtime` - what Twitch says as it happens beyond the chat: raids, polls and predictions
//! - `helix` - Twitch's official API, as far as moho uses it
//! - `history` - a channel's recent past, from a third-party service, if asked
//! - `connect` - an account's connection: keeping its token good, and the chat
//! connection with its channels
//!
//! As in the other backends, `spawn` is re-exported so callers say
//! `backend::twitch::spawn` without knowing which file that is.
pub mod auth;
pub mod badges;
pub mod chat;
pub mod connect;
pub mod emotes;
pub mod gql;
pub mod helix;
pub mod history;
pub mod irc;
pub mod login;
pub mod moderation;
pub mod playback;
pub mod points;
pub mod realtime;
pub mod stream;
pub mod watch;
pub use connect::{spawn, Command};
/// What routes this service's requests when any Twitch account goes through
/// Tor or the proxy. One key for the whole service, as Kick's is: the
/// requests that are made before an account exists (the sign-in) have no
/// account to name.
pub const SHARED_KEY: &str = "twitch:any-routed";
/// An HTTP client for Twitch's servers, through the route.
pub fn client() -> anyhow::Result<reqwest::Client> {
let router = crate::net::route::router();
let routed = router.routed(SHARED_KEY) || router.routed(&crate::net::route::pending_key("twitch"));
Ok(router.client_if("twitch", routed, |builder| builder.user_agent(concat!("moho/", env!("CARGO_PKG_VERSION"))).timeout(std::time::Duration::from_secs(20))))
}
+433
View File
@@ -0,0 +1,433 @@
//! What a person can do to a channel's chat, and to themselves, through
//! Twitch's official API: remove a message, time somebody out or ban them (and
//! let them back), change the room's modes, announce, change a name colour,
//! whisper.
//!
//! Twitch no longer reads these as chat text (`/ban` typed into IRC does
//! nothing), so they are API calls, and this reads the slash commands people
//! are used to typing and makes the calls. The window's own menus - a message's
//! Delete, a name's Time out - make the same ones.
//!
//! The rights come with signing in (see `auth::MODERATION_SCOPES`), so one sign-in has every feature;
//! what an account may actually do in a channel is Twitch's to say on each call, and moho offers
//! the menus only where the account is a moderator or the broadcaster. An account signed in before
//! they were part of it is told to sign in again.
use super::helix::{self, Caller, Room};
use crate::state::AppState;
/// One thing to do.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
/// A person, by login, for this many seconds.
Timeout { user: String, seconds: u64, reason: String },
Ban { user: String, reason: String },
Unban { user: String },
/// One message by id, or all of them.
Delete { message_id: Option<String> },
Slow(Option<u32>),
/// Followers-only, with the minutes a person must have followed for.
Followers(Option<u32>),
Subscribers(bool),
EmoteOnly(bool),
Unique(bool),
Announce { text: String, color: String },
Color(String),
Whisper { to: String, text: String },
/// Twitch's own block list, for the account.
Block { user: String },
Unblock { user: String },
/// A shoutout to another channel, in this one.
Shoutout { user: String },
}
impl Action {
/// Whether it is something a moderator does to the room, which needs the
/// right to, as against what a person does as themselves.
pub fn needs_moderator(&self) -> bool {
!matches!(self, Action::Color(_) | Action::Whisper { .. } | Action::Block { .. } | Action::Unblock { .. })
}
}
/// Twitch's longest timeout is fourteen days.
const LONGEST_TIMEOUT: u64 = 14 * 24 * 3600;
fn login_of(word: &str) -> Option<String> {
let login = word.trim().trim_start_matches('@').to_lowercase();
(!login.is_empty() && login.len() <= 25 && login.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')).then_some(login)
}
/// A length of time as people type it: `600`, `10m`, `2h`, `1d`, `2w`, in seconds.
pub fn duration_seconds(word: &str) -> Option<u64> {
let word = word.trim().to_lowercase();
let digits: String = word.chars().take_while(|c| c.is_ascii_digit()).collect();
let unit = &word[digits.len()..];
let n: u64 = digits.parse().ok()?;
let per = match unit {
"" | "s" => 1,
"m" => 60,
"h" => 3600,
"d" => 86_400,
"w" => 604_800,
_ => return None,
};
n.checked_mul(per)
}
/// Reads a slash command. `None` for text that is not one (and for `/me`,
/// which Twitch reads itself); an error for one that is wrong or unknown, in
/// words, so a mistyped command is never sent as a message.
pub fn parse(text: &str) -> Option<Result<Action, String>> {
let text = text.trim();
let body = text.strip_prefix('/')?;
// Twitch's own: sent as typed.
if body == "me" || body.starts_with("me ") || body.starts_with('/') || body.is_empty() {
return None;
}
let (name, rest) = body.split_once(char::is_whitespace).map(|(n, r)| (n, r.trim())).unwrap_or((body, ""));
let mut words = rest.split_whitespace();
let user = |w: Option<&str>| w.and_then(login_of).ok_or_else(|| format!("/{name} needs a user: /{name} <name>"));
Some(match name.to_lowercase().as_str() {
"timeout" => (|| {
let who = user(words.next())?;
let mut seconds = 600;
let mut reason: Vec<&str> = Vec::new();
if let Some(first) = words.next() {
match duration_seconds(first) {
Some(s) => seconds = s,
None => reason.push(first),
}
}
reason.extend(words);
if seconds == 0 || seconds > LONGEST_TIMEOUT {
return Err("A timeout is from 1 second to 14 days".to_string());
}
Ok(Action::Timeout { user: who, seconds, reason: reason.join(" ") })
})(),
"ban" => user(words.next()).map(|who| Action::Ban { user: who, reason: words.collect::<Vec<_>>().join(" ") }),
"unban" | "untimeout" => user(words.next()).map(|who| Action::Unban { user: who }),
"delete" => match words.next() {
Some(id) => Ok(Action::Delete { message_id: Some(id.to_string()) }),
None => Err("/delete needs the id of a message; use a message's own menu to delete one".to_string()),
},
"clear" => Ok(Action::Delete { message_id: None }),
"slow" => match words.next().map(|w| w.parse::<u32>()) {
None => Ok(Action::Slow(Some(30))),
Some(Ok(s)) if (3..=120).contains(&s) => Ok(Action::Slow(Some(s))),
_ => Err("Slow mode is 3 to 120 seconds: /slow <seconds>".to_string()),
},
"slowoff" => Ok(Action::Slow(None)),
"followers" => match words.next() {
None => Ok(Action::Followers(Some(0))),
Some(w) => match duration_seconds(&if w.chars().all(|c| c.is_ascii_digit()) { format!("{w}m") } else { w.to_string() }) {
Some(s) if s / 60 <= 129_600 => Ok(Action::Followers(Some((s / 60) as u32))),
_ => Err("Followers-only takes a length up to 3 months: /followers 30m, /followers 2h, /followers 1d".to_string()),
},
},
"followersoff" => Ok(Action::Followers(None)),
"subscribers" | "subscriberson" | "subs" => Ok(Action::Subscribers(true)),
"subscribersoff" | "subsoff" => Ok(Action::Subscribers(false)),
"emoteonly" | "emoteonlyon" => Ok(Action::EmoteOnly(true)),
"emoteonlyoff" => Ok(Action::EmoteOnly(false)),
"uniquechat" | "r9kbeta" => Ok(Action::Unique(true)),
"uniquechatoff" | "r9kbetaoff" => Ok(Action::Unique(false)),
"announce" | "announceblue" | "announcegreen" | "announceorange" | "announcepurple" => {
let color = name.to_lowercase().trim_start_matches("announce").to_string();
let color = if color.is_empty() { "primary".to_string() } else { color };
if rest.is_empty() { Err(format!("/{name} needs something to announce")) } else { Ok(Action::Announce { text: rest.to_string(), color }) }
}
"color" => {
if rest.is_empty() { Err("/color needs a colour: a name like coral or blue_violet, or #rrggbb with Twitch Turbo or Prime".to_string()) } else { Ok(Action::Color(rest.split_whitespace().next().unwrap_or("").to_string())) }
}
"block" => user(words.next()).map(|who| Action::Block { user: who }),
"unblock" => user(words.next()).map(|who| Action::Unblock { user: who }),
"shoutout" | "so" => user(words.next()).map(|who| Action::Shoutout { user: who }),
"w" | "whisper" => match (words.next().and_then(login_of), words.collect::<Vec<_>>().join(" ")) {
(Some(to), text) if !text.is_empty() => Ok(Action::Whisper { to, text }),
_ => Err("/w needs a name and a message: /w <name> <message>".to_string()),
},
other => Err(format!("Twitch has no /{other} command here. Type // to send a message that starts with a slash.")),
})
}
/// What is needed to act: who and where.
pub struct Ctx<'a> {
pub token: &'a str,
pub my_id: &'a str,
/// Twitch's number for the channel, from its room state.
pub room_id: &'a str,
/// Whether this account is a moderator (or the broadcaster) there.
pub moderator: bool,
pub api_root: &'a str,
pub client_id: &'a str,
}
fn said(r: Result<(), helix::HelixError>, done: String) -> Result<String, String> {
r.map(|_| done).map_err(|e| e.to_string())
}
/// Does it, and says what was done in words.
pub async fn run(http: &reqwest::Client, cx: &Ctx<'_>, action: &Action, target_id: Option<&str>) -> Result<String, String> {
if action.needs_moderator() && !cx.moderator {
return Err("You are not a moderator in this channel".to_string());
}
let who = Caller { root: cx.api_root, client_id: cx.client_id, token: cx.token };
let room = Room { broadcaster_id: cx.room_id, moderator_id: cx.my_id };
let resolve = |login: &str| {
let (who, login) = (&who, login.to_string());
let known = target_id.map(str::to_string);
async move {
if let Some(id) = known {
return Ok(id);
}
match helix::user_id_of(http, who, &login).await {
Ok(Some(id)) => Ok(id),
Ok(None) => Err(format!("Twitch has no user called {login}")),
Err(e) => Err(e.to_string()),
}
}
};
match action {
Action::Timeout { user, seconds, reason } => {
let id = resolve(user).await?;
said(helix::ban(http, &who, &room, &id, Some(*seconds), reason).await, format!("{user} was timed out for {}", super::chat::span(*seconds)))
}
Action::Ban { user, reason } => {
let id = resolve(user).await?;
said(helix::ban(http, &who, &room, &id, None, reason).await, format!("{user} was banned"))
}
Action::Unban { user } => {
let id = resolve(user).await?;
said(helix::unban(http, &who, &room, &id).await, format!("{user} is allowed back"))
}
Action::Delete { message_id } => said(
helix::delete_chat(http, &who, &room, message_id.as_deref()).await,
if message_id.is_some() { "The message was deleted".to_string() } else { "Chat was cleared".to_string() },
),
Action::Slow(Some(s)) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "slow_mode": true, "slow_mode_wait_time": s })).await, format!("Slow mode is on: {s} seconds")),
Action::Slow(None) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "slow_mode": false })).await, "Slow mode is off".to_string()),
Action::Followers(Some(minutes)) => said(
helix::chat_settings(http, &who, &room, serde_json::json!({ "follower_mode": true, "follower_mode_duration": minutes })).await,
if *minutes == 0 { "Followers-only mode is on".to_string() } else { format!("Followers-only mode is on: followers of {}", super::chat::span(*minutes as u64 * 60)) },
),
Action::Followers(None) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "follower_mode": false })).await, "Followers-only mode is off".to_string()),
Action::Subscribers(on) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "subscriber_mode": on })).await, format!("Subscribers-only mode is {}", if *on { "on" } else { "off" })),
Action::EmoteOnly(on) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "emote_mode": on })).await, format!("Emote-only mode is {}", if *on { "on" } else { "off" })),
Action::Unique(on) => said(helix::chat_settings(http, &who, &room, serde_json::json!({ "unique_chat_mode": on })).await, format!("Unique-chat mode is {}", if *on { "on" } else { "off" })),
Action::Announce { text, color } => said(helix::announce(http, &who, &room, text, color).await, "Announced".to_string()),
Action::Color(color) => said(helix::set_color(http, &who, cx.my_id, color).await, format!("Your name is {color}")),
Action::Block { user } => {
let id = resolve(user).await?;
said(helix::block(http, &who, &id).await, format!("{user} is blocked"))
}
Action::Unblock { user } => {
let id = resolve(user).await?;
said(helix::unblock(http, &who, &id).await, format!("{user} is unblocked"))
}
Action::Shoutout { user } => {
let id = resolve(user).await?;
said(helix::shoutout(http, &who, &room, &id).await, format!("Shouted out {user}"))
}
Action::Whisper { to, text } => {
let id = resolve(to).await?;
said(helix::whisper(http, &who, cx.my_id, &id, &super::emotes::tokens_to_names(text)).await, format!("Whisper sent to {to}"))
}
}
}
/// Said in the channel, as a line of its own, so the one who acted - and any
/// other window of theirs - sees what was done.
pub fn say_done(state: &AppState, account_id: &str, channel: &str, action: &Action, what: &str) {
// What Twitch tells the whole room comes back as its own card (modes, removals, timeouts, bans, clearing
// the chat, announcements); what only this account did is said here, as one.
let card = |icon: &str, subject: &str, what: &str| super::chat::moderation_card(state, account_id, channel, icon, subject, what, None);
match action {
Action::Unban { user } => return card("lock_open", user, "is allowed back"),
Action::Block { user } => return card("block", user, "was blocked"),
Action::Unblock { user } => return card("check_circle", user, "was unblocked"),
Action::Shoutout { user } => return card("campaign", user, "was given a shoutout"),
Action::Timeout { .. } | Action::Ban { .. } | Action::Delete { .. } | Action::Slow(_) | Action::Followers(_) | Action::Subscribers(_) | Action::EmoteOnly(_) | Action::Unique(_) | Action::Announce { .. } | Action::Whisper { .. } => return,
Action::Color(_) => {}
}
state.runtime.record_message(state, account_id, channel, "channel", channel, what, false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
}
#[cfg(test)]
mod tests {
use super::*;
fn ok(text: &str) -> Action {
parse(text).expect("a command").expect("a good one")
}
#[test]
fn what_is_not_a_command_is_not_read_as_one_and_me_is_twitchs_own() {
assert!(parse("hello").is_none());
assert!(parse("/me waves").is_none());
assert!(parse("//not a command").is_none(), "a doubled slash is how a message starting with one is sent");
assert!(parse("/").is_none());
}
#[test]
fn a_timeout_has_a_length_and_a_reason_either_of_which_may_be_left_out() {
assert_eq!(ok("/timeout @Bob"), Action::Timeout { user: "bob".into(), seconds: 600, reason: String::new() });
assert_eq!(ok("/timeout bob 30"), Action::Timeout { user: "bob".into(), seconds: 30, reason: String::new() });
assert_eq!(ok("/timeout bob 2h being rude"), Action::Timeout { user: "bob".into(), seconds: 7200, reason: "being rude".into() });
assert_eq!(ok("/timeout bob spamming links"), Action::Timeout { user: "bob".into(), seconds: 600, reason: "spamming links".into() });
assert!(parse("/timeout").unwrap().unwrap_err().contains("needs a user"));
assert!(parse("/timeout bob 99w").unwrap().unwrap_err().contains("14 days"));
}
#[test]
fn bans_unbans_and_deletes() {
assert_eq!(ok("/ban bob spam"), Action::Ban { user: "bob".into(), reason: "spam".into() });
assert_eq!(ok("/unban bob"), Action::Unban { user: "bob".into() });
assert_eq!(ok("/untimeout bob"), Action::Unban { user: "bob".into() });
assert_eq!(ok("/delete 6a349c82"), Action::Delete { message_id: Some("6a349c82".into()) });
assert_eq!(ok("/clear"), Action::Delete { message_id: None });
assert!(parse("/delete").unwrap().is_err());
}
#[test]
fn the_rooms_modes() {
assert_eq!(ok("/slow"), Action::Slow(Some(30)));
assert_eq!(ok("/slow 10"), Action::Slow(Some(10)));
assert!(parse("/slow 1").unwrap().is_err());
assert_eq!(ok("/slowoff"), Action::Slow(None));
assert_eq!(ok("/followers"), Action::Followers(Some(0)));
assert_eq!(ok("/followers 30"), Action::Followers(Some(30)));
assert_eq!(ok("/followers 2h"), Action::Followers(Some(120)));
assert_eq!(ok("/followers 1d"), Action::Followers(Some(1440)));
assert!(parse("/followers soon").unwrap().is_err());
assert_eq!(ok("/followersoff"), Action::Followers(None));
assert_eq!((ok("/subscribers"), ok("/subscribersoff")), (Action::Subscribers(true), Action::Subscribers(false)));
assert_eq!((ok("/emoteonly"), ok("/emoteonlyoff")), (Action::EmoteOnly(true), Action::EmoteOnly(false)));
assert_eq!((ok("/uniquechat"), ok("/uniquechatoff")), (Action::Unique(true), Action::Unique(false)));
}
#[test]
fn announcements_colours_and_whispers() {
assert_eq!(ok("/announce Giveaway at noon"), Action::Announce { text: "Giveaway at noon".into(), color: "primary".into() });
assert_eq!(ok("/announcegreen Hello"), Action::Announce { text: "Hello".into(), color: "green".into() });
assert!(parse("/announce").unwrap().is_err());
assert_eq!(ok("/color coral"), Action::Color("coral".into()));
assert_eq!((ok("/block @Bob"), ok("/unblock bob")), (Action::Block { user: "bob".into() }, Action::Unblock { user: "bob".into() }));
assert_eq!(ok("/so Bob"), Action::Shoutout { user: "bob".into() });
assert!(!Action::Block { user: "x".into() }.needs_moderator() && Action::Shoutout { user: "x".into() }.needs_moderator());
assert_eq!(ok("/w @Bob hello there"), Action::Whisper { to: "bob".into(), text: "hello there".into() });
assert!(parse("/w bob").unwrap().is_err());
assert!(!Action::Color("x".into()).needs_moderator() && Action::Slow(None).needs_moderator());
}
#[test]
fn an_unknown_command_is_said_and_never_sent_as_a_message() {
let e = parse("/frobnicate now").unwrap().unwrap_err();
assert!(e.contains("no /frobnicate command") && e.contains("//"), "{e}");
}
#[test]
fn lengths_are_read_as_people_write_them() {
for (typed, seconds) in [("90", 90), ("30s", 30), ("10m", 600), ("2H", 7200), ("1d", 86_400), ("1w", 604_800)] {
assert_eq!(duration_seconds(typed), Some(seconds), "{typed}");
}
for typed in ["", "m", "1y", "ten"] {
assert_eq!(duration_seconds(typed), None, "{typed}");
}
}
// The calls themselves, against a Twitch that is a server in a test.
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn helix_server(answers: Vec<(u16, &'static str)>) -> (String, Arc<Mutex<Vec<String>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let root = format!("http://{}", listener.local_addr().unwrap());
let seen: Arc<Mutex<Vec<String>>> = Default::default();
let log = seen.clone();
let answers = Arc::new(Mutex::new(answers.into_iter().collect::<std::collections::VecDeque<_>>()));
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let (log, answers) = (log.clone(), answers.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 8192];
let mut got = 0;
loop {
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
if n == 0 {
break;
}
got += n;
let text = String::from_utf8_lossy(&buf[..got]).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let length = text[..end].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if got >= end + 4 + length {
break;
}
}
}
log.lock().unwrap().push(String::from_utf8_lossy(&buf[..got]).to_string());
let (status, body) = answers.lock().unwrap().pop_front().unwrap_or((500, "{}"));
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = sock.write_all(reply.as_bytes()).await;
});
}
});
(root, seen)
}
fn cx<'a>(root: &'a str, moderator: bool) -> Ctx<'a> {
Ctx { token: "tok", my_id: "42", room_id: "100", moderator, api_root: root, client_id: "cid" }
}
#[tokio::test]
async fn a_timeout_looks_the_person_up_and_asks_twitch_for_it_in_the_rooms_name() {
let (root, seen) = helix_server(vec![(200, r#"{"data":[{"id":"7","login":"bob"}]}"#), (200, r#"{"data":[{}]}"#)]).await;
let done = run(&reqwest::Client::new(), &cx(&root, true), &Action::Timeout { user: "bob".into(), seconds: 600, reason: "spam".into() }, None).await.unwrap();
assert_eq!(done, "bob was timed out for 10 minutes");
let asked = seen.lock().unwrap().clone();
assert!(asked[0].starts_with("GET /users?login=bob "), "{}", asked[0]);
assert!(asked[1].starts_with("POST /moderation/bans?broadcaster_id=100&moderator_id=42 "), "{}", asked[1]);
assert!(asked[1].contains(r#""user_id":"7""#) && asked[1].contains(r#""duration":600"#) && asked[1].contains(r#""reason":"spam""#), "{}", asked[1]);
assert!(asked[1].to_lowercase().contains("authorization: bearer tok") && asked[1].to_lowercase().contains("client-id: cid"));
}
#[tokio::test]
async fn a_person_whose_id_is_known_is_not_looked_up_and_one_who_does_not_exist_is_said() {
let (root, seen) = helix_server(vec![(200, "{}"), (200, r#"{"data":[]}"#)]).await;
let client = reqwest::Client::new();
run(&client, &cx(&root, true), &Action::Ban { user: "bob".into(), reason: String::new() }, Some("7")).await.unwrap();
assert_eq!(seen.lock().unwrap().len(), 1, "the id was given, so only the ban was asked for");
let e = run(&client, &cx(&root, true), &Action::Unban { user: "ghost".into() }, None).await.unwrap_err();
assert!(e.contains("no user called ghost"), "{e}");
}
#[tokio::test]
async fn the_rooms_modes_are_patched_and_deleting_names_the_message() {
let (root, seen) = helix_server(vec![(200, "{}"), (200, "{}"), (204, "{}")]).await;
let client = reqwest::Client::new();
run(&client, &cx(&root, true), &Action::Slow(Some(30)), None).await.unwrap();
run(&client, &cx(&root, true), &Action::Followers(Some(60)), None).await.unwrap();
run(&client, &cx(&root, true), &Action::Delete { message_id: Some("m1".into()) }, None).await.unwrap();
let asked = seen.lock().unwrap().clone();
assert!(asked[0].starts_with("PATCH /chat/settings?broadcaster_id=100&moderator_id=42 ") && asked[0].contains(r#""slow_mode_wait_time":30"#), "{}", asked[0]);
assert!(asked[1].contains(r#""follower_mode":true"#) && asked[1].contains(r#""follower_mode_duration":60"#), "{}", asked[1]);
assert!(asked[2].starts_with("DELETE /moderation/chat?broadcaster_id=100&moderator_id=42&message_id=m1 "), "{}", asked[2]);
}
#[tokio::test]
async fn not_being_a_moderator_stops_it_before_twitch_is_asked_and_twitchs_no_is_said() {
let (root, seen) = helix_server(vec![(400, r#"{"message":"The user specified in user_id is already banned."}"#), (401, r#"{"message":"Missing scope: moderator:manage:banned_users"}"#)]).await;
let client = reqwest::Client::new();
let e = run(&client, &cx(&root, false), &Action::Slow(None), None).await.unwrap_err();
assert!(e.contains("not a moderator") && seen.lock().unwrap().is_empty());
let e = run(&client, &cx(&root, true), &Action::Ban { user: "bob".into(), reason: String::new() }, Some("7")).await.unwrap_err();
assert!(e.contains("already banned"), "{e}");
let e = run(&client, &cx(&root, true), &Action::Ban { user: "bob".into(), reason: String::new() }, Some("7")).await.unwrap_err();
assert!(e.contains("Sign in again"), "{e}");
// Whispers and colours are what a person does as themselves, with no moderator needed.
let (root, _) = helix_server(vec![(204, "{}"), (204, "{}")]).await;
run(&client, &cx(&root, false), &Action::Color("coral".into()), None).await.unwrap();
run(&client, &cx(&root, false), &Action::Whisper { to: "bob".into(), text: "hi".into() }, Some("7")).await.unwrap();
}
}
+471
View File
@@ -0,0 +1,471 @@
//! The address of a live channel's video.
//!
//! twitch.tv's own player asks Twitch for a signed token (a persisted GraphQL
//! query, see `gql.rs`) and then for the channel's playlist from the playlist
//! service, passing the token along. Both are reproduced here, and the second is
//! only *built* - the player in the window fetches it, as it fetches every
//! playlist and segment after it. What this returns is that address, when it
//! stops being good (the token lasts twenty minutes), and what the token says
//! about the account: whether ads are for it, which renditions it may not have.
//!
//! Anonymous works: no account, no sign-in, no browser. An account's own
//! `auth-token` (the cookie of a twitch.tv session, see Settings) is sent when
//! there is one, and is what makes a person's Turbo or subscription count.
//!
//! Twitch can demand an *integrity token*, which is proof that a real browser
//! is asking, and sometimes has, for a week at a time. It is not demanded
//! today. When it is, the daemon asks the window to make one (in a hidden page
//! on twitch.tv, which is the only place it can be made), keeps it until it is
//! nearly out, and asks again.
use super::gql::{self, Who};
use crate::state::AppState;
use anyhow::anyhow;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Mutex;
/// Where the playlist service is.
pub const USHER_ROOT: &str = "https://usher.ttvnw.net";
/// The player's own query for the token, by the hash it is stored under at Twitch.
const TOKEN_HASH: &str = "ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9";
/// The address of a stream and what is known about it.
#[derive(Debug, Clone, PartialEq)]
pub struct Playback {
/// The playlist, signed. Replaced before `expires`.
pub url: String,
/// When the signature stops being accepted, in Unix seconds.
pub expires: i64,
/// Whether Twitch says ads are not for this account.
pub hide_ads: bool,
/// Renditions this account may not have, by Twitch's names for them.
pub restricted: Vec<String>,
}
/// Why there is no address.
#[derive(Debug)]
pub enum PlaybackError {
/// The channel is not broadcasting.
Offline,
/// Twitch will not give this account the stream: subscribers only, and so on.
Forbidden(String),
/// Not available where this is being watched from.
Blocked(String),
/// Twitch wanted proof that a browser is asking, and none could be had.
NeedsIntegrity,
Failed(anyhow::Error),
}
impl std::fmt::Display for PlaybackError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlaybackError::Offline => write!(f, "this channel is not streaming"),
PlaybackError::Forbidden(why) if why.is_empty() => write!(f, "Twitch will not play this stream for this account (it may be subscribers only)"),
PlaybackError::Forbidden(why) => write!(f, "Twitch will not play this stream for this account: {why}"),
PlaybackError::Blocked(why) if why.is_empty() => write!(f, "this stream is not available where you are"),
PlaybackError::Blocked(why) => write!(f, "this stream is not available where you are ({why})"),
PlaybackError::NeedsIntegrity => write!(f, "Twitch is asking for proof that this is a browser, and moho could not give it"),
PlaybackError::Failed(e) => write!(f, "{e:#}"),
}
}
}
fn token_query(login: &str) -> Value {
json!({
"operationName": "PlaybackAccessToken",
"variables": { "isLive": true, "login": login, "isVod": false, "vodID": "", "playerType": "site", "platform": "web" },
"extensions": { "persistedQuery": { "version": 1, "sha256Hash": TOKEN_HASH } },
})
}
/// The playlist's address, from the signed token. Codecs are limited to H.264:
/// the window's player has no use for the AV1 and HEVC renditions Twitch offers
/// big channels, and a playlist that lists only what plays is one nothing has to
/// filter.
pub fn usher_url(root: &str, login: &str, signature: &str, token: &str) -> String {
let pass = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos() % 999_999).unwrap_or(1);
let mut query = url::form_urlencoded::Serializer::new(String::new());
for (k, v) in [
("allow_source", "true"),
("allow_audio_only", "true"),
("fast_bread", "true"),
("p", &pass.to_string()),
("platform", "web"),
("player_backend", "mediaplayer"),
("playlist_include_framerate", "true"),
("reassignments_supported", "true"),
("sig", signature),
("supported_codecs", "h264"),
("token", token),
("transcode_mode", "cbr_v1"),
] {
query.append_pair(k, v);
}
format!("{root}/api/v2/channel/hls/{}.m3u8?{}", login.to_lowercase(), query.finish())
}
/// Reads Twitch's answer to the token query.
pub fn read_token(answer: &Value, login: &str, usher_root: &str) -> Result<Playback, PlaybackError> {
let token = answer.pointer("/data/streamPlaybackAccessToken").filter(|t| !t.is_null()).ok_or(PlaybackError::Offline)?;
let value = token.get("value").and_then(|v| v.as_str()).ok_or_else(|| PlaybackError::Failed(anyhow!("Twitch's token had no value")))?;
let signature = token.get("signature").and_then(|v| v.as_str()).ok_or_else(|| PlaybackError::Failed(anyhow!("Twitch's token had no signature")))?;
let about: Value = serde_json::from_str(value).map_err(|e| PlaybackError::Failed(anyhow!("Twitch's token was not readable: {e}")))?;
if about.pointer("/authorization/forbidden").and_then(|f| f.as_bool()).unwrap_or(false) {
return Err(PlaybackError::Forbidden(about.pointer("/authorization/reason").and_then(|r| r.as_str()).unwrap_or("").to_string()));
}
if let Some(why) = about.get("geoblock_reason").and_then(|g| g.as_str()).filter(|g| !g.is_empty()) {
return Err(PlaybackError::Blocked(why.to_string()));
}
Ok(Playback {
url: usher_url(usher_root, login, signature, value),
expires: about.get("expires").and_then(|e| e.as_i64()).unwrap_or_else(|| chrono::Utc::now().timestamp() + 20 * 60),
hide_ads: about.get("hide_ads").and_then(|h| h.as_bool()).unwrap_or(false),
restricted: about
.pointer("/chansub/restricted_bitrates")
.and_then(|r| r.as_array())
.map(|a| a.iter().filter_map(|n| n.as_str().map(str::to_string)).collect())
.unwrap_or_default(),
})
}
// ---------------------------------------------------------------------------
// The integrity token
// ---------------------------------------------------------------------------
#[derive(Clone)]
struct Integrity {
device_id: String,
token: String,
/// Unix seconds.
expires_at: i64,
}
/// Integrity tokens by the session they were made for ("" for anonymous): Twitch wants the one made as the
/// account for a call made as the account, and refuses one made anonymously for it.
fn cache() -> &'static Mutex<HashMap<String, Integrity>> {
static CACHE: std::sync::OnceLock<Mutex<HashMap<String, Integrity>>> = std::sync::OnceLock::new();
CACHE.get_or_init(Default::default)
}
type Waiting = HashMap<String, tokio::sync::oneshot::Sender<Result<(String, i64), String>>>;
fn waiting() -> &'static Mutex<Waiting> {
static WAITING: std::sync::OnceLock<Mutex<Waiting>> = std::sync::OnceLock::new();
WAITING.get_or_init(Default::default)
}
/// How long before its end an integrity token is let go of.
const INTEGRITY_MARGIN: i64 = 600;
fn good_integrity(session: Option<&str>) -> Option<Integrity> {
let held = cache().lock().unwrap().get(session.unwrap_or("")).cloned()?;
(held.expires_at - chrono::Utc::now().timestamp() > INTEGRITY_MARGIN).then_some(held)
}
/// The window's answer to a request for an integrity token: the token and when
/// it ends in Unix milliseconds, or why one could not be made.
pub fn provide_integrity(request_id: &str, outcome: Result<(String, i64), String>) -> bool {
match waiting().lock().unwrap().remove(request_id) {
Some(reply) => reply.send(outcome).is_ok(),
None => false,
}
}
/// Asks the window to make an integrity token and waits for it.
async fn obtain_integrity(state: &AppState, session: Option<&str>) -> Option<Integrity> {
if let Some(held) = good_integrity(session) {
return Some(held);
}
let request_id = format!("integrity-{}", crate::model::next_message_id());
let (reply, answer) = tokio::sync::oneshot::channel();
waiting().lock().unwrap().insert(request_id.clone(), reply);
let device_id = gql::device_id().to_string();
// The window that makes it asks Twitch as the account (the session goes to main alone: it is not forwarded on).
state.events.emit("twitchIntegrityNeeded", json!({ "requestId": request_id, "deviceId": device_id, "authToken": session.unwrap_or("") }));
let outcome = tokio::time::timeout(std::time::Duration::from_secs(60), answer).await;
waiting().lock().unwrap().remove(&request_id);
match outcome {
Ok(Ok(Ok((token, expires_ms)))) => {
let made = Integrity { device_id, token, expires_at: expires_ms / 1000 };
cache().lock().unwrap().insert(session.unwrap_or("").to_string(), made.clone());
Some(made)
}
Ok(Ok(Err(why))) => {
tracing::debug!("twitch: no integrity token: {why}");
None
}
_ => None,
}
}
/// Makes the integrity token for a session ahead of the call that will want it (it takes a few seconds and lasts
/// hours), so that call does not wait for it. Nothing is said if it cannot be made: the call tries again then.
pub async fn warm_up(state: &AppState, session: &str) {
let _ = obtain_integrity(state, Some(session)).await;
}
/// One call to the GraphQL as the person's twitch.tv session, with an integrity token if Twitch asks for one.
/// Answers the JSON, whose own `errors` are the caller's to read; a refusal for want of integrity is a `NeedsIntegrity`.
pub async fn call_as_session(state: &AppState, http: &reqwest::Client, gql_root: &str, body: &Value, web_token: &str) -> Result<Value, PlaybackError> {
let mut integrity = good_integrity(Some(web_token));
for attempt in 0..2 {
let who = Who { web_token: Some(web_token), integrity: integrity.as_ref().map(|i| (i.device_id.as_str(), i.token.as_str())) };
let answer = gql::call(http, gql_root, body, &who).await.map_err(PlaybackError::Failed)?;
if gql::error_of(&answer).map(|s| s.to_lowercase().contains("integrity")).unwrap_or(false) {
if attempt == 0 {
integrity = obtain_integrity(state, Some(web_token)).await;
if integrity.is_some() {
continue;
}
}
return Err(PlaybackError::NeedsIntegrity);
}
return Ok(answer);
}
Err(PlaybackError::NeedsIntegrity)
}
/// The address of a channel's video, or why there is none.
pub async fn playback(state: &AppState, http: &reqwest::Client, gql_root: &str, usher_root: &str, login: &str, web_token: Option<&str>) -> Result<Playback, PlaybackError> {
let mut integrity = good_integrity(web_token.filter(|t| !t.is_empty()));
for attempt in 0..2 {
let who = Who { web_token, integrity: integrity.as_ref().map(|i| (i.device_id.as_str(), i.token.as_str())) };
let answer = gql::call(http, gql_root, &token_query(login), &who).await.map_err(PlaybackError::Failed)?;
if let Some(said) = gql::error_of(&answer) {
if said.to_lowercase().contains("integrity") {
if attempt == 0 {
integrity = obtain_integrity(state, web_token.filter(|t| !t.is_empty())).await;
if integrity.is_some() {
continue;
}
}
return Err(PlaybackError::NeedsIntegrity);
}
return Err(PlaybackError::Failed(anyhow!("Twitch would not give the stream a token: {said}")));
}
return read_token(&answer, login, usher_root);
}
Err(PlaybackError::NeedsIntegrity)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::kick::testkit;
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// The integrity cache and the requests waiting on the window are the process's, so the tests that touch
/// them take turns.
fn turn() -> &'static tokio::sync::Mutex<()> {
static TURN: std::sync::OnceLock<tokio::sync::Mutex<()>> = std::sync::OnceLock::new();
TURN.get_or_init(Default::default)
}
/// What the token's JSON says, as captured from a live answer, with the fields varied.
fn token(extra: Value) -> Value {
let mut about = json!({
"adblock": false, "authorization": { "forbidden": false, "reason": "" }, "channel": "tylorturner", "channel_id": 106959846,
"chansub": { "restricted_bitrates": [], "view_until": 1924905600 }, "device_id": "abc", "expires": 1791950724,
"hide_ads": false, "https_required": true, "platform": "web", "player_type": "site", "server_ads": true, "show_ads": true
});
for (k, v) in extra.as_object().cloned().unwrap_or_default() {
about[k] = v;
}
json!({ "data": { "streamPlaybackAccessToken": { "value": about.to_string(), "signature": "deadbeef" } } })
}
#[test]
fn a_token_becomes_a_playlist_address_that_names_the_channel_and_the_signature() {
let p = read_token(&token(json!({})), "TylorTurner", USHER_ROOT).unwrap();
assert!(p.url.starts_with("https://usher.ttvnw.net/api/v2/channel/hls/tylorturner.m3u8?"), "{}", p.url);
assert!(p.url.contains("sig=deadbeef") && p.url.contains("allow_source=true") && p.url.contains("supported_codecs=h264"), "{}", p.url);
assert!(p.url.contains("token=%7B%22adblock%22"), "the token is the JSON, escaped: {}", p.url);
assert_eq!((p.expires, p.hide_ads), (1791950724, false));
}
#[test]
fn what_the_token_says_about_the_account_is_kept() {
let p = read_token(&token(json!({ "hide_ads": true, "chansub": { "restricted_bitrates": ["chunked", "720p60"] } })), "x", USHER_ROOT).unwrap();
assert!(p.hide_ads);
assert_eq!(p.restricted, vec!["chunked", "720p60"]);
}
#[test]
fn a_channel_that_is_not_streaming_has_no_token_and_a_stream_that_may_not_be_had_says_why() {
assert!(matches!(read_token(&json!({ "data": { "streamPlaybackAccessToken": null } }), "x", USHER_ROOT), Err(PlaybackError::Offline)));
match read_token(&token(json!({ "authorization": { "forbidden": true, "reason": "This stream is for subscribers only" } })), "x", USHER_ROOT) {
Err(PlaybackError::Forbidden(why)) => assert!(why.contains("subscribers only")),
other => panic!("{other:?}"),
}
match read_token(&token(json!({ "geoblock_reason": "blocked in your country" })), "x", USHER_ROOT) {
Err(PlaybackError::Blocked(why)) => assert_eq!(why, "blocked in your country"),
other => panic!("{other:?}"),
}
assert!(matches!(read_token(&json!({ "data": { "streamPlaybackAccessToken": { "value": "not json", "signature": "s" } } }), "x", USHER_ROOT), Err(PlaybackError::Failed(_))));
let said = PlaybackError::Forbidden(String::new()).to_string();
assert!(said.contains("subscribers only"), "{said}");
}
/// A GraphQL that answers each call with the next canned answer, and remembers the headers it was sent.
async fn graphql(answers: Vec<(u16, String)>) -> (String, Arc<Mutex<Vec<String>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let root = format!("http://{}", listener.local_addr().unwrap());
let seen: Arc<Mutex<Vec<String>>> = Default::default();
let log = seen.clone();
let answers = Arc::new(Mutex::new(answers.into_iter().collect::<std::collections::VecDeque<_>>()));
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let (log, answers) = (log.clone(), answers.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 16384];
let mut got = 0;
loop {
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
if n == 0 {
break;
}
got += n;
let text = String::from_utf8_lossy(&buf[..got]).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let length = text[..end].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if got >= end + 4 + length {
break;
}
}
}
log.lock().unwrap().push(String::from_utf8_lossy(&buf[..got]).to_string());
let (status, body) = answers.lock().unwrap().pop_front().unwrap_or((500, "{}".to_string()));
let reply = format!("HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = sock.write_all(reply.as_bytes()).await;
});
}
});
(root, seen)
}
#[tokio::test]
async fn the_call_is_the_players_own_and_carries_the_accounts_token_only_when_there_is_one() {
let _turn = turn().lock().await;
cache().lock().unwrap().clear();
let state = testkit::simulated_daemon("twitch-playback");
let (root, seen) = graphql(vec![(200, token(json!({})).to_string()), (200, token(json!({ "hide_ads": true })).to_string())]).await;
let http = reqwest::Client::new();
let anonymous = playback(&state, &http, &root, USHER_ROOT, "xqc", None).await.unwrap();
assert!(!anonymous.hide_ads);
let signed = playback(&state, &http, &root, USHER_ROOT, "xqc", Some("web-session")).await.unwrap();
assert!(signed.hide_ads);
let asked = seen.lock().unwrap().clone();
let (first, second) = (asked[0].to_lowercase(), asked[1].to_lowercase());
assert!(first.contains("client-id: kimne78kx3ncx6brgo4mv6wki5h1ko") && first.contains("x-device-id: "), "{first}");
assert!(first.contains(r#""operationname":"playbackaccesstoken""#) && first.contains(&TOKEN_HASH.to_lowercase()), "{first}");
assert!(first.contains(r#""playertype":"site""#) && first.contains(r#""login":"xqc""#));
assert!(!first.contains("authorization:"), "an anonymous request carries no account");
assert!(second.contains("authorization: oauth web-session"), "{second}");
}
#[tokio::test]
async fn an_error_that_is_not_about_integrity_is_said_and_a_refusal_is_said_with_its_status() {
let _turn = turn().lock().await;
cache().lock().unwrap().clear();
let state = testkit::simulated_daemon("twitch-playback-errors");
let (root, _) = graphql(vec![
(200, json!({ "errors": [{ "message": "service timeout" }] }).to_string()),
(400, json!({ "message": "The \"Client-ID\" header is invalid." }).to_string()),
])
.await;
let http = reqwest::Client::new();
let e = playback(&state, &http, &root, USHER_ROOT, "xqc", None).await.unwrap_err();
assert!(e.to_string().contains("service timeout"), "{e}");
let e = playback(&state, &http, &root, USHER_ROOT, "xqc", None).await.unwrap_err();
assert!(e.to_string().contains("400") && e.to_string().contains("Client-ID"), "{e}");
}
#[tokio::test]
async fn when_twitch_wants_an_integrity_token_the_window_is_asked_and_the_call_is_made_again_with_it() {
let _turn = turn().lock().await;
cache().lock().unwrap().clear();
let state = testkit::simulated_daemon("twitch-playback-integrity");
cache().lock().unwrap().clear();
let (root, seen) = graphql(vec![(200, json!({ "errors": [{ "message": "failed integrity check" }] }).to_string()), (200, token(json!({})).to_string())]).await;
let mut events = state.events.subscribe();
let answerer = tokio::spawn(async move {
loop {
let event = events.recv().await.expect("the bus is open");
if event.name == "twitchIntegrityNeeded" {
let id = event.data["requestId"].as_str().unwrap().to_string();
assert_eq!(event.data["deviceId"], gql::device_id());
let ends = (chrono::Utc::now().timestamp() + 16 * 3600) * 1000;
assert!(provide_integrity(&id, Ok(("v4.local.made".to_string(), ends))));
return;
}
}
});
let got = playback(&state, &reqwest::Client::new(), &root, USHER_ROOT, "xqc", None).await.expect("works the second time");
answerer.await.unwrap();
assert!(got.url.contains("xqc.m3u8"));
let second = seen.lock().unwrap()[1].to_lowercase();
assert!(second.contains("client-integrity: v4.local.made"), "{second}");
// Kept: the next call carries it without asking again.
assert!(good_integrity(None).is_some());
cache().lock().unwrap().clear();
}
#[tokio::test]
async fn an_integrity_token_that_cannot_be_made_is_a_reason_not_a_hang() {
let _turn = turn().lock().await;
cache().lock().unwrap().clear();
let state = testkit::simulated_daemon("twitch-playback-integrity-fails");
cache().lock().unwrap().clear();
let (root, _) = graphql(vec![(200, json!({ "errors": [{ "message": "failed integrity check" }] }).to_string())]).await;
let mut events = state.events.subscribe();
tokio::spawn(async move {
loop {
let event = events.recv().await.expect("the bus is open");
if event.name == "twitchIntegrityNeeded" {
provide_integrity(event.data["requestId"].as_str().unwrap(), Err("the page would not load".to_string()));
return;
}
}
});
let e = playback(&state, &reqwest::Client::new(), &root, USHER_ROOT, "xqc", None).await.unwrap_err();
assert!(matches!(e, PlaybackError::NeedsIntegrity), "{e}");
assert!(!provide_integrity("nobody-asked", Err("late".into())), "a late answer to nothing is refused");
}
#[tokio::test]
async fn a_call_made_as_the_account_asks_for_an_integrity_token_made_as_the_account_and_keeps_it_apart_from_the_anonymous_one() {
let _turn = turn().lock().await;
cache().lock().unwrap().clear();
let state = testkit::simulated_daemon("twitch-session-integrity");
let (root, seen) = graphql(vec![(200, json!({ "errors": [{ "message": "failed integrity check" }] }).to_string()), (200, json!({ "data": { "x": 1 } }).to_string()), (200, json!({ "data": { "x": 2 } }).to_string())]).await;
let mut events = state.events.subscribe();
let answerer = tokio::spawn(async move {
loop {
let event = events.recv().await.expect("the bus is open");
if event.name == "twitchIntegrityNeeded" {
// Said to the window that makes it, so it can make it as the account.
assert_eq!(event.data["authToken"], "web-session");
let id = event.data["requestId"].as_str().unwrap().to_string();
let ends = (chrono::Utc::now().timestamp() + 16 * 3600) * 1000;
assert!(provide_integrity(&id, Ok(("v4.local.as-account".to_string(), ends))));
return;
}
}
});
let http = reqwest::Client::new();
call_as_session(&state, &http, &root, &json!({ "query": "mutation { x }" }), "web-session").await.unwrap();
answerer.await.unwrap();
assert!(seen.lock().unwrap()[1].contains("client-integrity: v4.local.as-account"));
// The token made as the account is the account's; an anonymous call has none held.
assert!(good_integrity(Some("web-session")).is_some());
assert!(good_integrity(None).is_none());
// And the next call as the account uses it without asking again.
call_as_session(&state, &http, &root, &json!({ "query": "mutation { x }" }), "web-session").await.unwrap();
assert!(seen.lock().unwrap()[2].contains("client-integrity: v4.local.as-account"));
cache().lock().unwrap().clear();
}
}
+746
View File
@@ -0,0 +1,746 @@
//! What a signed-in twitch.tv session can do in a channel that the official API cannot: see the channel
//! points balance, put points on a prediction, vote in a poll. (The bonus chest is deliberately not claimed from
//! here: it only appears to a viewer that reports watching, which moho does not do.)
//!
//! All of it is Twitch's private GraphQL (see gql.rs), and all of it as the person's own twitch.tv session - the
//! `auth-token` that signing in leaves with the account (see `playback`), since the token moho uses for chat
//! and the official API is not accepted there. Without that session these say so, in words, and do nothing.
//!
//! Nothing here runs by itself. A balance is read when the window shows the channel; a bet is placed and a vote
//! cast only when the person presses the button for it. The player's "watch time" reports,
//! which a points bot sends to earn faster, are not sent (see the plan, Gitea issue #55).
use super::gql;
use super::playback::{self, PlaybackError};
use crate::state::AppState;
use serde_json::{json, Value};
/// The balance in a channel.
#[derive(Debug, PartialEq, Clone)]
pub struct Points {
pub balance: Option<u64>,
/// What the channel calls its points, if it renamed them.
pub name: Option<String>,
}
const CONTEXT: &str = "query($login:String!){ user(login:$login){ channel{ self{ communityPoints{ balance } } communityPointsSettings{ name } } } }";
pub fn read_points(answer: &Value) -> Option<Points> {
let channel = answer.pointer("/data/user/channel").filter(|c| !c.is_null())?;
let points = channel.pointer("/self/communityPoints").filter(|p| !p.is_null());
Some(Points {
balance: points.and_then(|p| p.get("balance")).and_then(|b| b.as_u64()),
name: channel.pointer("/communityPointsSettings/name").and_then(|n| n.as_str()).filter(|n| !n.is_empty()).map(str::to_string),
})
}
/// What spending on a reward does, which decides what it takes to redeem it.
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum Kind {
/// One the streamer made: by id, with a word if it asks for one.
Custom,
/// Twitch's "Highlight My Message": the message is typed.
Highlight,
/// Twitch's "Unlock a Random Sub Emote".
RandomEmote,
/// "Choose an Emote to Unlock": one of the channel's subscriber emotes the account does not have.
ChosenEmote,
/// "Modify a Single Emote": one of the changed versions Twitch offers of a subscriber emote.
ModifiedEmote,
/// One that cannot be had from here (it is sent from twitch.tv's own chat box).
Other,
}
/// One of the things a reward asks the person to pick from.
#[derive(Debug, PartialEq, Clone)]
pub struct Choice {
pub id: String,
pub label: String,
pub image: Option<String>,
/// For a changed version of an emote: the emote it is a change of (its name), and that emote's picture, so the
/// window can show the emotes first and then what can be done to the one picked.
pub group: Option<String>,
pub group_image: Option<String>,
}
/// One thing a channel trades its points for.
#[derive(Debug, PartialEq, Clone)]
pub struct Reward {
pub id: String,
pub title: String,
pub cost: u64,
pub prompt: Option<String>,
/// Whether it can be had right now: switched on, in stock and not paused.
pub enabled: bool,
pub paused: bool,
/// It asks the person to type something.
pub needs_input: bool,
pub color: Option<String>,
pub image: Option<String>,
pub kind: Kind,
/// What it asks the person to pick from, for the emote unlocks.
pub choices: Vec<Choice>,
/// Why it cannot be had from here, where that is known beforehand.
pub unavailable: Option<String>,
}
const REWARDS: &str = "query($login:String!){ user(login:$login){ channel{ id self{ communityPoints{ balance } } communityPointsSettings{ name customRewards{ id title cost prompt isEnabled isPaused isInStock isUserInputRequired backgroundColor image{ url } defaultImage{ url } } automaticRewards{ id type cost defaultCost isEnabled defaultBackgroundColor backgroundColor defaultImage{ url } } emoteVariants{ id isUnlockable emote{ id token } modifications{ id } } } } } }";
fn emote_image(id: &str) -> String {
format!("https://static-cdn.jtvnw.net/emoticons/v2/{id}/default/dark/1.0")
}
/// What a modification's two letters do to an emote, in words.
fn modification_name(id: &str) -> &'static str {
match id.rsplit('_').next().unwrap_or("") {
"BW" => "black and white",
"HF" => "flipped",
"SG" => "with sunglasses",
"SQ" => "squished",
"TK" => "thick",
_ => "changed",
}
}
/// A channel's rewards, its number, and this account's balance, from one answer. `owned` is the ids of the
/// emotes the account can already use there: an unlock does not offer those again, and a change is offered
/// only for those.
pub fn read_rewards(answer: &Value, owned: &std::collections::HashSet<String>) -> Option<(String, Vec<Reward>, Points)> {
let channel = answer.pointer("/data/user/channel").filter(|c| !c.is_null())?;
let flag = |r: &Value, k: &str, default: bool| r.get(k).and_then(|b| b.as_bool()).unwrap_or(default);
let mut rewards: Vec<Reward> = channel
.pointer("/communityPointsSettings/customRewards")
.and_then(|r| r.as_array())
.map(|list| {
list.iter()
.filter_map(|r| {
Some(Reward {
id: r.get("id")?.as_str()?.to_string(),
title: r.get("title")?.as_str()?.to_string(),
cost: r.get("cost")?.as_u64()?,
prompt: r.get("prompt").and_then(|p| p.as_str()).filter(|p| !p.is_empty()).map(str::to_string),
enabled: flag(r, "isEnabled", false) && flag(r, "isInStock", true) && !flag(r, "isPaused", false),
paused: flag(r, "isPaused", false),
needs_input: flag(r, "isUserInputRequired", false),
color: r.get("backgroundColor").and_then(|c| c.as_str()).map(str::to_string),
image: r.pointer("/image/url").or_else(|| r.pointer("/defaultImage/url")).and_then(|u| u.as_str()).map(str::to_string),
kind: Kind::Custom,
choices: Vec::new(),
unavailable: None,
})
})
.collect()
})
.unwrap_or_default();
// The emotes Twitch lets be unlocked or changed are the channel's `emoteVariants`, and only the ones it marks
// unlockable are in the pool (asking for another is refused as an invalid emote). The unlocks offer those the
// account does not have; a change is offered for those it does.
struct Variant {
id: String,
token: String,
unlockable: bool,
modifications: Vec<String>,
}
let variants: Vec<Variant> = channel
.pointer("/communityPointsSettings/emoteVariants")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|v| {
Some(Variant {
id: v.get("id")?.as_str()?.to_string(),
token: v.pointer("/emote/token").and_then(|t| t.as_str())?.to_string(),
unlockable: flag(v, "isUnlockable", false),
modifications: v.get("modifications").and_then(|m| m.as_array()).into_iter().flatten().filter_map(|m| m.get("id").and_then(|i| i.as_str()).map(str::to_string)).collect(),
})
})
.collect();
let unowned: Vec<Choice> = variants.iter().filter(|v| v.unlockable && !owned.contains(&v.id)).map(|v| Choice { id: v.id.clone(), label: v.token.clone(), image: Some(emote_image(&v.id)), group: None, group_image: None }).collect();
let changes: Vec<Choice> = variants
.iter()
.filter(|v| owned.contains(&v.id))
.flat_map(|v| {
v.modifications.iter().map(move |m| Choice { label: format!("{}, {}", v.token, modification_name(m)), image: Some(emote_image(m)), id: m.clone(), group: Some(v.token.clone()), group_image: Some(emote_image(&v.id)) })
})
.collect();
let variants = changes;
for r in channel.pointer("/communityPointsSettings/automaticRewards").and_then(|r| r.as_array()).into_iter().flatten() {
let (Some(id), Some(kind)) = (r.get("id").and_then(|i| i.as_str()), r.get("type").and_then(|t| t.as_str())) else { continue };
let cost = r.get("cost").and_then(|c| c.as_u64()).or_else(|| r.get("defaultCost").and_then(|c| c.as_u64())).unwrap_or(0);
let (title, prompt, kind, needs_input, choices) = match kind {
"SEND_HIGHLIGHTED_MESSAGE" => ("Highlight My Message", "Write something special and give it some glitz in chat", Kind::Highlight, true, Vec::new()),
"RANDOM_SUB_EMOTE_UNLOCK" => ("Unlock a Random Sub Emote", "Unlocks a random subscriber emote you do not have", Kind::RandomEmote, false, Vec::new()),
"CHOSEN_SUB_EMOTE_UNLOCK" => ("Choose an Emote to Unlock", "Pick a subscriber emote to unlock for 24 hours", Kind::ChosenEmote, false, unowned.clone()),
"CHOSEN_MODIFIED_SUB_EMOTE_UNLOCK" => ("Modify a Single Emote", "Change one of this channel's emotes you have", Kind::ModifiedEmote, false, variants.clone()),
"SINGLE_MESSAGE_BYPASS_SUB_MODE" => ("Send a Message in Sub-Only Mode", "Send one message past subscribers-only mode", Kind::Other, false, Vec::new()),
// Paid for in Bits and not in points: not for here.
_ => continue,
};
let mut unavailable = None;
if kind == Kind::Other {
unavailable = Some("Send this one from twitch.tv's chat box".to_string());
} else if (matches!(kind, Kind::ChosenEmote | Kind::ModifiedEmote) && choices.is_empty()) || (kind == Kind::RandomEmote && unowned.is_empty()) {
unavailable = Some(if kind == Kind::ModifiedEmote { "You have none of this channel's emotes to change yet".to_string() } else { "There is nothing left here to unlock".to_string() });
}
rewards.push(Reward {
id: id.to_string(),
title: title.to_string(),
cost,
prompt: Some(prompt.to_string()),
enabled: flag(r, "isEnabled", false),
paused: false,
needs_input,
color: r.get("backgroundColor").and_then(|c| c.as_str()).or_else(|| r.get("defaultBackgroundColor").and_then(|c| c.as_str())).map(str::to_string),
image: r.pointer("/defaultImage/url").and_then(|u| u.as_str()).map(str::to_string),
kind,
choices,
unavailable,
});
}
Some((channel.get("id")?.as_str()?.to_string(), rewards, read_points(answer)?))
}
fn explain(e: PlaybackError) -> String {
match e {
PlaybackError::NeedsIntegrity => "Twitch wants proof this is a browser and moho could not make it. Try again in a moment".to_string(),
other => other.to_string(),
}
}
const NEEDS_SESSION: &str = "Sign in to Twitch from Accounts again: channel points are only available to the twitch.tv session a sign-in brings";
/// A fresh id for one vote or bet, so a request that is repeated is the same one and not a second. In the form
/// of a UUID, which is what Twitch's own page sends.
fn fresh_id() -> String {
use rand::RngCore;
let mut b = [0u8; 16];
rand::thread_rng().fill_bytes(&mut b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let h: String = b.iter().map(|x| format!("{x:02x}")).collect();
format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32])
}
/// A mutation's own refusal, in words. Twitch answers with a code in the payload, not an HTTP error.
fn refusal(answer: &Value, field: &str) -> Option<String> {
if let Some(said) = gql::error_of(answer) {
return Some(match said.as_str() {
"unauthenticated" => "Twitch no longer accepts the twitch.tv sign-in. Sign in to Twitch again from Accounts".to_string(),
other => other.to_string(),
});
}
let code = answer.pointer(&format!("/data/{field}/error/code")).and_then(|c| c.as_str())?;
Some(match code {
"NOT_ENOUGH_POINTS" | "INSUFFICIENT_FUNDS" => "Not enough channel points".to_string(),
"ALREADY_VOTED" | "ALREADY_PREDICTED" => "You have already answered that".to_string(),
"CANNOT_PREDICT_ON_BOTH_OUTCOMES" | "OTHER_OUTCOME_ALREADY_PREDICTED" => "You already put points on the other outcome".to_string(),
"EVENT_NOT_ACTIVE" | "POLL_NOT_ACTIVE" | "PREDICTION_LOCKED" => "That is closed".to_string(),
"GLOBAL_COOLDOWN" | "COOLDOWN" => "That reward is cooling down".to_string(),
"MAX_PER_STREAM" | "MAX_PER_USER_PER_STREAM" | "MAX_REDEMPTIONS" => "That reward has been had as many times as this stream allows".to_string(),
"REWARD_DISABLED" | "REWARD_NOT_ENABLED" | "REWARD_PAUSED" | "REWARD_OUT_OF_STOCK" => "That reward is not available right now".to_string(),
"CHANNEL_NOT_LIVE" | "STREAM_NOT_LIVE" => "That reward can be had while the channel is live".to_string(),
"NOT_FOUND" | "POLL_NOT_FOUND" | "EVENT_NOT_FOUND" | "CLAIM_NOT_FOUND" => "That is no longer there".to_string(),
"INVALID_EMOTE" | "EMOTE_NOT_UNLOCKABLE" | "EMOTE_ALREADY_UNLOCKED" | "EMOTE_NOT_FOUND" => "Twitch will not unlock that emote (it may already be yours, or not in this channel's pool)".to_string(),
"MAX_POINTS_EXCEEDED" => "That is more than the channel allows on one prediction".to_string(),
other => format!("Twitch refused: {}", other.to_lowercase().replace('_', " ")),
})
}
fn token(web_token: Option<&str>) -> Result<&str, String> {
web_token.filter(|t| !t.is_empty()).ok_or_else(|| NEEDS_SESSION.to_string())
}
/// The balance and any bonus waiting, for a channel by login.
pub async fn context(state: &AppState, http: &reqwest::Client, root: &str, login: &str, web_token: Option<&str>) -> Result<Points, String> {
let token = token(web_token)?;
let answer = playback::call_as_session(state, http, root, &gql::query(CONTEXT, json!({ "login": login })), token).await.map_err(explain)?;
if let Some(said) = gql::error_of(&answer) {
return Err(said);
}
read_points(&answer).ok_or_else(|| format!("Twitch has no channel points for {login}"))
}
/// Puts points on an outcome of a prediction.
pub async fn bet(state: &AppState, http: &reqwest::Client, root: &str, event_id: &str, outcome_id: &str, points: u64, web_token: Option<&str>) -> Result<(), String> {
let token = token(web_token)?;
const BET: &str = "mutation($i:MakePredictionInput!){ makePrediction(input:$i){ error{ code } } }";
let body = gql::query(BET, json!({ "i": { "eventID": event_id, "outcomeID": outcome_id, "points": points, "transactionID": fresh_id() } }));
let answer = playback::call_as_session(state, http, root, &body, token).await.map_err(explain)?;
refusal(&answer, "makePrediction").map_or(Ok(()), Err)
}
/// Votes in a poll with the free vote everybody has.
pub async fn vote(state: &AppState, http: &reqwest::Client, root: &str, poll_id: &str, choice_id: &str, user_id: &str, web_token: Option<&str>) -> Result<(), String> {
let token = token(web_token)?;
const VOTE: &str = "mutation($i:VoteInPollInput!){ voteInPoll(input:$i){ error{ code } } }";
let body = gql::query(VOTE, json!({ "i": { "pollID": poll_id, "choiceID": choice_id, "userID": user_id, "voteID": fresh_id() } }));
let answer = playback::call_as_session(state, http, root, &body, token).await.map_err(explain)?;
refusal(&answer, "voteInPoll").map_or(Ok(()), Err)
}
/// A channel's rewards and this account's balance: the panel over the chat lists them.
pub async fn rewards(state: &AppState, http: &reqwest::Client, root: &str, login: &str, owned: &std::collections::HashSet<String>, web_token: Option<&str>) -> Result<(String, Vec<Reward>, Points), String> {
let token = token(web_token)?;
let answer = playback::call_as_session(state, http, root, &gql::query(REWARDS, json!({ "login": login })), token).await.map_err(explain)?;
if let Some(said) = gql::error_of(&answer) {
return Err(said);
}
read_rewards(&answer, owned).ok_or_else(|| format!("Twitch has no channel points for {login}"))
}
/// Spends points on a reward. `input` is what a reward that asks for something was given.
pub async fn redeem(state: &AppState, http: &reqwest::Client, root: &str, channel_id: &str, reward: &Reward, input: Option<&str>, web_token: Option<&str>) -> Result<(), String> {
let token = token(web_token)?;
const REDEEM: &str = "mutation($i:RedeemCommunityPointsCustomRewardInput!){ redeemCommunityPointsCustomReward(input:$i){ error{ code } } }";
let mut i = json!({ "channelID": channel_id, "cost": reward.cost, "prompt": reward.prompt, "rewardID": reward.id, "title": reward.title, "transactionID": fresh_id() });
if let Some(text) = input.map(str::trim).filter(|t| !t.is_empty()) {
i["textInput"] = json!(text);
}
let answer = playback::call_as_session(state, http, root, &gql::query(REDEEM, json!({ "i": i })), token).await.map_err(explain)?;
refusal(&answer, "redeemCommunityPointsCustomReward").map_or(Ok(()), Err)
}
/// What the person gave a reward: the words typed for it, the emote picked for it.
#[derive(Default, Clone, Copy)]
pub struct Given<'a> {
pub input: Option<&'a str>,
pub choice: Option<&'a str>,
}
/// Spends points on one of Twitch's own rewards (a highlighted message, an emote unlock).
pub async fn redeem_automatic(state: &AppState, http: &reqwest::Client, root: &str, channel_id: &str, reward: &Reward, given: Given<'_>, web_token: Option<&str>) -> Result<(), String> {
let (input, choice) = (given.input, given.choice);
let token = token(web_token)?;
let base = json!({ "channelID": channel_id, "cost": reward.cost, "transactionID": fresh_id() });
let (name, input_type, mut i) = match reward.kind {
Kind::Highlight => {
let text = input.map(str::trim).filter(|t| !t.is_empty()).ok_or_else(|| "That reward asks you to type something".to_string())?;
let mut i = base;
i["message"] = json!(text);
("sendHighlightedChatMessage", "SendHighlightedChatMessageInput", i)
}
Kind::RandomEmote => ("unlockRandomSubscriberEmote", "UnlockRandomSubscriberEmoteInput", base),
Kind::ChosenEmote | Kind::ModifiedEmote => {
let emote = choice.filter(|c| reward.choices.iter().any(|o| o.id == *c)).ok_or_else(|| "Pick an emote first".to_string())?;
let mut i = base;
i["emoteID"] = json!(emote);
if reward.kind == Kind::ChosenEmote {
("unlockChosenSubscriberEmote", "UnlockChosenSubscriberEmoteInput", i)
} else {
("unlockChosenModifiedSubscriberEmote", "UnlockChosenModifiedSubscriberEmoteInput", i)
}
}
_ => return Err(reward.unavailable.clone().unwrap_or_else(|| "That reward cannot be had from here".to_string())),
};
let mutation = format!("mutation($i:{input_type}!){{ {name}(input:$i){{ error{{ code }} }} }}");
i["cost"] = json!(reward.cost);
let answer = playback::call_as_session(state, http, root, &gql::query(&mutation, json!({ "i": i })), token).await.map_err(explain)?;
refusal(&answer, name).map_or(Ok(()), Err)
}
// ---------------------------------------------------------------------------
// From a conversation: what the window's buttons call
// ---------------------------------------------------------------------------
/// The account behind a Twitch channel's conversation, and the channel's login.
pub(super) fn session_of(state: &AppState, buffer_id: &str) -> Result<(crate::accounts::TwitchAccountConfig, String), String> {
let buffer = state.runtime.get_buffer(buffer_id).filter(|b| b.account_id.starts_with("twitch:")).ok_or_else(|| "that is not a Twitch channel".to_string())?;
let cfg = state.accounts.get_twitch(&buffer.account_id).ok_or_else(|| "no such Twitch account".to_string())?;
Ok((cfg, buffer.name))
}
/// The id Twitch knows a card by, from the one the window knows it by (`poll:<id>`).
fn card_id(state: &AppState, buffer_id: &str, kind: &str) -> Result<(String, serde_json::Value), String> {
let card = state.runtime.live_card(buffer_id, kind).ok_or_else(|| format!("there is no {kind} open here"))?;
let id = card["id"].as_str().and_then(|i| i.strip_prefix(&format!("{kind}:"))).ok_or_else(|| format!("that {kind} has no id to answer"))?.to_string();
Ok((id, card))
}
/// Votes in the poll on screen, and shows the vote on its card at once (the tally follows from Twitch).
pub async fn vote_in_buffer(state: &AppState, root: &str, buffer_id: &str, choice_id: &str) -> Result<(), String> {
let (cfg, _) = session_of(state, buffer_id)?;
let (poll_id, mut card) = card_id(state, buffer_id, "poll")?;
let http = super::client().map_err(|e| format!("{e:#}"))?;
vote(state, &http, root, &poll_id, choice_id, &cfg.user_id, Some(&cfg.web_token)).await?;
card["hasVoted"] = json!(true);
card["votedOptionId"] = json!(choice_id);
crate::backend::kick::cards::publish_card(state, buffer_id, "poll", Some(card));
Ok(())
}
/// Puts points on the prediction on screen, and shows the bet on its card at once.
pub async fn bet_in_buffer(state: &AppState, root: &str, buffer_id: &str, outcome_id: &str, amount: u64) -> Result<(), String> {
let (cfg, channel) = session_of(state, buffer_id)?;
let (event_id, mut card) = card_id(state, buffer_id, "prediction")?;
if amount < super::realtime::MIN_BET as u64 {
return Err(format!("Bets are {} points or more", super::realtime::MIN_BET));
}
if let Some(balance) = card["balance"].as_u64() {
if amount > balance {
return Err("Not enough channel points".to_string());
}
}
let http = super::client().map_err(|e| format!("{e:#}"))?;
bet(state, &http, root, &event_id, outcome_id, amount, Some(&cfg.web_token)).await?;
let stake = card["stake"].as_f64().unwrap_or(0.0) + amount as f64;
card["hasVoted"] = json!(true);
card["votedOptionId"] = json!(outcome_id);
card["stake"] = json!(stake);
crate::backend::kick::cards::publish_card(state, buffer_id, "prediction", Some(card));
// What is left, from Twitch rather than this one minus the bet: a refund or a cap would make that wrong.
super::realtime::refresh_balance(state, root, &cfg.account_id(), &channel).await;
Ok(())
}
/// The balance and any bonus waiting in a conversation's channel; also said to the window as `twitchPoints`.
pub async fn points_in_buffer(state: &AppState, root: &str, buffer_id: &str) -> Result<Points, String> {
let (cfg, channel) = session_of(state, buffer_id)?;
let http = super::client().map_err(|e| format!("{e:#}"))?;
let points = context(state, &http, root, &channel, Some(&cfg.web_token)).await?;
state.events.emit("twitchPoints", json!({ "bufferId": buffer_id, "points": points.balance, "name": points.name }));
Ok(points)
}
/// The ids of the emotes this account can already use in a conversation's channel (the picker's entries are named by
/// a token that carries the id: `[twitch:emote:<id>:<name>]`).
fn owned_emotes(state: &AppState, account_id: &str, buffer_id: &str) -> std::collections::HashSet<String> {
state
.runtime
.emoji_catalogue(account_id)
.into_iter()
.filter(|s| s.buffers.iter().any(|b| b == buffer_id))
.flat_map(|s| s.emoji)
.filter(|e| !e.locked)
.filter_map(|e| e.id.strip_prefix("[twitch:").and_then(|t| t.split(':').nth(1)).map(str::to_string))
.collect()
}
/// The rewards of a conversation's channel, and the balance, for the panel; the balance is also said to the window.
pub async fn rewards_in_buffer(state: &AppState, root: &str, buffer_id: &str) -> Result<(Vec<Reward>, Points), String> {
let (cfg, channel) = session_of(state, buffer_id)?;
let http = super::client().map_err(|e| format!("{e:#}"))?;
let owned = owned_emotes(state, &cfg.account_id(), buffer_id);
let (_, rewards, points) = rewards(state, &http, root, &channel, &owned, Some(&cfg.web_token)).await?;
state.events.emit("twitchPoints", json!({ "bufferId": buffer_id, "points": points.balance, "name": points.name }));
// Twitch's proof that this is a browser takes a few seconds to make the first time (and then lasts for hours):
// made now, while the panel is being read, so a press of Redeem is not what waits for it.
if rewards.iter().any(|r| r.enabled && r.kind != Kind::Other) {
let (state, token) = (state.clone(), cfg.web_token.clone());
tokio::spawn(async move {
playback::warm_up(&state, &token).await;
});
}
Ok((rewards, points))
}
/// Redeems a reward of a conversation's channel by its id, after checking it is there, is on offer, and is affordable,
/// and answers the balance after. An emote unlock also reads the account's emotes again, so the new one is in the picker.
pub async fn redeem_in_buffer(state: &AppState, root: &str, buffer_id: &str, reward_id: &str, input: Option<&str>, choice: Option<&str>) -> Result<Points, String> {
let (cfg, channel) = session_of(state, buffer_id)?;
let http = super::client().map_err(|e| format!("{e:#}"))?;
let owned = owned_emotes(state, &cfg.account_id(), buffer_id);
let (channel_id, rewards, points) = rewards(state, &http, root, &channel, &owned, Some(&cfg.web_token)).await?;
let reward = rewards.into_iter().find(|r| r.id == reward_id).ok_or_else(|| "That reward is no longer there".to_string())?;
if let Some(why) = &reward.unavailable {
return Err(why.clone());
}
if !reward.enabled {
return Err(if reward.paused { "That reward is paused right now".to_string() } else { "That reward is not available right now".to_string() });
}
if reward.needs_input && input.map(str::trim).unwrap_or("").is_empty() {
return Err("That reward asks you to type something".to_string());
}
if let Some(balance) = points.balance {
if balance < reward.cost {
return Err(format!("You have {balance} of the {} points that costs", reward.cost));
}
}
match reward.kind {
Kind::Custom => redeem(state, &http, root, &channel_id, &reward, input, Some(&cfg.web_token)).await?,
_ => redeem_automatic(state, &http, root, &channel_id, &reward, Given { input, choice }, Some(&cfg.web_token)).await?,
}
if matches!(reward.kind, Kind::RandomEmote | Kind::ChosenEmote | Kind::ModifiedEmote) {
super::connect::refresh_user_emotes(state, &cfg.account_id(), &channel, &channel_id).await;
}
points_in_buffer(state, root, buffer_id).await
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
#[test]
fn the_balance_and_the_bonus_are_read_and_a_signed_out_answer_has_neither() {
let signed_in = json!({ "data": { "user": { "channel": { "self": { "communityPoints": { "balance": 4200 } }, "communityPointsSettings": { "name": "Sloths" } } } } });
assert_eq!(read_points(&signed_in), Some(Points { balance: Some(4200), name: Some("Sloths".into()) }));
let anonymous = json!({ "data": { "user": { "channel": { "self": { "communityPoints": null }, "communityPointsSettings": { "name": null } } } } });
assert_eq!(read_points(&anonymous), Some(Points { balance: None, name: None }));
assert_eq!(read_points(&json!({ "data": { "user": null } })), None);
}
#[test]
fn a_refusal_is_said_in_words_whether_it_came_as_an_error_or_in_the_payload() {
let payload = json!({ "data": { "makePrediction": { "error": { "code": "NOT_ENOUGH_POINTS" } } } });
assert_eq!(refusal(&payload, "makePrediction").as_deref(), Some("Not enough channel points"));
let auth = json!({ "errors": [{ "message": "unauthenticated" }], "data": { "makePrediction": null } });
assert!(refusal(&auth, "makePrediction").unwrap().contains("Sign in to Twitch again"));
let fine = json!({ "data": { "makePrediction": { "error": null } } });
assert_eq!(refusal(&fine, "makePrediction"), None);
let odd = json!({ "data": { "voteInPoll": { "error": { "code": "SOMETHING_NEW" } } } });
assert_eq!(refusal(&odd, "voteInPoll").as_deref(), Some("Twitch refused: something new"));
}
#[test]
fn an_id_for_a_vote_is_a_uuid_and_is_new_each_time() {
let a = fresh_id();
assert_eq!(a.len(), 36);
assert_eq!(a.matches('-').count(), 4);
assert_eq!(&a[14..15], "4");
assert_ne!(a, fresh_id());
}
use crate::accounts::TwitchAccountConfig;
use crate::backend::kick::testkit;
use std::sync::{Arc, Mutex};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// A GraphQL that answers by what was asked, and keeps what it was sent (headers and body).
pub(in crate::backend::twitch) async fn fake_gql(answer: impl Fn(&str) -> String + Send + Sync + 'static) -> (String, Arc<Mutex<Vec<String>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let root = format!("http://{}", listener.local_addr().unwrap());
let seen: Arc<Mutex<Vec<String>>> = Default::default();
let (log, answer) = (seen.clone(), Arc::new(answer));
tokio::spawn(async move {
loop {
let Ok((mut sock, _)) = listener.accept().await else { return };
let (log, answer) = (log.clone(), answer.clone());
tokio::spawn(async move {
let mut buf = vec![0u8; 16384];
let mut got = 0;
loop {
let n = sock.read(&mut buf[got..]).await.unwrap_or(0);
if n == 0 {
break;
}
got += n;
let text = String::from_utf8_lossy(&buf[..got]).to_string();
if let Some(end) = text.find("\r\n\r\n") {
let length = text[..end].lines().find_map(|l| l.to_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse::<usize>().unwrap_or(0))).unwrap_or(0);
if got >= end + 4 + length {
break;
}
}
}
let request = String::from_utf8_lossy(&buf[..got]).to_string();
let body = answer(&request);
log.lock().unwrap().push(request);
let reply = format!("HTTP/1.1 200 X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
let _ = sock.write_all(reply.as_bytes()).await;
});
}
});
(root, seen)
}
pub(in crate::backend::twitch) fn channel(name: &str, web_token: &str) -> (AppState, String) {
let state = testkit::simulated_daemon(name);
state.accounts.add_twitch(TwitchAccountConfig { login: "me".into(), user_id: "42".into(), access_token: "t".into(), refresh_token: "r".into(), expires_at: chrono::Utc::now().timestamp() + 9000, web_token: web_token.into(), ..Default::default() }).unwrap();
state.runtime.ensure_buffer(&state, "twitch:me", "xqc", "channel");
(state, "twitch:me|xqc".to_string())
}
#[tokio::test]
async fn a_vote_is_sent_as_the_twitch_session_and_shows_on_the_card() {
let (state, buffer) = channel("twitch-points-vote", "websession");
state.runtime.set_live_card(&buffer, "poll", json!({ "id": "poll:q1", "kind": "poll", "options": [] }));
let (root, seen) = fake_gql(|_| r#"{"data":{"voteInPoll":{"error":null}}}"#.to_string()).await;
vote_in_buffer(&state, &root, &buffer, "choice-x").await.unwrap();
let request = seen.lock().unwrap()[0].clone();
assert!(request.to_lowercase().contains("authorization: oauth websession"), "{request}");
assert!(request.contains("\"pollID\":\"q1\"") && request.contains("\"choiceID\":\"choice-x\"") && request.contains("\"userID\":\"42\""), "{request}");
assert!(request.contains("voteID"), "{request}");
let card = state.runtime.live_card(&buffer, "poll").unwrap();
assert_eq!((card["hasVoted"].as_bool(), card["votedOptionId"].as_str()), (Some(true), Some("choice-x")));
}
#[tokio::test]
async fn a_refusal_is_said_and_the_card_is_left_alone() {
let (state, buffer) = channel("twitch-points-refused", "websession");
state.runtime.set_live_card(&buffer, "poll", json!({ "id": "poll:q1", "kind": "poll", "options": [] }));
let (root, _) = fake_gql(|_| r#"{"data":{"voteInPoll":{"error":{"code":"ALREADY_VOTED"}}}}"#.to_string()).await;
assert_eq!(vote_in_buffer(&state, &root, &buffer, "x").await.unwrap_err(), "You have already answered that");
assert!(state.runtime.live_card(&buffer, "poll").unwrap()["hasVoted"].is_null());
// And no session, no call.
let (state, buffer) = channel("twitch-points-nosession", "");
state.runtime.set_live_card(&buffer, "poll", json!({ "id": "poll:q1", "kind": "poll" }));
let e = vote_in_buffer(&state, &root, &buffer, "x").await.unwrap_err();
assert!(e.contains("Sign in to Twitch from Accounts again"), "{e}");
}
#[tokio::test]
async fn a_bet_checks_its_size_places_it_and_asks_what_is_left() {
let (state, buffer) = channel("twitch-points-bet", "websession");
state.runtime.set_live_card(&buffer, "prediction", json!({ "id": "prediction:e1", "kind": "prediction", "balance": 500, "options": [] }));
let (root, seen) = fake_gql(|req| {
if req.contains("makePrediction") {
r#"{"data":{"makePrediction":{"error":null}}}"#.to_string()
} else {
r#"{"data":{"user":{"channel":{"self":{"communityPoints":{"balance":400}},"communityPointsSettings":{"name":null}}}}}"#.to_string()
}
})
.await;
assert!(bet_in_buffer(&state, &root, &buffer, "o1", 5).await.unwrap_err().contains("10 points or more"));
assert_eq!(bet_in_buffer(&state, &root, &buffer, "o1", 900).await.unwrap_err(), "Not enough channel points");
assert!(seen.lock().unwrap().is_empty(), "a bet that cannot be placed is not sent");
bet_in_buffer(&state, &root, &buffer, "o1", 100).await.unwrap();
let requests = seen.lock().unwrap().clone();
assert!(requests[0].contains("\"eventID\":\"e1\"") && requests[0].contains("\"outcomeID\":\"o1\"") && requests[0].contains("\"points\":100") && requests[0].contains("transactionID"), "{}", requests[0]);
let card = state.runtime.live_card(&buffer, "prediction").unwrap();
assert_eq!((card["votedOptionId"].as_str(), card["stake"].as_f64(), card["balance"].as_u64()), (Some("o1"), Some(100.0), Some(400)));
}
/// Against the real Twitch, with nothing at stake: reads a balance, and sends a vote and a bet that
/// name things that do not exist, which Twitch refuses in words. Shows the session is accepted and the
/// requests are shaped as it wants. `TWITCH_LIVE_WEB_TOKEN=... TWITCH_LIVE_USER_ID=... TWITCH_LIVE_CHANNEL=...
/// cargo test --release live_ -- --ignored --nocapture`.
#[tokio::test]
#[ignore]
async fn live_the_session_reads_a_balance_and_twitch_refuses_what_does_not_exist() {
let _ = rustls::crypto::ring::default_provider().install_default();
let (token, user, channel) = (std::env::var("TWITCH_LIVE_WEB_TOKEN").unwrap(), std::env::var("TWITCH_LIVE_USER_ID").unwrap(), std::env::var("TWITCH_LIVE_CHANNEL").unwrap());
let state = testkit::simulated_daemon("twitch-points-live");
let http = crate::backend::twitch::client().unwrap();
let root = gql::ROOT;
// A real prediction and outcome (from `TWITCH_LIVE_EVENT` and `TWITCH_LIVE_OUTCOME`), with more points than
// the account has: Twitch refuses it for want of points, which proves the bet is understood and spends nothing.
if let (Ok(event), Ok(outcome)) = (std::env::var("TWITCH_LIVE_EVENT"), std::env::var("TWITCH_LIVE_OUTCOME")) {
let amount: u64 = std::env::var("TWITCH_LIVE_BET").ok().and_then(|a| a.parse().ok()).unwrap_or(10);
println!("a bet of {amount} on a real prediction: {:?}", bet(&state, &http, root, &event, &outcome, amount, Some(&token)).await);
}
println!("balance: {:?}", context(&state, &http, root, &channel, Some(&token)).await);
// The channel's rewards, and a redeem of one that does not exist (nothing is spent).
match rewards(&state, &http, root, &channel, &Default::default(), Some(&token)).await {
Ok((channel_id, list, points)) => {
println!("rewards: {} offered, balance {:?}; first: {:?}", list.len(), points.balance, list.first().map(|r| (&r.title, r.cost, r.enabled)));
for r in list.iter().filter(|r| r.kind != Kind::Custom) {
println!(" own reward: {} | {} | enabled {} | {} choices | {:?}", r.title, r.cost, r.enabled, r.choices.len(), r.unavailable);
}
let fake = Reward { id: "00000000-0000-0000-0000-000000000000".into(), title: "nothing".into(), cost: 10, prompt: None, enabled: true, paused: false, needs_input: false, color: None, image: None, kind: Kind::Custom, choices: Vec::new(), unavailable: None };
println!("redeem a reward that is not there: {:?}", redeem(&state, &http, root, &channel_id, &fake, None, Some(&token)).await);
}
Err(e) => println!("rewards: {e}"),
}
println!("vote in a poll that is not there: {:?}", vote(&state, &http, root, "00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000001", &user, Some(&token)).await);
println!("bet on a prediction that is not there: {:?}", bet(&state, &http, root, "00000000-0000-0000-0000-000000000000", "00000000-0000-0000-0000-000000000001", 10, Some(&token)).await);
}
const REWARDS_ANSWER: &str = r##"{"data":{"user":{"channel":{"id":"71","self":{"communityPoints":{"balance":600}},"communityPointsSettings":{"name":"Sloths","customRewards":[
{"id":"r1","title":"Drink water","cost":500,"prompt":null,"isEnabled":true,"isPaused":false,"isInStock":true,"isUserInputRequired":false,"backgroundColor":"#6BB7FA","image":null,"defaultImage":{"url":"https://img/default.png"}},
{"id":"r2","title":"Say a name","cost":100,"prompt":"Who?","isEnabled":true,"isPaused":false,"isInStock":true,"isUserInputRequired":true,"backgroundColor":"#E37C10","image":{"url":"https://img/own.png"},"defaultImage":null},
{"id":"r3","title":"Dance","cost":50,"prompt":null,"isEnabled":true,"isPaused":true,"isInStock":true,"isUserInputRequired":false,"backgroundColor":null,"image":null,"defaultImage":null},
{"id":"r4","title":"Costly","cost":9000,"prompt":null,"isEnabled":true,"isPaused":false,"isInStock":true,"isUserInputRequired":false,"backgroundColor":null,"image":null,"defaultImage":null}],"automaticRewards":[
{"id":"71:SEND_HIGHLIGHTED_MESSAGE","type":"SEND_HIGHLIGHTED_MESSAGE","cost":null,"defaultCost":100,"isEnabled":true,"defaultBackgroundColor":"#FF6905","backgroundColor":null,"defaultImage":{"url":"https://img/hl.png"}},
{"id":"71:RANDOM_SUB_EMOTE_UNLOCK","type":"RANDOM_SUB_EMOTE_UNLOCK","cost":null,"defaultCost":320,"isEnabled":true,"defaultBackgroundColor":"#8205B4","backgroundColor":null,"defaultImage":null},
{"id":"71:CHOSEN_SUB_EMOTE_UNLOCK","type":"CHOSEN_SUB_EMOTE_UNLOCK","cost":null,"defaultCost":480,"isEnabled":true,"defaultBackgroundColor":"#00C8AF","backgroundColor":null,"defaultImage":null},
{"id":"71:CHOSEN_MODIFIED_SUB_EMOTE_UNLOCK","type":"CHOSEN_MODIFIED_SUB_EMOTE_UNLOCK","cost":900,"defaultCost":900,"isEnabled":true,"defaultBackgroundColor":"#00FA05","backgroundColor":null,"defaultImage":null},
{"id":"71:SINGLE_MESSAGE_BYPASS_SUB_MODE","type":"SINGLE_MESSAGE_BYPASS_SUB_MODE","cost":350,"defaultCost":350,"isEnabled":true,"defaultBackgroundColor":"#9146FF","backgroundColor":null,"defaultImage":null},
{"id":"71:SEND_GIGANTIFIED_EMOTE","type":"SEND_GIGANTIFIED_EMOTE","cost":null,"defaultCost":0,"isEnabled":true,"defaultBackgroundColor":"#69B6FF","backgroundColor":null,"defaultImage":null}],
"emoteVariants":[{"id":"e1","isUnlockable":true,"emote":{"id":"e1","token":"binxbWut"},"modifications":[]},{"id":"e2","isUnlockable":true,"emote":{"id":"e2","token":"binxbYAP"},"modifications":[]},{"id":"e3","isUnlockable":true,"emote":{"id":"e3","token":"binxbDrama"},"modifications":[{"id":"e3_BW"},{"id":"e3_SG"}]},{"id":"e4","isUnlockable":false,"emote":{"id":"e4","token":"binxbNope"},"modifications":[{"id":"e4_BW"}]}]}}}}}"##;
#[test]
fn a_channels_rewards_are_read_with_what_makes_each_available() {
let (channel, rewards, points) = read_rewards(&serde_json::from_str(REWARDS_ANSWER).unwrap(), &Default::default()).unwrap();
assert_eq!((channel.as_str(), points.balance, points.name.as_deref()), ("71", Some(600), Some("Sloths")));
let custom: Vec<(&str, u64, bool, bool, bool)> = rewards.iter().filter(|r| r.kind == Kind::Custom).map(|r| (r.id.as_str(), r.cost, r.enabled, r.paused, r.needs_input)).collect();
assert_eq!(custom, vec![("r1", 500, true, false, false), ("r2", 100, true, false, true), ("r3", 50, false, true, false), ("r4", 9000, true, false, false)]);
assert_eq!(rewards[0].image.as_deref(), Some("https://img/default.png"));
assert_eq!(rewards[1].image.as_deref(), Some("https://img/own.png"));
assert_eq!(rewards[1].prompt.as_deref(), Some("Who?"));
}
#[test]
fn twitchs_own_rewards_are_listed_with_what_each_takes_and_the_bits_ones_are_left_out() {
let owned: std::collections::HashSet<String> = ["e1", "e3"].iter().map(|s| s.to_string()).collect();
let (_, rewards, _) = read_rewards(&serde_json::from_str(REWARDS_ANSWER).unwrap(), &owned).unwrap();
let auto: Vec<&Reward> = rewards.iter().filter(|r| r.kind != Kind::Custom).collect();
let kinds: Vec<(Kind, u64, bool)> = auto.iter().map(|r| (r.kind, r.cost, r.needs_input)).collect();
// Highlight (typed), random, chosen, modified, and the one that is sent from twitch.tv; the Bits one is not here.
assert_eq!(kinds, vec![(Kind::Highlight, 100, true), (Kind::RandomEmote, 320, false), (Kind::ChosenEmote, 480, false), (Kind::ModifiedEmote, 900, false), (Kind::Other, 350, false)]);
// What the account already has is not offered again, nor is an emote Twitch does not put in the pool; a change is
// offered only for an emote the account has.
let chosen: Vec<&str> = auto[2].choices.iter().map(|c| c.label.as_str()).collect();
assert_eq!(chosen, vec!["binxbYAP"]);
let modified: Vec<&str> = auto[3].choices.iter().map(|c| c.label.as_str()).collect();
assert_eq!(modified, vec!["binxbDrama, black and white", "binxbDrama, with sunglasses"]);
assert_eq!(auto[4].unavailable.as_deref(), Some("Send this one from twitch.tv's chat box"));
// With every emote owned there is nothing to unlock, which is said.
let all: std::collections::HashSet<String> = ["e1", "e2", "e3"].iter().map(|s| s.to_string()).collect();
let (_, none_left, _) = read_rewards(&serde_json::from_str(REWARDS_ANSWER).unwrap(), &all).unwrap();
let chosen_none = none_left.iter().find(|r| r.kind == Kind::ChosenEmote).unwrap();
assert_eq!(chosen_none.unavailable.as_deref(), Some("There is nothing left here to unlock"));
}
#[tokio::test]
async fn the_automatic_rewards_are_redeemed_through_their_own_calls_with_what_each_needs() {
let (state, buffer) = channel("twitch-points-auto", "websession");
let (root, seen) = fake_gql(|req| {
if req.contains("\"mutation(") || req.contains("mutation($i") {
r#"{"data":{"x":{"error":null}}}"#.replace("\"x\"", if req.contains("sendHighlightedChatMessage") { "\"sendHighlightedChatMessage\"" } else if req.contains("unlockRandomSubscriberEmote") { "\"unlockRandomSubscriberEmote\"" } else if req.contains("unlockChosenModifiedSubscriberEmote") { "\"unlockChosenModifiedSubscriberEmote\"" } else { "\"unlockChosenSubscriberEmote\"" })
} else {
REWARDS_ANSWER.to_string()
}
})
.await;
redeem_in_buffer(&state, &root, &buffer, "71:SEND_HIGHLIGHTED_MESSAGE", Some("look at me"), None).await.unwrap();
redeem_in_buffer(&state, &root, &buffer, "71:RANDOM_SUB_EMOTE_UNLOCK", None, None).await.unwrap();
redeem_in_buffer(&state, &root, &buffer, "71:CHOSEN_SUB_EMOTE_UNLOCK", None, Some("e2")).await.unwrap();
redeem_in_buffer(&state, &root, &buffer, "71:CHOSEN_MODIFIED_SUB_EMOTE_UNLOCK", None, Some("e3_SG")).await.unwrap_err();
let requests = seen.lock().unwrap().clone();
let sent = |name: &str| requests.iter().find(|r| r.contains(name) && r.contains("transactionID")).cloned().unwrap_or_default();
let highlight = sent("sendHighlightedChatMessage");
for part in ["\"channelID\":\"71\"", "\"cost\":100", "\"message\":\"look at me\"", "SendHighlightedChatMessageInput"] {
assert!(highlight.contains(part), "{part} in {highlight}");
}
assert!(sent("unlockRandomSubscriberEmote").contains("\"cost\":320"));
let chosen = sent("unlockChosenSubscriberEmote");
assert!(chosen.contains("\"emoteID\":\"e2\"") && chosen.contains("\"cost\":480"), "{chosen}");
// An emote that was not offered, and a message that was not typed, are stopped before anything is sent.
let before = requests.iter().filter(|r| r.contains("transactionID")).count();
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "71:CHOSEN_SUB_EMOTE_UNLOCK", None, Some("not-offered")).await.unwrap_err(), "Pick an emote first");
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "71:SEND_HIGHLIGHTED_MESSAGE", Some(" "), None).await.unwrap_err(), "That reward asks you to type something");
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "71:SINGLE_MESSAGE_BYPASS_SUB_MODE", None, None).await.unwrap_err(), "Send this one from twitch.tv's chat box");
assert_eq!(seen.lock().unwrap().iter().filter(|r| r.contains("transactionID")).count(), before);
}
#[tokio::test]
async fn a_reward_is_redeemed_with_what_twitch_needs_and_a_refusal_is_stopped_before_it_is_sent() {
let (state, buffer) = channel("twitch-points-redeem", "websession");
let asked = Arc::new(Mutex::new(0));
let counter = asked.clone();
let (root, seen) = fake_gql(move |req| {
if req.contains("redeemCommunityPointsCustomReward") {
return r#"{"data":{"redeemCommunityPointsCustomReward":{"error":null}}}"#.to_string();
}
let mut n = counter.lock().unwrap();
*n += 1;
if *n <= 4 { REWARDS_ANSWER.to_string() } else { REWARDS_ANSWER.replace("\"balance\":600", "\"balance\":500") }
})
.await;
// Paused, too dear, asks for a word and was not given one, and not there: each said, none sent.
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "r3", None, None).await.unwrap_err(), "That reward is paused right now");
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "r4", None, None).await.unwrap_err(), "You have 600 of the 9000 points that costs");
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "r2", Some(" "), None).await.unwrap_err(), "That reward asks you to type something");
assert_eq!(redeem_in_buffer(&state, &root, &buffer, "nope", None, None).await.unwrap_err(), "That reward is no longer there");
assert!(!seen.lock().unwrap().iter().any(|r| r.contains("redeemCommunityPointsCustomReward")), "nothing was spent");
// One that is on offer, with what it asks for.
let after = redeem_in_buffer(&state, &root, &buffer, "r2", Some("Greg"), None).await.unwrap();
assert_eq!(after.balance, Some(500));
let request = seen.lock().unwrap().iter().find(|r| r.contains("redeemCommunityPointsCustomReward")).cloned().unwrap();
for part in ["\"channelID\":\"71\"", "\"rewardID\":\"r2\"", "\"cost\":100", "\"title\":\"Say a name\"", "\"prompt\":\"Who?\"", "\"textInput\":\"Greg\"", "transactionID"] {
assert!(request.contains(part), "{part} in {request}");
}
assert!(request.to_lowercase().contains("authorization: oauth websession"));
}
}
+695
View File
@@ -0,0 +1,695 @@
//! What Twitch says as it happens, beyond the chat: a channel that raids another,
//! the poll it runs, the prediction it opens.
//!
//! twitch.tv's own pages hear these over a websocket of their own (Hermes), by
//! subscribing to a topic per channel and kind of thing. It can be listened to
//! with no account at all - the subscriptions are accepted anonymously, which
//! is how a logged-out visitor sees a poll - so this works for every channel
//! every account has open. Like the GraphQL it is private and not Twitch's to
//! promise (see gql.rs and the plan, Gitea issue #55), and a socket that will
//! not open costs nothing but these cards.
//!
//! One socket per account (so it goes by that account's route), subscribed to
//! three topics per open channel: `raid.<id>`, `polls.<id>` and
//! `predictions-channel-v1.<id>`. Each message is a JSON string inside the
//! envelope; what it says is read by the plain functions below and the cards
//! are the ones Kick's polls and predictions already use.
//!
//! A poll or a prediction that began before the channel was opened is asked for
//! once, from the GraphQL, since the socket only says what changes.
use super::gql;
use crate::state::AppState;
use anyhow::{Context, Result};
use futures::{SinkExt, StreamExt};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message as WsMessage;
pub const HERMES_URL: &str = "wss://hermes.twitch.tv/v1?clientId=kimne78kx3ncx6brgo4mv6wki5h1ko";
/// How long without a word (it sends a keepalive every 15 seconds) before the connection is taken for dead.
const SILENCE: Duration = Duration::from_secs(60);
/// What the connection is asked to do.
pub enum Watch {
Add { channel: String, room_id: String },
Remove { channel: String },
}
fn registry() -> &'static Mutex<HashMap<String, mpsc::UnboundedSender<Watch>>> {
static REGISTRY: std::sync::OnceLock<Mutex<HashMap<String, mpsc::UnboundedSender<Watch>>>> = std::sync::OnceLock::new();
REGISTRY.get_or_init(Default::default)
}
/// Makes the way to tell an account's socket what to listen to. Called when
/// the account starts; the receiver is `run`'s.
pub fn register(account_id: &str) -> mpsc::UnboundedReceiver<Watch> {
let (tx, rx) = mpsc::unbounded_channel();
registry().lock().unwrap().insert(account_id.to_string(), tx);
rx
}
/// Listens for a channel, now and after any reconnection.
pub fn watch(account_id: &str, channel: &str, room_id: &str) {
if let Some(tx) = registry().lock().unwrap().get(account_id) {
let _ = tx.send(Watch::Add { channel: channel.to_string(), room_id: room_id.to_string() });
}
}
pub fn unwatch(account_id: &str, channel: &str) {
if let Some(tx) = registry().lock().unwrap().get(account_id) {
let _ = tx.send(Watch::Remove { channel: channel.to_string() });
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Kind {
Raid,
Polls,
Predictions,
}
impl Kind {
const ALL: [Kind; 3] = [Kind::Raid, Kind::Polls, Kind::Predictions];
fn topic(self, room_id: &str) -> String {
match self {
Kind::Raid => format!("raid.{room_id}"),
Kind::Polls => format!("polls.{room_id}"),
Kind::Predictions => format!("predictions-channel-v1.{room_id}"),
}
}
}
// ---------------------------------------------------------------------------
// Reading what was said
// ---------------------------------------------------------------------------
/// What a message means.
#[derive(Debug, PartialEq)]
pub enum Event {
/// The channel is sending its viewers to another: who, and how many.
Raid { id: String, login: String, display: String, viewers: Option<u64> },
RaidCalledOff { id: String },
/// A poll or prediction card, and whether it is the last word on it.
Card { kind: &'static str, card: Value },
/// The poll or prediction is over and filed away: take the card down.
CardGone { kind: &'static str },
}
fn text<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter().find_map(|k| v.get(*k).and_then(|x| x.as_str())).filter(|s| !s.is_empty())
}
fn number(v: &Value, keys: &[&str]) -> Option<f64> {
keys.iter().find_map(|k| v.get(*k).and_then(|x| x.as_f64()))
}
/// Reads one pubsub message of the given kind. `now` is unix seconds, for the clock of a prediction (which carries
/// when it began and not how long is left).
pub fn read(kind: &str, payload: &Value, now: i64) -> Option<Event> {
let what = payload.get("type")?.as_str()?;
match kind {
"raid" => {
let raid = payload.get("raid")?;
let id = text(raid, &["id"])?.to_string();
match what {
"raid_update_v2" => {
let login = text(raid, &["target_login"])?.to_string();
Some(Event::Raid {
display: text(raid, &["target_display_name"]).unwrap_or(&login).to_string(),
viewers: number(raid, &["viewer_count"]).map(|v| v as u64).filter(|v| *v > 0),
login,
id,
})
}
"raid_cancel_v2" => Some(Event::RaidCalledOff { id }),
_ => None,
}
}
"polls" => {
let poll = payload.pointer("/data/poll")?;
match what {
"POLL_ARCHIVE" => Some(Event::CardGone { kind: "poll" }),
_ => poll_card(poll, now).map(|card| Event::Card { kind: "poll", card }),
}
}
"predictions" => {
let event = payload.pointer("/data/event")?;
let card = prediction_card(event, now)?;
// Called off: nothing was decided, and there is nothing to show of it.
if card["state"].as_str() == Some("canceled") {
return Some(Event::CardGone { kind: "prediction" });
}
Some(Event::Card { kind: "prediction", card })
}
_ => None,
}
}
fn parse_time(v: &Value, keys: &[&str]) -> Option<i64> {
text(v, keys).and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok()).map(|d| d.timestamp())
}
/// A poll in the shape the card reads, from either of the shapes Twitch has it in: the socket's
/// (`poll_id`, `choice_id`, `duration_seconds`) and the GraphQL's (`id`, `durationSeconds`).
pub fn poll_card(poll: &Value, _now: i64) -> Option<Value> {
let id = text(poll, &["poll_id", "id"])?;
let status = text(poll, &["status"]).unwrap_or("ACTIVE").to_uppercase();
let open = status == "ACTIVE";
let duration = number(poll, &["duration_seconds", "durationSeconds"]).unwrap_or(0.0).max(0.0) as u32;
let remaining = if open { (number(poll, &["remaining_duration_milliseconds", "remainingDurationMilliseconds"]).unwrap_or(duration as f64 * 1000.0) / 1000.0).max(0.0).ceil() as u32 } else { 0 };
let choices = poll.get("choices")?.as_array()?;
let votes_of = |c: &Value| c.pointer("/votes/total").and_then(|t| t.as_f64()).unwrap_or(0.0);
let top = choices.iter().map(votes_of).fold(0.0_f64, f64::max);
let options: Vec<Value> = choices
.iter()
.filter_map(|c| {
let id = text(c, &["choice_id", "id"])?;
Some(json!({
"id": id,
"label": text(c, &["title"]).unwrap_or(""),
"votes": votes_of(c),
"backers": number(c, &["total_voters", "totalVoters"]),
// The one with the most, once it is decided.
"winner": !open && top > 0.0 && votes_of(c) == top,
}))
})
.collect();
Some(json!({
"kind": "poll",
"id": format!("poll:{id}"),
"title": text(poll, &["title"]).unwrap_or(""),
"options": options,
"duration": duration,
"remaining": remaining,
// The result stays up a while after it closes, as Twitch's own does.
"resultDisplayDuration": 20,
"hasVoted": false,
"votedOptionId": null,
"state": status.to_lowercase(),
"open": open,
// Twitch's polls are ended on twitch.tv by the streamer or a moderator, not from a viewer's card.
"noEnd": true,
}))
}
/// A prediction in the shape the card reads, from the socket's event or the GraphQL's.
pub fn prediction_card(event: &Value, now: i64) -> Option<Value> {
let id = text(event, &["id"])?;
let status = text(event, &["status"]).unwrap_or("ACTIVE").to_uppercase();
let open = status == "ACTIVE";
let window = number(event, &["prediction_window_seconds", "predictionWindowSeconds"]).unwrap_or(0.0).max(0.0) as u32;
let started = parse_time(event, &["created_at", "createdAt"]);
let remaining = match (open, started) {
(true, Some(started)) => (window as i64 - (now - started)).max(0) as u32,
(true, None) => window,
_ => 0,
};
let outcomes = event.get("outcomes")?.as_array()?;
let points_of = |o: &Value| number(o, &["total_points", "totalPoints"]).unwrap_or(0.0);
let pot: f64 = outcomes.iter().map(points_of).sum();
let winner = text(event, &["winning_outcome_id"]).map(str::to_string).or_else(|| event.pointer("/winningOutcome/id").and_then(|w| w.as_str()).map(str::to_string));
let options: Vec<Value> = outcomes
.iter()
.filter_map(|o| {
let oid = text(o, &["id"])?;
let points = points_of(o);
Some(json!({
"id": oid,
"label": text(o, &["title"]).unwrap_or(""),
"votes": points,
"backers": number(o, &["total_users", "totalUsers"]),
// What a point put on it pays back, written the way Kick's page writes it.
"odds": (points > 0.0 && pot > 0.0).then(|| format!("1:{:.1}", pot / points)),
"winner": winner.as_deref() == Some(oid),
}))
})
.collect();
let state = match status.as_str() {
"ACTIVE" => "open",
"LOCKED" => "locked",
"RESOLVE_PENDING" | "RESOLVED" => "resolved",
"CANCEL_PENDING" | "CANCELED" => "canceled",
other => return Some(json!({ "kind": "prediction", "id": format!("prediction:{id}"), "state": other.to_lowercase(), "options": options, "title": text(event, &["title"]).unwrap_or(""), "duration": window, "remaining": 0, "resultDisplayDuration": 0, "hasVoted": false, "votedOptionId": null, "total": pot, "minBet": MIN_BET, "noEnd": true })),
};
Some(json!({
"kind": "prediction",
"id": format!("prediction:{id}"),
"title": text(event, &["title"]).unwrap_or(""),
"options": options,
"duration": window,
"remaining": remaining,
"resultDisplayDuration": 0,
"hasVoted": false,
"votedOptionId": null,
"total": pot,
"state": state,
"minBet": MIN_BET,
"noEnd": true,
}))
}
/// The smallest bet Twitch takes.
pub const MIN_BET: u32 = 10;
// ---------------------------------------------------------------------------
// Saying it
// ---------------------------------------------------------------------------
/// Whether this account can answer a poll or a prediction: it has the twitch.tv session that Twitch takes votes
/// and bets from.
fn answerable(state: &AppState, account_id: &str) -> bool {
state.accounts.get_twitch(account_id).is_some_and(|a| !a.web_token.is_empty())
}
/// Publishes a card, carrying across what the socket cannot know: this account's own vote and bet and what it has to spend.
pub fn publish(state: &AppState, account_id: &str, buffer_id: &str, kind: &str, mut card: Value) {
card["readOnly"] = json!(!answerable(state, account_id));
if let Some(known) = state.runtime.live_card(buffer_id, kind) {
if known["id"] == card["id"] {
for key in ["hasVoted", "votedOptionId", "stake", "balance"] {
if !known[key].is_null() {
card[key] = known[key].clone();
}
}
}
}
crate::backend::kick::cards::publish_card(state, buffer_id, kind, Some(card));
}
fn gone(state: &AppState, buffer_id: &str, kind: &str) {
crate::backend::kick::cards::publish_card(state, buffer_id, kind, None);
}
/// A raid, said in the channel with a way to follow it.
fn raid_said(state: &AppState, account_id: &str, channel: &str, event: &Event) {
match event {
Event::Raid { login, display, viewers, .. } => {
let embed = crate::model::Embed { kind: Some("raid-out".into()), author: Some(display.clone()), title: viewers.map(|v| v.to_string()), description: Some(login.clone()), ..Default::default() };
state.runtime.record_message(state, account_id, channel, "channel", channel, &format!("{channel} is raiding {display}"), false, "raid", None, None, false, None, vec![embed], Vec::new(), None);
}
Event::RaidCalledOff { .. } => {
state.runtime.record_message(state, account_id, channel, "channel", channel, "The raid was called off", false, "system", None, None, false, None, Vec::new(), Vec::new(), None);
}
_ => {}
}
}
/// What has to be known to act on a message: which channel a subscription was for, and what kind it was.
type Subscriptions = HashMap<String, (String, Kind)>;
/// Acts on one pubsub message.
fn act(state: &AppState, account_id: &str, channel: &str, kind: Kind, payload: &Value, raids: &mut Vec<String>) {
let now = crate::backend::kick::cards::now_secs();
let name = match kind {
Kind::Raid => "raid",
Kind::Polls => "polls",
Kind::Predictions => "predictions",
};
let buffer_id = crate::model::buffer_id(account_id, channel);
match read(name, payload, now) {
Some(event @ Event::Raid { .. }) => {
if let Event::Raid { id, .. } = &event {
// The same raid is described again as its clock runs: said once.
if raids.contains(id) {
return;
}
raids.push(id.clone());
if raids.len() > 32 {
raids.remove(0);
}
}
raid_said(state, account_id, channel, &event);
}
Some(event @ Event::RaidCalledOff { .. }) => raid_said(state, account_id, channel, &event),
Some(Event::Card { kind, card }) => {
let fresh = kind == "prediction" && state.runtime.live_card(&buffer_id, kind).is_none_or(|known| known["id"] != card["id"]);
publish(state, account_id, &buffer_id, kind, card);
// A new prediction asks what this account has to bet with, once.
if fresh && answerable(state, account_id) {
let (state, account_id, channel) = (state.clone(), account_id.to_string(), channel.to_string());
tokio::spawn(async move { refresh_balance(&state, gql::ROOT, &account_id, &channel).await });
}
}
Some(Event::CardGone { kind }) => gone(state, &buffer_id, kind),
None => {}
}
}
/// Pulls out the pubsub payload a message carries: the topic's subscription id and the JSON inside.
pub fn notification(frame: &Value) -> Option<(String, Value)> {
let n = frame.get("notification")?;
let sub = n.pointer("/subscription/id")?.as_str()?.to_string();
let payload: Value = serde_json::from_str(n.get("pubsub")?.as_str()?).ok()?;
Some((sub, payload))
}
/// Reads what this account has to bet with in a channel and puts it on the prediction card that is up, if any.
pub async fn refresh_balance(state: &AppState, root: &str, account_id: &str, channel: &str) {
let Some(cfg) = state.accounts.get_twitch(account_id) else { return };
let Ok(http) = super::client() else { return };
let Ok(points) = super::points::context(state, &http, root, channel, Some(&cfg.web_token)).await else { return };
let (buffer_id, Some(balance)) = (crate::model::buffer_id(account_id, channel), points.balance) else { return };
if let Some(mut card) = state.runtime.live_card(&buffer_id, "prediction") {
card["balance"] = json!(balance);
// Directly: `publish` would carry the old balance back over the new one.
crate::backend::kick::cards::publish_card(state, &buffer_id, "prediction", Some(card));
}
state.events.emit("twitchPoints", json!({ "bufferId": buffer_id, "points": balance, "name": points.name }));
}
/// A poll or prediction the channel already has going, asked for from the GraphQL when it is opened.
pub async fn current(state: &AppState, account_id: &str, channel: &str, gql_root: &str) {
let Ok(http) = super::client() else { return };
const ASK: &str = "query($login:String!){ user(login:$login){ viewablePoll{ id title status durationSeconds remainingDurationMilliseconds choices{ id title votes{ total } totalVoters } } channel{ activePredictionEvents{ id title status createdAt predictionWindowSeconds winningOutcome{ id } outcomes{ id title totalPoints totalUsers } } } } }";
let answer = match gql::call(&http, gql_root, &gql::query(ASK, json!({ "login": channel })), &gql::Who::default()).await {
Ok(a) => a,
Err(e) => {
tracing::debug!("twitch[{account_id}]: {channel}'s poll and prediction: {e:#}");
return;
}
};
let now = crate::backend::kick::cards::now_secs();
let buffer_id = crate::model::buffer_id(account_id, channel);
if let Some(card) = answer.pointer("/data/user/viewablePoll").filter(|p| !p.is_null()).and_then(|p| poll_card(p, now)) {
publish(state, account_id, &buffer_id, "poll", card);
}
if let Some(card) = answer.pointer("/data/user/channel/activePredictionEvents/0").filter(|p| !p.is_null()).and_then(|p| prediction_card(p, now)) {
publish(state, account_id, &buffer_id, "prediction", card);
if answerable(state, account_id) {
refresh_balance(state, gql_root, account_id, channel).await;
}
}
}
// ---------------------------------------------------------------------------
// The connection
// ---------------------------------------------------------------------------
/// Listens for as long as the account runs, reconnecting with a growing wait.
pub async fn run(state: AppState, account_id: String, mut rx: mpsc::UnboundedReceiver<Watch>, url: String, gql_root: String) {
let mut watched: HashMap<String, String> = HashMap::new();
let mut wait = 3u64;
loop {
match session(&state, &account_id, &mut rx, &mut watched, &url, &gql_root).await {
Ok(()) => return,
Err(e) => {
tracing::debug!("twitch[{account_id}]: realtime ended: {e:#}");
// Anything asked for while it was down is kept in `watched` by the drain below.
while let Ok(w) = rx.try_recv() {
apply(&mut watched, w);
}
}
}
tokio::time::sleep(Duration::from_secs(wait)).await;
wait = (wait * 2).min(120);
}
}
fn apply(watched: &mut HashMap<String, String>, w: Watch) {
match w {
Watch::Add { channel, room_id } => {
watched.insert(channel, room_id);
}
Watch::Remove { channel } => {
watched.remove(&channel);
}
}
}
fn subscribe_frame(id: &str, topic: &str) -> String {
json!({
"type": "subscribe",
"id": id,
"subscribe": { "id": id, "type": "pubsub", "pubsub": { "topic": topic } },
"timestamp": chrono::Utc::now().to_rfc3339(),
})
.to_string()
}
async fn session(state: &AppState, account_id: &str, rx: &mut mpsc::UnboundedReceiver<Watch>, watched: &mut HashMap<String, String>, url: &str, gql_root: &str) -> Result<()> {
let (mut socket, _) = crate::net::route::websocket(super::SHARED_KEY, url).await.context("connecting to Twitch's realtime service")?;
let mut subs: Subscriptions = HashMap::new();
let mut next = 0u64;
let mut raids: Vec<String> = Vec::new();
// Nothing to listen to is nothing to hold open: the socket waits for the first channel.
let mut listening: Vec<String> = Vec::new();
let mut last_heard = tokio::time::Instant::now();
let mut check = tokio::time::interval(Duration::from_secs(15));
check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
macro_rules! listen {
($channel:expr, $room:expr) => {{
let (channel, room): (String, String) = ($channel, $room);
if !room.is_empty() && !listening.contains(&channel) {
listening.push(channel.clone());
for kind in Kind::ALL {
let id = format!("s{next}");
next += 1;
socket.send(WsMessage::Text(subscribe_frame(&id, &kind.topic(&room)))).await?;
subs.insert(id, (channel.clone(), kind));
}
let (state, account_id, gql_root) = (state.clone(), account_id.to_string(), gql_root.to_string());
tokio::spawn(async move { current(&state, &account_id, &channel, &gql_root).await });
}
}};
}
for (channel, room) in watched.clone() {
listen!(channel, room);
}
loop {
tokio::select! {
frame = socket.next() => {
let Some(frame) = frame else { anyhow::bail!("the connection closed") };
match frame.context("reading from Twitch's realtime service")? {
WsMessage::Text(text) => {
last_heard = tokio::time::Instant::now();
let Ok(value) = serde_json::from_str::<Value>(&text) else { continue };
if value.get("type").and_then(|t| t.as_str()) != Some("notification") {
continue;
}
let Some((sub, payload)) = notification(&value) else { continue };
let Some((channel, kind)) = subs.get(&sub).cloned() else { continue };
// A channel closed since is no longer listened for.
if !watched.contains_key(&channel) {
continue;
}
act(state, account_id, &channel, kind, &payload, &mut raids);
}
WsMessage::Ping(data) => socket.send(WsMessage::Pong(data)).await?,
WsMessage::Close(_) => anyhow::bail!("Twitch's realtime service closed the connection"),
_ => {}
}
}
command = rx.recv() => {
let Some(command) = command else { return Ok(()) };
match command {
Watch::Add { channel, room_id } => {
watched.insert(channel.clone(), room_id.clone());
listen!(channel, room_id);
}
Watch::Remove { channel } => {
watched.remove(&channel);
listening.retain(|c| *c != channel);
}
}
}
_ = check.tick() => {
if !listening.is_empty() && last_heard.elapsed() > SILENCE {
anyhow::bail!("nothing heard from Twitch's realtime service for {} seconds", SILENCE.as_secs());
}
if listening.is_empty() {
last_heard = tokio::time::Instant::now();
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn prediction(status: &str) -> Value {
json!({ "type": "event-updated", "data": { "event": {
"id": "p1", "title": "Map 2", "status": status, "created_at": "2026-10-14T13:00:00Z", "prediction_window_seconds": 600,
"winning_outcome_id": if status == "RESOLVED" { json!("b") } else { Value::Null },
"outcomes": [{ "id": "a", "title": "Falcons", "total_points": 750, "total_users": 7 }, { "id": "b", "title": "NIP", "total_points": 250, "total_users": 3 }]
} } })
}
#[test]
fn a_prediction_counts_down_from_when_it_began_and_pays_by_its_pot() {
let began = chrono::DateTime::parse_from_rfc3339("2026-10-14T13:00:00Z").unwrap().timestamp();
let Some(Event::Card { kind, card }) = read("predictions", &prediction("ACTIVE"), began + 100) else { panic!("a card") };
assert_eq!(kind, "prediction");
assert_eq!(card["id"], "prediction:p1");
assert_eq!(card["remaining"], 500);
assert_eq!(card["state"], "open");
assert_eq!(card["total"], 1000.0);
assert_eq!(card["options"][0]["odds"], "1:1.3");
assert_eq!(card["options"][1]["odds"], "1:4.0");
assert_eq!(card["options"][1]["backers"], 3.0);
}
#[test]
fn a_locked_or_decided_prediction_has_no_time_left_and_names_its_winner() {
let began = chrono::DateTime::parse_from_rfc3339("2026-10-14T13:00:00Z").unwrap().timestamp();
let Some(Event::Card { card, .. }) = read("predictions", &prediction("LOCKED"), began + 100) else { panic!() };
assert_eq!((card["remaining"].as_u64(), card["state"].as_str()), (Some(0), Some("locked")));
let Some(Event::Card { card, .. }) = read("predictions", &prediction("RESOLVED"), began + 700) else { panic!() };
assert_eq!(card["state"], "resolved");
assert_eq!((card["options"][0]["winner"].as_bool(), card["options"][1]["winner"].as_bool()), (Some(false), Some(true)));
}
#[test]
fn a_called_off_prediction_is_taken_down() {
assert_eq!(read("predictions", &prediction("CANCELED"), 0), Some(Event::CardGone { kind: "prediction" }));
}
#[test]
fn a_poll_is_read_in_the_sockets_shape_and_in_the_graphqls() {
let socket = json!({ "type": "POLL_UPDATE", "data": { "poll": { "poll_id": "q1", "title": "Next?", "status": "ACTIVE", "duration_seconds": 60, "remaining_duration_milliseconds": 41_500,
"choices": [{ "choice_id": "x", "title": "Chess", "votes": { "total": 3 }, "total_voters": 3 }, { "choice_id": "y", "title": "Go", "votes": { "total": 1 }, "total_voters": 1 }] } } });
let Some(Event::Card { kind, card }) = read("polls", &socket, 0) else { panic!() };
assert_eq!((kind, card["id"].as_str(), card["remaining"].as_u64(), card["open"].as_bool()), ("poll", Some("poll:q1"), Some(42), Some(true)));
assert_eq!(card["options"][0], json!({ "id": "x", "label": "Chess", "votes": 3.0, "backers": 3.0, "winner": false }));
let graphql = json!({ "id": "q1", "title": "Next?", "status": "ACTIVE", "durationSeconds": 60, "remainingDurationMilliseconds": 10_000, "choices": [{ "id": "x", "title": "Chess", "votes": { "total": 3 }, "totalVoters": 3 }] });
assert_eq!(poll_card(&graphql, 0).unwrap()["remaining"], 10);
}
#[test]
fn a_finished_poll_shows_how_it_went_and_an_archived_one_goes() {
let done = json!({ "type": "POLL_COMPLETE", "data": { "poll": { "poll_id": "q1", "title": "Next?", "status": "COMPLETED", "duration_seconds": 60,
"choices": [{ "choice_id": "x", "title": "Chess", "votes": { "total": 3 } }, { "choice_id": "y", "title": "Go", "votes": { "total": 1 } }] } } });
let Some(Event::Card { card, .. }) = read("polls", &done, 0) else { panic!() };
assert_eq!((card["open"].as_bool(), card["remaining"].as_u64()), (Some(false), Some(0)));
assert_eq!((card["options"][0]["winner"].as_bool(), card["options"][1]["winner"].as_bool()), (Some(true), Some(false)));
let archived = json!({ "type": "POLL_ARCHIVE", "data": { "poll": { "poll_id": "q1" } } });
assert_eq!(read("polls", &archived, 0), Some(Event::CardGone { kind: "poll" }));
}
#[test]
fn a_raid_names_where_it_is_going_and_can_be_called_off() {
let raid = json!({ "type": "raid_update_v2", "raid": { "id": "r1", "target_login": "friend", "target_display_name": "Friend", "viewer_count": 120 } });
assert_eq!(read("raid", &raid, 0), Some(Event::Raid { id: "r1".into(), login: "friend".into(), display: "Friend".into(), viewers: Some(120) }));
let off = json!({ "type": "raid_cancel_v2", "raid": { "id": "r1" } });
assert_eq!(read("raid", &off, 0), Some(Event::RaidCalledOff { id: "r1".into() }));
assert_eq!(read("raid", &json!({ "type": "raid_go_v2", "raid": { "id": "r1" } }), 0), None);
}
#[test]
fn the_envelope_gives_up_the_payload_inside_it() {
let frame = json!({ "type": "notification", "notification": { "subscription": { "id": "s3" }, "type": "pubsub", "pubsub": prediction("ACTIVE").to_string() } });
let (sub, payload) = notification(&frame).unwrap();
assert_eq!(sub, "s3");
assert_eq!(payload["data"]["event"]["id"], "p1");
assert!(notification(&json!({ "type": "keepalive" })).is_none());
}
use crate::backend::kick::testkit;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message as Ws;
/// A Hermes that, once a client has subscribed to its three topics, announces a prediction, a raid (twice) and a poll.
async fn fake_hermes(subscribed: Arc<Mutex<Vec<String>>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("ws://{}", listener.local_addr().unwrap());
tokio::spawn(async move {
let (tcp, _) = listener.accept().await.unwrap();
let mut ws = tokio_tungstenite::accept_async(tcp).await.unwrap();
let _ = ws.send(Ws::Text(json!({ "type": "welcome", "welcome": { "keepaliveSec": 15 } }).to_string())).await;
let mut ids: Vec<(String, String)> = Vec::new();
while let Some(Ok(Ws::Text(text))) = ws.next().await {
let v: Value = serde_json::from_str(&text).unwrap();
if v["type"] != "subscribe" {
continue;
}
let (id, topic) = (v["subscribe"]["id"].as_str().unwrap().to_string(), v["subscribe"]["pubsub"]["topic"].as_str().unwrap().to_string());
subscribed.lock().unwrap().push(topic.clone());
let _ = ws.send(Ws::Text(json!({ "type": "subscribeResponse", "subscribeResponse": { "subscription": { "id": id }, "result": "ok" } }).to_string())).await;
ids.push((id, topic));
if ids.len() == 3 {
let say = |topic: &str, payload: Value| {
let id = ids.iter().find(|(_, t)| t.starts_with(topic)).unwrap().0.clone();
Ws::Text(json!({ "type": "notification", "notification": { "subscription": { "id": id }, "type": "pubsub", "pubsub": payload.to_string() } }).to_string())
};
let began = chrono::Utc::now().to_rfc3339();
let _ = ws.send(say("predictions-channel-v1", json!({ "type": "event-created", "data": { "event": { "id": "p1", "title": "Map 2", "status": "ACTIVE", "created_at": began, "prediction_window_seconds": 600,
"outcomes": [{ "id": "a", "title": "Falcons", "total_points": 0, "total_users": 0 }, { "id": "b", "title": "NIP", "total_points": 0, "total_users": 0 }] } } }))).await;
let raid = json!({ "type": "raid_update_v2", "raid": { "id": "r1", "target_login": "friend", "target_display_name": "Friend", "viewer_count": 120 } });
let _ = ws.send(say("raid", raid.clone())).await;
// Described again as its clock runs: said once.
let _ = ws.send(say("raid", raid)).await;
let _ = ws.send(say("polls", json!({ "type": "POLL_CREATE", "data": { "poll": { "poll_id": "q1", "title": "Next?", "status": "ACTIVE", "duration_seconds": 60, "choices": [{ "choice_id": "x", "title": "Chess", "votes": { "total": 0 } }] } } }))).await;
}
}
});
url
}
#[tokio::test]
async fn a_channels_prediction_poll_and_raid_are_heard_from_the_realtime_service_and_shown() {
let state = testkit::simulated_daemon("twitch-realtime");
let account = "twitch:~anonymous";
let buffer = crate::model::buffer_id(account, "xqc");
state.runtime.ensure_buffer(&state, account, "xqc", "channel");
let subscribed = Arc::new(Mutex::new(Vec::new()));
let url = fake_hermes(subscribed.clone()).await;
let rx = register("twitch:realtime-test");
// Told about the channel before the socket is up, as a join is.
watch("twitch:realtime-test", "xqc", "100");
let task = tokio::spawn(run(state.clone(), account.to_string(), rx, url, "http://127.0.0.1:9".to_string()));
for _ in 0..100 {
if state.runtime.live_card(&buffer, "prediction").is_some() && state.runtime.live_card(&buffer, "poll").is_some() && !state.store.get_backlog(&buffer, 0, 10).unwrap().is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(30)).await;
}
let mut topics = subscribed.lock().unwrap().clone();
topics.sort();
assert_eq!(topics, vec!["polls.100", "predictions-channel-v1.100", "raid.100"]);
let prediction = state.runtime.live_card(&buffer, "prediction").expect("the prediction is on screen");
assert_eq!((prediction["title"].as_str(), prediction["state"].as_str()), (Some("Map 2"), Some("open")));
assert!(prediction["remaining"].as_u64().unwrap() > 590);
assert_eq!(state.runtime.live_card(&buffer, "poll").unwrap()["options"][0]["label"], "Chess");
let log = state.store.get_backlog(&buffer, 0, 10).unwrap();
let raids: Vec<_> = log.iter().filter(|m| m.body.contains("raiding")).collect();
assert_eq!(raids.len(), 1, "a raid is said once: {log:?}");
assert_eq!(raids[0].embeds[0].kind.as_deref(), Some("raid-out"));
assert_eq!(raids[0].embeds[0].description.as_deref(), Some("friend"));
task.abort();
}
/// Against the real services: `TWITCH_LIVE_CHANNEL=<login> cargo test --release live_ -- --ignored --nocapture`.
/// Opens the channel the way a join does and prints what Twitch says about its poll and prediction.
#[tokio::test]
#[ignore]
async fn live_a_real_channels_prediction_is_read_from_the_graphql_and_the_socket() {
let _ = rustls::crypto::ring::default_provider().install_default();
let channel = std::env::var("TWITCH_LIVE_CHANNEL").expect("TWITCH_LIVE_CHANNEL=<login>");
let state = testkit::simulated_daemon("twitch-realtime-live");
let account = "twitch:~anonymous";
let buffer = crate::model::buffer_id(account, &channel);
state.runtime.ensure_buffer(&state, account, &channel, "channel");
let http = crate::backend::twitch::client().unwrap();
let id = crate::backend::twitch::stream::channels(&http, gql::ROOT, std::slice::from_ref(&channel)).await.unwrap()[0].clone().unwrap().id;
let rx = register("twitch:live");
watch("twitch:live", &channel, &id);
let task = tokio::spawn(run(state.clone(), account.to_string(), rx, HERMES_URL.to_string(), gql::ROOT.to_string()));
tokio::time::sleep(Duration::from_secs(20)).await;
println!("prediction: {:?}", state.runtime.live_card(&buffer, "prediction"));
println!("poll: {:?}", state.runtime.live_card(&buffer, "poll"));
task.abort();
}
}
+258
View File
@@ -0,0 +1,258 @@
//! What is on air in a channel: whether it is live, what it is called, how many
//! are watching, and the address of its video.
//!
//! The answer comes from Twitch's GraphQL (see `gql.rs`) because it can be
//! asked anonymously and in one request for many channels at once, where the
//! official API wants a signed-in account and one call per kind of thing. It is
//! said to the window as the same `kickStream` event Kick's streams use, with
//! the same fields, so the header's live chip, the Watch button and the player
//! are the code Kick already has - the event is named for the service that had
//! it first and carries any service's stream.
use super::gql;
use super::playback::{self, Playback, PlaybackError};
use crate::state::AppState;
use anyhow::{Context, Result};
use serde_json::{json, Value};
/// A channel as Twitch describes it.
#[derive(Debug, Clone, PartialEq)]
pub struct Channel {
/// Twitch's number for it, which the official API and a shared chat are keyed by.
pub id: String,
pub login: String,
pub display_name: String,
/// The streamer's own picture.
pub avatar: Option<String>,
pub followers: Option<u64>,
pub live: Option<Live>,
}
/// A broadcast in progress.
#[derive(Debug, Clone, PartialEq)]
pub struct Live {
/// The broadcast's own id: a different one is a different stream, even in the same channel.
pub id: u64,
pub title: String,
pub category: Option<String>,
pub viewers: Option<u64>,
pub started_ts: Option<i64>,
}
const USERS: &str = "query($logins:[String!]!){ users(logins:$logins){ id login displayName profileImageURL(width:300) followers{ totalCount } broadcastSettings{ title game{ displayName } } stream{ id title viewersCount createdAt type game{ displayName } } } }";
/// How many channels one question asks about. The poll covers every channel an
/// account has open, and a list longer than this is asked in pieces.
const BATCH: usize = 30;
fn read_channel(v: &Value) -> Option<Channel> {
let login = v.get("login")?.as_str()?.to_lowercase();
let live = v.get("stream").filter(|s| !s.is_null()).map(|s| Live {
id: s.get("id").and_then(|i| i.as_str()).and_then(|i| i.parse().ok()).unwrap_or(0),
title: s.get("title").and_then(|t| t.as_str()).filter(|t| !t.is_empty()).or_else(|| v.pointer("/broadcastSettings/title").and_then(|t| t.as_str())).unwrap_or("").to_string(),
category: s.pointer("/game/displayName").and_then(|g| g.as_str()).map(str::to_string),
viewers: s.get("viewersCount").and_then(|n| n.as_u64()),
started_ts: s.get("createdAt").and_then(|d| d.as_str()).and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok()).map(|d| d.timestamp()),
});
Some(Channel {
id: v.get("id")?.as_str()?.to_string(),
display_name: v.get("displayName").and_then(|d| d.as_str()).filter(|d| !d.is_empty()).unwrap_or(&login).to_string(),
avatar: v.get("profileImageURL").and_then(|a| a.as_str()).map(str::to_string),
followers: v.pointer("/followers/totalCount").and_then(|n| n.as_u64()),
login,
live,
})
}
/// Asks about several channels at once. A login Twitch has no channel for comes
/// back as `None` in its place, so the answers line up with the question.
pub async fn channels(http: &reqwest::Client, root: &str, logins: &[String]) -> Result<Vec<Option<Channel>>> {
let mut out = Vec::with_capacity(logins.len());
for chunk in logins.chunks(BATCH) {
let answer = gql::call(http, root, &gql::query(USERS, json!({ "logins": chunk })), &gql::Who::default()).await?;
if let Some(why) = gql::error_of(&answer) {
anyhow::bail!("Twitch would not say what is on air: {why}");
}
let users = answer.pointer("/data/users").and_then(|u| u.as_array()).context("Twitch's answer about channels had no list of them")?;
for (i, login) in chunk.iter().enumerate() {
out.push(users.get(i).filter(|u| !u.is_null()).and_then(read_channel).filter(|c| c.login == login.to_lowercase()));
}
}
Ok(out)
}
/// What a stream event says, built from what is known. Public for the tests.
pub fn stream_event(buffer_id: &str, channel: &Channel, playback: Option<&Playback>, why_not: Option<&str>) -> Value {
json!({
"bufferId": buffer_id,
"service": "twitch",
"live": channel.live.is_some(),
"title": channel.live.as_ref().map(|l| l.title.clone()),
"category": channel.live.as_ref().and_then(|l| l.category.clone()),
"livestreamId": channel.live.as_ref().map(|l| l.id).filter(|id| *id != 0),
"viewers": channel.live.as_ref().and_then(|l| l.viewers),
"startedTs": channel.live.as_ref().and_then(|l| l.started_ts),
"followers": channel.followers,
"playbackUrl": playback.map(|p| p.url.clone()),
"playbackExpires": playback.map(|p| p.expires),
// Whether this account's own token says ads are not for it, which is
// the one thing a client does not choose: a subscriber's or Turbo's.
"adFree": playback.map(|p| p.hide_ads),
// Why there is no picture, in Twitch's words or ours, for a stream that
// is live: sub-only, blocked in this country, and so on.
"playbackError": why_not,
})
}
/// How long before an address stops being accepted a new one is made: the
/// token is good for twenty minutes, and the player can be several minutes
/// into a playlist that carries it.
pub const RENEW_BEFORE: i64 = 300;
/// The address for a live channel: the one already held while it is good for
/// a while yet, else a new one.
async fn playback_for(state: &AppState, account_id: &str, buffer_id: &str, http: &reqwest::Client, login: &str) -> (Option<Playback>, Option<String>) {
let now = chrono::Utc::now().timestamp();
if let Some(have) = state.runtime.kick_stream(buffer_id) {
if let (Some(url), Some(expires)) = (have["playbackUrl"].as_str(), have["playbackExpires"].as_i64()) {
if expires - now > RENEW_BEFORE {
return (Some(Playback { url: url.to_string(), expires, hide_ads: have["adFree"].as_bool().unwrap_or(false), restricted: Vec::new() }), None);
}
}
}
let web = state.accounts.get_twitch(account_id).map(|a| a.web_token).filter(|t| !t.is_empty());
match playback::playback(state, http, gql::ROOT, playback::USHER_ROOT, login, web.as_deref()).await {
Ok(p) => (Some(p), None),
Err(PlaybackError::Offline) => (None, None),
Err(e) => {
tracing::debug!("twitch: the address for {login}: {e}");
(None, Some(e.to_string()))
}
}
}
/// Reads a batch of channels' state into the stream events, saying going live
/// and going off the air in the channel when that is what changed.
///
/// Each live channel is made sure to have a good address too. That costs a
/// call only for a channel whose address is not yet held or is running out:
/// one that is good for a while yet is kept as it is.
pub async fn refresh(state: &AppState, account_id: &str, logins: &[String]) {
let Ok(http) = super::client() else { return };
let rows = match channels(&http, gql::ROOT, logins).await {
Ok(rows) => rows,
Err(e) => {
tracing::debug!("twitch[{account_id}]: asking what is on air: {e:#}");
return;
}
};
for (login, row) in logins.iter().zip(rows) {
let buffer_id = crate::model::buffer_id(account_id, login);
let Some(channel) = row else { continue };
let before = state.runtime.kick_stream(&buffer_id);
let was_live = before.as_ref().map(|s| s["live"].as_bool().unwrap_or(false));
let (playback, why_not) = if channel.live.is_some() { playback_for(state, account_id, &buffer_id, &http, login).await } else { (None, None) };
let next = stream_event(&buffer_id, &channel, playback.as_ref(), why_not.as_deref());
if before.as_ref() != Some(&next) {
state.runtime.set_kick_stream(&buffer_id, next.clone());
state.events.emit("kickStream", next);
}
if let Some(avatar) = channel.avatar.as_deref() {
state.runtime.set_buffer_avatar(state, &buffer_id, avatar);
}
// Said in the channel when it changes, never on first sight of a
// channel (which would announce every channel as it is joined).
if let (Some(was), now) = (was_live, channel.live.is_some()) {
if was != now {
say_live_change(state, account_id, &channel, now);
}
}
}
}
fn say_live_change(state: &AppState, account_id: &str, channel: &Channel, live: bool) {
let what = if live { "went live" } else { "ended the stream" };
state.runtime.record_message(
state,
account_id,
&channel.login,
"channel",
&channel.login,
what,
false,
"stream",
None,
None,
false,
None,
vec![crate::model::Embed {
kind: Some("stream".into()),
author: Some(channel.display_name.clone()),
title: Some(what.to_string()),
icon: Some(if live { "live" } else { "offline" }.to_string()),
..Default::default()
}],
Vec::new(),
None,
);
}
/// One channel, now: used when it has just been joined and when the window
/// asks on its own minute for the one on screen.
pub async fn refresh_one(state: &AppState, buffer_id: &str) {
let Some(buffer) = state.runtime.get_buffer(buffer_id) else { return };
if !buffer.account_id.starts_with("twitch:") {
return;
}
refresh(state, &buffer.account_id, std::slice::from_ref(&buffer.name)).await
}
#[cfg(test)]
mod tests {
use super::*;
fn answer() -> Value {
json!({ "data": { "users": [
{ "id": "71092938", "login": "xqc", "displayName": "xQc", "profileImageURL": "https://static-cdn.jtvnw.net/x.jpeg", "followers": { "totalCount": 12641353 },
"broadcastSettings": { "title": "old title", "game": { "displayName": "Just Chatting" } },
"stream": { "id": "321500000001", "title": "LOCK IN", "viewersCount": 25551, "createdAt": "2026-10-14T01:02:03Z", "type": "live", "game": { "displayName": "GTA V" } } },
{ "id": "43001749", "login": "kotbarsik_twt", "displayName": "kotbarsik_twt", "profileImageURL": null, "followers": { "totalCount": 0 }, "broadcastSettings": { "title": "Afk cloaking", "game": { "displayName": "EVE Online" } }, "stream": null },
null
] } })
}
#[test]
fn a_live_channel_is_read_with_its_broadcast_and_an_offline_one_without() {
let users = answer()["data"]["users"].as_array().unwrap().clone();
let live = read_channel(&users[0]).unwrap();
assert_eq!((live.login.as_str(), live.display_name.as_str(), live.followers), ("xqc", "xQc", Some(12641353)));
let on = live.live.unwrap();
assert_eq!((on.id, on.title.as_str(), on.category.as_deref(), on.viewers), (321500000001, "LOCK IN", Some("GTA V"), Some(25551)));
assert_eq!(on.started_ts, Some(1791939723));
let off = read_channel(&users[1]).unwrap();
assert!(off.live.is_none());
assert_eq!(off.avatar, None);
assert!(read_channel(&users[2]).is_none(), "a login Twitch has no channel for comes back as null");
}
#[test]
fn the_event_is_the_shape_the_window_already_draws_for_kick() {
let users = answer()["data"]["users"].as_array().unwrap().clone();
let channel = read_channel(&users[0]).unwrap();
let playback = Playback { url: "https://usher.ttvnw.net/x".into(), expires: 1800000000, hide_ads: false, restricted: vec![] };
let e = stream_event("twitch:~anonymous|xqc", &channel, Some(&playback), None);
assert_eq!(e["bufferId"], "twitch:~anonymous|xqc");
assert_eq!((e["live"].clone(), e["title"].clone(), e["category"].clone(), e["viewers"].clone()), (json!(true), json!("LOCK IN"), json!("GTA V"), json!(25551)));
assert_eq!((e["playbackUrl"].clone(), e["playbackExpires"].clone(), e["adFree"].clone()), (json!("https://usher.ttvnw.net/x"), json!(1800000000), json!(false)));
assert_eq!(e["livestreamId"], json!(321500000001u64));
// An offline channel has no broadcast and no address, and no `following` for a button to offer.
let off = stream_event("b", &read_channel(&users[1]).unwrap(), None, None);
assert_eq!((off["live"].clone(), off["playbackUrl"].clone(), off["livestreamId"].clone()), (json!(false), Value::Null, Value::Null));
assert!(e.get("following").is_none());
// Live but with nothing to play, and why.
let blocked = stream_event("b", &channel, None, Some("subscribers only"));
assert_eq!((blocked["live"].clone(), blocked["playbackError"].clone()), (json!(true), json!("subscribers only")));
}
}
+160
View File
@@ -0,0 +1,160 @@
//! Telling Twitch a channel is being watched.
//!
//! Twitch gives a viewer channel points for time spent watching, and counts that time from what the website's
//! player reports: once a minute, a small "minute-watched" event naming the channel, the broadcast and the viewer,
//! sent to Twitch's telemetry address. Moho plays the video itself, so without this Twitch never hears that anyone
//! is watching and no points build up. This sends the same report, and only while the window is really playing
//! the channel's video: the window asks once a minute while the picture is playing and visible, and stops when it
//! is paused, hidden, closed or minimised.
//!
//! What it does not do: it claims nothing (the bonus chest is out of scope), it reports no faster than once a
//! minute for a channel however many windows ask, it reports nothing for a channel that is not live or an
//! account with no twitch.tv session, and it can be turned off in Settings -> Twitch.
use super::stream;
use crate::state::AppState;
use base64::Engine;
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant};
/// Twitch's own page, which says where its player reports to.
pub const PAGE_ROOT: &str = "https://www.twitch.tv";
/// Just under the minute the window asks on, so two windows on one channel cost one report.
const MIN_GAP: Duration = Duration::from_secs(50);
fn last_reports() -> &'static Mutex<HashMap<String, Instant>> {
static LAST: std::sync::OnceLock<Mutex<HashMap<String, Instant>>> = std::sync::OnceLock::new();
LAST.get_or_init(Default::default)
}
fn known_address() -> &'static Mutex<Option<String>> {
static ADDRESS: std::sync::OnceLock<Mutex<Option<String>>> = std::sync::OnceLock::new();
ADDRESS.get_or_init(Default::default)
}
/// The address of the website's settings script, which a channel's page names.
pub fn settings_script(page: &str) -> Option<String> {
let found = regex::Regex::new(r#"https?://[A-Za-z0-9.:/_-]*?/config/settings\.[A-Za-z0-9]+\.js"#).ok()?.find(page)?;
Some(found.as_str().to_string())
}
/// Where the player reports to, from the settings script.
pub fn spade_address(script: &str) -> Option<String> {
let found = regex::Regex::new(r#""spade_url":"([^"]+)""#).ok()?.captures(script)?;
Some(found[1].replace("\\/", "/"))
}
/// The report, as the player writes it: a list with one event, as JSON, in base64.
pub fn minute_watched(channel_id: &str, broadcast_id: u64, user_id: &str) -> String {
let events = serde_json::json!([{
"event": "minute-watched",
"properties": { "channel_id": channel_id, "broadcast_id": broadcast_id.to_string(), "player": "site", "user_id": user_id }
}]);
base64::engine::general_purpose::STANDARD.encode(events.to_string())
}
async fn address(http: &reqwest::Client, page_root: &str, channel: &str, fresh: bool) -> Result<String, String> {
if !fresh {
if let Some(known) = known_address().lock().unwrap().clone() {
return Ok(known);
}
}
let ask = |url: String| async move { http.get(url).send().await.map_err(|e| format!("Twitch's page would not load: {e}"))?.text().await.map_err(|e| format!("Twitch's page would not load: {e}")) };
let page = ask(format!("{page_root}/{channel}")).await?;
let script_url = settings_script(&page).ok_or("Twitch's page does not say where its player reports")?;
let script = ask(script_url).await?;
let found = spade_address(&script).ok_or("Twitch's settings do not say where its player reports")?;
*known_address().lock().unwrap() = Some(found.clone());
Ok(found)
}
/// Reports that the account is watching the channel of this conversation, if it is live and the account has a
/// twitch.tv session and this channel was not reported in the last minute. True when a report was sent.
pub async fn report(state: &AppState, gql_root: &str, page_root: &str, buffer_id: &str) -> Result<bool, String> {
let (cfg, channel) = super::points::session_of(state, buffer_id)?;
if cfg.web_token.is_empty() || cfg.user_id.is_empty() {
return Ok(false);
}
{
let mut last = last_reports().lock().unwrap();
let key = format!("{}|{channel}", cfg.user_id);
if last.get(&key).is_some_and(|at| at.elapsed() < MIN_GAP) {
return Ok(false);
}
last.insert(key, Instant::now());
}
let http = super::client().map_err(|e| format!("{e:#}"))?;
let found = stream::channels(&http, gql_root, std::slice::from_ref(&channel)).await.map_err(|e| format!("{e:#}"))?;
let Some((id, live)) = found.into_iter().next().flatten().and_then(|c| c.live.map(|l| (c.id, l))) else { return Ok(false) };
let body = format!("data={}", minute_watched(&id, live.id, &cfg.user_id));
for fresh in [false, true] {
let to = address(&http, page_root, &channel, fresh).await?;
let sent = http.post(&to).header("Content-Type", "application/x-www-form-urlencoded").body(body.clone()).send().await;
match sent {
Ok(res) if res.status().is_success() => return Ok(true),
// The address moved with a new script: look again once.
Ok(res) if fresh => return Err(format!("Twitch did not take the report ({})", res.status())),
Err(e) if fresh => return Err(format!("Twitch's reports are not answering: {e}")),
_ => {}
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_report_is_the_players_own_shape() {
let encoded = minute_watched("71", 1234, "9");
let decoded: serde_json::Value = serde_json::from_slice(&base64::engine::general_purpose::STANDARD.decode(encoded).unwrap()).unwrap();
assert_eq!(decoded[0]["event"], "minute-watched");
assert_eq!(decoded[0]["properties"]["channel_id"], "71");
assert_eq!(decoded[0]["properties"]["broadcast_id"], "1234");
assert_eq!(decoded[0]["properties"]["user_id"], "9");
assert_eq!(decoded[0]["properties"]["player"], "site");
}
#[tokio::test]
async fn a_live_channel_is_reported_once_a_minute() {
use super::super::points::tests::{channel, fake_gql};
*known_address().lock().unwrap() = None;
last_reports().lock().unwrap().clear();
let (state, buffer) = channel("twitch-watch", "websession");
let (root, seen) = fake_gql(|req| {
let line = req.lines().next().unwrap_or("").to_string();
if line.starts_with("POST /gql") || req.contains("users(logins") {
r#"{"data":{"users":[{"id":"71","login":"xqc","displayName":"xQc","stream":{"id":"555","title":"t","viewersCount":5,"createdAt":"2026-10-14T10:00:00Z","type":"live"}}]}}"#.to_string()
} else if line.starts_with("GET /xqc ") {
// The page names the script on the same fake server.
let host = req.lines().find_map(|l| l.strip_prefix("host: ").or_else(|| l.strip_prefix("Host: "))).unwrap_or("").trim().to_string();
format!(r#"<script src="http://{host}/config/settings.abc.js"></script>"#)
} else if line.starts_with("GET /config/") {
let host = req.lines().find_map(|l| l.strip_prefix("host: ").or_else(|| l.strip_prefix("Host: "))).unwrap_or("").trim().to_string();
format!(r#"x={{"spade_url":"http://{host}/track"}}"#)
} else {
String::new()
}
})
.await;
assert!(report(&state, &format!("{root}/gql"), &root, &buffer).await.unwrap());
let requests = seen.lock().unwrap().clone();
let posted = requests.iter().find(|r| r.starts_with("POST /track")).expect("a report was sent");
assert!(posted.contains("data="), "{posted}");
// Asked again at once: nothing more goes out.
assert!(!report(&state, &format!("{root}/gql"), &root, &buffer).await.unwrap());
assert_eq!(seen.lock().unwrap().iter().filter(|r| r.starts_with("POST /track")).count(), 1);
}
#[test]
fn where_to_report_is_read_from_the_page_and_its_script() {
let page = r#"<script src="https://assets.twitch.tv/config/settings.abc123.js"></script>"#;
assert_eq!(settings_script(page).as_deref(), Some("https://assets.twitch.tv/config/settings.abc123.js"));
assert_eq!(spade_address(r#"x={"spade_url":"https:\/\/spade.example\/track","a":1}"#).as_deref(), Some("https://spade.example/track"));
assert_eq!(settings_script("<html></html>"), None);
assert_eq!(spade_address("nothing"), None);
}
}
+5 -4
View File
@@ -25,11 +25,12 @@ pub struct Builtin {
}
const IRC: &[&str] = &["irc"];
const IRC_AND_MATRIX: &[&str] = &["irc", "matrix"];
/// `/me` is the one command Twitch's chat itself understands, so it is sent as typed.
const ME: &[&str] = &["irc", "matrix", "twitch"];
const MATRIX: &[&str] = &["matrix"];
/// Everywhere, including the services with no list of their own - moho keeps
/// one for those.
const EVERY: &[&str] = &["irc", "matrix", "discord", "kick", "sneedchat"];
const EVERY: &[&str] = &["irc", "matrix", "discord", "kick", "sneedchat", "twitch"];
/// The ones that are only text, and so work wherever this daemon does the
/// sending itself.
///
@@ -37,7 +38,7 @@ const EVERY: &[&str] = &["irc", "matrix", "discord", "kick", "sneedchat"];
/// anybody else sees it, and inventing four more that only this client knows
/// about would mean the same typing doing different things depending on which
/// client you were at.
const TEXT_ONLY: &[&str] = &["irc", "matrix", "discord", "kick"];
const TEXT_ONLY: &[&str] = &["irc", "matrix", "discord", "kick", "twitch"];
/// Spoilers exist on the two services that have a syntax for them.
const SPOILERS: &[&str] = &["discord", "matrix"];
const SNEEDCHAT: &[&str] = &["sneedchat"];
@@ -45,7 +46,7 @@ const SNEEDCHAT: &[&str] = &["sneedchat"];
/// Ordered roughly by how often each is wanted, since a fuzzy match that ties
/// keeps this order.
pub const BUILTINS: &[Builtin] = &[
Builtin { name: "me", usage: "<action>", description: "Say something as an action", services: IRC_AND_MATRIX },
Builtin { name: "me", usage: "<action>", description: "Say something as an action", services: ME },
Builtin { name: "join", usage: "<#channel>", description: "Join a channel", services: IRC },
Builtin { name: "part", usage: "", description: "Leave this channel", services: IRC },
Builtin { name: "msg", usage: "<nick> <message>", description: "Send somebody a private message", services: IRC },
+6
View File
@@ -337,6 +337,12 @@ async fn run() -> Result<()> {
for cfg in state.accounts.all_kick() {
backend::kick::spawn(state.clone(), cfg);
}
for cfg in state.accounts.all_steam() {
backend::steam::spawn(state.clone(), cfg);
}
for cfg in state.accounts.all_twitch() {
backend::twitch::spawn(state.clone(), cfg);
}
// What was going on last time, before anything is served: a window that
// connects immediately should see the same list it was looking at.
+72 -6
View File
@@ -188,6 +188,15 @@ pub struct Account {
/// Matrix only: whether this account keeps a dehydrated device.
#[serde(rename = "dehydration", default)]
pub dehydration: bool,
/// Twitch only: whether this account holds a twitch.tv website session for
/// playing video as the person (their Turbo, their subscriptions). Said as
/// a yes or no and never the session itself.
#[serde(rename = "webSession", default, skip_serializing_if = "std::ops::Not::not")]
pub web_session: bool,
/// Twitch only: whether this account was signed in with the rights to
/// moderate, whisper and change its colour.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub moderation: bool,
}
/// One room in a Sneedchat account's currently-enabled list, as exposed on
@@ -242,6 +251,17 @@ impl BufferLink {
}
}
/// A warning the service has put on a conversation, drawn above it until the
/// person has seen it. Steam's "this person may not be who they appear to be".
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct BufferNotice {
/// Which warning it is, for a client that draws them differently:
/// "suspicious".
pub kind: String,
/// What a person reads.
pub text: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Buffer {
pub id: String,
@@ -286,6 +306,9 @@ pub struct Buffer {
/// Set while this buffer's own connection is not up - see BufferLink.
#[serde(skip_serializing_if = "Option::is_none")]
pub link: Option<BufferLink>,
/// A warning on this conversation, until it is dismissed - see BufferNotice.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notice: Option<BufferNotice>,
/// Whether this room is end-to-end encrypted (Matrix only - absent,
/// not `false`, for every other protocol, since "encrypted" isn't a
/// meaningful concept for them at all). Drives the lock/unlock
@@ -449,6 +472,32 @@ pub struct ReplyPreview {
/// something else entirely.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub forwarded: bool,
/// The message a whole chain of replies grew from, where the service
/// groups them into one conversation.
///
/// Kick does: every reply carries the id of the message at the top of its
/// chain, however deep it is, while `id` stays the message it answered. A
/// reply therefore points two ways - back at what it quoted, and up at the
/// thread it belongs to - and a client wants both: the first for the quote
/// above it, the second for a thread to open.
#[serde(rename = "threadRoot", default, skip_serializing_if = "Option::is_none")]
pub thread_root: Option<String>,
}
/// Why a message the service took down is gone, kept with the message.
///
/// Kick tells everyone in a channel when a message is removed, and says by
/// whom only as far as "a machine did it, and for breaking these rules". The
/// text stays here rather than leaving the log, so that what it said is still
/// readable - how it is drawn is up to the client.
#[derive(Serialize, serde::Deserialize, Clone, Debug, Default, PartialEq)]
pub struct Moderated {
/// Whether Kick's automatic moderation did it, rather than a person.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub ai: bool,
/// The rules it was said to break: `hate`, `harassment`, and so on.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rules: Vec<String>,
}
/// How a service says a sender should look.
@@ -467,7 +516,7 @@ pub struct SenderStyle {
/// account is among the reactors - Discord's REACTION_ADD/REMOVE events
/// are per-user, so this is accumulated incrementally rather than
/// snapshotted (see backend/discord/messages.rs's reaction handling).
#[derive(Serialize, Deserialize, Clone, Debug)]
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Reaction {
pub emoji: String,
pub count: i64,
@@ -489,7 +538,7 @@ pub struct Reaction {
/// Discord's own client does, and re-renders it live if a later edit
/// changes it (a webhook can restyle an existing embed - status-bridge
/// bots commonly do this to signal connected/degraded/down).
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct Embed {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
@@ -556,7 +605,7 @@ pub struct Embed {
}
/// A link with its words.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct EmbedLink {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -564,7 +613,7 @@ pub struct EmbedLink {
}
/// One of an embed's labelled lines.
#[derive(Serialize, Deserialize, Clone, Debug, Default)]
#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
pub struct EmbedField {
pub name: String,
pub value: String,
@@ -777,6 +826,10 @@ pub struct Message {
/// Buttons and menus on the message, where the service has them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub components: Vec<Component>,
/// Set once the service has taken this message down: it is still here, and
/// this says it should be drawn as removed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub moderated: Option<Moderated>,
}
pub fn buffer_id(account_id: &str, name: &str) -> String {
@@ -855,6 +908,8 @@ pub fn service_of(account_id: &str) -> &'static str {
Some("discord") => "discord",
Some("kick") => "kick",
Some("sneedchat") => "sneedchat",
Some("steam") => "steam",
Some("twitch") => "twitch",
Some("jabber") => "jabber",
Some("slack") => "slack",
_ => "irc",
@@ -871,6 +926,7 @@ mod service_tests {
assert_eq!(service_of("discord:167790743988076545"), "discord");
assert_eq!(service_of("kick:someone"), "kick");
assert_eq!(service_of("sneedchat:Ancient Pioneer"), "sneedchat");
assert_eq!(service_of("steam:someone"), "steam");
// IRC is the one without a prefix, and a host with a port in it must
// not be read as one.
assert_eq!(service_of("[email protected]"), "irc");
@@ -967,6 +1023,7 @@ mod tests {
position: 0,
syncing: false,
link: None,
notice: None,
encrypted: None,
channel_modes: None,
group_id: None,
@@ -1003,7 +1060,8 @@ mod tests {
/// Whether a service has anything to make of a status.
///
/// Offered by what each can actually express, and not by what could be faked
/// out of something nearby: Discord has all four; IRC has away and back, which
/// out of something nearby: Discord has all four; Steam has online, away
/// (idle) and invisible; IRC has away and back, which
/// are idle and online; Matrix has
/// online and offline presence, so invisible is real and idle is not; Kick and
/// Sneedchat have no presence at all.
@@ -1016,7 +1074,7 @@ pub fn status_supported(account_id: &str, status: &str) -> bool {
match status {
"online" | "dnd" => true,
"idle" => account_id.starts_with("discord:") || !(account_id.starts_with("matrix:") || account_id.starts_with("kick:") || account_id.starts_with("sneedchat:")),
"invisible" => account_id.starts_with("discord:") || account_id.starts_with("matrix:"),
"invisible" => account_id.starts_with("discord:") || account_id.starts_with("matrix:") || account_id.starts_with("steam:"),
_ => false,
}
}
@@ -1045,6 +1103,14 @@ mod status_tests {
assert!(!status_supported("matrix:@me:poa.st", "idle"));
}
#[test]
fn steam_has_online_idle_and_invisible() {
for s in ["online", "idle", "invisible", "dnd"] {
assert!(status_supported("steam:someone", s), "{s}");
}
assert!(!status_supported("steam:someone", "nonsense"));
}
#[test]
fn kick_and_sneedchat_have_nothing_but_do_not_disturb_is_still_a_switch() {
for account in ["kick:me", "sneedchat:me"] {
+906 -4
View File
File diff suppressed because it is too large Load Diff
+261 -8
View File
@@ -449,6 +449,13 @@ pub struct Runtime {
/// snapshot here lets subscribe() replay it immediately - see
/// rpc/methods.rs's subscribe handler.
presence: Mutex<HashMap<String, serde_json::Value>>,
/// How many people a roster stands for, where that is more than it lists.
///
/// Kick's roster is the most recent speakers and is cut off to something
/// a window can draw, while the count of everybody who has spoken keeps
/// going. Beside the snapshot above so that a window opened later is told
/// it as well.
presence_total: Mutex<HashMap<String, usize>>,
/// Generic (protocol-agnostic) "what's my own display name on this
/// account" - record_message's highlight check needs this for every
/// backend, not just IRC (which already tracked it per-connection via
@@ -530,6 +537,16 @@ pub struct Runtime {
/// One per connected Kick account, since one websocket carries every
/// channel that account watches - see backend::kick's module doc.
kick_senders: Mutex<HashMap<String, tokio::sync::mpsc::UnboundedSender<crate::backend::kick::Command>>>,
twitch_senders: Mutex<HashMap<String, tokio::sync::mpsc::UnboundedSender<crate::backend::twitch::Command>>>,
/// Whether this account may moderate each Twitch channel it has open, as Twitch last said.
twitch_rights: Mutex<HashMap<String, bool>>,
/// Each connected Steam account's connection, by account id: how a message
/// or a status change reaches Steam from outside the connection's own task.
steam_links: Mutex<HashMap<String, crate::backend::steam::link::Link>>,
/// Who has asked to be a Steam account's friend, and who it has asked:
/// what the window is shown on the account's page, kept so a window opened
/// after the request arrived can still be told.
steam_requests: Mutex<HashMap<String, serde_json::Value>>,
/// What is known about each watched Kick channel, by buffer id.
kick_channels: Mutex<HashMap<String, KickChannel>>,
/// buffer id -> what that Kick channel is broadcasting, as last asked.
@@ -833,7 +850,8 @@ pub struct Runtime {
///
/// Per account rather than per channel: away is a property of the person,
/// and the same nick in three channels is away in all of them.
irc_away: Mutex<HashMap<String, std::collections::HashSet<String>>>,
/// Who is away, by lowercased nick, and what they said when they went.
irc_away: Mutex<HashMap<String, HashMap<String, Option<String>>>>,
/// (account, guild id or "@me" for direct messages) -> what that account
/// has silenced on Discord itself.
///
@@ -1036,6 +1054,13 @@ fn cap_cache<K: std::hash::Hash + Eq, V>(map: &mut HashMap<K, V>, limit: usize)
});
}
/// The heading a conversation is always filed under, for a service whose list has fixed ones: Twitch's is
/// "Channels" and "Direct Messages" (whispers), the second drawn only while there is a whisper to put under it.
/// They are the service's own headings, so they cannot be renamed, removed or filled by hand.
fn fixed_category(account_id: &str, kind: &str) -> Option<String> {
account_id.starts_with("twitch:").then(|| if kind == "dm" { "Direct Messages".to_string() } else { "Channels".to_string() })
}
impl Runtime {
pub fn new() -> Self {
Self {
@@ -1057,6 +1082,7 @@ impl Runtime {
discord_member_windows: Mutex::new(HashMap::new()),
buffers: Mutex::new(HashMap::new()),
presence: Mutex::new(HashMap::new()),
presence_total: Mutex::new(HashMap::new()),
own_identity: Mutex::new(HashMap::new()),
discord_channels: Mutex::new(HashMap::new()),
discord_gateway_sessions: Mutex::new(HashMap::new()),
@@ -1077,6 +1103,10 @@ impl Runtime {
discord_friends: Mutex::new(HashMap::new()),
sneedchat_senders: Mutex::new(HashMap::new()),
kick_senders: Mutex::new(HashMap::new()),
twitch_senders: Mutex::new(HashMap::new()),
twitch_rights: Mutex::new(HashMap::new()),
steam_links: Mutex::new(HashMap::new()),
steam_requests: Mutex::new(HashMap::new()),
kick_channels: Mutex::new(HashMap::new()),
kick_streams: Mutex::new(HashMap::new()),
kick_stream_fetched: Mutex::new(HashMap::new()),
@@ -1216,6 +1246,16 @@ impl Runtime {
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
crate::accounts::kick_account_to_json(cfg, conn)
}));
out.extend(state.accounts.all_steam().iter().map(|cfg| {
let id = cfg.account_id();
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
crate::accounts::steam_account_to_json(cfg, conn, self.own_identity(&id).as_deref())
}));
out.extend(state.accounts.all_twitch().iter().map(|cfg| {
let id = cfg.account_id();
let conn = conn_states.get(&id).map(|s| s.as_str()).unwrap_or("disconnected");
crate::accounts::twitch_account_to_json(cfg, conn)
}));
out
}
@@ -1615,7 +1655,12 @@ impl Runtime {
let id = model::buffer_id(account_id, name);
self.confirm_restored(account_id, &id, false);
let mut buffers = self.buffers.lock().unwrap();
if let Some(existing) = buffers.get(&id) {
if let Some(existing) = buffers.get_mut(&id) {
// A Twitch account's list is always headed the same two ways (see `fixed_category`), including
// for what was open before it was.
if existing.category.is_none() {
existing.category = fixed_category(account_id, kind);
}
return existing.clone();
}
let last_activity_ts = state.store.last_activity(&id).unwrap_or(0);
@@ -1631,10 +1676,11 @@ impl Runtime {
// Filled in by whichever backend knows: a Discord guild files its
// channels under the server's own headings, and everything else
// has none.
category: None,
category: fixed_category(account_id, kind),
position: 0,
syncing: false,
link: None,
notice: None,
encrypted: None,
channel_modes: None,
group_id: Some(model::account_group_id(account_id)),
@@ -2004,6 +2050,14 @@ impl Runtime {
self.presence.lock().unwrap().insert(buffer_id.to_string(), members);
}
pub fn set_presence_total(&self, buffer_id: &str, total: usize) {
self.presence_total.lock().unwrap().insert(buffer_id.to_string(), total);
}
pub fn get_presence_total(&self, buffer_id: &str) -> Option<usize> {
self.presence_total.lock().unwrap().get(buffer_id).copied()
}
pub fn get_presence(&self, buffer_id: &str) -> Option<serde_json::Value> {
self.presence.lock().unwrap().get(buffer_id).cloned()
}
@@ -2022,6 +2076,14 @@ impl Runtime {
.insert(source.id.clone(), source);
}
/// Forgets a source and the ones named under it (`id:...`), for a set that is rebuilt whole.
pub fn clear_emoji_sources(&self, account_id: &str, id: &str) {
if let Some(sources) = self.emoji_catalogue.lock().unwrap().get_mut(account_id) {
let under = format!("{id}:");
sources.retain(|have, _| have != id && !have.starts_with(&under));
}
}
/// Everything this account can reach, newest knowledge of each source.
pub fn emoji_catalogue(&self, account_id: &str) -> Vec<crate::model::EmojiSource> {
self.emoji_catalogue
@@ -2569,6 +2631,21 @@ impl Runtime {
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
}
/// Puts a warning on a conversation, or takes it off. Broadcast only when it
/// changes, so a notice Steam says again is not announced again.
pub fn set_buffer_notice(&self, state: &AppState, buffer_id: &str, notice: Option<model::BufferNotice>) {
let updated = {
let mut buffers = self.buffers.lock().unwrap();
let Some(buffer) = buffers.get_mut(buffer_id) else { return };
if buffer.notice == notice {
return;
}
buffer.notice = notice;
buffer.clone()
};
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
}
pub fn set_buffer_syncing(&self, state: &AppState, buffer_id: &str, syncing: bool) {
let updated = {
let mut buffers = self.buffers.lock().unwrap();
@@ -3442,13 +3519,37 @@ impl Runtime {
/// Records that somebody is away, or is back. Returns whether this
/// changed anything, so a caller only redraws rosters that moved.
pub fn set_irc_away(&self, account_id: &str, nick: &str, away: bool) -> bool {
self.set_irc_away_because(account_id, nick, away.then_some(None))
}
/// As `set_irc_away`, with what they said when they went: `None` is back,
/// `Some(None)` is away without a word, `Some(Some(words))` away with some.
/// A change of words alone counts as a change, since it is drawn.
pub fn set_irc_away_because(&self, account_id: &str, nick: &str, away: Option<Option<String>>) -> bool {
let mut all = self.irc_away.lock().unwrap();
let set = all.entry(account_id.to_string()).or_default();
if away { set.insert(nick.to_lowercase()) } else { set.remove(&nick.to_lowercase()) }
let held = all.entry(account_id.to_string()).or_default();
let key = nick.to_lowercase();
match away {
Some(reason) => {
let reason = reason.map(|r| r.trim().to_string()).filter(|r| !r.is_empty());
// A reason is only known from an AWAY; learning somebody is away
// some other way (a WHOIS) must not forget what they said.
if reason.is_none() && held.contains_key(&key) {
return false;
}
held.insert(key, reason.clone()) != Some(reason)
}
None => held.remove(&key).is_some(),
}
}
pub fn is_irc_away(&self, account_id: &str, nick: &str) -> bool {
self.irc_away.lock().unwrap().get(account_id).is_some_and(|set| set.contains(&nick.to_lowercase()))
self.irc_away.lock().unwrap().get(account_id).is_some_and(|held| held.contains_key(&nick.to_lowercase()))
}
/// What somebody who is away said when they went.
pub fn irc_away_reason(&self, account_id: &str, nick: &str) -> Option<String> {
self.irc_away.lock().unwrap().get(account_id).and_then(|held| held.get(&nick.to_lowercase()).cloned().flatten())
}
pub fn sneedchat_room_catalogue(&self, account_id: &str) -> Option<Vec<crate::accounts::SneedChatRoom>> {
@@ -3742,6 +3843,18 @@ impl Runtime {
self.kick_streams.lock().unwrap().get(buffer_id).cloned()
}
/// The conversation an account has open for a Kick channel, by the channel's
/// own number - which is how the points event names it.
pub fn kick_buffer_of_channel(&self, account_id: &str, channel_id: u64) -> Option<String> {
let prefix = format!("{account_id}|");
self.kick_channels
.lock()
.unwrap()
.iter()
.find(|(buffer_id, channel)| buffer_id.starts_with(&prefix) && channel.channel_id == channel_id)
.map(|(buffer_id, _)| buffer_id.clone())
}
pub fn kick_channel(&self, buffer_id: &str) -> Option<KickChannel> {
self.kick_channels.lock().unwrap().get(buffer_id).cloned()
}
@@ -3820,6 +3933,22 @@ impl Runtime {
self.kick_senders.lock().unwrap().insert(account_id.to_string(), sender);
}
pub fn set_twitch_sender(&self, account_id: &str, sender: tokio::sync::mpsc::UnboundedSender<crate::backend::twitch::Command>) {
self.twitch_senders.lock().unwrap().insert(account_id.to_string(), sender);
}
pub fn set_twitch_rights(&self, buffer_id: &str, moderator: bool) {
self.twitch_rights.lock().unwrap().insert(buffer_id.to_string(), moderator);
}
pub fn twitch_rights(&self, buffer_id: &str) -> Option<bool> {
self.twitch_rights.lock().unwrap().get(buffer_id).copied()
}
pub fn twitch_sender(&self, account_id: &str) -> Option<tokio::sync::mpsc::UnboundedSender<crate::backend::twitch::Command>> {
self.twitch_senders.lock().unwrap().get(account_id).cloned()
}
pub fn kick_sender(&self, account_id: &str) -> Option<tokio::sync::mpsc::UnboundedSender<crate::backend::kick::Command>> {
self.kick_senders.lock().unwrap().get(account_id).cloned()
}
@@ -4215,6 +4344,56 @@ impl Runtime {
self.kick_senders.lock().unwrap().remove(account_id);
}
/// Registers a signed-in Steam connection.
pub fn set_steam_link(&self, account_id: &str, link: crate::backend::steam::link::Link) {
self.steam_links.lock().unwrap().insert(account_id.to_string(), link);
}
/// The account's connection, if it is signed in right now.
pub fn steam_link(&self, account_id: &str) -> Option<crate::backend::steam::link::Link> {
self.steam_links.lock().unwrap().get(account_id).filter(|l| l.is_open()).cloned()
}
/// Remembers the friend requests an account has, as the window shows them.
pub fn set_steam_requests(&self, account_id: &str, requests: serde_json::Value) {
self.steam_requests.lock().unwrap().insert(account_id.to_string(), requests);
}
/// The friend requests last remembered for the account.
pub fn steam_requests(&self, account_id: &str) -> serde_json::Value {
self.steam_requests
.lock()
.unwrap()
.get(account_id)
.cloned()
.unwrap_or_else(|| serde_json::json!({ "incoming": [], "outgoing": [] }))
}
/// Takes the connection out, leaving none: ending a session means
/// nothing else can reach it.
pub fn take_steam_link(&self, account_id: &str) -> Option<crate::backend::steam::link::Link> {
self.steam_links.lock().unwrap().remove(account_id)
}
/// Records which person a conversation is with, as the service names them,
/// and says so when it changed. The conversation's own name can change
/// under it - a person renames themselves - and this does not.
pub fn set_buffer_remote_id(&self, state: &AppState, buffer_id: &str, remote_id: &str) {
let updated = {
let mut buffers = self.buffers.lock().unwrap();
match buffers.get_mut(buffer_id) {
Some(b) if b.remote_id.as_deref() != Some(remote_id) => {
b.remote_id = Some(remote_id.to_string());
Some(b.clone())
}
_ => None,
}
};
if let Some(b) = updated {
state.events.emit("bufferListChange", serde_json::to_value(&b).unwrap());
}
}
pub fn clear_sneedchat_senders(&self, account_id: &str) {
self.sneedchat_senders.lock().unwrap().remove(account_id);
}
@@ -4250,6 +4429,11 @@ impl Runtime {
for (sender, message) in farewells {
let _ = sender.send_quit(if message.is_empty() { fallback } else { &message });
}
// Steam is told it is being left rather than dropped. The links stay
// held, so the goodbye has the rest of the shutdown to be written.
for link in self.steam_links.lock().unwrap().values() {
crate::backend::steam::say_goodbye(link);
}
}
/// Stops a connection: gracefully QUITs if it already reached the
@@ -4266,6 +4450,12 @@ impl Runtime {
let _ = sender.send_quit(&quit_message);
return true;
}
// A signed-in Steam session says goodbye first: the abort below stops
// the task without letting it, and a session that is dropped instead
// of left stays listed as online for a while.
if let Some(link) = self.take_steam_link(account_id) {
crate::backend::steam::sign_off(link);
}
let handle = self.task_handles.lock().unwrap().remove(account_id);
match handle {
Some(handle) => {
@@ -4492,6 +4682,7 @@ impl Runtime {
// Filled in by the backend that has them, immediately after this
// returns - only Discord has any, and it is the one that knows.
components: Vec::new(),
moderated: None,
id: msg_id,
buffer_id: buffer.id.clone(),
from: from.to_string(),
@@ -4641,6 +4832,45 @@ impl Runtime {
/// Discord's MESSAGE_DELETE - removes the row and tells clients to
/// drop it from view, same as Discord's own clients (no tombstone).
/// A message the service took down, kept and marked as taken down. Quiet
/// about one that is not stored: a removal of something older than this
/// client's scrollback is not news here.
pub fn moderate_message(&self, state: &AppState, buffer_id: &str, msg_id: &str, why: &model::Moderated) {
match state.store.mark_moderated(buffer_id, msg_id, why) {
Ok(true) => state.events.emit("messageModerated", json!({ "bufferId": buffer_id, "id": msg_id, "moderated": why })),
Ok(false) => {}
Err(e) => tracing::warn!("failed to mark a message as removed: {e}"),
}
}
/// Everything a person said in a conversation, kept and marked as removed:
/// what a timeout or a ban does to their messages.
pub fn moderate_sender(&self, state: &AppState, buffer_id: &str, sender_id: &str, why: &model::Moderated) {
match state.store.mark_moderated_by_sender(buffer_id, sender_id, why) {
Ok(ids) => {
for id in ids {
state.events.emit("messageModerated", json!({ "bufferId": buffer_id, "id": id, "moderated": why }));
}
}
Err(e) => tracing::warn!("failed to mark a person's messages as removed: {e}"),
}
}
/// Says how a channel's chat is restricted, in the words the header shows,
/// where the service has its own modes (Twitch's room state).
pub fn set_channel_modes(&self, state: &AppState, buffer_id: &str, modes: Option<String>) {
let updated = {
let mut buffers = self.buffers.lock().unwrap();
let Some(buffer) = buffers.get_mut(buffer_id) else { return };
if buffer.channel_modes == modes {
return;
}
buffer.channel_modes = modes;
buffer.clone()
};
state.events.emit("bufferListChange", serde_json::to_value(&updated).unwrap());
}
pub fn delete_message(&self, state: &AppState, buffer_id: &str, msg_id: &str) {
match state.store.delete_message(buffer_id, msg_id) {
Ok(true) => state.events.emit("messageDeleted", json!({ "bufferId": buffer_id, "id": msg_id })),
@@ -5385,11 +5615,11 @@ impl Runtime {
restored_buffers, restored_groups, layout_confirmed,
connect_generation, matrix_invites, discord_resync,
discord_own_roles, discord_guild_roles, discord_guild_owners,
discord_member_windows, presence, own_identity,
discord_member_windows, presence, presence_total, own_identity,
discord_channels, discord_gateway_sessions, ringing_calls,
discord_guild_id, discord_buffer_emojis, emoji_catalogue,
kick_watching, matrix_emoticons, discord_friends,
sneedchat_senders, kick_senders, kick_channels,
sneedchat_senders, kick_senders, twitch_senders, twitch_rights, steam_links, steam_requests, kick_channels,
kick_streams, kick_stream_fetched, live_cards,
irc_channel_lists, irc_channel_directory, irc_caps, irc_metadata,
discord_last_interaction, matrix_room_creators, matrix_room_versions,
@@ -5480,6 +5710,24 @@ mod forget_tests {
/// The sweep itself, run: the other test reads the list, this one checks
/// that what it names is actually emptied - for this account and its
/// buffers, and for nobody else's.
#[test]
fn away_remembers_what_was_said_and_a_change_of_words_is_a_change() {
let rt = super::Runtime::new();
assert!(rt.set_irc_away_because("irc:a", "Bob", Some(Some("gone fishing".into()))));
assert_eq!(rt.irc_away_reason("irc:a", "bob").as_deref(), Some("gone fishing"));
assert!(!rt.set_irc_away_because("irc:a", "bob", Some(Some("gone fishing".into()))), "the same again is nothing");
assert!(rt.set_irc_away_because("irc:a", "bob", Some(Some("lunch".into()))), "new words are drawn");
// Learning they are away some other way must not forget the words.
assert!(!rt.set_irc_away("irc:a", "bob", true));
assert_eq!(rt.irc_away_reason("irc:a", "bob").as_deref(), Some("lunch"));
// Away with nothing said, and back.
assert!(rt.set_irc_away_because("irc:a", "amy", Some(None)));
assert!(rt.is_irc_away("irc:a", "amy") && rt.irc_away_reason("irc:a", "amy").is_none());
assert!(rt.set_irc_away("irc:a", "bob", false));
assert!(!rt.is_irc_away("irc:a", "bob"));
assert!(!rt.set_irc_away("irc:a", "bob", false), "back when already back is nothing");
}
#[test]
fn forgetting_an_account_empties_it_and_only_it() {
let rt = super::Runtime::new();
@@ -5492,9 +5740,14 @@ mod forget_tests {
rt.silenced.lock().unwrap().insert("irc:a|#one".into());
rt.silenced.lock().unwrap().insert("irc:ab|#one".into());
rt.discord_members.lock().unwrap().insert(("irc:a|#one".into(), "u".into()), serde_json::json!({}));
rt.set_steam_requests("irc:a", serde_json::json!({ "incoming": [1], "outgoing": [] }));
rt.set_steam_requests("irc:ab", serde_json::json!({ "incoming": [2], "outgoing": [] }));
rt.forget_account("irc:a");
assert_eq!(rt.steam_requests("irc:a")["incoming"], serde_json::json!([]), "a forgotten account has no requests");
assert_eq!(rt.steam_requests("irc:ab")["incoming"], serde_json::json!([2]));
assert!(rt.own_identity.lock().unwrap().get("irc:a").is_none());
assert!(rt.own_identity.lock().unwrap().get("irc:ab").is_some(), "a longer id that starts the same is another account");
assert_eq!(rt.matrix_rooms.lock().unwrap().keys().collect::<Vec<_>>(), ["irc:ab|#one"]);
+135 -13
View File
@@ -187,6 +187,12 @@ impl Store {
// Whether that reference is a message brought here rather than
// one answered - see model::ReplyPreview::forwarded.
"ALTER TABLE messages ADD COLUMN reply_forwarded INTEGER NOT NULL DEFAULT 0",
// The message a chain of replies grew from - see
// model::ReplyPreview::thread_root.
"ALTER TABLE messages ADD COLUMN reply_thread_root TEXT",
// A message the service took down and this client kept - see
// model::Moderated. JSON, and null for a message nobody removed.
"ALTER TABLE messages ADD COLUMN moderated TEXT",
] {
let _ = conn.execute(stmt, []);
}
@@ -279,6 +285,29 @@ impl Store {
CREATE TABLE IF NOT EXISTS known_groups (id TEXT PRIMARY KEY, data TEXT NOT NULL);",
);
// A warning the service put on a conversation, and whether it has been
// dismissed: what keeps a banner over the conversation until it is, across
// a restart - see backend/steam/notices.rs.
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS buffer_notices (
buffer_id TEXT NOT NULL,
notice_id TEXT NOT NULL,
state TEXT NOT NULL,
PRIMARY KEY (buffer_id, notice_id)
);",
);
// What each person on a Steam account was last called, by their number: so a start shows
// names, and not numbers until Steam has said who they are - see backend/steam/friends.rs.
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS steam_names (
account_id TEXT NOT NULL,
steamid INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (account_id, steamid)
);",
);
let _ = conn.execute_batch(
"CREATE TABLE IF NOT EXISTS media_sources (
path TEXT PRIMARY KEY,
@@ -420,8 +449,8 @@ impl Store {
// OR IGNORE against the (buffer_id, msg_id) index: recording the
// same message twice is a backend replaying history it already
// has, and the right answer is to keep the copy already stored.
"INSERT OR IGNORE INTO messages (msg_id, buffer_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, buffer_kind, sender_color, badges, reply_is_thread, reply_forwarded)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, COALESCE(?12, '[]'), ?13, ?14, COALESCE(?15, '[]'), ?16, COALESCE(?17, '[]'), ?18, ?19, ?20, COALESCE(?21, '[]'), ?22, ?23)",
"INSERT OR IGNORE INTO messages (msg_id, buffer_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, buffer_kind, sender_color, badges, reply_is_thread, reply_forwarded, reply_thread_root)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, COALESCE(?12, '[]'), ?13, ?14, COALESCE(?15, '[]'), ?16, COALESCE(?17, '[]'), ?18, ?19, ?20, COALESCE(?21, '[]'), ?22, ?23, ?24)",
params![
msg_id,
buffer_id,
@@ -446,6 +475,7 @@ impl Store {
serde_json::to_string(badges).ok(),
reply_to.is_some_and(|r| r.thread) as i32,
reply_to.is_some_and(|r| r.forwarded) as i32,
reply_to.and_then(|r| r.thread_root.as_deref()),
],
)?;
// Whether this was actually new. The insert has always ignored a
@@ -556,6 +586,30 @@ impl Store {
Ok(bodies)
}
/// Keeps a message the service has removed, marked as removed. Answers
/// whether it was here to mark.
pub fn mark_moderated(&self, buffer_id: &str, msg_id: &str, why: &crate::model::Moderated) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let json = serde_json::to_string(why)?;
let rows = conn.execute("UPDATE messages SET moderated = ?3 WHERE buffer_id = ?1 AND msg_id = ?2", params![buffer_id, msg_id, json])?;
Ok(rows > 0)
}
/// Marks everything one person said in a conversation as removed, and answers
/// which messages that was. Twitch does this to a person's messages when it
/// times them out or bans them: no message is named, the person is.
pub fn mark_moderated_by_sender(&self, buffer_id: &str, sender_id: &str, why: &crate::model::Moderated) -> Result<Vec<String>> {
let conn = self.conn.lock().unwrap();
let json = serde_json::to_string(why)?;
let mut stmt = conn.prepare("SELECT msg_id FROM messages WHERE buffer_id = ?1 AND sender_id = ?2 AND kind = 'chat' AND moderated IS NULL")?;
let ids: Vec<String> = stmt.query_map(params![buffer_id, sender_id], |row| row.get::<_, String>(0))?.filter_map(|r| r.ok()).collect();
drop(stmt);
for id in &ids {
conn.execute("UPDATE messages SET moderated = ?3 WHERE buffer_id = ?1 AND msg_id = ?2", params![buffer_id, id, json])?;
}
Ok(ids)
}
pub fn delete_message(&self, buffer_id: &str, msg_id: &str) -> Result<bool> {
let conn = self.conn.lock().unwrap();
let rows = conn.execute("DELETE FROM messages WHERE buffer_id = ?1 AND msg_id = ?2", params![buffer_id, msg_id])?;
@@ -638,6 +692,8 @@ impl Store {
"UPDATE OR IGNORE messages SET buffer_id = ?2 WHERE buffer_id = ?1",
params![old_id, new_id],
)?;
// A warning on the conversation goes with it.
conn.execute("UPDATE OR IGNORE buffer_notices SET buffer_id = ?2 WHERE buffer_id = ?1", params![old_id, new_id])?;
Ok(moved)
}
@@ -723,7 +779,8 @@ impl Store {
from: row.get::<_, Option<String>>("reply_to_from").ok().flatten().unwrap_or_default(),
body: row.get::<_, Option<String>>("reply_to_body").ok().flatten().unwrap_or_default(),
thread: row.get::<_, Option<i64>>("reply_is_thread").ok().flatten().unwrap_or(0) != 0,
forwarded: row.get::<_, Option<i64>>("reply_forwarded").ok().flatten().unwrap_or(0) != 0 });
forwarded: row.get::<_, Option<i64>>("reply_forwarded").ok().flatten().unwrap_or(0) != 0,
thread_root: row.get::<_, Option<String>>("reply_thread_root").ok().flatten() });
let json_column = |name: &str| -> String {
row.get::<_, Option<String>>(name).ok().flatten().unwrap_or_else(|| "[]".to_string())
};
@@ -733,6 +790,7 @@ impl Store {
drop_missing_local_copies(&mut attachments);
let badges = serde_json::from_str(&json_column("badges")).unwrap_or_default();
let components = serde_json::from_str(&json_column("components")).unwrap_or_default();
let moderated = row.get::<_, Option<String>>("moderated").ok().flatten().and_then(|json| serde_json::from_str(&json).ok());
Ok(Message {
id: row.get::<_, Option<String>>("msg_id")?.unwrap_or_default(),
buffer_id: buffer_id.to_string(),
@@ -757,6 +815,7 @@ impl Store {
badges,
html: row.get("html")?,
components,
moderated,
})
}
@@ -764,7 +823,7 @@ impl Store {
let conn = self.conn.lock().unwrap();
let limit = if limit > 0 { limit } else { 200 };
let mut stmt = conn.prepare(
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
FROM messages
WHERE buffer_id = ?1 AND (?2 <= 0 OR ts < ?2)
ORDER BY ts DESC LIMIT ?3",
@@ -840,7 +899,7 @@ impl Store {
let conn = self.conn.lock().unwrap();
let limit = if limit > 0 { limit } else { 200 };
let mut stmt = conn.prepare(
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
FROM messages
WHERE buffer_id = ?1 AND ts > ?2
ORDER BY ts ASC LIMIT ?3",
@@ -863,7 +922,7 @@ impl Store {
let conn = self.conn.lock().unwrap();
let limit = if limit > 0 { limit } else { 500 };
let mut stmt = conn.prepare(
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
FROM messages
WHERE buffer_id = ?1 AND ts > ?2 AND ts <= ?3
ORDER BY ts ASC LIMIT ?4",
@@ -904,7 +963,7 @@ impl Store {
pub fn messages_around(&self, buffer_id: &str, ts: i64, span: i64) -> Result<Vec<Message>> {
let conn = self.conn.lock().unwrap();
let span = if span > 0 { span } else { 50 };
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components";
const COLUMNS: &str = "msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated";
let mut older = conn.prepare(&format!(
"SELECT {COLUMNS} FROM messages WHERE buffer_id = ?1 AND ts <= ?2 ORDER BY ts DESC LIMIT ?3"
@@ -950,7 +1009,7 @@ impl Store {
format!(" OR (buffer_kind IS NULL AND buffer_id IN ({places}))")
};
let sql = format!(
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, buffer_id
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated, buffer_id
FROM messages
WHERE is_highlight = 1 AND is_own = 0 AND (buffer_kind = 'channel'{legacy})
ORDER BY ts DESC LIMIT ?"
@@ -1026,6 +1085,31 @@ impl Store {
Ok(())
}
pub fn set_notice_state(&self, buffer_id: &str, notice_id: &str, state: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT INTO buffer_notices (buffer_id, notice_id, state) VALUES (?1, ?2, ?3)
ON CONFLICT(buffer_id, notice_id) DO UPDATE SET state = ?3",
params![buffer_id, notice_id, state],
)?;
Ok(())
}
/// The warnings of one account's conversations: each with its state, or
/// none for a warning that was said in the log before anything recorded it.
/// Found by the log line a warning leaves, which has an id of its own.
pub fn account_notices(&self, account_id: &str) -> Result<Vec<(String, String, Option<String>)>> {
let conn = self.conn.lock().unwrap();
let like = format!("{account_id}|%");
let mut stmt = conn.prepare(
"SELECT m.buffer_id, m.msg_id, n.state FROM messages m
LEFT JOIN buffer_notices n ON n.buffer_id = m.buffer_id AND n.notice_id = m.msg_id
WHERE m.buffer_id LIKE ?1 AND m.msg_id LIKE 'notice:%'",
)?;
let rows = stmt.query_map(params![like], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?;
rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
}
/// The polls or predictions this conversation has seen, newest first.
pub fn live_cards(&self, buffer_id: &str, kind: &str, limit: i64) -> Result<Vec<(String, i64)>> {
let conn = self.conn.lock().unwrap();
@@ -1098,6 +1182,31 @@ impl Store {
Ok((buffers, groups))
}
/// What the people on a Steam account were last called, by account number.
pub fn steam_names(&self, account_id: &str) -> Result<Vec<(u32, String)>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare("SELECT steamid, name FROM steam_names WHERE account_id = ?1")?;
let rows = stmt.query_map(params![account_id], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?;
Ok(rows.filter_map(|r| r.ok()).filter_map(|(id, name)| Some((u32::try_from(id).ok()?, name))).collect())
}
/// Keeps what these people are called now, in place of what they were.
pub fn remember_steam_names(&self, account_id: &str, names: &[(u32, String)]) -> Result<()> {
let mut conn = self.conn.lock().unwrap();
let tx = conn.transaction()?;
{
let mut put = tx.prepare(
"INSERT INTO steam_names (account_id, steamid, name) VALUES (?1, ?2, ?3)
ON CONFLICT(account_id, steamid) DO UPDATE SET name = excluded.name",
)?;
for (id, name) in names {
put.execute(params![account_id, i64::from(*id), name])?;
}
}
tx.commit()?;
Ok(())
}
/// Records where a cached media file came from - see media_cache.rs.
pub fn record_media_source(&self, path: &str, source: &serde_json::Value) -> Result<()> {
let conn = self.conn.lock().unwrap();
@@ -1161,7 +1270,7 @@ impl Store {
let escaped = query.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{escaped}%");
let mut stmt = conn.prepare(
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
FROM messages
WHERE buffer_id = ?1 AND body LIKE ?2 ESCAPE '\\'
ORDER BY ts DESC LIMIT ?3",
@@ -1226,9 +1335,9 @@ impl Store {
pub fn thread_messages(&self, buffer_id: &str, root_id: &str) -> Result<Vec<Message>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
FROM messages
WHERE buffer_id = ?1 AND ((reply_to_id = ?2 AND reply_is_thread = 1) OR msg_id = ?2)
WHERE buffer_id = ?1 AND ((reply_to_id = ?2 AND reply_is_thread = 1) OR reply_thread_root = ?2 OR msg_id = ?2)
ORDER BY ts ASC, rowid ASC",
)?;
let rows = stmt.query_map(params![buffer_id, root_id], |row| Self::row_to_message(buffer_id, row))?;
@@ -1238,7 +1347,7 @@ impl Store {
pub fn get_message(&self, buffer_id: &str, msg_id: &str) -> Result<Option<Message>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components
"SELECT msg_id, from_nick, body, ts, is_action, is_highlight, kind, reply_to_id, reply_to_from, reply_to_body, edited, reactions, is_own, avatar_url, embeds, sender_id, attachments, html, sender_color, badges, reply_is_thread, reply_forwarded, components, reply_thread_root, moderated
FROM messages
WHERE buffer_id = ?1 AND msg_id = ?2",
)?;
@@ -1288,7 +1397,7 @@ impl Store {
let conn = self.conn.lock().unwrap();
let like = format!("{account_id}|%");
let mut gone = 0;
for table in ["messages", "live_cards", "matrix_reactions"] {
for table in ["messages", "live_cards", "matrix_reactions", "buffer_notices"] {
gone += conn.execute(
&format!("DELETE FROM {table} WHERE buffer_id = ?1 OR buffer_id LIKE ?2"),
params![account_id, like],
@@ -1946,6 +2055,19 @@ mod dedupe_tests {
Store::open(std::path::Path::new(":memory:")).unwrap()
}
#[test]
fn what_steam_people_were_called_is_kept_per_account_and_replaced_when_they_rename() {
let s = store();
assert!(s.steam_names("steam:a").unwrap().is_empty());
s.remember_steam_names("steam:a", &[(7, "Gabe".into()), (8, "Alyx".into())]).unwrap();
s.remember_steam_names("steam:b", &[(7, "Somebody else".into())]).unwrap();
s.remember_steam_names("steam:a", &[(7, "Gabe N.".into())]).unwrap();
let mut a = s.steam_names("steam:a").unwrap();
a.sort();
assert_eq!(a, vec![(7, "Gabe N.".to_string()), (8, "Alyx".to_string())]);
assert_eq!(s.steam_names("steam:b").unwrap(), vec![(7, "Somebody else".to_string())]);
}
fn put(s: &Store, buffer: &str, id: &str, body: &str) {
let _ = s.append_message(buffer, id, "nick", body, 1, false, false, "chat", None, &[], false, None, &[], &[], None, None, "channel", None, &[]);
}