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,130 @@
|
|
|
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/matrix"
|
|
8
|
+
|
|
9
|
+
module Async
|
|
10
|
+
module Matrix
|
|
11
|
+
module Bridge
|
|
12
|
+
module Discord
|
|
13
|
+
module DB
|
|
14
|
+
# Maps a Discord user to a Matrix ghost user.
|
|
15
|
+
#
|
|
16
|
+
# The ghost's Matrix ID is derived from the username template in the
|
|
17
|
+
# bridge config (e.g., @discord_123456:example.com). This model
|
|
18
|
+
# tracks the Discord user's profile info for syncing to Matrix.
|
|
19
|
+
#
|
|
20
|
+
# puppet = Puppet.create(discord_id: "123", username: "alice", name: "Alice")
|
|
21
|
+
# puppet.is_bot? # => false
|
|
22
|
+
# puppet.double_puppet? # => true (if custom_mxid is set)
|
|
23
|
+
#
|
|
24
|
+
class Puppet < Sequel::Model
|
|
25
|
+
unrestrict_primary_key
|
|
26
|
+
|
|
27
|
+
def validate
|
|
28
|
+
super
|
|
29
|
+
errors.add(:discord_id, "cannot be empty") if discord_id.nil? || discord_id.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Is this puppet configured for double puppeting?
|
|
33
|
+
def double_puppet?
|
|
34
|
+
!custom_mxid.nil? && !custom_mxid.empty?
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
test do
|
|
44
|
+
describe "Async::Matrix::Bridge::Discord::DB::Puppet" do
|
|
45
|
+
def setup_db
|
|
46
|
+
db = Sequel.sqlite
|
|
47
|
+
Sequel::Migrator.run(db, File.join(__dir__, "migrations"))
|
|
48
|
+
db
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "creates and retrieves a puppet" do
|
|
52
|
+
db = setup_db
|
|
53
|
+
Async::Matrix::Bridge::Discord::DB::Puppet.dataset = db[:puppets]
|
|
54
|
+
|
|
55
|
+
puppet = Async::Matrix::Bridge::Discord::DB::Puppet.create(
|
|
56
|
+
discord_id: "123",
|
|
57
|
+
name: "Alice",
|
|
58
|
+
username: "alice",
|
|
59
|
+
global_name: "Alice Wonderland"
|
|
60
|
+
)
|
|
61
|
+
puppet.discord_id.should == "123"
|
|
62
|
+
puppet.name.should == "Alice"
|
|
63
|
+
puppet.username.should == "alice"
|
|
64
|
+
puppet.global_name.should == "Alice Wonderland"
|
|
65
|
+
db.disconnect
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "defaults boolean flags to false" do
|
|
69
|
+
db = setup_db
|
|
70
|
+
Async::Matrix::Bridge::Discord::DB::Puppet.dataset = db[:puppets]
|
|
71
|
+
|
|
72
|
+
puppet = Async::Matrix::Bridge::Discord::DB::Puppet.create(discord_id: "200")
|
|
73
|
+
puppet.is_bot.should == false
|
|
74
|
+
puppet.is_webhook.should == false
|
|
75
|
+
puppet.contact_info_set.should == false
|
|
76
|
+
db.disconnect
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "tracks bot and webhook puppets" do
|
|
80
|
+
db = setup_db
|
|
81
|
+
Async::Matrix::Bridge::Discord::DB::Puppet.dataset = db[:puppets]
|
|
82
|
+
|
|
83
|
+
bot = Async::Matrix::Bridge::Discord::DB::Puppet.create(discord_id: "300", is_bot: true)
|
|
84
|
+
bot.is_bot.should == true
|
|
85
|
+
|
|
86
|
+
webhook = Async::Matrix::Bridge::Discord::DB::Puppet.create(discord_id: "301", is_webhook: true)
|
|
87
|
+
webhook.is_webhook.should == true
|
|
88
|
+
db.disconnect
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "detects double puppet configuration" do
|
|
92
|
+
db = setup_db
|
|
93
|
+
Async::Matrix::Bridge::Discord::DB::Puppet.dataset = db[:puppets]
|
|
94
|
+
|
|
95
|
+
regular = Async::Matrix::Bridge::Discord::DB::Puppet.create(discord_id: "400")
|
|
96
|
+
regular.double_puppet?.should == false
|
|
97
|
+
|
|
98
|
+
double = Async::Matrix::Bridge::Discord::DB::Puppet.create(
|
|
99
|
+
discord_id: "401",
|
|
100
|
+
custom_mxid: "@alice:example.com",
|
|
101
|
+
access_token: "syt_token"
|
|
102
|
+
)
|
|
103
|
+
double.double_puppet?.should == true
|
|
104
|
+
double.access_token.should == "syt_token"
|
|
105
|
+
db.disconnect
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "validates discord_id is present" do
|
|
109
|
+
db = setup_db
|
|
110
|
+
Async::Matrix::Bridge::Discord::DB::Puppet.dataset = db[:puppets]
|
|
111
|
+
|
|
112
|
+
puppet = Async::Matrix::Bridge::Discord::DB::Puppet.new(discord_id: "")
|
|
113
|
+
puppet.valid?.should == false
|
|
114
|
+
puppet.errors[:discord_id].should.not.be.empty
|
|
115
|
+
db.disconnect
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it "updates avatar info" do
|
|
119
|
+
db = setup_db
|
|
120
|
+
Async::Matrix::Bridge::Discord::DB::Puppet.dataset = db[:puppets]
|
|
121
|
+
|
|
122
|
+
puppet = Async::Matrix::Bridge::Discord::DB::Puppet.create(discord_id: "500")
|
|
123
|
+
puppet.update(avatar_hash: "abc123", avatar_url: "mxc://example.com/abc")
|
|
124
|
+
puppet.refresh
|
|
125
|
+
puppet.avatar_hash.should == "abc123"
|
|
126
|
+
puppet.avatar_url.should == "mxc://example.com/abc"
|
|
127
|
+
db.disconnect
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
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/matrix"
|
|
8
|
+
|
|
9
|
+
module Async
|
|
10
|
+
module Matrix
|
|
11
|
+
module Bridge
|
|
12
|
+
module Discord
|
|
13
|
+
module DB
|
|
14
|
+
# Maps a Discord reaction to a Matrix reaction event.
|
|
15
|
+
#
|
|
16
|
+
# Uniquely identified by (discord_message_id, discord_sender,
|
|
17
|
+
# discord_emoji_name) — one reaction per emoji per user per message.
|
|
18
|
+
#
|
|
19
|
+
# reaction = Reaction.create(
|
|
20
|
+
# discord_message_id: "msg1", discord_sender: "user1",
|
|
21
|
+
# discord_emoji_name: "\u{1f44d}", mxid: "$react1",
|
|
22
|
+
# discord_channel_id: "ch1"
|
|
23
|
+
# )
|
|
24
|
+
#
|
|
25
|
+
class Reaction < Sequel::Model
|
|
26
|
+
def validate
|
|
27
|
+
super
|
|
28
|
+
errors.add(:discord_message_id, "cannot be empty") if discord_message_id.nil? || discord_message_id.empty?
|
|
29
|
+
errors.add(:discord_sender, "cannot be empty") if discord_sender.nil? || discord_sender.empty?
|
|
30
|
+
errors.add(:discord_emoji_name, "cannot be empty") if discord_emoji_name.nil? || discord_emoji_name.empty?
|
|
31
|
+
errors.add(:mxid, "cannot be empty") if mxid.nil? || mxid.empty?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Find all reactions on a Discord message.
|
|
35
|
+
def self.by_discord_message(message_id, channel_id, receiver = "")
|
|
36
|
+
where(
|
|
37
|
+
discord_message_id: message_id,
|
|
38
|
+
discord_channel_id: channel_id,
|
|
39
|
+
discord_channel_receiver: receiver
|
|
40
|
+
).all
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Find a specific reaction by its Matrix event ID.
|
|
44
|
+
def self.by_mxid(mxid)
|
|
45
|
+
first(mxid: mxid)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Find a specific reaction by Discord compound key.
|
|
49
|
+
def self.by_discord_key(message_id:, sender:, emoji_name:)
|
|
50
|
+
first(
|
|
51
|
+
discord_message_id: message_id,
|
|
52
|
+
discord_sender: sender,
|
|
53
|
+
discord_emoji_name: emoji_name
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
test do
|
|
64
|
+
describe "Async::Matrix::Bridge::Discord::DB::Reaction" do
|
|
65
|
+
def setup_db
|
|
66
|
+
db = Sequel.sqlite
|
|
67
|
+
Sequel::Migrator.run(db, File.join(__dir__, "migrations"))
|
|
68
|
+
db
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it "creates and retrieves a reaction" do
|
|
72
|
+
db = setup_db
|
|
73
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.dataset = db[:reactions]
|
|
74
|
+
|
|
75
|
+
reaction = Async::Matrix::Bridge::Discord::DB::Reaction.create(
|
|
76
|
+
discord_message_id: "msg1",
|
|
77
|
+
discord_sender: "user1",
|
|
78
|
+
discord_emoji_name: "\u{1f44d}",
|
|
79
|
+
mxid: "$react1",
|
|
80
|
+
discord_channel_id: "ch1"
|
|
81
|
+
)
|
|
82
|
+
reaction.discord_emoji_name.should == "\u{1f44d}"
|
|
83
|
+
reaction.mxid.should == "$react1"
|
|
84
|
+
db.disconnect
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it "finds reactions by discord message" do
|
|
88
|
+
db = setup_db
|
|
89
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.dataset = db[:reactions]
|
|
90
|
+
|
|
91
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.create(
|
|
92
|
+
discord_message_id: "msg2", discord_sender: "u1",
|
|
93
|
+
discord_emoji_name: "\u{1f44d}", mxid: "$r1", discord_channel_id: "ch1"
|
|
94
|
+
)
|
|
95
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.create(
|
|
96
|
+
discord_message_id: "msg2", discord_sender: "u2",
|
|
97
|
+
discord_emoji_name: "\u{2764}", mxid: "$r2", discord_channel_id: "ch1"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
reactions = Async::Matrix::Bridge::Discord::DB::Reaction.by_discord_message("msg2", "ch1")
|
|
101
|
+
reactions.length.should == 2
|
|
102
|
+
db.disconnect
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it "finds by Discord compound key" do
|
|
106
|
+
db = setup_db
|
|
107
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.dataset = db[:reactions]
|
|
108
|
+
|
|
109
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.create(
|
|
110
|
+
discord_message_id: "msg3", discord_sender: "u1",
|
|
111
|
+
discord_emoji_name: "custom_emoji", mxid: "$r3", discord_channel_id: "ch1"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
found = Async::Matrix::Bridge::Discord::DB::Reaction.by_discord_key(
|
|
115
|
+
message_id: "msg3", sender: "u1", emoji_name: "custom_emoji"
|
|
116
|
+
)
|
|
117
|
+
found.should.not.be.nil
|
|
118
|
+
found.mxid.should == "$r3"
|
|
119
|
+
db.disconnect
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "finds by Matrix event ID" do
|
|
123
|
+
db = setup_db
|
|
124
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.dataset = db[:reactions]
|
|
125
|
+
|
|
126
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.create(
|
|
127
|
+
discord_message_id: "msg4", discord_sender: "u1",
|
|
128
|
+
discord_emoji_name: "x", mxid: "$find_react", discord_channel_id: "ch1"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
found = Async::Matrix::Bridge::Discord::DB::Reaction.by_mxid("$find_react")
|
|
132
|
+
found.discord_message_id.should == "msg4"
|
|
133
|
+
db.disconnect
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it "enforces unique reaction per user per emoji per message" do
|
|
137
|
+
db = setup_db
|
|
138
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.dataset = db[:reactions]
|
|
139
|
+
|
|
140
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.create(
|
|
141
|
+
discord_message_id: "msg5", discord_sender: "u1",
|
|
142
|
+
discord_emoji_name: "x", mxid: "$r5", discord_channel_id: "ch1"
|
|
143
|
+
)
|
|
144
|
+
lambda {
|
|
145
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.create(
|
|
146
|
+
discord_message_id: "msg5", discord_sender: "u1",
|
|
147
|
+
discord_emoji_name: "x", mxid: "$r6", discord_channel_id: "ch1"
|
|
148
|
+
)
|
|
149
|
+
}.should.raise(Sequel::UniqueConstraintViolation)
|
|
150
|
+
db.disconnect
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it "validates required fields" do
|
|
154
|
+
db = setup_db
|
|
155
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.dataset = db[:reactions]
|
|
156
|
+
|
|
157
|
+
r = Async::Matrix::Bridge::Discord::DB::Reaction.new(
|
|
158
|
+
discord_message_id: "", discord_sender: "",
|
|
159
|
+
discord_emoji_name: "", mxid: ""
|
|
160
|
+
)
|
|
161
|
+
r.valid?.should == false
|
|
162
|
+
r.errors[:discord_message_id].should.not.be.empty
|
|
163
|
+
r.errors[:mxid].should.not.be.empty
|
|
164
|
+
db.disconnect
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
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/matrix"
|
|
8
|
+
|
|
9
|
+
module Async
|
|
10
|
+
module Matrix
|
|
11
|
+
module Bridge
|
|
12
|
+
module Discord
|
|
13
|
+
module DB
|
|
14
|
+
# Maps a Matrix user to their Discord identity and session state.
|
|
15
|
+
#
|
|
16
|
+
# user = User.create(mxid: "@alice:example.com", discord_id: "123456789")
|
|
17
|
+
# user.discord_id # => "123456789"
|
|
18
|
+
# user.space_room # => "!space:example.com"
|
|
19
|
+
# user.portals # => [Portal, ...]
|
|
20
|
+
#
|
|
21
|
+
class User < Sequel::Model
|
|
22
|
+
unrestrict_primary_key
|
|
23
|
+
|
|
24
|
+
one_to_many :portals,
|
|
25
|
+
class: "Async::Matrix::Bridge::Discord::DB::Portal",
|
|
26
|
+
key: :receiver,
|
|
27
|
+
primary_key: :discord_id
|
|
28
|
+
|
|
29
|
+
def validate
|
|
30
|
+
super
|
|
31
|
+
errors.add(:mxid, "cannot be empty") if mxid.nil? || mxid.empty?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
test do
|
|
41
|
+
describe "Async::Matrix::Bridge::Discord::DB::User" do
|
|
42
|
+
def setup_db
|
|
43
|
+
db = Sequel.sqlite
|
|
44
|
+
Sequel::Migrator.run(db, File.join(__dir__, "migrations"))
|
|
45
|
+
db
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it "creates and retrieves a user by mxid" do
|
|
49
|
+
db = setup_db
|
|
50
|
+
Async::Matrix::Bridge::Discord::DB::User.dataset = db[:users]
|
|
51
|
+
|
|
52
|
+
user = Async::Matrix::Bridge::Discord::DB::User.create(
|
|
53
|
+
mxid: "@alice:example.com",
|
|
54
|
+
discord_id: "123456789"
|
|
55
|
+
)
|
|
56
|
+
user.mxid.should == "@alice:example.com"
|
|
57
|
+
user.discord_id.should == "123456789"
|
|
58
|
+
|
|
59
|
+
found = Async::Matrix::Bridge::Discord::DB::User["@alice:example.com"]
|
|
60
|
+
found.discord_id.should == "123456789"
|
|
61
|
+
db.disconnect
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "stores optional session fields" do
|
|
65
|
+
db = setup_db
|
|
66
|
+
Async::Matrix::Bridge::Discord::DB::User.dataset = db[:users]
|
|
67
|
+
|
|
68
|
+
user = Async::Matrix::Bridge::Discord::DB::User.create(
|
|
69
|
+
mxid: "@bob:example.com",
|
|
70
|
+
discord_token: "secret_token",
|
|
71
|
+
management_room: "!mgmt:example.com",
|
|
72
|
+
space_room: "!space:example.com",
|
|
73
|
+
dm_space_room: "!dm:example.com"
|
|
74
|
+
)
|
|
75
|
+
user.discord_token.should == "secret_token"
|
|
76
|
+
user.management_room.should == "!mgmt:example.com"
|
|
77
|
+
user.space_room.should == "!space:example.com"
|
|
78
|
+
user.dm_space_room.should == "!dm:example.com"
|
|
79
|
+
db.disconnect
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it "enforces unique discord_id" do
|
|
83
|
+
db = setup_db
|
|
84
|
+
Async::Matrix::Bridge::Discord::DB::User.dataset = db[:users]
|
|
85
|
+
|
|
86
|
+
Async::Matrix::Bridge::Discord::DB::User.create(mxid: "@a:x", discord_id: "111")
|
|
87
|
+
lambda {
|
|
88
|
+
Async::Matrix::Bridge::Discord::DB::User.create(mxid: "@b:x", discord_id: "111")
|
|
89
|
+
}.should.raise(Sequel::UniqueConstraintViolation)
|
|
90
|
+
db.disconnect
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it "validates mxid is present" do
|
|
94
|
+
db = setup_db
|
|
95
|
+
Async::Matrix::Bridge::Discord::DB::User.dataset = db[:users]
|
|
96
|
+
|
|
97
|
+
user = Async::Matrix::Bridge::Discord::DB::User.new(mxid: "")
|
|
98
|
+
user.valid?.should == false
|
|
99
|
+
user.errors[:mxid].should.not.be.empty
|
|
100
|
+
db.disconnect
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it "updates discord token" do
|
|
104
|
+
db = setup_db
|
|
105
|
+
Async::Matrix::Bridge::Discord::DB::User.dataset = db[:users]
|
|
106
|
+
|
|
107
|
+
user = Async::Matrix::Bridge::Discord::DB::User.create(mxid: "@c:x")
|
|
108
|
+
user.update(discord_token: "new_token")
|
|
109
|
+
user.refresh
|
|
110
|
+
user.discord_token.should == "new_token"
|
|
111
|
+
db.disconnect
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
require "sequel/extensions/migration"
|
|
9
|
+
require "async/matrix"
|
|
10
|
+
|
|
11
|
+
# Sequel::Model requires a database connection at subclass definition time.
|
|
12
|
+
# Set a placeholder in-memory SQLite so models can be defined at require
|
|
13
|
+
# time. The real database is bound later via DB.connect.
|
|
14
|
+
#
|
|
15
|
+
# require_valid_table = false suppresses the column introspection that
|
|
16
|
+
# Sequel normally does on inherited — critical because the placeholder
|
|
17
|
+
# DB has no tables.
|
|
18
|
+
unless Sequel::Model.instance_variable_get(:@db)
|
|
19
|
+
Sequel::Model.db = Sequel.sqlite
|
|
20
|
+
Sequel::Model.require_valid_table = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module Async
|
|
24
|
+
module Matrix
|
|
25
|
+
module Bridge
|
|
26
|
+
module Discord
|
|
27
|
+
# Database module for the Discord bridge.
|
|
28
|
+
#
|
|
29
|
+
# Establishes a connection using the bridge config's database section,
|
|
30
|
+
# runs Sequel migrations, and wires up all model classes to the database.
|
|
31
|
+
#
|
|
32
|
+
# Supports both SQLite (with foreign keys + WAL) and PostgreSQL.
|
|
33
|
+
#
|
|
34
|
+
# db = Async::Matrix::Bridge::Discord::DB.connect(config)
|
|
35
|
+
# # All models are now ready:
|
|
36
|
+
# DB::User.create(mxid: "@alice:example.com")
|
|
37
|
+
# DB::Portal.where(discord_guild_id: "123").all
|
|
38
|
+
#
|
|
39
|
+
module DB
|
|
40
|
+
MIGRATIONS_PATH = File.join(__dir__, "db", "migrations").freeze
|
|
41
|
+
|
|
42
|
+
# Connect to the database, run migrations, and bind all models.
|
|
43
|
+
#
|
|
44
|
+
# @param config [Async::Matrix::ApplicationService::Config] the bridge config
|
|
45
|
+
# @return [Sequel::Database]
|
|
46
|
+
def self.connect(config)
|
|
47
|
+
db = Connection.establish(config.database)
|
|
48
|
+
migrate!(db)
|
|
49
|
+
bind_models(db)
|
|
50
|
+
db
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Run pending migrations.
|
|
54
|
+
#
|
|
55
|
+
# @param db [Sequel::Database]
|
|
56
|
+
def self.migrate!(db)
|
|
57
|
+
Sequel::Migrator.run(db, MIGRATIONS_PATH)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Bind all model classes to the given database.
|
|
61
|
+
#
|
|
62
|
+
# @param db [Sequel::Database]
|
|
63
|
+
def self.bind_models(db)
|
|
64
|
+
Async::Matrix::Bridge::Discord::DB::User.dataset = db[:users]
|
|
65
|
+
Async::Matrix::Bridge::Discord::DB::Guild.dataset = db[:guilds]
|
|
66
|
+
Async::Matrix::Bridge::Discord::DB::Portal.dataset = db[:portals]
|
|
67
|
+
Async::Matrix::Bridge::Discord::DB::Puppet.dataset = db[:puppets]
|
|
68
|
+
Async::Matrix::Bridge::Discord::DB::Message.dataset = db[:messages]
|
|
69
|
+
Async::Matrix::Bridge::Discord::DB::Reaction.dataset = db[:reactions]
|
|
70
|
+
Async::Matrix::Bridge::Discord::DB::CachedFile.dataset = db[:files]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
test do
|
|
79
|
+
describe "Async::Matrix::Bridge::Discord::DB" do
|
|
80
|
+
it "connects, migrates, and binds models from config" do
|
|
81
|
+
database_config = Object.new
|
|
82
|
+
database_config.define_singleton_method(:type) { "sqlite3-fk-wal" }
|
|
83
|
+
database_config.define_singleton_method(:uri) { "sqlite:/" }
|
|
84
|
+
database_config.define_singleton_method(:max_open_conns) { 1 }
|
|
85
|
+
|
|
86
|
+
config = Object.new
|
|
87
|
+
config.define_singleton_method(:database) { database_config }
|
|
88
|
+
|
|
89
|
+
db = Async::Matrix::Bridge::Discord::DB.connect(config)
|
|
90
|
+
db.should.be.kind_of Sequel::Database
|
|
91
|
+
|
|
92
|
+
# All tables should exist
|
|
93
|
+
db.tables.should.include :users
|
|
94
|
+
db.tables.should.include :guilds
|
|
95
|
+
db.tables.should.include :portals
|
|
96
|
+
db.tables.should.include :puppets
|
|
97
|
+
db.tables.should.include :messages
|
|
98
|
+
db.tables.should.include :reactions
|
|
99
|
+
db.tables.should.include :files
|
|
100
|
+
|
|
101
|
+
# Models should be bound and functional
|
|
102
|
+
db[:users].delete
|
|
103
|
+
db[:guilds].delete
|
|
104
|
+
db[:portals].delete
|
|
105
|
+
db[:puppets].delete
|
|
106
|
+
|
|
107
|
+
Async::Matrix::Bridge::Discord::DB::User.create(mxid: "@db_int:x")
|
|
108
|
+
Async::Matrix::Bridge::Discord::DB::User["@db_int:x"].should.not.be.nil
|
|
109
|
+
|
|
110
|
+
Async::Matrix::Bridge::Discord::DB::Guild.create(discord_id: "g_int")
|
|
111
|
+
Async::Matrix::Bridge::Discord::DB::Guild["g_int"].should.not.be.nil
|
|
112
|
+
|
|
113
|
+
Async::Matrix::Bridge::Discord::DB::Portal.create(discord_id: "p_int", receiver: "")
|
|
114
|
+
Async::Matrix::Bridge::Discord::DB::Portal["p_int", ""].should.not.be.nil
|
|
115
|
+
|
|
116
|
+
Async::Matrix::Bridge::Discord::DB::Puppet.create(discord_id: "d_int")
|
|
117
|
+
Async::Matrix::Bridge::Discord::DB::Puppet["d_int"].should.not.be.nil
|
|
118
|
+
|
|
119
|
+
db.disconnect
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it "exposes MIGRATIONS_PATH" do
|
|
123
|
+
File.directory?(Async::Matrix::Bridge::Discord::DB::MIGRATIONS_PATH).should == true
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "is idempotent — running migrations twice does not raise" do
|
|
127
|
+
database_config = Object.new
|
|
128
|
+
database_config.define_singleton_method(:type) { "sqlite3-fk-wal" }
|
|
129
|
+
database_config.define_singleton_method(:uri) { "sqlite:/" }
|
|
130
|
+
database_config.define_singleton_method(:max_open_conns) { 1 }
|
|
131
|
+
|
|
132
|
+
config = Object.new
|
|
133
|
+
config.define_singleton_method(:database) { database_config }
|
|
134
|
+
|
|
135
|
+
db = Async::Matrix::Bridge::Discord::DB.connect(config)
|
|
136
|
+
lambda { Async::Matrix::Bridge::Discord::DB.migrate!(db) }.should.not.raise
|
|
137
|
+
db.disconnect
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
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/matrix"
|
|
8
|
+
|
|
9
|
+
module Async
|
|
10
|
+
module Matrix
|
|
11
|
+
# A Client subclass that authenticates with a user's double puppet token
|
|
12
|
+
# instead of the appservice's as_token.
|
|
13
|
+
#
|
|
14
|
+
# This allows the bridge to send events as the real Matrix user rather
|
|
15
|
+
# than as the appservice bot or a ghost user.
|
|
16
|
+
#
|
|
17
|
+
# puppet = DoublePuppetClient.new(config, double_puppet_token: "syt_...")
|
|
18
|
+
# puppet.send_text("!room:example.com", "sent as the real user")
|
|
19
|
+
# puppet.whoami # => {"user_id" => "@alice:example.com"}
|
|
20
|
+
#
|
|
21
|
+
class DoublePuppetClient < Client
|
|
22
|
+
def initialize(config, double_puppet_token:, **kwargs)
|
|
23
|
+
super(config, **kwargs)
|
|
24
|
+
@headers[0] = ["authorization", "Bearer #{double_puppet_token}"]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
test do
|
|
31
|
+
describe "Async::Matrix::DoublePuppetClient" do
|
|
32
|
+
def make_config
|
|
33
|
+
Async::Matrix::ApplicationService::Config.new({
|
|
34
|
+
"homeserver" => { "address" => "http://localhost:8008", "domain" => "localhost" },
|
|
35
|
+
"appservice" => { "as_token" => "as_token_value", "hs_token" => "hs_secret", "bot" => { "username" => "bot" } }
|
|
36
|
+
})
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it "uses the double_puppet_token for authorization" do
|
|
40
|
+
puppet = Async::Matrix::DoublePuppetClient.new(make_config, double_puppet_token: "syt_puppet_token")
|
|
41
|
+
auth_header = puppet.instance_variable_get(:@headers).find { |k, _| k == "authorization" }
|
|
42
|
+
auth_header[1].should == "Bearer syt_puppet_token"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it "does not use the as_token from config" do
|
|
46
|
+
puppet = Async::Matrix::DoublePuppetClient.new(make_config, double_puppet_token: "syt_puppet_token")
|
|
47
|
+
auth_header = puppet.instance_variable_get(:@headers).find { |k, _| k == "authorization" }
|
|
48
|
+
auth_header[1].should.not.include "as_token_value"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it "inherits retry defaults from Client" do
|
|
52
|
+
puppet = Async::Matrix::DoublePuppetClient.new(make_config, double_puppet_token: "syt_puppet_token")
|
|
53
|
+
puppet.config.appservice.as_token.should == "as_token_value"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it "accepts custom retry configuration" do
|
|
57
|
+
puppet = Async::Matrix::DoublePuppetClient.new(
|
|
58
|
+
make_config,
|
|
59
|
+
double_puppet_token: "syt_puppet_token",
|
|
60
|
+
max_retries: 5,
|
|
61
|
+
retry_base_delay: 1.0,
|
|
62
|
+
max_retry_delay: 60
|
|
63
|
+
)
|
|
64
|
+
auth_header = puppet.instance_variable_get(:@headers).find { |k, _| k == "authorization" }
|
|
65
|
+
auth_header[1].should == "Bearer syt_puppet_token"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "responds to all Client methods" do
|
|
69
|
+
puppet = Async::Matrix::DoublePuppetClient.new(make_config, double_puppet_token: "syt_puppet_token")
|
|
70
|
+
puppet.should.respond_to :send_text
|
|
71
|
+
puppet.should.respond_to :send_html
|
|
72
|
+
puppet.should.respond_to :send_notice
|
|
73
|
+
puppet.should.respond_to :join_room
|
|
74
|
+
puppet.should.respond_to :leave_room
|
|
75
|
+
puppet.should.respond_to :whoami
|
|
76
|
+
puppet.should.respond_to :api
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it "is a subclass of Client" do
|
|
80
|
+
puppet = Async::Matrix::DoublePuppetClient.new(make_config, double_puppet_token: "syt_puppet_token")
|
|
81
|
+
puppet.should.be.kind_of Async::Matrix::Client
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/async/matrix/schema.rb
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
# Released under the Apache License, Version 2.0.
|
|
4
4
|
# Copyright, 2026, by General Intelligence Systems.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
require "bundler/setup"
|
|
7
|
+
require "async/matrix"
|
|
8
8
|
|
|
9
9
|
module Async
|
|
10
10
|
module Matrix
|
data/lib/async/matrix/server.rb
CHANGED
data/lib/async/matrix/version.rb
CHANGED
data/lib/async/matrix.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
# Released under the Apache License, Version 2.0.
|
|
4
4
|
# Copyright, 2026, by General Intelligence Systems.
|
|
5
5
|
|
|
6
|
+
require "bundler/setup"
|
|
6
7
|
require "async/http"
|
|
7
8
|
require "scampi"
|
|
8
9
|
|
|
@@ -12,6 +13,7 @@ module Async
|
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
Dir.glob("#{__dir__}/matrix/**/*.rb").sort.each do |path|
|
|
16
|
+
next if path.include?("/migrations/")
|
|
15
17
|
require path
|
|
16
18
|
end
|
|
17
19
|
|