async-matrix 1.0.0 → 1.1.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 +4 -4
- data/README.md +2 -2
- data/data/discord-api-spec/openapi.json +40404 -0
- data/lib/async/discord/api/path_tree.rb +130 -0
- data/lib/async/discord/api.rb +156 -0
- data/lib/async/discord/client.rb +286 -0
- data/lib/async/discord/error.rb +88 -0
- data/lib/async/discord/gateway.rb +362 -0
- data/lib/async/discord.rb +16 -0
- data/lib/async/matrix/api/chain.rb +9 -0
- data/lib/async/matrix/application_service/config/vivify.rb +3 -0
- data/lib/async/matrix/application_service/event.rb +9 -20
- data/lib/async/matrix/application_service/server.rb +2 -2
- data/lib/async/matrix/bridge/discord/db/connection.rb +143 -0
- data/lib/async/matrix/bridge/discord/db/file.rb +120 -0
- data/lib/async/matrix/bridge/discord/db/guild.rb +122 -0
- data/lib/async/matrix/bridge/discord/db/message.rb +162 -0
- data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +14 -0
- data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +14 -0
- data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +23 -0
- data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +19 -0
- data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +20 -0
- data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +19 -0
- data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +18 -0
- data/lib/async/matrix/bridge/discord/db/portal.rb +152 -0
- data/lib/async/matrix/bridge/discord/db/puppet.rb +130 -0
- data/lib/async/matrix/bridge/discord/db/reaction.rb +167 -0
- data/lib/async/matrix/bridge/discord/db/user.rb +114 -0
- data/lib/async/matrix/bridge/discord/db.rb +140 -0
- data/lib/async/matrix/double_puppet_client.rb +84 -0
- data/lib/async/matrix/schema.rb +2 -2
- data/lib/async/matrix/server.rb +1 -0
- data/lib/async/matrix/version.rb +1 -1
- data/lib/async/matrix.rb +2 -0
- metadata +67 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
require "bundler/setup"
|
|
7
|
+
require "async"
|
|
8
|
+
require "async/http/endpoint"
|
|
9
|
+
require "async/websocket/client"
|
|
10
|
+
require "json"
|
|
11
|
+
require "console"
|
|
12
|
+
require "async/discord"
|
|
13
|
+
|
|
14
|
+
module Async
|
|
15
|
+
module Discord
|
|
16
|
+
# WebSocket client for the Discord Gateway (v10).
|
|
17
|
+
#
|
|
18
|
+
# Connects to Discord's WebSocket gateway, authenticates with a bot token,
|
|
19
|
+
# manages heartbeating, and dispatches incoming events to registered handlers.
|
|
20
|
+
#
|
|
21
|
+
# Supports session resumption on reconnect.
|
|
22
|
+
#
|
|
23
|
+
# gateway = Async::Discord::Gateway.new(token: "MTk...", intents: 0x30001)
|
|
24
|
+
# gateway.on("MESSAGE_CREATE") { |data| puts data["content"] }
|
|
25
|
+
# gateway.on("READY") { |data| puts "Connected as #{data["user"]["username"]}" }
|
|
26
|
+
# gateway.run # blocks, runs inside Async reactor
|
|
27
|
+
#
|
|
28
|
+
class Gateway
|
|
29
|
+
GATEWAY_URL = "wss://gateway.discord.gg/?v=10&encoding=json"
|
|
30
|
+
|
|
31
|
+
# Discord Gateway opcodes
|
|
32
|
+
DISPATCH = 0
|
|
33
|
+
HEARTBEAT = 1
|
|
34
|
+
IDENTIFY = 2
|
|
35
|
+
PRESENCE_UPDATE = 3
|
|
36
|
+
VOICE_STATE_UPDATE = 4
|
|
37
|
+
RESUME = 6
|
|
38
|
+
RECONNECT = 7
|
|
39
|
+
REQUEST_GUILD_MEMBERS = 8
|
|
40
|
+
INVALID_SESSION = 9
|
|
41
|
+
HELLO = 10
|
|
42
|
+
HEARTBEAT_ACK = 11
|
|
43
|
+
|
|
44
|
+
attr_reader :session_id, :seq
|
|
45
|
+
|
|
46
|
+
def initialize(token:, intents:, gateway_url: GATEWAY_URL)
|
|
47
|
+
@token = token
|
|
48
|
+
@intents = intents
|
|
49
|
+
@gateway_url = gateway_url
|
|
50
|
+
@handlers = Hash.new { |h, k| h[k] = [] }
|
|
51
|
+
@seq = nil
|
|
52
|
+
@session_id = nil
|
|
53
|
+
@resume_url = nil
|
|
54
|
+
@heartbeat_interval = nil
|
|
55
|
+
@heartbeat_acked = true
|
|
56
|
+
@connection = nil
|
|
57
|
+
@running = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Register a handler for a Discord event type.
|
|
61
|
+
#
|
|
62
|
+
# gateway.on("MESSAGE_CREATE") { |data| ... }
|
|
63
|
+
# gateway.on("READY") { |data| ... }
|
|
64
|
+
#
|
|
65
|
+
def on(event_type, &block)
|
|
66
|
+
@handlers[event_type] << block
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Connect to the gateway and run the event loop.
|
|
70
|
+
# Blocks until the connection is closed or an unrecoverable error occurs.
|
|
71
|
+
# Must be called inside an Async reactor.
|
|
72
|
+
def run
|
|
73
|
+
@running = true
|
|
74
|
+
|
|
75
|
+
while @running
|
|
76
|
+
begin
|
|
77
|
+
connect_and_run
|
|
78
|
+
rescue => e
|
|
79
|
+
Console.error(self) { "Gateway error: #{e.class}: #{e.message}" }
|
|
80
|
+
break unless @running
|
|
81
|
+
sleep(5)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Gracefully stop the gateway.
|
|
87
|
+
def stop
|
|
88
|
+
@running = false
|
|
89
|
+
@connection&.close
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Send a raw payload to the gateway.
|
|
93
|
+
def send_payload(op:, d: nil)
|
|
94
|
+
return unless @connection
|
|
95
|
+
|
|
96
|
+
payload = {op: op, d: d}
|
|
97
|
+
@connection.write(payload.to_json)
|
|
98
|
+
@connection.flush
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def connect_and_run
|
|
104
|
+
url = @resume_url || @gateway_url
|
|
105
|
+
endpoint = Async::HTTP::Endpoint.parse(url)
|
|
106
|
+
|
|
107
|
+
Console.info(self) { "Connecting to #{url}" }
|
|
108
|
+
|
|
109
|
+
Async::WebSocket::Client.connect(endpoint) do |connection|
|
|
110
|
+
@connection = connection
|
|
111
|
+
|
|
112
|
+
# First message should be HELLO
|
|
113
|
+
hello = read_message
|
|
114
|
+
unless hello && hello["op"] == HELLO
|
|
115
|
+
raise GatewayError.new("GATEWAY", "Expected HELLO, got: #{hello&.dig("op")}")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
@heartbeat_interval = hello["d"]["heartbeat_interval"] / 1000.0
|
|
119
|
+
Console.info(self) { "Heartbeat interval: #{@heartbeat_interval}s" }
|
|
120
|
+
|
|
121
|
+
# Start heartbeat fiber
|
|
122
|
+
heartbeat_task = Async do
|
|
123
|
+
heartbeat_loop
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Identify or resume
|
|
127
|
+
if @session_id && @seq
|
|
128
|
+
send_resume
|
|
129
|
+
else
|
|
130
|
+
send_identify
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Read events until disconnected
|
|
134
|
+
read_loop
|
|
135
|
+
|
|
136
|
+
ensure
|
|
137
|
+
heartbeat_task&.stop
|
|
138
|
+
@connection = nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def read_message
|
|
143
|
+
message = @connection.read
|
|
144
|
+
return nil unless message
|
|
145
|
+
|
|
146
|
+
JSON.parse(message.to_str)
|
|
147
|
+
rescue => e
|
|
148
|
+
Console.error(self) { "Failed to read/parse gateway message: #{e.message}" }
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def read_loop
|
|
153
|
+
loop do
|
|
154
|
+
payload = read_message
|
|
155
|
+
break unless payload
|
|
156
|
+
|
|
157
|
+
handle_payload(payload)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def handle_payload(payload)
|
|
162
|
+
op = payload["op"]
|
|
163
|
+
data = payload["d"]
|
|
164
|
+
seq = payload["s"]
|
|
165
|
+
type = payload["t"]
|
|
166
|
+
|
|
167
|
+
@seq = seq if seq
|
|
168
|
+
|
|
169
|
+
case op
|
|
170
|
+
when DISPATCH
|
|
171
|
+
Console.debug(self) { "DISPATCH: #{type}" }
|
|
172
|
+
dispatch(type, data)
|
|
173
|
+
|
|
174
|
+
when HEARTBEAT
|
|
175
|
+
# Server is asking us to heartbeat immediately
|
|
176
|
+
send_heartbeat
|
|
177
|
+
|
|
178
|
+
when RECONNECT
|
|
179
|
+
Console.info(self) { "Server requested reconnect" }
|
|
180
|
+
@connection&.close
|
|
181
|
+
|
|
182
|
+
when INVALID_SESSION
|
|
183
|
+
resumable = data == true
|
|
184
|
+
Console.warn(self) { "Invalid session (resumable=#{resumable})" }
|
|
185
|
+
unless resumable
|
|
186
|
+
@session_id = nil
|
|
187
|
+
@seq = nil
|
|
188
|
+
end
|
|
189
|
+
sleep(rand(1.0..5.0))
|
|
190
|
+
@connection&.close
|
|
191
|
+
|
|
192
|
+
when HEARTBEAT_ACK
|
|
193
|
+
@heartbeat_acked = true
|
|
194
|
+
|
|
195
|
+
else
|
|
196
|
+
Console.debug(self) { "Unknown opcode: #{op}" }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def dispatch(event_type, data)
|
|
201
|
+
case event_type
|
|
202
|
+
when "READY"
|
|
203
|
+
@session_id = data["session_id"]
|
|
204
|
+
@resume_url = data["resume_gateway_url"]
|
|
205
|
+
Console.info(self) { "Ready: session=#{@session_id}" }
|
|
206
|
+
when "RESUMED"
|
|
207
|
+
Console.info(self) { "Resumed successfully" }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
@handlers[event_type].each do |handler|
|
|
211
|
+
begin
|
|
212
|
+
handler.call(data)
|
|
213
|
+
rescue => e
|
|
214
|
+
Console.error(self) { "Handler for #{event_type} raised: #{e.class}: #{e.message}" }
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def send_identify
|
|
220
|
+
send_payload(
|
|
221
|
+
op: IDENTIFY,
|
|
222
|
+
d: {
|
|
223
|
+
token: @token,
|
|
224
|
+
intents: @intents,
|
|
225
|
+
properties: {
|
|
226
|
+
os: RUBY_PLATFORM,
|
|
227
|
+
browser: "async-discord",
|
|
228
|
+
device: "async-discord"
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def send_resume
|
|
235
|
+
Console.info(self) { "Resuming session #{@session_id} at seq #{@seq}" }
|
|
236
|
+
send_payload(
|
|
237
|
+
op: RESUME,
|
|
238
|
+
d: {
|
|
239
|
+
token: @token,
|
|
240
|
+
session_id: @session_id,
|
|
241
|
+
seq: @seq
|
|
242
|
+
}
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def send_heartbeat
|
|
247
|
+
send_payload(op: HEARTBEAT, d: @seq)
|
|
248
|
+
@heartbeat_acked = false
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def heartbeat_loop
|
|
252
|
+
# Jitter the first heartbeat as per Discord docs
|
|
253
|
+
sleep(@heartbeat_interval * rand)
|
|
254
|
+
send_heartbeat
|
|
255
|
+
|
|
256
|
+
loop do
|
|
257
|
+
sleep(@heartbeat_interval)
|
|
258
|
+
|
|
259
|
+
unless @heartbeat_acked
|
|
260
|
+
Console.warn(self) { "Heartbeat not ACKed, reconnecting" }
|
|
261
|
+
@connection&.close
|
|
262
|
+
break
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
send_heartbeat
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
test do
|
|
273
|
+
describe "Async::Discord::Gateway" do
|
|
274
|
+
it "initializes with token and intents" do
|
|
275
|
+
gw = Async::Discord::Gateway.new(token: "test_token", intents: 0x30001)
|
|
276
|
+
gw.session_id.should.be.nil
|
|
277
|
+
gw.seq.should.be.nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it "registers event handlers" do
|
|
281
|
+
gw = Async::Discord::Gateway.new(token: "test_token", intents: 0)
|
|
282
|
+
received = []
|
|
283
|
+
gw.on("MESSAGE_CREATE") { |data| received << data }
|
|
284
|
+
gw.on("MESSAGE_CREATE") { |data| received << :second }
|
|
285
|
+
|
|
286
|
+
# Dispatch manually via send to test handler registration
|
|
287
|
+
gw.send(:dispatch, "MESSAGE_CREATE", {"content" => "hello"})
|
|
288
|
+
received.length.should == 2
|
|
289
|
+
received[0]["content"].should == "hello"
|
|
290
|
+
received[1].should == :second
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
it "handles READY event and stores session_id" do
|
|
294
|
+
gw = Async::Discord::Gateway.new(token: "test_token", intents: 0)
|
|
295
|
+
ready_data = {
|
|
296
|
+
"session_id" => "abc123",
|
|
297
|
+
"resume_gateway_url" => "wss://resume.discord.gg",
|
|
298
|
+
"user" => {"username" => "testbot"}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
gw.send(:dispatch, "READY", ready_data)
|
|
302
|
+
gw.session_id.should == "abc123"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it "updates seq from dispatch payloads" do
|
|
306
|
+
gw = Async::Discord::Gateway.new(token: "test_token", intents: 0)
|
|
307
|
+
gw.send(:handle_payload, {
|
|
308
|
+
"op" => 0,
|
|
309
|
+
"d" => {"content" => "hi"},
|
|
310
|
+
"s" => 42,
|
|
311
|
+
"t" => "MESSAGE_CREATE"
|
|
312
|
+
})
|
|
313
|
+
gw.seq.should == 42
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
it "clears session on non-resumable INVALID_SESSION" do
|
|
317
|
+
gw = Async::Discord::Gateway.new(token: "test_token", intents: 0)
|
|
318
|
+
# Set a session first
|
|
319
|
+
gw.send(:dispatch, "READY", {
|
|
320
|
+
"session_id" => "sess123",
|
|
321
|
+
"resume_gateway_url" => "wss://resume.discord.gg"
|
|
322
|
+
})
|
|
323
|
+
gw.session_id.should == "sess123"
|
|
324
|
+
|
|
325
|
+
# Simulate non-resumable invalid session (stub connection close)
|
|
326
|
+
gw.instance_variable_set(:@connection, nil)
|
|
327
|
+
gw.send(:handle_payload, {
|
|
328
|
+
"op" => Async::Discord::Gateway::INVALID_SESSION,
|
|
329
|
+
"d" => false,
|
|
330
|
+
"s" => nil,
|
|
331
|
+
"t" => nil
|
|
332
|
+
})
|
|
333
|
+
gw.session_id.should.be.nil
|
|
334
|
+
gw.seq.should.be.nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it "continues dispatching when a handler raises" do
|
|
338
|
+
gw = Async::Discord::Gateway.new(token: "test_token", intents: 0)
|
|
339
|
+
results = []
|
|
340
|
+
gw.on("TEST") { |_| raise "boom" }
|
|
341
|
+
gw.on("TEST") { |data| results << data }
|
|
342
|
+
|
|
343
|
+
gw.send(:dispatch, "TEST", {"value" => 1})
|
|
344
|
+
results.length.should == 1
|
|
345
|
+
results.first["value"].should == 1
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it "responds to stop" do
|
|
349
|
+
gw = Async::Discord::Gateway.new(token: "test_token", intents: 0)
|
|
350
|
+
gw.should.respond_to :stop
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it "accepts custom gateway URL" do
|
|
354
|
+
gw = Async::Discord::Gateway.new(
|
|
355
|
+
token: "tok",
|
|
356
|
+
intents: 0,
|
|
357
|
+
gateway_url: "wss://custom.gateway.example.com"
|
|
358
|
+
)
|
|
359
|
+
gw.instance_variable_get(:@gateway_url).should == "wss://custom.gateway.example.com"
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
require "async/http"
|
|
7
|
+
require "scampi"
|
|
8
|
+
|
|
9
|
+
module Async
|
|
10
|
+
module Discord
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
Dir.glob("#{__dir__}/discord/**/*.rb").sort.each do |path|
|
|
15
|
+
require path
|
|
16
|
+
end
|
|
@@ -75,6 +75,13 @@ module Async
|
|
|
75
75
|
execute("DELETE", query: kwargs, max_retries: max_retries)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
def patch(body = nil, **kwargs)
|
|
79
|
+
content_type = kwargs.delete(:content_type)
|
|
80
|
+
max_retries = kwargs.delete(:max_retries)
|
|
81
|
+
query = _extract_query_params(kwargs)
|
|
82
|
+
execute("PATCH", body: body || kwargs, content_type: content_type, max_retries: max_retries, query: query)
|
|
83
|
+
end
|
|
84
|
+
|
|
78
85
|
# ── Chain inspection ───────────────────────────────────────
|
|
79
86
|
|
|
80
87
|
def to_s
|
|
@@ -153,6 +160,8 @@ module Async
|
|
|
153
160
|
@client.put(path, body || {}, **retry_opts)
|
|
154
161
|
when "DELETE"
|
|
155
162
|
@client.request("DELETE", path, nil, **retry_opts)
|
|
163
|
+
when "PATCH"
|
|
164
|
+
@client.request("PATCH", path, body || {}, **retry_opts)
|
|
156
165
|
end
|
|
157
166
|
end
|
|
158
167
|
end
|
|
@@ -32,15 +32,12 @@ module Async
|
|
|
32
32
|
@membership = data["membership"]
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
35
|
+
def to_h = @data
|
|
36
|
+
def to_s = @body.to_s
|
|
37
|
+
def to_str = to_s
|
|
39
38
|
|
|
40
|
-
#
|
|
41
|
-
def
|
|
42
|
-
@data
|
|
43
|
-
end
|
|
39
|
+
# Direct hash access for any content field.
|
|
40
|
+
def [](key) = @data[key.to_s]
|
|
44
41
|
|
|
45
42
|
# Dynamic access to any content field present in the raw data.
|
|
46
43
|
def method_missing(name, *args)
|
|
@@ -86,15 +83,11 @@ module Async
|
|
|
86
83
|
end
|
|
87
84
|
|
|
88
85
|
# The JSONSchemer::Schema for this event's type, or nil if unknown.
|
|
89
|
-
def schema
|
|
90
|
-
Schema[@type]
|
|
91
|
-
end
|
|
86
|
+
def schema = Schema[@type]
|
|
92
87
|
|
|
93
88
|
# Validate this event against its schema.
|
|
94
89
|
# Returns true if valid or if no schema exists (lenient).
|
|
95
|
-
def valid?
|
|
96
|
-
Schema.valid?(@raw)
|
|
97
|
-
end
|
|
90
|
+
def valid? = Schema.valid?(@raw)
|
|
98
91
|
|
|
99
92
|
# Validate this event against its schema.
|
|
100
93
|
# Raises Schema::ValidationError with detailed errors on failure.
|
|
@@ -113,14 +106,10 @@ module Async
|
|
|
113
106
|
|
|
114
107
|
# Content property names defined by the schema for this event type.
|
|
115
108
|
# @return [Array<String>]
|
|
116
|
-
def content_properties
|
|
117
|
-
Schema.content_properties(@type)
|
|
118
|
-
end
|
|
109
|
+
def content_properties = Schema.content_properties(@type)
|
|
119
110
|
|
|
120
111
|
# Is this a state event? (has a state_key)
|
|
121
|
-
def state_event?
|
|
122
|
-
!@state_key.nil?
|
|
123
|
-
end
|
|
112
|
+
def state_event? = !@state_key.nil?
|
|
124
113
|
end
|
|
125
114
|
end
|
|
126
115
|
end
|
|
@@ -119,8 +119,8 @@ module Async
|
|
|
119
119
|
parse_json(request).then do |body|
|
|
120
120
|
if body
|
|
121
121
|
Console.info(self) {
|
|
122
|
-
|
|
123
|
-
"Transaction #{txn_id}: #{
|
|
122
|
+
events = body["events"] || []
|
|
123
|
+
"Transaction #{txn_id}: #{events.size} event(s) — #{JSON.generate(events)}"
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
@dispatcher.dispatch_transaction(body)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Released under the Apache License, Version 2.0.
|
|
4
|
+
# Copyright, 2026, by General Intelligence Systems.
|
|
5
|
+
|
|
6
|
+
require "bundler/setup"
|
|
7
|
+
require "sequel"
|
|
8
|
+
|
|
9
|
+
module Async
|
|
10
|
+
module Matrix
|
|
11
|
+
module Bridge
|
|
12
|
+
module Discord
|
|
13
|
+
module DB
|
|
14
|
+
# Establishes a Sequel database connection from the bridge config's
|
|
15
|
+
# database section.
|
|
16
|
+
#
|
|
17
|
+
# Supports both SQLite (with foreign keys and WAL mode) and PostgreSQL,
|
|
18
|
+
# matching the mautrix bridgev2 database config schema.
|
|
19
|
+
#
|
|
20
|
+
# db = Connection.establish(config.database)
|
|
21
|
+
# db[:users].all # => [...]
|
|
22
|
+
#
|
|
23
|
+
module Connection
|
|
24
|
+
SQLITE_TYPE = "sqlite3-fk-wal"
|
|
25
|
+
POSTGRES_TYPE = "postgres"
|
|
26
|
+
|
|
27
|
+
# Establish a database connection from a config.database section.
|
|
28
|
+
#
|
|
29
|
+
# @param database_config [Hash, Vivify] config.database with :type, :uri, :max_open_conns
|
|
30
|
+
# @return [Sequel::Database]
|
|
31
|
+
def self.establish(database_config)
|
|
32
|
+
type = database_config.type || SQLITE_TYPE
|
|
33
|
+
uri = database_config.uri || "sqlite://bridge.db"
|
|
34
|
+
|
|
35
|
+
db = case type
|
|
36
|
+
when SQLITE_TYPE
|
|
37
|
+
connect_sqlite(uri, database_config)
|
|
38
|
+
when POSTGRES_TYPE
|
|
39
|
+
connect_postgres(uri, database_config)
|
|
40
|
+
else
|
|
41
|
+
raise ArgumentError, "Unknown database type: #{type.inspect}. Expected #{SQLITE_TYPE.inspect} or #{POSTGRES_TYPE.inspect}."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
db
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
class << self
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def connect_sqlite(uri, config)
|
|
51
|
+
# Normalize mautrix-style URIs: "file:bridge.db?..." -> "sqlite://bridge.db"
|
|
52
|
+
sequel_uri = if uri.start_with?("file:")
|
|
53
|
+
path = uri.sub(%r{\Afile:}, "").split("?").first
|
|
54
|
+
"sqlite://#{path}"
|
|
55
|
+
elsif uri.start_with?("sqlite://")
|
|
56
|
+
uri
|
|
57
|
+
else
|
|
58
|
+
"sqlite://#{uri}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
db = Sequel.connect(sequel_uri, max_connections: max_conns(config))
|
|
62
|
+
db.run("PRAGMA foreign_keys = ON")
|
|
63
|
+
db.run("PRAGMA journal_mode = WAL")
|
|
64
|
+
db
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def connect_postgres(uri, config)
|
|
68
|
+
Sequel.connect(uri, max_connections: max_conns(config))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def max_conns(config)
|
|
72
|
+
val = config.respond_to?(:max_open_conns) ? config.max_open_conns : nil
|
|
73
|
+
val && val > 0 ? val : 5
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
test do
|
|
84
|
+
describe "Async::Matrix::Bridge::Discord::DB::Connection" do
|
|
85
|
+
it "connects to an in-memory SQLite database" do
|
|
86
|
+
config = Object.new
|
|
87
|
+
config.define_singleton_method(:type) { "sqlite3-fk-wal" }
|
|
88
|
+
config.define_singleton_method(:uri) { "sqlite:/" }
|
|
89
|
+
config.define_singleton_method(:max_open_conns) { 1 }
|
|
90
|
+
|
|
91
|
+
db = Async::Matrix::Bridge::Discord::DB::Connection.establish(config)
|
|
92
|
+
db.should.be.kind_of Sequel::Database
|
|
93
|
+
db.database_type.should == :sqlite
|
|
94
|
+
db.disconnect
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "enables foreign keys and WAL on SQLite" do
|
|
98
|
+
config = Object.new
|
|
99
|
+
config.define_singleton_method(:type) { "sqlite3-fk-wal" }
|
|
100
|
+
config.define_singleton_method(:uri) { "sqlite:/" }
|
|
101
|
+
config.define_singleton_method(:max_open_conns) { 1 }
|
|
102
|
+
|
|
103
|
+
db = Async::Matrix::Bridge::Discord::DB::Connection.establish(config)
|
|
104
|
+
fk = db["PRAGMA foreign_keys"].first
|
|
105
|
+
fk[:foreign_keys].should == 1
|
|
106
|
+
db.disconnect
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "normalizes mautrix file: URIs to sequel sqlite:// URIs" do
|
|
110
|
+
config = Object.new
|
|
111
|
+
config.define_singleton_method(:type) { "sqlite3-fk-wal" }
|
|
112
|
+
config.define_singleton_method(:uri) { "file:test.db?_txlock=immediate" }
|
|
113
|
+
config.define_singleton_method(:max_open_conns) { 1 }
|
|
114
|
+
|
|
115
|
+
# Should not raise — the URI is normalized
|
|
116
|
+
db = Async::Matrix::Bridge::Discord::DB::Connection.establish(config)
|
|
117
|
+
db.should.be.kind_of Sequel::Database
|
|
118
|
+
db.disconnect
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it "defaults to sqlite with 5 connections when config is empty" do
|
|
122
|
+
config = Object.new
|
|
123
|
+
config.define_singleton_method(:type) { nil }
|
|
124
|
+
config.define_singleton_method(:uri) { nil }
|
|
125
|
+
config.define_singleton_method(:max_open_conns) { nil }
|
|
126
|
+
|
|
127
|
+
db = Async::Matrix::Bridge::Discord::DB::Connection.establish(config)
|
|
128
|
+
db.should.be.kind_of Sequel::Database
|
|
129
|
+
db.disconnect
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "raises on unknown database type" do
|
|
133
|
+
config = Object.new
|
|
134
|
+
config.define_singleton_method(:type) { "mysql" }
|
|
135
|
+
config.define_singleton_method(:uri) { "mysql://localhost/db" }
|
|
136
|
+
config.define_singleton_method(:max_open_conns) { 1 }
|
|
137
|
+
|
|
138
|
+
lambda {
|
|
139
|
+
Async::Matrix::Bridge::Discord::DB::Connection.establish(config)
|
|
140
|
+
}.should.raise(ArgumentError)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|