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,120 @@
|
|
|
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
|
+
# Caches uploaded media to avoid re-uploading the same Discord
|
|
15
|
+
# attachment to the Matrix homeserver.
|
|
16
|
+
#
|
|
17
|
+
# Uses a composite primary key of (url, encrypted) because the same
|
|
18
|
+
# source URL may be uploaded both encrypted and unencrypted to
|
|
19
|
+
# different portal rooms.
|
|
20
|
+
#
|
|
21
|
+
# cached = CachedFile.create(
|
|
22
|
+
# url: "https://cdn.discordapp.com/...",
|
|
23
|
+
# encrypted: false,
|
|
24
|
+
# mxc: "mxc://example.com/abc"
|
|
25
|
+
# )
|
|
26
|
+
#
|
|
27
|
+
class CachedFile < Sequel::Model
|
|
28
|
+
unrestrict_primary_key
|
|
29
|
+
|
|
30
|
+
def validate
|
|
31
|
+
super
|
|
32
|
+
errors.add(:url, "cannot be empty") if url.nil? || url.empty?
|
|
33
|
+
errors.add(:mxc, "cannot be empty") if mxc.nil? || mxc.empty?
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Look up a cached file by source URL and encryption status.
|
|
37
|
+
def self.by_url(url, encrypted: false)
|
|
38
|
+
first(url: url, encrypted: encrypted)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
test do
|
|
48
|
+
describe "Async::Matrix::Bridge::Discord::DB::CachedFile" do
|
|
49
|
+
def setup_db
|
|
50
|
+
db = Sequel.sqlite
|
|
51
|
+
Sequel::Migrator.run(db, File.join(__dir__, "migrations"))
|
|
52
|
+
db
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it "creates and retrieves a cached file" do
|
|
56
|
+
db = setup_db
|
|
57
|
+
Async::Matrix::Bridge::Discord::DB::CachedFile.dataset = db[:files]
|
|
58
|
+
|
|
59
|
+
cached = Async::Matrix::Bridge::Discord::DB::CachedFile.create(
|
|
60
|
+
url: "https://cdn.discordapp.com/attachments/123/456/image.png",
|
|
61
|
+
mxc: "mxc://example.com/abc",
|
|
62
|
+
mime_type: "image/png",
|
|
63
|
+
size: 102400,
|
|
64
|
+
width: 800,
|
|
65
|
+
height: 600
|
|
66
|
+
)
|
|
67
|
+
cached.url.should.include "discordapp.com"
|
|
68
|
+
cached.mxc.should == "mxc://example.com/abc"
|
|
69
|
+
cached.mime_type.should == "image/png"
|
|
70
|
+
cached.width.should == 800
|
|
71
|
+
cached.height.should == 600
|
|
72
|
+
db.disconnect
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "looks up by URL and encryption status" do
|
|
76
|
+
db = setup_db
|
|
77
|
+
Async::Matrix::Bridge::Discord::DB::CachedFile.dataset = db[:files]
|
|
78
|
+
|
|
79
|
+
Async::Matrix::Bridge::Discord::DB::CachedFile.create(
|
|
80
|
+
url: "https://example.com/file.png", encrypted: false,
|
|
81
|
+
mxc: "mxc://x/plain"
|
|
82
|
+
)
|
|
83
|
+
Async::Matrix::Bridge::Discord::DB::CachedFile.create(
|
|
84
|
+
url: "https://example.com/file.png", encrypted: true,
|
|
85
|
+
mxc: "mxc://x/encrypted",
|
|
86
|
+
decryption_info: '{"key":"abc"}'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
plain = Async::Matrix::Bridge::Discord::DB::CachedFile.by_url("https://example.com/file.png")
|
|
90
|
+
plain.mxc.should == "mxc://x/plain"
|
|
91
|
+
|
|
92
|
+
enc = Async::Matrix::Bridge::Discord::DB::CachedFile.by_url("https://example.com/file.png", encrypted: true)
|
|
93
|
+
enc.mxc.should == "mxc://x/encrypted"
|
|
94
|
+
enc.decryption_info.should == '{"key":"abc"}'
|
|
95
|
+
db.disconnect
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "defaults encrypted to false" do
|
|
99
|
+
db = setup_db
|
|
100
|
+
Async::Matrix::Bridge::Discord::DB::CachedFile.dataset = db[:files]
|
|
101
|
+
|
|
102
|
+
cached = Async::Matrix::Bridge::Discord::DB::CachedFile.create(
|
|
103
|
+
url: "https://example.com/a.jpg", mxc: "mxc://x/y"
|
|
104
|
+
)
|
|
105
|
+
cached.encrypted.should == false
|
|
106
|
+
db.disconnect
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it "validates required fields" do
|
|
110
|
+
db = setup_db
|
|
111
|
+
Async::Matrix::Bridge::Discord::DB::CachedFile.dataset = db[:files]
|
|
112
|
+
|
|
113
|
+
f = Async::Matrix::Bridge::Discord::DB::CachedFile.new(url: "", mxc: "")
|
|
114
|
+
f.valid?.should == false
|
|
115
|
+
f.errors[:url].should.not.be.empty
|
|
116
|
+
f.errors[:mxc].should.not.be.empty
|
|
117
|
+
db.disconnect
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
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 guild (server) to a Matrix Space room.
|
|
15
|
+
#
|
|
16
|
+
# Bridging modes control how aggressively the bridge creates portals:
|
|
17
|
+
# BRIDGE_NOTHING = 0 — never bridge
|
|
18
|
+
# BRIDGE_IF_PORTAL_EXISTS = 1 — only bridge existing portals
|
|
19
|
+
# BRIDGE_CREATE_ON_MESSAGE = 2 — create portals on first message
|
|
20
|
+
# BRIDGE_EVERYTHING = 3 — proactively create all portals
|
|
21
|
+
#
|
|
22
|
+
# guild = Guild.create(discord_id: "999", name: "My Server", bridging_mode: 3)
|
|
23
|
+
# guild.portals # => [Portal, ...]
|
|
24
|
+
#
|
|
25
|
+
class Guild < Sequel::Model
|
|
26
|
+
unrestrict_primary_key
|
|
27
|
+
|
|
28
|
+
BRIDGE_NOTHING = 0
|
|
29
|
+
BRIDGE_IF_PORTAL_EXISTS = 1
|
|
30
|
+
BRIDGE_CREATE_ON_MESSAGE = 2
|
|
31
|
+
BRIDGE_EVERYTHING = 3
|
|
32
|
+
|
|
33
|
+
one_to_many :portals,
|
|
34
|
+
class: "Async::Matrix::Bridge::Discord::DB::Portal",
|
|
35
|
+
key: :discord_guild_id,
|
|
36
|
+
primary_key: :discord_id
|
|
37
|
+
|
|
38
|
+
def validate
|
|
39
|
+
super
|
|
40
|
+
errors.add(:discord_id, "cannot be empty") if discord_id.nil? || discord_id.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bridge_nothing?
|
|
44
|
+
bridging_mode == BRIDGE_NOTHING
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def bridge_everything?
|
|
48
|
+
bridging_mode == BRIDGE_EVERYTHING
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
test do
|
|
58
|
+
describe "Async::Matrix::Bridge::Discord::DB::Guild" do
|
|
59
|
+
def setup_db
|
|
60
|
+
db = Sequel.sqlite
|
|
61
|
+
Sequel::Migrator.run(db, File.join(__dir__, "migrations"))
|
|
62
|
+
db
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it "creates and retrieves a guild" do
|
|
66
|
+
db = setup_db
|
|
67
|
+
Async::Matrix::Bridge::Discord::DB::Guild.dataset = db[:guilds]
|
|
68
|
+
|
|
69
|
+
guild = Async::Matrix::Bridge::Discord::DB::Guild.create(
|
|
70
|
+
discord_id: "999",
|
|
71
|
+
name: "Test Guild",
|
|
72
|
+
mxid: "!space:example.com"
|
|
73
|
+
)
|
|
74
|
+
guild.discord_id.should == "999"
|
|
75
|
+
guild.name.should == "Test Guild"
|
|
76
|
+
guild.mxid.should == "!space:example.com"
|
|
77
|
+
db.disconnect
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it "defaults bridging_mode to 0 (nothing)" do
|
|
81
|
+
db = setup_db
|
|
82
|
+
Async::Matrix::Bridge::Discord::DB::Guild.dataset = db[:guilds]
|
|
83
|
+
|
|
84
|
+
guild = Async::Matrix::Bridge::Discord::DB::Guild.create(discord_id: "100")
|
|
85
|
+
guild.bridging_mode.should == 0
|
|
86
|
+
guild.bridge_nothing?.should == true
|
|
87
|
+
guild.bridge_everything?.should == false
|
|
88
|
+
db.disconnect
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "supports all bridging modes" do
|
|
92
|
+
db = setup_db
|
|
93
|
+
Async::Matrix::Bridge::Discord::DB::Guild.dataset = db[:guilds]
|
|
94
|
+
|
|
95
|
+
guild = Async::Matrix::Bridge::Discord::DB::Guild.create(discord_id: "200", bridging_mode: 3)
|
|
96
|
+
guild.bridge_everything?.should == true
|
|
97
|
+
guild.bridge_nothing?.should == false
|
|
98
|
+
db.disconnect
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it "validates discord_id is present" do
|
|
102
|
+
db = setup_db
|
|
103
|
+
Async::Matrix::Bridge::Discord::DB::Guild.dataset = db[:guilds]
|
|
104
|
+
|
|
105
|
+
guild = Async::Matrix::Bridge::Discord::DB::Guild.new(discord_id: "")
|
|
106
|
+
guild.valid?.should == false
|
|
107
|
+
guild.errors[:discord_id].should.not.be.empty
|
|
108
|
+
db.disconnect
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it "enforces unique mxid" do
|
|
112
|
+
db = setup_db
|
|
113
|
+
Async::Matrix::Bridge::Discord::DB::Guild.dataset = db[:guilds]
|
|
114
|
+
|
|
115
|
+
Async::Matrix::Bridge::Discord::DB::Guild.create(discord_id: "300", mxid: "!a:x")
|
|
116
|
+
lambda {
|
|
117
|
+
Async::Matrix::Bridge::Discord::DB::Guild.create(discord_id: "301", mxid: "!a:x")
|
|
118
|
+
}.should.raise(Sequel::UniqueConstraintViolation)
|
|
119
|
+
db.disconnect
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
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 message to one or more Matrix events.
|
|
15
|
+
#
|
|
16
|
+
# A single Discord message may produce multiple Matrix events (one per
|
|
17
|
+
# attachment), distinguished by discord_attachment_id. The composite
|
|
18
|
+
# unique index on (discord_id, discord_attachment_id, discord_channel_id,
|
|
19
|
+
# discord_channel_receiver) ensures no duplicates.
|
|
20
|
+
#
|
|
21
|
+
# msg = Message.create(
|
|
22
|
+
# discord_id: "msg1", discord_channel_id: "ch1",
|
|
23
|
+
# discord_sender: "user1", mxid: "$evt1", timestamp: 1234567890
|
|
24
|
+
# )
|
|
25
|
+
# msg.portal # => Portal
|
|
26
|
+
#
|
|
27
|
+
class Message < Sequel::Model
|
|
28
|
+
many_to_one :portal,
|
|
29
|
+
class: "Async::Matrix::Bridge::Discord::DB::Portal",
|
|
30
|
+
key: [:discord_channel_id, :discord_channel_receiver],
|
|
31
|
+
primary_key: [:discord_id, :receiver]
|
|
32
|
+
|
|
33
|
+
def validate
|
|
34
|
+
super
|
|
35
|
+
errors.add(:discord_id, "cannot be empty") if discord_id.nil? || discord_id.empty?
|
|
36
|
+
errors.add(:mxid, "cannot be empty") if mxid.nil? || mxid.empty?
|
|
37
|
+
errors.add(:discord_sender, "cannot be empty") if discord_sender.nil? || discord_sender.empty?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Find all parts of a Discord message (text + attachments).
|
|
41
|
+
def self.by_discord_id(discord_id, channel_id, receiver = "")
|
|
42
|
+
where(
|
|
43
|
+
discord_id: discord_id,
|
|
44
|
+
discord_channel_id: channel_id,
|
|
45
|
+
discord_channel_receiver: receiver
|
|
46
|
+
).order(:discord_attachment_id).all
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Find a message by its Matrix event ID.
|
|
50
|
+
def self.by_mxid(mxid)
|
|
51
|
+
first(mxid: mxid)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
test do
|
|
61
|
+
describe "Async::Matrix::Bridge::Discord::DB::Message" do
|
|
62
|
+
def setup_db
|
|
63
|
+
db = Sequel.sqlite
|
|
64
|
+
Sequel::Migrator.run(db, File.join(__dir__, "migrations"))
|
|
65
|
+
db
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def set_datasets(db)
|
|
69
|
+
Async::Matrix::Bridge::Discord::DB::Message.dataset = db[:messages]
|
|
70
|
+
Async::Matrix::Bridge::Discord::DB::Portal.dataset = db[:portals]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it "creates and retrieves a message" do
|
|
74
|
+
db = setup_db
|
|
75
|
+
set_datasets(db)
|
|
76
|
+
|
|
77
|
+
msg = Async::Matrix::Bridge::Discord::DB::Message.create(
|
|
78
|
+
discord_id: "msg1",
|
|
79
|
+
discord_channel_id: "ch1",
|
|
80
|
+
discord_sender: "user1",
|
|
81
|
+
mxid: "$evt1",
|
|
82
|
+
timestamp: 1234567890
|
|
83
|
+
)
|
|
84
|
+
msg.discord_id.should == "msg1"
|
|
85
|
+
msg.mxid.should == "$evt1"
|
|
86
|
+
msg.discord_attachment_id.should == ""
|
|
87
|
+
db.disconnect
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it "supports multi-part messages with attachments" do
|
|
91
|
+
db = setup_db
|
|
92
|
+
set_datasets(db)
|
|
93
|
+
|
|
94
|
+
Async::Matrix::Bridge::Discord::DB::Message.create(
|
|
95
|
+
discord_id: "msg2", discord_attachment_id: "",
|
|
96
|
+
discord_channel_id: "ch1", discord_sender: "u1",
|
|
97
|
+
mxid: "$text", timestamp: 100
|
|
98
|
+
)
|
|
99
|
+
Async::Matrix::Bridge::Discord::DB::Message.create(
|
|
100
|
+
discord_id: "msg2", discord_attachment_id: "att1",
|
|
101
|
+
discord_channel_id: "ch1", discord_sender: "u1",
|
|
102
|
+
mxid: "$img1", timestamp: 100
|
|
103
|
+
)
|
|
104
|
+
Async::Matrix::Bridge::Discord::DB::Message.create(
|
|
105
|
+
discord_id: "msg2", discord_attachment_id: "att2",
|
|
106
|
+
discord_channel_id: "ch1", discord_sender: "u1",
|
|
107
|
+
mxid: "$img2", timestamp: 100
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
parts = Async::Matrix::Bridge::Discord::DB::Message.by_discord_id("msg2", "ch1")
|
|
111
|
+
parts.length.should == 3
|
|
112
|
+
parts.map(&:mxid).should == ["$text", "$img1", "$img2"]
|
|
113
|
+
db.disconnect
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it "finds by Matrix event ID" do
|
|
117
|
+
db = setup_db
|
|
118
|
+
set_datasets(db)
|
|
119
|
+
|
|
120
|
+
Async::Matrix::Bridge::Discord::DB::Message.create(
|
|
121
|
+
discord_id: "msg3", discord_channel_id: "ch1",
|
|
122
|
+
discord_sender: "u1", mxid: "$find_me", timestamp: 200
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
found = Async::Matrix::Bridge::Discord::DB::Message.by_mxid("$find_me")
|
|
126
|
+
found.should.not.be.nil
|
|
127
|
+
found.discord_id.should == "msg3"
|
|
128
|
+
db.disconnect
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
it "enforces unique composite key" do
|
|
132
|
+
db = setup_db
|
|
133
|
+
set_datasets(db)
|
|
134
|
+
|
|
135
|
+
Async::Matrix::Bridge::Discord::DB::Message.create(
|
|
136
|
+
discord_id: "msg4", discord_attachment_id: "",
|
|
137
|
+
discord_channel_id: "ch1", discord_channel_receiver: "",
|
|
138
|
+
discord_sender: "u1", mxid: "$a", timestamp: 300
|
|
139
|
+
)
|
|
140
|
+
lambda {
|
|
141
|
+
Async::Matrix::Bridge::Discord::DB::Message.create(
|
|
142
|
+
discord_id: "msg4", discord_attachment_id: "",
|
|
143
|
+
discord_channel_id: "ch1", discord_channel_receiver: "",
|
|
144
|
+
discord_sender: "u1", mxid: "$b", timestamp: 300
|
|
145
|
+
)
|
|
146
|
+
}.should.raise(Sequel::UniqueConstraintViolation)
|
|
147
|
+
db.disconnect
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "validates required fields" do
|
|
151
|
+
db = setup_db
|
|
152
|
+
set_datasets(db)
|
|
153
|
+
|
|
154
|
+
msg = Async::Matrix::Bridge::Discord::DB::Message.new(discord_id: "", mxid: "", discord_sender: "")
|
|
155
|
+
msg.valid?.should == false
|
|
156
|
+
msg.errors[:discord_id].should.not.be.empty
|
|
157
|
+
msg.errors[:mxid].should.not.be.empty
|
|
158
|
+
msg.errors[:discord_sender].should.not.be.empty
|
|
159
|
+
db.disconnect
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:users) do
|
|
6
|
+
String :mxid, primary_key: true
|
|
7
|
+
String :discord_id, unique: true
|
|
8
|
+
String :discord_token
|
|
9
|
+
String :management_room
|
|
10
|
+
String :space_room
|
|
11
|
+
String :dm_space_room
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:guilds) do
|
|
6
|
+
String :discord_id, primary_key: true
|
|
7
|
+
String :mxid, unique: true
|
|
8
|
+
String :name
|
|
9
|
+
String :avatar_hash
|
|
10
|
+
String :avatar_url
|
|
11
|
+
Integer :bridging_mode, default: 0, null: false
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:portals) do
|
|
6
|
+
String :discord_id, null: false
|
|
7
|
+
String :receiver, null: false, default: ""
|
|
8
|
+
String :mxid, unique: true
|
|
9
|
+
String :discord_guild_id
|
|
10
|
+
String :name
|
|
11
|
+
String :topic
|
|
12
|
+
String :avatar_hash
|
|
13
|
+
String :avatar_url
|
|
14
|
+
TrueClass :encrypted, default: false, null: false
|
|
15
|
+
Integer :channel_type, default: 0, null: false
|
|
16
|
+
String :relay_webhook_id
|
|
17
|
+
String :relay_webhook_secret
|
|
18
|
+
|
|
19
|
+
primary_key [:discord_id, :receiver]
|
|
20
|
+
foreign_key [:discord_guild_id], :guilds, key: :discord_id
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:puppets) do
|
|
6
|
+
String :discord_id, primary_key: true
|
|
7
|
+
String :name
|
|
8
|
+
String :avatar_hash
|
|
9
|
+
String :avatar_url
|
|
10
|
+
String :username
|
|
11
|
+
String :global_name
|
|
12
|
+
TrueClass :is_bot, default: false, null: false
|
|
13
|
+
TrueClass :is_webhook, default: false, null: false
|
|
14
|
+
TrueClass :contact_info_set, default: false, null: false
|
|
15
|
+
String :custom_mxid
|
|
16
|
+
String :access_token
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:messages) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
String :discord_id, null: false
|
|
8
|
+
String :discord_attachment_id, null: false, default: ""
|
|
9
|
+
String :discord_channel_id, null: false
|
|
10
|
+
String :discord_channel_receiver, null: false, default: ""
|
|
11
|
+
String :discord_sender, null: false
|
|
12
|
+
String :mxid, null: false
|
|
13
|
+
Bignum :timestamp, null: false
|
|
14
|
+
String :discord_thread_id
|
|
15
|
+
|
|
16
|
+
unique [:discord_id, :discord_attachment_id, :discord_channel_id, :discord_channel_receiver]
|
|
17
|
+
index :mxid
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:reactions) do
|
|
6
|
+
primary_key :id
|
|
7
|
+
String :discord_message_id, null: false
|
|
8
|
+
String :discord_sender, null: false
|
|
9
|
+
String :discord_emoji_name, null: false
|
|
10
|
+
String :mxid, null: false
|
|
11
|
+
String :discord_channel_id, null: false
|
|
12
|
+
String :discord_channel_receiver, null: false, default: ""
|
|
13
|
+
String :discord_thread_id
|
|
14
|
+
|
|
15
|
+
unique [:discord_message_id, :discord_sender, :discord_emoji_name]
|
|
16
|
+
index :mxid
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Sequel.migration do
|
|
4
|
+
change do
|
|
5
|
+
create_table(:files) do
|
|
6
|
+
String :url, null: false
|
|
7
|
+
TrueClass :encrypted, null: false, default: false
|
|
8
|
+
String :mxc, null: false
|
|
9
|
+
String :mime_type
|
|
10
|
+
Bignum :size
|
|
11
|
+
Integer :width
|
|
12
|
+
Integer :height
|
|
13
|
+
String :decryption_info
|
|
14
|
+
|
|
15
|
+
primary_key [:url, :encrypted]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
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 channel to a Matrix room.
|
|
15
|
+
#
|
|
16
|
+
# Uses a composite primary key of (discord_id, receiver). For guild
|
|
17
|
+
# channels, receiver is an empty string. For DMs, receiver is the
|
|
18
|
+
# Discord user ID of the bridge user who owns the portal.
|
|
19
|
+
#
|
|
20
|
+
# portal = Portal.create(discord_id: "chan123", receiver: "", mxid: "!room:x")
|
|
21
|
+
# portal.guild # => Guild or nil
|
|
22
|
+
# portal.messages # => [Message, ...]
|
|
23
|
+
#
|
|
24
|
+
class Portal < Sequel::Model
|
|
25
|
+
unrestrict_primary_key
|
|
26
|
+
|
|
27
|
+
many_to_one :guild,
|
|
28
|
+
class: "Async::Matrix::Bridge::Discord::DB::Guild",
|
|
29
|
+
key: :discord_guild_id,
|
|
30
|
+
primary_key: :discord_id
|
|
31
|
+
|
|
32
|
+
one_to_many :messages,
|
|
33
|
+
class: "Async::Matrix::Bridge::Discord::DB::Message",
|
|
34
|
+
key: [:discord_channel_id, :discord_channel_receiver],
|
|
35
|
+
primary_key: [:discord_id, :receiver]
|
|
36
|
+
|
|
37
|
+
def validate
|
|
38
|
+
super
|
|
39
|
+
errors.add(:discord_id, "cannot be empty") if discord_id.nil? || discord_id.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Is this a DM portal? (has a receiver)
|
|
43
|
+
def dm?
|
|
44
|
+
receiver && !receiver.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Is this a guild channel portal?
|
|
48
|
+
def guild_channel?
|
|
49
|
+
!dm?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
test do
|
|
59
|
+
describe "Async::Matrix::Bridge::Discord::DB::Portal" do
|
|
60
|
+
def setup_db
|
|
61
|
+
db = Sequel.sqlite
|
|
62
|
+
Sequel::Migrator.run(db, File.join(__dir__, "migrations"))
|
|
63
|
+
db
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def set_datasets(db)
|
|
67
|
+
Async::Matrix::Bridge::Discord::DB::Portal.dataset = db[:portals]
|
|
68
|
+
Async::Matrix::Bridge::Discord::DB::Guild.dataset = db[:guilds]
|
|
69
|
+
Async::Matrix::Bridge::Discord::DB::Message.dataset = db[:messages]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it "creates a guild channel portal" do
|
|
73
|
+
db = setup_db
|
|
74
|
+
set_datasets(db)
|
|
75
|
+
|
|
76
|
+
portal = Async::Matrix::Bridge::Discord::DB::Portal.create(
|
|
77
|
+
discord_id: "chan1", receiver: "", mxid: "!room:x", name: "general"
|
|
78
|
+
)
|
|
79
|
+
portal.discord_id.should == "chan1"
|
|
80
|
+
portal.receiver.should == ""
|
|
81
|
+
portal.guild_channel?.should == true
|
|
82
|
+
portal.dm?.should == false
|
|
83
|
+
db.disconnect
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "creates a DM portal with receiver" do
|
|
87
|
+
db = setup_db
|
|
88
|
+
set_datasets(db)
|
|
89
|
+
|
|
90
|
+
portal = Async::Matrix::Bridge::Discord::DB::Portal.create(
|
|
91
|
+
discord_id: "dm1", receiver: "user123", mxid: "!dm:x"
|
|
92
|
+
)
|
|
93
|
+
portal.dm?.should == true
|
|
94
|
+
portal.guild_channel?.should == false
|
|
95
|
+
db.disconnect
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "finds by composite primary key" do
|
|
99
|
+
db = setup_db
|
|
100
|
+
set_datasets(db)
|
|
101
|
+
|
|
102
|
+
Async::Matrix::Bridge::Discord::DB::Portal.create(discord_id: "c1", receiver: "")
|
|
103
|
+
Async::Matrix::Bridge::Discord::DB::Portal.create(discord_id: "c1", receiver: "u1")
|
|
104
|
+
|
|
105
|
+
found = Async::Matrix::Bridge::Discord::DB::Portal["c1", ""]
|
|
106
|
+
found.should.not.be.nil
|
|
107
|
+
found.receiver.should == ""
|
|
108
|
+
|
|
109
|
+
found2 = Async::Matrix::Bridge::Discord::DB::Portal["c1", "u1"]
|
|
110
|
+
found2.receiver.should == "u1"
|
|
111
|
+
db.disconnect
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it "associates with a guild" do
|
|
115
|
+
db = setup_db
|
|
116
|
+
set_datasets(db)
|
|
117
|
+
|
|
118
|
+
guild = Async::Matrix::Bridge::Discord::DB::Guild.create(discord_id: "g_assoc", name: "Guild")
|
|
119
|
+
portal = Async::Matrix::Bridge::Discord::DB::Portal.create(
|
|
120
|
+
discord_id: "c_assoc", receiver: "", discord_guild_id: "g_assoc"
|
|
121
|
+
)
|
|
122
|
+
# Query through the db directly to avoid races with parallel tests
|
|
123
|
+
# that rebind the class-level dataset on Sequel models.
|
|
124
|
+
portal.discord_guild_id.should == "g_assoc"
|
|
125
|
+
linked_guild = db[:guilds].where(discord_id: portal.discord_guild_id).first
|
|
126
|
+
linked_guild[:discord_id].should == "g_assoc"
|
|
127
|
+
linked_portals = db[:portals].where(discord_guild_id: guild.discord_id).all
|
|
128
|
+
linked_portals.length.should == 1
|
|
129
|
+
db.disconnect
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "enforces unique mxid" do
|
|
133
|
+
db = setup_db
|
|
134
|
+
set_datasets(db)
|
|
135
|
+
|
|
136
|
+
Async::Matrix::Bridge::Discord::DB::Portal.create(discord_id: "a", receiver: "", mxid: "!r:x")
|
|
137
|
+
lambda {
|
|
138
|
+
Async::Matrix::Bridge::Discord::DB::Portal.create(discord_id: "b", receiver: "", mxid: "!r:x")
|
|
139
|
+
}.should.raise(Sequel::UniqueConstraintViolation)
|
|
140
|
+
db.disconnect
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "defaults encrypted to false" do
|
|
144
|
+
db = setup_db
|
|
145
|
+
set_datasets(db)
|
|
146
|
+
|
|
147
|
+
portal = Async::Matrix::Bridge::Discord::DB::Portal.create(discord_id: "e1", receiver: "")
|
|
148
|
+
portal.encrypted.should == false
|
|
149
|
+
db.disconnect
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|