async-matrix 1.0.0 → 1.1.1

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -2
  3. data/data/discord-api-spec/openapi.json +40404 -0
  4. data/lib/async/discord/api/path_tree.rb +130 -0
  5. data/lib/async/discord/api.rb +156 -0
  6. data/lib/async/discord/client.rb +286 -0
  7. data/lib/async/discord/error.rb +88 -0
  8. data/lib/async/discord/gateway.rb +362 -0
  9. data/lib/async/discord.rb +16 -0
  10. data/lib/async/matrix/api/chain.rb +14 -8
  11. data/lib/async/matrix/application_service/config/vivify.rb +3 -0
  12. data/lib/async/matrix/application_service/event.rb +9 -20
  13. data/lib/async/matrix/application_service/server.rb +2 -2
  14. data/lib/async/matrix/bridge/discord/db/connection.rb +143 -0
  15. data/lib/async/matrix/bridge/discord/db/file.rb +120 -0
  16. data/lib/async/matrix/bridge/discord/db/guild.rb +122 -0
  17. data/lib/async/matrix/bridge/discord/db/message.rb +162 -0
  18. data/lib/async/matrix/bridge/discord/db/migrations/001_create_users.rb +14 -0
  19. data/lib/async/matrix/bridge/discord/db/migrations/002_create_guilds.rb +14 -0
  20. data/lib/async/matrix/bridge/discord/db/migrations/003_create_portals.rb +23 -0
  21. data/lib/async/matrix/bridge/discord/db/migrations/004_create_puppets.rb +19 -0
  22. data/lib/async/matrix/bridge/discord/db/migrations/005_create_messages.rb +20 -0
  23. data/lib/async/matrix/bridge/discord/db/migrations/006_create_reactions.rb +19 -0
  24. data/lib/async/matrix/bridge/discord/db/migrations/007_create_files.rb +18 -0
  25. data/lib/async/matrix/bridge/discord/db/portal.rb +152 -0
  26. data/lib/async/matrix/bridge/discord/db/puppet.rb +130 -0
  27. data/lib/async/matrix/bridge/discord/db/reaction.rb +167 -0
  28. data/lib/async/matrix/bridge/discord/db/user.rb +114 -0
  29. data/lib/async/matrix/bridge/discord/db.rb +140 -0
  30. data/lib/async/matrix/double_puppet_client.rb +84 -0
  31. data/lib/async/matrix/schema.rb +2 -2
  32. data/lib/async/matrix/server.rb +1 -0
  33. data/lib/async/matrix/version.rb +1 -1
  34. data/lib/async/matrix.rb +2 -0
  35. 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
@@ -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
- require_relative "schema/registry"
7
- require_relative "schema/validation_error"
6
+ require "bundler/setup"
7
+ require "async/matrix"
8
8
 
9
9
  module Async
10
10
  module Matrix
@@ -5,6 +5,7 @@
5
5
 
6
6
  require "bundler/setup"
7
7
  require "async/http"
8
+ require "async/matrix"
8
9
 
9
10
  module Async
10
11
  module Matrix
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module Matrix
8
- VERSION = "1.0.0"
8
+ VERSION = "1.1.1"
9
9
  end
10
10
  end
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