chats 0.3.1 → 0.3.2
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/CHANGELOG.md +12 -0
- data/README.md +5 -1
- data/app/controllers/chats/messages_controller.rb +1 -13
- data/chats-0.3.1.gem +0 -0
- data/lib/chats/engine.rb +1 -1
- data/lib/chats/inbox.rb +30 -4
- data/lib/chats/models/concerns/messager.rb +0 -5
- data/lib/chats/models/conversation.rb +13 -20
- data/lib/chats/models/message.rb +7 -7
- data/lib/chats/models/participant.rb +9 -1
- data/lib/chats/send_rate_limited.rb +30 -0
- data/lib/chats/version.rb +1 -1
- data/lib/chats.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 80fff570dabbff3e99a5a5f37c43b43acd334a94aae30c23b8c76bfbab5441e8
|
|
4
|
+
data.tar.gz: 72efe83f785fe99876d6b5922a5d834897f1771f3880bead3b43ae6f342ba96d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 362eda363af938a99823b38d7fd4e0a5594d62d305f6a47f88a1dc9978c6556ab37561259007f1649567472a98deecff6cc96050afbbedc3d36637ee99207a84
|
|
7
|
+
data.tar.gz: aa7b170ba97de94d58b08516dbd4674905f1ab78e2bcd4a2e9af997e0aab6d71a3fc35b1aea2f6f0ab97fd02d1d6fa46494a1826f784b35184105c0c1c3c89b6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ All notable changes to this project are documented here.
|
|
|
4
4
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
5
5
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [0.3.2] - 2026-09-17
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- Grouped inbox limits preserve other counterparts even when one desk has many recent conversations.
|
|
12
|
+
- Conversation creation and participant departure events run after commit and remain silent on rollback. Direct conversation creation includes its roster in the transaction.
|
|
13
|
+
- A message author may be any persisted model; staff need not become messagers to sign a desk's reply.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `Chats::SendRateLimited`, a controller concern sharing the configured sender budget across chat and product-specific composers, using the host's cache store on all supported Rails versions.
|
|
18
|
+
|
|
7
19
|
## [0.3.1] - 2026-09-16
|
|
8
20
|
|
|
9
21
|
### Fixed
|
data/README.md
CHANGED
|
@@ -368,6 +368,10 @@ message.authored_by?(lucia)
|
|
|
368
368
|
|
|
369
369
|
The bundled bubble renders a signature line ("— Lucía G.") via `Chats.display_name_for`; `config.message_signature = ->(message) { … }` rewrites it. Ordinary messages have no author and render exactly as before.
|
|
370
370
|
|
|
371
|
+
An author must be persisted, but needs neither `acts_as_messager` nor a conversation
|
|
372
|
+
seat. The host authorizes who may send on behalf of a shared identity; the HTTP
|
|
373
|
+
message controller never accepts an author from request parameters.
|
|
374
|
+
|
|
371
375
|
Existing installs get the columns with one command:
|
|
372
376
|
|
|
373
377
|
```bash
|
|
@@ -457,7 +461,7 @@ Chats.configure do |config|
|
|
|
457
461
|
config.max_group_size = 32
|
|
458
462
|
config.max_attachment_size = 10.megabytes
|
|
459
463
|
config.max_attachments_per_message = 4
|
|
460
|
-
config.send_rate_limit = { to: 60, within: 1.minute } #
|
|
464
|
+
config.send_rate_limit = { to: 60, within: 1.minute } # shared sender budget; nil disables
|
|
461
465
|
config.encrypt_messages = false # ActiveRecord Encryption on bodies
|
|
462
466
|
|
|
463
467
|
# Policies (on top of — never instead of — block enforcement)
|
|
@@ -12,19 +12,7 @@ module Chats
|
|
|
12
12
|
# opened the composer" race in one place.
|
|
13
13
|
before_action :refuse_when_locked!, only: %i[update destroy]
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
# limiting (https://api.rubyonrails.org/classes/ActionController/RateLimiting.html).
|
|
17
|
-
# Feature-detected so the gem still loads on Rails 7.1 (where this is
|
|
18
|
-
# simply not enforced). Keyed by messager, not IP — one abusive account
|
|
19
|
-
# behind a corporate NAT must not silence the rest.
|
|
20
|
-
if respond_to?(:rate_limit) && Chats.config.send_rate_limit
|
|
21
|
-
rate_limit(
|
|
22
|
-
**Chats.config.send_rate_limit,
|
|
23
|
-
only: :create,
|
|
24
|
-
by: -> { send(Chats.config.current_messager_method)&.to_gid&.to_s || request.remote_ip },
|
|
25
|
-
with: -> { head :too_many_requests }
|
|
26
|
-
)
|
|
27
|
-
end
|
|
15
|
+
include Chats::SendRateLimited
|
|
28
16
|
|
|
29
17
|
# A single bubble, re-rendered. Exists for one delightful reason: it's
|
|
30
18
|
# the "cancel edit" target — replacing the inline edit form back with the
|
data/chats-0.3.1.gem
ADDED
|
Binary file
|
data/lib/chats/engine.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Chats
|
|
|
30
30
|
CHATS_LIB = File.expand_path("chats", LIB_ROOT)
|
|
31
31
|
|
|
32
32
|
ZEITWERK_IGNORED = %w[
|
|
33
|
-
version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb
|
|
33
|
+
version.rb errors.rb configuration.rb engine.rb macros.rb subscribers.rb send_rate_limited.rb
|
|
34
34
|
].freeze
|
|
35
35
|
|
|
36
36
|
initializer "chats.autoload", before: :set_autoload_paths do
|
data/lib/chats/inbox.rb
CHANGED
|
@@ -151,10 +151,13 @@ module Chats
|
|
|
151
151
|
else
|
|
152
152
|
stacked = Chats::Conversation.direct.where(id: stacked_seats)
|
|
153
153
|
@ungrouped_relation = base_relation.where.not(id: stacked).limit(limit)
|
|
154
|
-
|
|
155
|
-
# the
|
|
156
|
-
#
|
|
157
|
-
|
|
154
|
+
recent = base_relation.direct.where(id: stacked_seats).except(:includes).select(:id).limit(limit)
|
|
155
|
+
# Preserve the recent search window, plus the freshest conversation
|
|
156
|
+
# of each counterpart. A busy stack cannot consume another stack's
|
|
157
|
+
# row; at most twice the row limit is materialized.
|
|
158
|
+
candidates = base_relation.where(id: limited_ids(recent))
|
|
159
|
+
.or(base_relation.where(id: limited_ids(stack_representatives)))
|
|
160
|
+
@stacked = apply_search(candidates)
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
@ungrouped = apply_search(@ungrouped_relation)
|
|
@@ -163,6 +166,29 @@ module Chats
|
|
|
163
166
|
@flat = @stacked.empty? && query.nil? ? @ungrouped_relation : @ungrouped + @stacked
|
|
164
167
|
end
|
|
165
168
|
|
|
169
|
+
# MySQL rejects LIMIT directly in an IN subquery. A derived table keeps
|
|
170
|
+
# the bounded selection in SQL and works on all three supported adapters.
|
|
171
|
+
def limited_ids(relation)
|
|
172
|
+
Chats::Conversation.from(relation, :limited_conversations).select(:id)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# ROW_NUMBER works on every supported adapter (PostgreSQL, SQLite and
|
|
176
|
+
# MySQL 8). Ranking in SQL avoids loading an entire busy desk's history.
|
|
177
|
+
def stack_representatives
|
|
178
|
+
activity = "COALESCE(chats_conversations.last_message_at, chats_conversations.created_at)"
|
|
179
|
+
ranking = "ROW_NUMBER() OVER (PARTITION BY stack_seats.messager_type, stack_seats.messager_id " \
|
|
180
|
+
"ORDER BY #{activity} DESC, chats_conversations.id DESC) AS stack_rank"
|
|
181
|
+
ranked = base_relation.direct.except(:includes, :order)
|
|
182
|
+
.joins("INNER JOIN chats_participants stack_seats " \
|
|
183
|
+
"ON stack_seats.conversation_id = chats_conversations.id")
|
|
184
|
+
.where(stack_seats: { messager_type: grouped_types })
|
|
185
|
+
.where.not(stack_seats: { messager_type: viewer.class.polymorphic_name,
|
|
186
|
+
messager_id: viewer.id })
|
|
187
|
+
.select("chats_conversations.id, #{activity} AS activity_at", ranking)
|
|
188
|
+
Chats::Conversation.from(ranked, :ranked_stacks).where("stack_rank = 1")
|
|
189
|
+
.order(Arel.sql("activity_at DESC, id DESC")).limit(limit).select(:id)
|
|
190
|
+
end
|
|
191
|
+
|
|
166
192
|
# Only the direct threads shared with one counterpart. Direct only, by
|
|
167
193
|
# design: a group that happens to include the desk is not part of the
|
|
168
194
|
# desk's stack.
|
|
@@ -156,11 +156,6 @@ module Chats
|
|
|
156
156
|
#
|
|
157
157
|
# desk.message!(alice, "On it!", author: lucia)
|
|
158
158
|
def message!(target, body = nil, about: nil, files: [], reply_to: nil, author: nil)
|
|
159
|
-
if author && !Chats.messager_class?(author.class)
|
|
160
|
-
raise Chats::NotAllowedError,
|
|
161
|
-
"author must be a messager (acts_as_messager), got #{author.class.name}"
|
|
162
|
-
end
|
|
163
|
-
|
|
164
159
|
conversation =
|
|
165
160
|
case target
|
|
166
161
|
when Chats::Conversation then target
|
|
@@ -114,6 +114,8 @@ module Chats
|
|
|
114
114
|
# mechanism. https://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-create_or_find_by
|
|
115
115
|
validate :groups_must_be_enabled, if: :group?
|
|
116
116
|
|
|
117
|
+
after_create_commit -> { Chats.notify(:conversation_created, conversation: self) }
|
|
118
|
+
|
|
117
119
|
# --- Finding & creating ---------------------------------------------------
|
|
118
120
|
|
|
119
121
|
class << self
|
|
@@ -133,21 +135,17 @@ module Chats
|
|
|
133
135
|
raise Chats::BlockedError, "messagers are blocked" if Chats.blocked_between?(a, b)
|
|
134
136
|
raise Chats::NotAllowedError, "policy forbids messaging" unless Chats.can_message?(a, b)
|
|
135
137
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
transaction do
|
|
139
|
+
conversation = create_or_find_by!(direct_key: direct_key_for([a, b], subject: about)) do |c|
|
|
140
|
+
c.kind = "direct"
|
|
141
|
+
c.subject = about
|
|
142
|
+
end
|
|
143
|
+
# Keep the roster in the creation transaction: the after-commit
|
|
144
|
+
# event must describe a complete conversation, including under an
|
|
145
|
+
# outer host transaction or a failed participant insertion.
|
|
146
|
+
[a, b].each { |messager| conversation.add_participant!(messager) }
|
|
147
|
+
conversation
|
|
139
148
|
end
|
|
140
|
-
|
|
141
|
-
# `create_or_find_by!` may have FOUND a conversation created a moment
|
|
142
|
-
# ago by the other side — participants are ensured idempotently
|
|
143
|
-
# either way (their own unique index makes this race-safe too).
|
|
144
|
-
# `previously_new_record?` is how we tell the two apart, so the
|
|
145
|
-
# :conversation_created event fires ONCE per conversation, not on
|
|
146
|
-
# every resume.
|
|
147
|
-
created = conversation.previously_new_record?
|
|
148
|
-
[a, b].each { |messager| conversation.add_participant!(messager) }
|
|
149
|
-
Chats.notify(:conversation_created, conversation: conversation) if created
|
|
150
|
-
conversation
|
|
151
149
|
end
|
|
152
150
|
|
|
153
151
|
# Create a group conversation. +others+ excludes the creator (who joins
|
|
@@ -163,17 +161,12 @@ module Chats
|
|
|
163
161
|
others = Array(others) - [creator]
|
|
164
162
|
raise ArgumentError, "a group needs at least 2 other participants" if others.size < 2
|
|
165
163
|
|
|
166
|
-
|
|
164
|
+
transaction do
|
|
167
165
|
created = create!(kind: "group", title: title, subject: about)
|
|
168
166
|
created.add_participant!(creator, role: "owner")
|
|
169
167
|
others.each { |messager| created.add_participant!(messager) }
|
|
170
168
|
created
|
|
171
169
|
end
|
|
172
|
-
|
|
173
|
-
# Emitted AFTER the transaction: subscribers see a complete roster
|
|
174
|
-
# and never run inside the write that created it.
|
|
175
|
-
Chats.notify(:conversation_created, conversation: conversation)
|
|
176
|
-
conversation
|
|
177
170
|
end
|
|
178
171
|
|
|
179
172
|
# Deterministic identity for a direct pair (+ optional subject).
|
data/lib/chats/models/message.rb
CHANGED
|
@@ -96,7 +96,7 @@ module Chats
|
|
|
96
96
|
validate :sender_must_be_active_participant, on: :create
|
|
97
97
|
validate :sender_must_not_be_blocked, on: :create
|
|
98
98
|
validate :conversation_must_not_be_locked, on: :create
|
|
99
|
-
validate :
|
|
99
|
+
validate :author_must_be_persisted
|
|
100
100
|
validate :files_must_be_allowed
|
|
101
101
|
|
|
102
102
|
after_create :register_on_conversation
|
|
@@ -312,13 +312,13 @@ module Chats
|
|
|
312
312
|
errors.add(:base, :blocked) if other && Chats.blocked_between?(sender, other)
|
|
313
313
|
end
|
|
314
314
|
|
|
315
|
-
#
|
|
316
|
-
#
|
|
317
|
-
#
|
|
318
|
-
def
|
|
319
|
-
return if author.nil? ||
|
|
315
|
+
# Authorship is a signature, not a conversation seat. A host may keep
|
|
316
|
+
# staff in a separate model without giving them messaging capabilities.
|
|
317
|
+
# Never implicitly create that identity while sending a message.
|
|
318
|
+
def author_must_be_persisted
|
|
319
|
+
return if author.nil? || author.persisted?
|
|
320
320
|
|
|
321
|
-
errors.add(:author, :
|
|
321
|
+
errors.add(:author, :invalid)
|
|
322
322
|
end
|
|
323
323
|
|
|
324
324
|
# The subject owns the conversation's openness (Chats::ChatSubject#
|
|
@@ -43,6 +43,8 @@ module Chats
|
|
|
43
43
|
# concurrent joins race-safe. Same rationale as Conversation#direct_key.
|
|
44
44
|
validate :group_must_have_room, on: :create
|
|
45
45
|
|
|
46
|
+
after_update_commit :notify_departure, if: -> { saved_change_to_left_at? && left? }
|
|
47
|
+
|
|
46
48
|
def owner? = role == "owner"
|
|
47
49
|
def left? = left_at.present?
|
|
48
50
|
def active? = left_at.nil?
|
|
@@ -104,11 +106,17 @@ module Chats
|
|
|
104
106
|
def unmute! = update!(muted_at: nil)
|
|
105
107
|
|
|
106
108
|
def leave!
|
|
109
|
+
return self if left?
|
|
110
|
+
|
|
107
111
|
update!(left_at: Time.current)
|
|
108
|
-
Chats.notify(:participant_left, participant: self)
|
|
109
112
|
self
|
|
110
113
|
end
|
|
111
114
|
|
|
115
|
+
def notify_departure
|
|
116
|
+
Chats.notify(:participant_left, participant: self)
|
|
117
|
+
end
|
|
118
|
+
private :notify_departure
|
|
119
|
+
|
|
112
120
|
# Hand this seat to a different messager, keeping the read horizon, the
|
|
113
121
|
# role and the history: the guest who signs up, the agent who takes over
|
|
114
122
|
# a shared mailbox. The MESSAGES keep their original sender — what was
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Chats
|
|
4
|
+
# Share one sender budget across chat and product-specific HTTP composers.
|
|
5
|
+
# The host owns the cache store. Using its atomic increment also works on
|
|
6
|
+
# Rails 7 and avoids depending on Rails' private controller limiter API.
|
|
7
|
+
module SendRateLimited
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
included do
|
|
11
|
+
before_action :enforce_chat_send_rate_limit, only: :create
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def chat_rate_limit_messager
|
|
17
|
+
send(Chats.config.current_messager_method)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def enforce_chat_send_rate_limit
|
|
21
|
+
limit = Chats.config.send_rate_limit
|
|
22
|
+
return unless limit
|
|
23
|
+
|
|
24
|
+
sender = chat_rate_limit_messager&.to_gid&.to_s || request.remote_ip
|
|
25
|
+
count = self.class.cache_store.increment("rate-limit:chats/messages:#{sender}", 1,
|
|
26
|
+
expires_in: limit.fetch(:within))
|
|
27
|
+
head :too_many_requests if count && count > limit.fetch(:to)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/chats/version.rb
CHANGED
data/lib/chats.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "chats/version"
|
|
|
7
7
|
require_relative "chats/errors"
|
|
8
8
|
require_relative "chats/configuration"
|
|
9
9
|
require_relative "chats/subscribers"
|
|
10
|
+
require_relative "chats/send_rate_limited"
|
|
10
11
|
require_relative "chats/macros"
|
|
11
12
|
|
|
12
13
|
require_relative "chats/engine" if defined?(::Rails::Engine)
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: chats
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.3.
|
|
4
|
+
version: 0.3.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- rameerez
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-17 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activerecord
|
|
@@ -152,6 +152,7 @@ files:
|
|
|
152
152
|
- app/views/chats/messages/locked.turbo_stream.erb
|
|
153
153
|
- app/views/chats/shared/_unread_badge.html.erb
|
|
154
154
|
- app/views/chats/shared/_verified_badge.html.erb
|
|
155
|
+
- chats-0.3.1.gem
|
|
155
156
|
- config/importmap.rb
|
|
156
157
|
- config/locales/en.yml
|
|
157
158
|
- config/locales/es.yml
|
|
@@ -177,6 +178,7 @@ files:
|
|
|
177
178
|
- lib/chats/models/message.rb
|
|
178
179
|
- lib/chats/models/participant.rb
|
|
179
180
|
- lib/chats/models/reaction.rb
|
|
181
|
+
- lib/chats/send_rate_limited.rb
|
|
180
182
|
- lib/chats/subscribers.rb
|
|
181
183
|
- lib/chats/version.rb
|
|
182
184
|
- lib/generators/chats/install_generator.rb
|