e621stream 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +35 -0
- data/lib/e621stream/client/options.rb +20 -0
- data/lib/e621stream/client.rb +211 -0
- data/lib/e621stream/close_codes.rb +23 -0
- data/lib/e621stream/event.rb +24 -0
- data/lib/e621stream/events.rb +29 -0
- data/lib/e621stream/stream_error.rb +18 -0
- data/lib/e621stream/version.rb +5 -0
- data/lib/e621stream.rb +18 -0
- data/sorbet/config +6 -0
- data/sorbet/rbi/annotations/.gitattributes +1 -0
- data/sorbet/rbi/annotations/minitest.rbi +120 -0
- data/sorbet/rbi/annotations/rainbow.rbi +269 -0
- data/sorbet/rbi/gems/.gitattributes +1 -0
- data/sorbet/rbi/gems/ast@2.4.3.rbi +550 -0
- data/sorbet/rbi/gems/async-http@0.105.0.rbi +1649 -0
- data/sorbet/rbi/gems/async-pool@0.12.0.rbi +219 -0
- data/sorbet/rbi/gems/async-websocket@0.30.2.rbi +271 -0
- data/sorbet/rbi/gems/async@2.46.0.rbi +2193 -0
- data/sorbet/rbi/gems/benchmark@0.5.0.rbi +495 -0
- data/sorbet/rbi/gems/console@1.38.0.rbi +1552 -0
- data/sorbet/rbi/gems/erb@6.0.7.rbi +1581 -0
- data/sorbet/rbi/gems/erubi@1.13.1.rbi +153 -0
- data/sorbet/rbi/gems/fiber-annotation@0.2.0.rbi +62 -0
- data/sorbet/rbi/gems/fiber-local@1.1.0.rbi +36 -0
- data/sorbet/rbi/gems/fiber-storage@1.0.1.rbi +43 -0
- data/sorbet/rbi/gems/io-console@0.9.5.rbi +9 -0
- data/sorbet/rbi/gems/io-endpoint@0.19.0.rbi +1079 -0
- data/sorbet/rbi/gems/io-event@1.22.3.rbi +971 -0
- data/sorbet/rbi/gems/io-stream@0.14.0.rbi +805 -0
- data/sorbet/rbi/gems/json@3.1.0.rbi +1929 -0
- data/sorbet/rbi/gems/language_server-protocol@3.17.0.6.rbi +9 -0
- data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +189 -0
- data/sorbet/rbi/gems/logger@1.7.0.rbi +896 -0
- data/sorbet/rbi/gems/minitest@5.27.0.rbi +1499 -0
- data/sorbet/rbi/gems/netrc@0.11.0.rbi +147 -0
- data/sorbet/rbi/gems/openssl@4.0.2.rbi +4366 -0
- data/sorbet/rbi/gems/parallel@2.3.0.rbi +332 -0
- data/sorbet/rbi/gems/parser@3.3.13.0.rbi +5146 -0
- data/sorbet/rbi/gems/pp@0.6.4.rbi +373 -0
- data/sorbet/rbi/gems/prettyprint@0.2.1.rbi +476 -0
- data/sorbet/rbi/gems/prism@1.9.0.rbi +42213 -0
- data/sorbet/rbi/gems/protocol-hpack@1.5.1.rbi +418 -0
- data/sorbet/rbi/gems/protocol-http1@0.41.1.rbi +894 -0
- data/sorbet/rbi/gems/protocol-http2@0.29.2.rbi +2277 -0
- data/sorbet/rbi/gems/protocol-http@0.72.1.rbi +3801 -0
- data/sorbet/rbi/gems/protocol-rack@0.23.0.rbi +9 -0
- data/sorbet/rbi/gems/protocol-url@0.19.0.rbi +9 -0
- data/sorbet/rbi/gems/protocol-websocket@0.21.3.rbi +1087 -0
- data/sorbet/rbi/gems/racc@1.8.1.rbi +169 -0
- data/sorbet/rbi/gems/rack@3.2.8.rbi +9 -0
- data/sorbet/rbi/gems/rainbow@3.1.1.rbi +362 -0
- data/sorbet/rbi/gems/rake@13.4.2.rbi +3127 -0
- data/sorbet/rbi/gems/rbi@0.5.0.rbi +5875 -0
- data/sorbet/rbi/gems/rbs@4.2.0.rbi +7133 -0
- data/sorbet/rbi/gems/rdoc@8.1.0.rbi +9 -0
- data/sorbet/rbi/gems/regexp_parser@2.13.1.rbi +3500 -0
- data/sorbet/rbi/gems/reline@0.7.0.rbi +2449 -0
- data/sorbet/rbi/gems/require-hooks@0.5.1.rbi +152 -0
- data/sorbet/rbi/gems/rexml@3.4.4.rbi +4905 -0
- data/sorbet/rbi/gems/rubocop-ast@1.51.0.rbi +7230 -0
- data/sorbet/rbi/gems/rubocop-rake@0.7.1.rbi +314 -0
- data/sorbet/rbi/gems/rubocop@1.92.2.rbi +69094 -0
- data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +988 -0
- data/sorbet/rbi/gems/rubydex@0.4.4.rbi +1620 -0
- data/sorbet/rbi/gems/spoom@1.8.13.rbi +7380 -0
- data/sorbet/rbi/gems/tapioca@0.20.0.rbi +3752 -0
- data/sorbet/rbi/gems/thor@1.5.0.rbi +3870 -0
- data/sorbet/rbi/gems/tsort@0.2.0.rbi +389 -0
- data/sorbet/rbi/gems/unicode-display_width@3.3.0.rbi +130 -0
- data/sorbet/rbi/gems/unicode-emoji@4.3.0.rbi +254 -0
- data/sorbet/rbi/gems/zeitwerk@2.8.3.rbi +1178 -0
- data/sorbet/tapioca/config.yml +13 -0
- data/sorbet/tapioca/require.rb +4 -0
- metadata +175 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b16a81259148bd2d44ea56a857d2aeefbf15029660f8bd79d219198e3231711e
|
|
4
|
+
data.tar.gz: d24d99e59eb909cbf80f39108c9d90835022f2d886b4f89060245e8c6ceb86c7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d8b860f937490e74e90af663866c043b9923321ef85a6d2a84a7a8ba57571605079c71fcaf9153f28343262583647ceb1bd4b3c659cb1aa0346e8d2581c1cd47
|
|
7
|
+
data.tar.gz: aa52fe6a7af0ac4f3bf7e0adff246b800b16b8350a12ea27feb55c4d389c06bdacd54e17c03e62f84d11bc40cbb7c2d49b5b1b62dd9df16b894b5e82fcabd83c
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) undefined Donovan_DMC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# e621stream
|
|
2
|
+
|
|
3
|
+
A Ruby client for [E621Stream](https://e621.stream), a live WebSocket stream of e621 activity. It's built on [async-websocket](https://github.com/socketry/async-websocket) and typed with Sorbet.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
gem install e621stream
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
require "e621stream"
|
|
11
|
+
|
|
12
|
+
client = E621Stream::Client.new(
|
|
13
|
+
username: "your_name",
|
|
14
|
+
api_key: "your e621 api key",
|
|
15
|
+
subscriptions: ["post.create", { "type" => "post.update", "filter" => { "tags" => "fox -solo" } }, "comment.*"],
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
client.on("post.create") { |event| puts(event.data["post"]["id"]) }
|
|
19
|
+
client.on("post.update") { |event| p(event.data["changes"]) }
|
|
20
|
+
client.on(:error) { |error| warn(error.message) }
|
|
21
|
+
|
|
22
|
+
client.run
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- **Events:** `on("<event type>")` for one type, or `on(:dispatch)` for everything. Each is an `E621Stream::Event` with `id`, `type`, `time` and `data`, which is the record as e621's api returns it. See [Events](https://e621.stream/events/) for what each one is.
|
|
26
|
+
- **Running:** `run` blocks. Inside a reactor already, it runs as a task, so `client.close` from another task stops it.
|
|
27
|
+
- **Reconnecting:** the connection is reopened with backoff when it drops, and resumes from the last event it got. It isn't reopened after a close retrying won't fix, like bad credentials. `reconnect: false` turns it off.
|
|
28
|
+
- **Resuming:** pass `cursor:` (an event id, or a millisecond timestamp) to replay from there. `client.cursor` is the last event received.
|
|
29
|
+
- **Changing subscriptions:** `client.subscribe([...])`.
|
|
30
|
+
- **Private events:** like `dmail.create`, these are polled with your own credentials and have to be subscribed to by name.
|
|
31
|
+
- **Other events:** `:ready`, `:replay_complete`, `:subscribed`, `:reconnecting`, `:close` and `:error`.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bundle exec rake # rubocop, zeitwerk, tests and sorbet
|
|
35
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module E621Stream
|
|
5
|
+
class Client
|
|
6
|
+
class Options < T::Struct
|
|
7
|
+
const(:subscriptions, T::Array[Subscription])
|
|
8
|
+
# Your e621 username and an api key, from Account > Manage API Access.
|
|
9
|
+
const(:username, T.nilable(String), default: nil)
|
|
10
|
+
const(:api_key, T.nilable(String), default: nil)
|
|
11
|
+
const(:url, String, default: "wss://e621.stream/ws")
|
|
12
|
+
# A name for your client, shown in the server's logs.
|
|
13
|
+
const(:client, T.nilable(String), default: nil)
|
|
14
|
+
# An event id (or a millisecond timestamp) to replay from. After that, reconnecting resumes from the last event.
|
|
15
|
+
const(:cursor, T.nilable(T.any(String, Integer)), default: nil)
|
|
16
|
+
# Reconnects with backoff when the connection drops, unless the server closed it for something retrying won't fix.
|
|
17
|
+
const(:reconnect, T::Boolean, default: true)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
require("async")
|
|
5
|
+
require("async/http/endpoint")
|
|
6
|
+
require("async/websocket/client")
|
|
7
|
+
|
|
8
|
+
module E621Stream
|
|
9
|
+
# A connection to E621Stream. Listeners run inside the client's reactor, between reading messages.
|
|
10
|
+
#
|
|
11
|
+
# client = E621Stream::Client.new(username: "...", api_key: "...", subscriptions: ["post.create"])
|
|
12
|
+
# client.on("post.create") { |event| puts(event.data["post"]["id"]) }
|
|
13
|
+
# client.run
|
|
14
|
+
class Client
|
|
15
|
+
extend(T::Sig)
|
|
16
|
+
|
|
17
|
+
Listener = T.type_alias { T.proc.params(arg0: T.untyped).void }
|
|
18
|
+
# An event type, `<resource>.*`, `*`, or `{ "type" => ..., "filter" => { ... } }`.
|
|
19
|
+
Subscription = T.type_alias { T.any(String, T::Hash[T.any(String, Symbol), T.untyped]) }
|
|
20
|
+
|
|
21
|
+
# Opens the real connection, tests swap it for a fake one.
|
|
22
|
+
class Connector
|
|
23
|
+
extend(T::Sig)
|
|
24
|
+
|
|
25
|
+
sig { params(url: String, block: T.proc.params(connection: T.untyped).void).void }
|
|
26
|
+
def connect(url, &block)
|
|
27
|
+
endpoint = Async::HTTP::Endpoint.parse(url, alpn_protocols: Async::HTTP::Protocol::HTTP11.names)
|
|
28
|
+
Async::WebSocket::Client.connect(endpoint, &block)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
HEARTBEAT_SECONDS = 30
|
|
33
|
+
# The server answers heartbeats and pings, nothing for this long means the connection is gone without having closed.
|
|
34
|
+
SILENT_SECONDS = 75
|
|
35
|
+
MAX_DELAY_SECONDS = 60
|
|
36
|
+
|
|
37
|
+
sig { returns(Options) }
|
|
38
|
+
attr_reader(:options)
|
|
39
|
+
|
|
40
|
+
# The newest event received, which reconnecting resumes from.
|
|
41
|
+
sig { returns(T.nilable(String)) }
|
|
42
|
+
attr_reader(:cursor)
|
|
43
|
+
|
|
44
|
+
sig do
|
|
45
|
+
params(
|
|
46
|
+
subscriptions: T::Array[Subscription],
|
|
47
|
+
username: T.nilable(String),
|
|
48
|
+
api_key: T.nilable(String),
|
|
49
|
+
url: String,
|
|
50
|
+
client: T.nilable(String),
|
|
51
|
+
cursor: T.nilable(T.any(String, Integer)),
|
|
52
|
+
reconnect: T::Boolean,
|
|
53
|
+
connector: Connector,
|
|
54
|
+
).void
|
|
55
|
+
end
|
|
56
|
+
def initialize(subscriptions:, username: nil, api_key: nil, url: "wss://e621.stream/ws", client: nil, cursor: nil, reconnect: true, connector: Connector.new)
|
|
57
|
+
@options = T.let(Options.new(subscriptions: subscriptions, username: username, api_key: api_key, url: url, client: client, cursor: cursor, reconnect: reconnect), Options)
|
|
58
|
+
@connector = connector
|
|
59
|
+
@cursor = T.let(cursor&.to_s, T.nilable(String))
|
|
60
|
+
@listeners = T.let(Hash.new { |hash, key| hash[key] = [] }, T::Hash[String, T::Array[Listener]])
|
|
61
|
+
@connection = T.let(nil, T.untyped)
|
|
62
|
+
@closing = T.let(false, T::Boolean)
|
|
63
|
+
@attempt = T.let(0, Integer)
|
|
64
|
+
@last_message = T.let(0.0, Float)
|
|
65
|
+
@last_error = T.let(nil, T.nilable(StreamError))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Listens for an event type (like "post.create"), or one of :dispatch (every event), :ready, :replay_complete,
|
|
69
|
+
# :subscribed, :error, :close and :reconnecting.
|
|
70
|
+
sig { params(name: T.any(String, Symbol), block: Listener).returns(T.self_type) }
|
|
71
|
+
def on(name, &block)
|
|
72
|
+
T.must(@listeners[name.to_s]) << block
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Connects and keeps the connection going until #close is called, or the server closes it for something
|
|
77
|
+
# reconnecting won't fix. Blocks, or runs as a task when called inside a reactor already.
|
|
78
|
+
sig { void }
|
|
79
|
+
def run
|
|
80
|
+
@closing = false
|
|
81
|
+
Sync do
|
|
82
|
+
loop do
|
|
83
|
+
code, reason = connect_once
|
|
84
|
+
error = @last_error
|
|
85
|
+
@last_error = nil
|
|
86
|
+
reconnecting = !closing? && options.reconnect && !CloseCodes::FATAL.include?(code)
|
|
87
|
+
emit("error", error) if error
|
|
88
|
+
emit("close", { code: code, reason: error&.message || reason, reconnecting: reconnecting })
|
|
89
|
+
break unless reconnecting
|
|
90
|
+
|
|
91
|
+
@attempt += 1
|
|
92
|
+
# a server that's rate limiting this connection wants a longer break
|
|
93
|
+
base = code == CloseCodes::RATE_LIMITED ? 30 : 1
|
|
94
|
+
delay = [base * (2**(@attempt - 1)), MAX_DELAY_SECONDS].min * rand(0.75..1.25)
|
|
95
|
+
emit("reconnecting", { attempt: @attempt, delay: delay })
|
|
96
|
+
sleep(delay)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Replaces the current subscriptions, and keeps them for reconnecting.
|
|
102
|
+
sig { params(subscriptions: T::Array[Subscription]).void }
|
|
103
|
+
def subscribe(subscriptions)
|
|
104
|
+
@options = Options.new(**options.serialize.transform_keys(&:to_sym), subscriptions: subscriptions)
|
|
105
|
+
send_message({ op: "subscribe", d: { subscriptions: subscriptions } })
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Closes the connection for good.
|
|
109
|
+
sig { void }
|
|
110
|
+
def close
|
|
111
|
+
@closing = true
|
|
112
|
+
@connection&.close(1000, "closed by client")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# resolves to the close code and reason
|
|
118
|
+
sig { returns([Integer, String]) }
|
|
119
|
+
def connect_once
|
|
120
|
+
result = T.let([1000, ""], [Integer, String])
|
|
121
|
+
@connector.connect(options.url) do |connection|
|
|
122
|
+
@connection = connection
|
|
123
|
+
@last_message = now
|
|
124
|
+
heartbeat = Async { heartbeat_loop(connection) }
|
|
125
|
+
begin
|
|
126
|
+
while (message = connection.read)
|
|
127
|
+
@last_message = now
|
|
128
|
+
handle(JSON.parse(message.buffer))
|
|
129
|
+
end
|
|
130
|
+
ensure
|
|
131
|
+
heartbeat.stop
|
|
132
|
+
@connection = nil
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
result
|
|
136
|
+
rescue Protocol::WebSocket::ClosedError => e
|
|
137
|
+
[e.code || 1006, e.message]
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
# closing tears the connection down under the read, which isn't worth reporting
|
|
140
|
+
return [1000, "closed by client"] if closing?
|
|
141
|
+
|
|
142
|
+
emit("error", e)
|
|
143
|
+
[1006, e.message]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
sig { params(connection: T.untyped).void }
|
|
147
|
+
def heartbeat_loop(connection)
|
|
148
|
+
loop do
|
|
149
|
+
sleep(HEARTBEAT_SECONDS)
|
|
150
|
+
if now - @last_message > SILENT_SECONDS
|
|
151
|
+
connection.close(4999, "no response from the server")
|
|
152
|
+
break
|
|
153
|
+
end
|
|
154
|
+
send_message({ op: "heartbeat" })
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
sig { params(message: T::Hash[String, T.untyped]).void }
|
|
159
|
+
def handle(message)
|
|
160
|
+
data = message["d"]
|
|
161
|
+
case message["op"]
|
|
162
|
+
when "hello"
|
|
163
|
+
identify = { subscriptions: options.subscriptions }
|
|
164
|
+
identify[:auth] = { username: options.username, api_key: options.api_key } if options.username && options.api_key
|
|
165
|
+
identify[:client] = options.client if options.client
|
|
166
|
+
identify[:cursor] = @cursor if @cursor
|
|
167
|
+
send_message({ op: "identify", d: identify })
|
|
168
|
+
when "ready"
|
|
169
|
+
@attempt = 0
|
|
170
|
+
emit("ready", data)
|
|
171
|
+
when "dispatch"
|
|
172
|
+
event = Event.new(id: message["id"], type: message["type"], time: message["time"], data: message["data"], private: message["private"] == true)
|
|
173
|
+
# private events aren't in the stream, so they can't be resumed from
|
|
174
|
+
@cursor = event.id unless event.private?
|
|
175
|
+
emit("dispatch", event)
|
|
176
|
+
emit(event.type, event)
|
|
177
|
+
when "replay_complete"
|
|
178
|
+
emit("replay_complete", data)
|
|
179
|
+
when "subscribed"
|
|
180
|
+
emit("subscribed", data["subscriptions"])
|
|
181
|
+
when "error"
|
|
182
|
+
@last_error = StreamError.new(data["code"], data["message"])
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
sig { params(payload: T::Hash[Symbol, T.untyped]).void }
|
|
187
|
+
def send_message(payload)
|
|
188
|
+
connection = @connection
|
|
189
|
+
return unless connection
|
|
190
|
+
|
|
191
|
+
connection.write(Protocol::WebSocket::TextMessage.generate(payload))
|
|
192
|
+
connection.flush
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
sig { params(name: String, value: T.untyped).void }
|
|
196
|
+
def emit(name, value)
|
|
197
|
+
@listeners[name]&.each { |listener| listener.call(value) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# read through a method, since #close changes it from another fiber while #run is waiting
|
|
201
|
+
sig { returns(T::Boolean) }
|
|
202
|
+
def closing?
|
|
203
|
+
@closing
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
sig { returns(Float) }
|
|
207
|
+
def now
|
|
208
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC).to_f
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module E621Stream
|
|
5
|
+
# Why the server closed the connection, see https://e621.stream/close-codes/
|
|
6
|
+
module CloseCodes
|
|
7
|
+
UNKNOWN_ERROR = 4000
|
|
8
|
+
UNKNOWN_OP = 4001
|
|
9
|
+
DECODE_ERROR = 4002
|
|
10
|
+
NOT_IDENTIFIED = 4003
|
|
11
|
+
AUTHENTICATION_FAILED = 4004
|
|
12
|
+
ALREADY_IDENTIFIED = 4005
|
|
13
|
+
INVALID_SUBSCRIPTION = 4006
|
|
14
|
+
INVALID_CURSOR = 4007
|
|
15
|
+
RATE_LIMITED = 4008
|
|
16
|
+
SLOW_CONSUMER = 4009
|
|
17
|
+
TOO_MANY_CONNECTIONS = 4010
|
|
18
|
+
AUTHENTICATION_EXPIRED = 4011
|
|
19
|
+
|
|
20
|
+
# Closing for any of these again would just get the same result, so they aren't reconnected from.
|
|
21
|
+
FATAL = T.let([UNKNOWN_OP, DECODE_ERROR, AUTHENTICATION_FAILED, ALREADY_IDENTIFIED, INVALID_SUBSCRIPTION, INVALID_CURSOR, AUTHENTICATION_EXPIRED].freeze, T::Array[Integer])
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module E621Stream
|
|
5
|
+
# One event from the stream. `data` is the record as e621's api returns it, or for update events
|
|
6
|
+
# `{ "<resource>" => record, "changes" => ... }`, see https://e621.stream/events/
|
|
7
|
+
class Event < T::Struct
|
|
8
|
+
extend(T::Sig)
|
|
9
|
+
|
|
10
|
+
# The event's cursor, pass it as `cursor:` to resume after it. Private events have no cursor.
|
|
11
|
+
const(:id, String)
|
|
12
|
+
const(:type, String)
|
|
13
|
+
# When it happened on e621.
|
|
14
|
+
const(:time, String)
|
|
15
|
+
const(:data, T::Hash[String, T.untyped])
|
|
16
|
+
# Only for private events, which are polled with your own credentials and never stored.
|
|
17
|
+
const(:private, T::Boolean, default: false)
|
|
18
|
+
|
|
19
|
+
sig { returns(T::Boolean) }
|
|
20
|
+
def private?
|
|
21
|
+
private
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module E621Stream
|
|
5
|
+
module Events
|
|
6
|
+
# Kept identical to the server's src/events.ts, the repository's tests check it.
|
|
7
|
+
EVENT_TYPES = %w[
|
|
8
|
+
artist.create artist.delete artist.update artist_url.create artist_version.create avoid_posting.create
|
|
9
|
+
avoid_posting.delete avoid_posting.destroy avoid_posting.undelete avoid_posting.update
|
|
10
|
+
avoid_posting_version.create ban.create ban.delete ban.update blip.create bulk_update_request.create
|
|
11
|
+
bulk_update_request.update comment.create comment.delete comment.hide comment.unhide comment.update
|
|
12
|
+
forum_post.create forum_post.delete forum_post.hide forum_post.unhide forum_post.update forum_topic.create
|
|
13
|
+
forum_topic.delete forum_topic.hide forum_topic.unhide forum_topic.update help_page.create help_page.update
|
|
14
|
+
mod_action.create news_update.create news_update.update note.create note.update note_version.create
|
|
15
|
+
pool.add_posts pool.create pool.delete pool.remove_posts pool.update pool_version.create post.create post.update
|
|
16
|
+
post_approval.create post_event.create post_flag.create post_flag.update post_replacement.create
|
|
17
|
+
post_set.add_posts post_set.create post_set.delete post_set.remove_posts post_set.update post_version.create
|
|
18
|
+
post_version.hide post_version.unhide tag.create tag.delete tag.update tag_alias.create tag_alias.update
|
|
19
|
+
tag_implication.create tag_implication.update tag_type_version.create takedown.create takedown.delete
|
|
20
|
+
takedown.update upload_karma_event.create user.create user.delete user.update user_feedback.create
|
|
21
|
+
user_feedback.delete user_feedback.destroy user_feedback.undelete user_feedback.update wiki_page.create
|
|
22
|
+
wiki_page.delete wiki_page.update wiki_page_version.create
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# Polled with your own credentials and only sent to you. They have to be subscribed to by name, `*` doesn't include
|
|
26
|
+
# them.
|
|
27
|
+
PRIVATE_EVENT_TYPES = %w[dmail.create].freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# typed: strict
|
|
3
|
+
|
|
4
|
+
module E621Stream
|
|
5
|
+
# Sent by the server right before it closes the connection, `code` is one of CloseCodes.
|
|
6
|
+
class StreamError < StandardError
|
|
7
|
+
extend(T::Sig)
|
|
8
|
+
|
|
9
|
+
sig { returns(Integer) }
|
|
10
|
+
attr_reader(:code)
|
|
11
|
+
|
|
12
|
+
sig { params(code: Integer, message: String).void }
|
|
13
|
+
def initialize(code, message)
|
|
14
|
+
super(message)
|
|
15
|
+
@code = code
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/e621stream.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require("json")
|
|
4
|
+
require("zeitwerk")
|
|
5
|
+
require("sorbet-runtime")
|
|
6
|
+
|
|
7
|
+
# Loaded eagerly (rather than left to Zeitwerk) because the gemspec already requires version.rb to read the version,
|
|
8
|
+
# which defines E621Stream before Zeitwerk::Loader#setup runs.
|
|
9
|
+
require_relative("e621stream/version")
|
|
10
|
+
|
|
11
|
+
loader = Zeitwerk::Loader.for_gem
|
|
12
|
+
loader.inflector.inflect({ "e621stream" => "E621Stream" })
|
|
13
|
+
loader.ignore("#{__dir__}/e621stream/version.rb")
|
|
14
|
+
loader.setup
|
|
15
|
+
|
|
16
|
+
# A client for E621Stream, a live WebSocket stream of e621 activity. See https://e621.stream
|
|
17
|
+
module E621Stream
|
|
18
|
+
end
|
data/sorbet/config
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
**/*.rbi linguist-vendored=true
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
|
|
3
|
+
# DO NOT EDIT MANUALLY
|
|
4
|
+
# This file was pulled from a central RBI files repository.
|
|
5
|
+
# Please run `bin/tapioca annotations` to update it.
|
|
6
|
+
|
|
7
|
+
module Minitest::Assertions
|
|
8
|
+
sig { params(test: T.anything, msg: T.anything).returns(TrueClass) }
|
|
9
|
+
def assert(test, msg = nil); end
|
|
10
|
+
|
|
11
|
+
sig { params(obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
12
|
+
def assert_empty(obj, msg = nil); end
|
|
13
|
+
|
|
14
|
+
sig { params(exp: T.anything, act: T.anything, msg: T.anything).returns(TrueClass) }
|
|
15
|
+
def assert_equal(exp, act, msg = nil); end
|
|
16
|
+
|
|
17
|
+
sig { params(exp: T.anything, act: T.anything, delta: Numeric, msg: T.anything).returns(TrueClass) }
|
|
18
|
+
def assert_in_delta(exp, act, delta = T.unsafe(nil), msg = nil); end
|
|
19
|
+
|
|
20
|
+
sig { params(a: T.anything, b: T.anything, epsilon: Numeric, msg: T.anything).returns(TrueClass) }
|
|
21
|
+
def assert_in_epsilon(a, b, epsilon = T.unsafe(nil), msg = nil); end
|
|
22
|
+
|
|
23
|
+
sig { params(collection: T.anything, obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
24
|
+
def assert_includes(collection, obj, msg = nil); end
|
|
25
|
+
|
|
26
|
+
sig { params(cls: T.anything, obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
27
|
+
def assert_instance_of(cls, obj, msg = nil); end
|
|
28
|
+
|
|
29
|
+
sig { params(cls: T.anything, obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
30
|
+
def assert_kind_of(cls, obj, msg = nil); end
|
|
31
|
+
|
|
32
|
+
sig { params(matcher: T.any(String, Regexp), obj: T.anything, msg: T.anything).returns(MatchData) }
|
|
33
|
+
def assert_match(matcher, obj, msg = nil); end
|
|
34
|
+
|
|
35
|
+
sig { params(obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
36
|
+
def assert_nil(obj, msg = nil); end
|
|
37
|
+
|
|
38
|
+
sig { params(o1: T.anything, op: T.any(Symbol, String), o2: T.anything, msg: T.anything).returns(TrueClass) }
|
|
39
|
+
def assert_operator(o1, op, o2 = T.unsafe(nil), msg = nil); end
|
|
40
|
+
|
|
41
|
+
sig { params(stdout: T.nilable(T.any(String, Regexp)), stderr: T.nilable(T.any(String, Regexp)), block: T.proc.void).returns(T::Boolean) }
|
|
42
|
+
def assert_output(stdout = nil, stderr = nil, &block); end
|
|
43
|
+
|
|
44
|
+
sig { params(path: T.any(String, Pathname), msg: T.anything).returns(TrueClass) }
|
|
45
|
+
def assert_path_exists(path, msg = nil); end
|
|
46
|
+
|
|
47
|
+
sig { params(block: T.proc.void).returns(TrueClass) }
|
|
48
|
+
def assert_pattern(&block); end
|
|
49
|
+
|
|
50
|
+
sig { params(o1: T.anything, op: T.any(String, Symbol), msg: T.anything).returns(TrueClass) }
|
|
51
|
+
def assert_predicate(o1, op, msg = nil); end
|
|
52
|
+
|
|
53
|
+
sig { params(exp: NilClass, block: T.proc.void).returns(StandardError) }
|
|
54
|
+
sig { type_parameters(:T).params(exp: T.any(T::Class[T.type_parameter(:T)], Regexp, String), block: T.proc.void).returns(T.type_parameter(:T)) }
|
|
55
|
+
def assert_raises(*exp, &block); end
|
|
56
|
+
|
|
57
|
+
sig { params(obj: T.anything, meth: T.any(String, Symbol), msg: T.anything, include_all: T::Boolean).returns(TrueClass) }
|
|
58
|
+
def assert_respond_to(obj, meth, msg = nil, include_all: false); end
|
|
59
|
+
|
|
60
|
+
sig { params(exp: T.anything, act: T.anything, msg: T.anything).returns(TrueClass) }
|
|
61
|
+
def assert_same(exp, act, msg = nil); end
|
|
62
|
+
|
|
63
|
+
# @version < 6.0.0
|
|
64
|
+
sig { params(send_ary: T::Array[T.anything], m: T.anything).returns(T::Boolean) }
|
|
65
|
+
def assert_send(send_ary, m = nil); end
|
|
66
|
+
|
|
67
|
+
sig { params(block: T.proc.void).returns(T::Boolean) }
|
|
68
|
+
def assert_silent(&block); end
|
|
69
|
+
|
|
70
|
+
sig { params(sym: Symbol, msg: T.anything, block: T.proc.void).returns(T.anything) }
|
|
71
|
+
def assert_throws(sym, msg = nil, &block); end
|
|
72
|
+
|
|
73
|
+
sig { params(test: T.anything, msg: T.anything).returns(TrueClass) }
|
|
74
|
+
def refute(test, msg = nil); end
|
|
75
|
+
|
|
76
|
+
sig { params(obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
77
|
+
def refute_empty(obj, msg = nil); end
|
|
78
|
+
|
|
79
|
+
sig { params(exp: T.anything, act: T.anything, msg: T.anything).returns(TrueClass) }
|
|
80
|
+
def refute_equal(exp, act, msg = nil); end
|
|
81
|
+
|
|
82
|
+
sig { params(exp: T.anything, act: T.anything, delta: Numeric, msg: T.anything).returns(TrueClass) }
|
|
83
|
+
def refute_in_delta(exp, act, delta = T.unsafe(nil), msg = nil); end
|
|
84
|
+
|
|
85
|
+
sig { params(a: T.anything, b: T.anything, epsilon: Numeric, msg: T.anything).returns(TrueClass) }
|
|
86
|
+
def refute_in_epsilon(a, b, epsilon = T.unsafe(nil), msg = nil); end
|
|
87
|
+
|
|
88
|
+
sig { params(collection: T.anything, obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
89
|
+
def refute_includes(collection, obj, msg = nil); end
|
|
90
|
+
|
|
91
|
+
sig { params(cls: T.anything, obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
92
|
+
def refute_instance_of(cls, obj, msg = nil); end
|
|
93
|
+
|
|
94
|
+
sig { params(cls: T.anything, obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
95
|
+
def refute_kind_of(cls, obj, msg = nil); end
|
|
96
|
+
|
|
97
|
+
sig { params(matcher: T.any(String, Regexp), obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
98
|
+
def refute_match(matcher, obj, msg = nil); end
|
|
99
|
+
|
|
100
|
+
sig { params(obj: T.anything, msg: T.anything).returns(TrueClass) }
|
|
101
|
+
def refute_nil(obj, msg = nil); end
|
|
102
|
+
|
|
103
|
+
sig { params(block: T.proc.void).returns(TrueClass) }
|
|
104
|
+
def refute_pattern(&block); end
|
|
105
|
+
|
|
106
|
+
sig { params(o1: T.anything, op: T.any(Symbol, String), o2: T.anything, msg: T.anything).returns(TrueClass) }
|
|
107
|
+
def refute_operator(o1, op, o2 = T.unsafe(nil), msg = nil); end
|
|
108
|
+
|
|
109
|
+
sig { params(path: T.any(String, Pathname), msg: T.anything).returns(TrueClass) }
|
|
110
|
+
def refute_path_exists(path, msg = nil); end
|
|
111
|
+
|
|
112
|
+
sig { params(o1: T.anything, op: T.any(String, Symbol), msg: T.anything).returns(TrueClass) }
|
|
113
|
+
def refute_predicate(o1, op, msg = nil); end
|
|
114
|
+
|
|
115
|
+
sig { params(obj: T.anything, meth: T.any(String, Symbol), msg: T.anything, include_all: T::Boolean).returns(TrueClass) }
|
|
116
|
+
def refute_respond_to(obj, meth, msg = nil, include_all: false); end
|
|
117
|
+
|
|
118
|
+
sig { params(exp: T.anything, act: T.anything, msg: T.anything).returns(TrueClass) }
|
|
119
|
+
def refute_same(exp, act, msg = nil); end
|
|
120
|
+
end
|