where_is_waldo 0.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE +21 -0
- data/README.md +467 -0
- data/VERSION +1 -0
- data/app/channels/where_is_waldo/application_cable/channel.rb +19 -0
- data/app/channels/where_is_waldo/application_cable/connection.rb +45 -0
- data/app/channels/where_is_waldo/jwt_connection.rb +83 -0
- data/app/channels/where_is_waldo/presence_channel.rb +86 -0
- data/app/channels/where_is_waldo/roster_channel.rb +52 -0
- data/app/jobs/where_is_waldo/application_job.rb +6 -0
- data/app/jobs/where_is_waldo/presence_cleanup_job.rb +13 -0
- data/app/models/concerns/where_is_waldo/broadcastable.rb +85 -0
- data/app/models/where_is_waldo/application_record.rb +7 -0
- data/app/models/where_is_waldo/presence.rb +87 -0
- data/app/services/where_is_waldo/adapters/base_adapter.rb +80 -0
- data/app/services/where_is_waldo/adapters/database_adapter.rb +104 -0
- data/app/services/where_is_waldo/adapters/redis_adapter.rb +205 -0
- data/app/services/where_is_waldo/broadcaster.rb +79 -0
- data/app/services/where_is_waldo/presence_service.rb +119 -0
- data/app/services/where_is_waldo/roster.rb +246 -0
- data/app/services/where_is_waldo/roster_delivery.rb +184 -0
- data/lib/generators/where_is_waldo/install/install_generator.rb +103 -0
- data/lib/generators/where_is_waldo/install/templates/channel.rb.tt +6 -0
- data/lib/generators/where_is_waldo/install/templates/connection.rb.tt +51 -0
- data/lib/generators/where_is_waldo/install/templates/create_presences.rb.tt +39 -0
- data/lib/generators/where_is_waldo/install/templates/initializer.rb.tt +37 -0
- data/lib/generators/where_is_waldo/install/templates/migration.rb.tt +20 -0
- data/lib/generators/where_is_waldo/install/templates/presence_channel.rb.tt +37 -0
- data/lib/where_is_waldo/configuration.rb +209 -0
- data/lib/where_is_waldo/engine.rb +17 -0
- data/lib/where_is_waldo/version.rb +5 -0
- data/lib/where_is_waldo.rb +117 -0
- metadata +249 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
module Adapters
|
|
5
|
+
class RedisAdapter < BaseAdapter
|
|
6
|
+
def connect(session_id:, subject_id:, metadata: {})
|
|
7
|
+
now = Time.current.to_i
|
|
8
|
+
|
|
9
|
+
presence_data = {
|
|
10
|
+
session_id: session_id,
|
|
11
|
+
subject_id: subject_id,
|
|
12
|
+
connected_at: now,
|
|
13
|
+
last_heartbeat: now,
|
|
14
|
+
tab_visible: true,
|
|
15
|
+
subject_active: true,
|
|
16
|
+
last_activity: now,
|
|
17
|
+
metadata: metadata
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
redis.multi do |tx|
|
|
21
|
+
# Store session data
|
|
22
|
+
tx.set(session_key(session_id), presence_data.to_json, ex: ttl)
|
|
23
|
+
|
|
24
|
+
# Add to online subjects sorted set with timestamp as score
|
|
25
|
+
tx.zadd(online_subjects_key, now, subject_id)
|
|
26
|
+
|
|
27
|
+
# Add to subject's sessions set
|
|
28
|
+
tx.sadd(subject_sessions_key(subject_id), session_id)
|
|
29
|
+
|
|
30
|
+
# Map session to subject for reverse lookup
|
|
31
|
+
tx.set(session_subject_key(session_id), subject_id, ex: ttl)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
true
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
Rails.logger.error "[WhereIsWaldo] Redis connect failed: #{e.message}"
|
|
37
|
+
false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def disconnect(session_id: nil, subject_id: nil)
|
|
41
|
+
if session_id
|
|
42
|
+
disconnect_session(session_id)
|
|
43
|
+
elsif subject_id
|
|
44
|
+
disconnect_subject(subject_id)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
true
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Rails.logger.error "[WhereIsWaldo] Redis disconnect failed: #{e.message}"
|
|
50
|
+
false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
|
|
54
|
+
data = get_presence_data(session_id)
|
|
55
|
+
return false unless data
|
|
56
|
+
|
|
57
|
+
now = Time.current.to_i
|
|
58
|
+
data["last_heartbeat"] = now
|
|
59
|
+
data["tab_visible"] = tab_visible
|
|
60
|
+
data["subject_active"] = subject_active
|
|
61
|
+
# Update last_activity: use JS timestamp if provided, otherwise use current time when active
|
|
62
|
+
if last_activity_at
|
|
63
|
+
data["last_activity"] = (last_activity_at / 1000.0).to_i
|
|
64
|
+
elsif subject_active
|
|
65
|
+
data["last_activity"] = now
|
|
66
|
+
end
|
|
67
|
+
data["metadata"] = data["metadata"].merge(metadata) if metadata.present?
|
|
68
|
+
|
|
69
|
+
redis.multi do |tx|
|
|
70
|
+
tx.set(session_key(session_id), data.to_json, ex: ttl)
|
|
71
|
+
tx.zadd(online_subjects_key, now, data["subject_id"])
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
true
|
|
75
|
+
rescue StandardError => e
|
|
76
|
+
Rails.logger.error "[WhereIsWaldo] Redis heartbeat failed: #{e.message}"
|
|
77
|
+
false
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def online_subject_ids(timeout: nil)
|
|
81
|
+
threshold = Time.current.to_i - (timeout || default_timeout)
|
|
82
|
+
|
|
83
|
+
# Get subject IDs with heartbeat after threshold
|
|
84
|
+
redis.zrangebyscore(online_subjects_key, threshold, "+inf").map(&:to_i)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def sessions_for_subject(subject_id)
|
|
88
|
+
session_ids = redis.smembers(subject_sessions_key(subject_id))
|
|
89
|
+
|
|
90
|
+
session_ids.filter_map do |sid|
|
|
91
|
+
data = get_presence_data(sid)
|
|
92
|
+
build_presence_hash(data) if data
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def session_status(session_id)
|
|
97
|
+
data = get_presence_data(session_id)
|
|
98
|
+
return nil unless data
|
|
99
|
+
|
|
100
|
+
build_presence_hash(data)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def cleanup(timeout: nil)
|
|
104
|
+
threshold = Time.current.to_i - (timeout || default_timeout)
|
|
105
|
+
cleaned = 0
|
|
106
|
+
|
|
107
|
+
# Get stale subject IDs
|
|
108
|
+
stale_subject_ids = redis.zrangebyscore(online_subjects_key, "-inf", threshold)
|
|
109
|
+
|
|
110
|
+
stale_subject_ids.each do |subject_id|
|
|
111
|
+
# Check if any sessions are still active
|
|
112
|
+
session_ids = redis.smembers(subject_sessions_key(subject_id))
|
|
113
|
+
all_stale = session_ids.all? do |sid|
|
|
114
|
+
data = get_presence_data(sid)
|
|
115
|
+
!data || data["last_heartbeat"].to_i < threshold
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if all_stale
|
|
119
|
+
disconnect_subject(subject_id)
|
|
120
|
+
cleaned += 1
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Remove stale entries from sorted set
|
|
125
|
+
redis.zremrangebyscore(online_subjects_key, "-inf", threshold)
|
|
126
|
+
|
|
127
|
+
cleaned
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def redis
|
|
133
|
+
config.redis_client || raise("Redis client not configured")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def ttl
|
|
137
|
+
(config.timeout * 1.5).to_i # Give some buffer beyond timeout
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def key_prefix
|
|
141
|
+
config.redis_prefix || "where_is_waldo"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def session_key(session_id)
|
|
145
|
+
"#{key_prefix}:session:#{session_id}"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def online_subjects_key
|
|
149
|
+
"#{key_prefix}:online_subjects"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def subject_sessions_key(subject_id)
|
|
153
|
+
"#{key_prefix}:subject:#{subject_id}:sessions"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def session_subject_key(session_id)
|
|
157
|
+
"#{key_prefix}:session_subject:#{session_id}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def get_presence_data(session_id)
|
|
161
|
+
json = redis.get(session_key(session_id))
|
|
162
|
+
return nil unless json
|
|
163
|
+
|
|
164
|
+
JSON.parse(json)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def disconnect_session(session_id)
|
|
168
|
+
data = get_presence_data(session_id)
|
|
169
|
+
return unless data
|
|
170
|
+
|
|
171
|
+
subject_id = data["subject_id"]
|
|
172
|
+
|
|
173
|
+
redis.multi do |tx|
|
|
174
|
+
tx.del(session_key(session_id))
|
|
175
|
+
tx.srem(subject_sessions_key(subject_id), session_id)
|
|
176
|
+
tx.del(session_subject_key(session_id))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# If no more sessions for this subject, remove from online set
|
|
180
|
+
remaining = redis.scard(subject_sessions_key(subject_id))
|
|
181
|
+
redis.zrem(online_subjects_key, subject_id) if remaining.zero?
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def disconnect_subject(subject_id)
|
|
185
|
+
session_ids = redis.smembers(subject_sessions_key(subject_id))
|
|
186
|
+
session_ids.each { |sid| disconnect_session(sid) }
|
|
187
|
+
redis.zrem(online_subjects_key, subject_id)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def build_presence_hash(data)
|
|
191
|
+
{
|
|
192
|
+
session_id: data["session_id"],
|
|
193
|
+
subject_id: data["subject_id"],
|
|
194
|
+
connected_at: Time.zone.at(data["connected_at"]),
|
|
195
|
+
last_heartbeat: Time.zone.at(data["last_heartbeat"]),
|
|
196
|
+
last_activity: data["last_activity"] ? Time.zone.at(data["last_activity"]) : nil,
|
|
197
|
+
tab_visible: data["tab_visible"],
|
|
198
|
+
subject_active: data["subject_active"],
|
|
199
|
+
metadata: data["metadata"] || {},
|
|
200
|
+
subject: nil # Redis adapter doesn't eager load subjects
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
class Broadcaster
|
|
5
|
+
class << self
|
|
6
|
+
# Broadcast a message to subjects in an AR scope
|
|
7
|
+
# @param scope [ActiveRecord::Relation, ActiveRecord::Base] Subjects to broadcast to
|
|
8
|
+
# @param message_type [String, Symbol] Message type for client handler routing
|
|
9
|
+
# @param data [Hash] Message payload
|
|
10
|
+
def broadcast_to(scope, message_type, data = {})
|
|
11
|
+
subject_ids = extract_subject_ids(scope)
|
|
12
|
+
return if subject_ids.empty?
|
|
13
|
+
|
|
14
|
+
message = build_message(message_type, data)
|
|
15
|
+
|
|
16
|
+
subject_ids.each do |subject_id|
|
|
17
|
+
ActionCable.server.broadcast(subject_stream(subject_id), message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Broadcast to online subjects only (from an AR scope)
|
|
22
|
+
# @param scope [ActiveRecord::Relation] Scope to filter
|
|
23
|
+
# @param message_type [String, Symbol] Message type
|
|
24
|
+
# @param data [Hash] Message payload
|
|
25
|
+
def broadcast_to_online(scope, message_type, data = {})
|
|
26
|
+
online_ids = PresenceService.online_ids(scope)
|
|
27
|
+
return if online_ids.empty?
|
|
28
|
+
|
|
29
|
+
message = build_message(message_type, data)
|
|
30
|
+
|
|
31
|
+
online_ids.each do |subject_id|
|
|
32
|
+
ActionCable.server.broadcast(subject_stream(subject_id), message)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Broadcast to a specific session
|
|
37
|
+
# @param session_id [String] Session identifier
|
|
38
|
+
# @param message_type [String, Symbol] Message type
|
|
39
|
+
# @param data [Hash] Message payload
|
|
40
|
+
def broadcast_to_session(session_id, message_type, data = {})
|
|
41
|
+
status = PresenceService.session_status(session_id)
|
|
42
|
+
return false unless status
|
|
43
|
+
|
|
44
|
+
message = build_message(message_type, data, target_session: session_id)
|
|
45
|
+
ActionCable.server.broadcast(subject_stream(status[:subject_id]), message)
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def extract_subject_ids(scope)
|
|
52
|
+
case scope
|
|
53
|
+
when ActiveRecord::Relation
|
|
54
|
+
scope.pluck(:id)
|
|
55
|
+
when ActiveRecord::Base
|
|
56
|
+
[scope.id]
|
|
57
|
+
when Array
|
|
58
|
+
scope.map { |s| s.respond_to?(:id) ? s.id : s }
|
|
59
|
+
else
|
|
60
|
+
Array(scope)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_message(message_type, data, target_session: nil)
|
|
65
|
+
msg = {
|
|
66
|
+
type: message_type.to_s,
|
|
67
|
+
data: data,
|
|
68
|
+
timestamp: Time.current.iso8601
|
|
69
|
+
}
|
|
70
|
+
msg[:_target_session] = target_session if target_session
|
|
71
|
+
msg
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def subject_stream(subject_id)
|
|
75
|
+
"where_is_waldo:subject:#{subject_id}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
class PresenceService
|
|
5
|
+
class << self
|
|
6
|
+
# Register a new presence
|
|
7
|
+
# @param session_id [String] Unique session identifier
|
|
8
|
+
# @param subject_id [Integer/String] Subject identifier (user, member, etc.)
|
|
9
|
+
# @param metadata [Hash] Additional data
|
|
10
|
+
# @return [Boolean] success
|
|
11
|
+
def connect(session_id:, subject_id:, metadata: {})
|
|
12
|
+
adapter.connect(
|
|
13
|
+
session_id: session_id,
|
|
14
|
+
subject_id: subject_id,
|
|
15
|
+
metadata: metadata
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Remove a presence by session, or all presences for a subject
|
|
20
|
+
# @param session_id [String] Session identifier (optional)
|
|
21
|
+
# @param subject_id [Integer/String] Subject identifier (optional)
|
|
22
|
+
# @return [Boolean] success
|
|
23
|
+
def disconnect(session_id: nil, subject_id: nil)
|
|
24
|
+
adapter.disconnect(
|
|
25
|
+
session_id: session_id,
|
|
26
|
+
subject_id: subject_id
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Update heartbeat for a session
|
|
31
|
+
# @param session_id [String] Session identifier
|
|
32
|
+
# @param tab_visible [Boolean] Is tab in foreground
|
|
33
|
+
# @param subject_active [Boolean] Recent activity
|
|
34
|
+
# @param last_activity_at [Integer] Unix timestamp (ms) of last user activity
|
|
35
|
+
# @param metadata [Hash] Additional data to merge
|
|
36
|
+
# @return [Boolean] success
|
|
37
|
+
def heartbeat(session_id:, tab_visible: true, subject_active: true, last_activity_at: nil, metadata: {})
|
|
38
|
+
adapter.heartbeat(
|
|
39
|
+
session_id: session_id,
|
|
40
|
+
tab_visible: tab_visible,
|
|
41
|
+
subject_active: subject_active,
|
|
42
|
+
last_activity_at: last_activity_at,
|
|
43
|
+
metadata: metadata
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get online subjects from an AR scope
|
|
48
|
+
# @param scope [ActiveRecord::Relation] Scope to filter
|
|
49
|
+
# @param timeout [Integer] Seconds threshold (defaults to config.timeout)
|
|
50
|
+
# @return [ActiveRecord::Relation] Online subjects from the scope
|
|
51
|
+
def online(scope, timeout: nil)
|
|
52
|
+
online_ids = adapter.online_subject_ids(timeout: timeout)
|
|
53
|
+
scope.where(id: online_ids)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Get online subject IDs from an AR scope
|
|
57
|
+
# @param scope [ActiveRecord::Relation] Scope to filter
|
|
58
|
+
# @param timeout [Integer] Seconds threshold (defaults to config.timeout)
|
|
59
|
+
# @return [Array<Integer>] Online subject IDs
|
|
60
|
+
def online_ids(scope, timeout: nil)
|
|
61
|
+
online_ids = adapter.online_subject_ids(timeout: timeout)
|
|
62
|
+
scope.where(id: online_ids).pluck(:id)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Get all online subject IDs (no scope filtering)
|
|
66
|
+
# @param timeout [Integer] Seconds threshold
|
|
67
|
+
# @return [Array<Integer>]
|
|
68
|
+
def all_online_ids(timeout: nil)
|
|
69
|
+
adapter.online_subject_ids(timeout: timeout)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get all sessions for a subject
|
|
73
|
+
# @param subject_id [Integer/String] Subject identifier
|
|
74
|
+
# @return [Array<Hash>] Presence records
|
|
75
|
+
delegate :sessions_for_subject, to: :adapter
|
|
76
|
+
|
|
77
|
+
# Get status of a specific session
|
|
78
|
+
# @param session_id [String] Session identifier
|
|
79
|
+
# @return [Hash, nil] Presence record or nil
|
|
80
|
+
delegate :session_status, to: :adapter
|
|
81
|
+
|
|
82
|
+
# Check if a subject is online
|
|
83
|
+
# @param subject_id [Integer/String] Subject identifier
|
|
84
|
+
# @return [Boolean]
|
|
85
|
+
def subject_online?(subject_id)
|
|
86
|
+
sessions_for_subject(subject_id).any?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Cleanup stale records
|
|
90
|
+
# @param timeout [Integer] Seconds threshold (defaults to config.timeout)
|
|
91
|
+
# @return [Integer] Number of records removed
|
|
92
|
+
def cleanup(timeout: nil)
|
|
93
|
+
adapter.cleanup(timeout: timeout)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def adapter
|
|
99
|
+
@adapter ||= build_adapter
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def build_adapter
|
|
103
|
+
case WhereIsWaldo.config.adapter
|
|
104
|
+
when :database
|
|
105
|
+
Adapters::DatabaseAdapter.new
|
|
106
|
+
when :redis
|
|
107
|
+
Adapters::RedisAdapter.new
|
|
108
|
+
else
|
|
109
|
+
raise ArgumentError, "Unknown adapter: #{WhereIsWaldo.config.adapter}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Reset adapter (useful for testing or config changes)
|
|
114
|
+
def reset_adapter!
|
|
115
|
+
@adapter = nil
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhereIsWaldo
|
|
4
|
+
# Live presence roster for an org/account: "who's around, and how present are
|
|
5
|
+
# they right now." Built for efficiency — a single shared ActionCable stream
|
|
6
|
+
# per org, an initial snapshot on subscribe, and compact deltas broadcast only
|
|
7
|
+
# on state transitions (never on steady-state heartbeats).
|
|
8
|
+
#
|
|
9
|
+
# A member's presence is reported per device AND as an overall roll-up:
|
|
10
|
+
#
|
|
11
|
+
# { id: 7, status: "active", devices: { "web" => "active", "mobile" => "idle" } }
|
|
12
|
+
#
|
|
13
|
+
# `devices[platform]` is that platform's own aggregate status (several browser
|
|
14
|
+
# tabs roll up into one "web" status); `status` is the highest level across
|
|
15
|
+
# all platforms — the "is the user active anywhere?" answer, while
|
|
16
|
+
# `devices["mobile"]` answers "is the user active on mobile?". Each status is:
|
|
17
|
+
#
|
|
18
|
+
# active - a live session on that device is tab-visible AND working
|
|
19
|
+
# idle - a live session is tab-visible but not actively using
|
|
20
|
+
# background - only backgrounded/hidden sessions are live (tab hidden or
|
|
21
|
+
# mobile app backgrounded)
|
|
22
|
+
# offline - no live sessions (overall status only; absent from devices)
|
|
23
|
+
#
|
|
24
|
+
# Enable by configuring `roster_org` (and optionally `roster_members`).
|
|
25
|
+
class Roster
|
|
26
|
+
# Activity ranking used to pick a subject's aggregate status from the "most
|
|
27
|
+
# present" of their sessions.
|
|
28
|
+
RANK = { active: 3, idle: 2, background: 1, offline: 0 }.freeze
|
|
29
|
+
DEFAULT_PLATFORM = "web"
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# Shared stream name for an org record (nil when org is absent).
|
|
33
|
+
def stream_name(org)
|
|
34
|
+
return nil unless org
|
|
35
|
+
|
|
36
|
+
klass = org.class.respond_to?(:base_class) ? org.class.base_class : org.class
|
|
37
|
+
"where_is_waldo:roster:#{klass.name}:#{org.id}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Shared stream name for the org a subject belongs to (nil when the roster
|
|
41
|
+
# is unconfigured or the subject has no org).
|
|
42
|
+
def stream_for_subject(subject)
|
|
43
|
+
stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Full roster state for an org: every member subject with their current
|
|
47
|
+
# aggregate presence. Sent once, on subscribe.
|
|
48
|
+
# @return [Array<Hash>] member state hashes
|
|
49
|
+
def snapshot(org, timeout: nil)
|
|
50
|
+
members = WhereIsWaldo.config.resolve_members(org)
|
|
51
|
+
return [] unless members
|
|
52
|
+
|
|
53
|
+
members_for(members, timeout: timeout).values
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Member states for an arbitrary AR scope of subjects, keyed by id. Used
|
|
57
|
+
# by :poll to snapshot / diff a viewer's visible scope.
|
|
58
|
+
# @return [Hash{Object => Hash}] id => member hash
|
|
59
|
+
def members_for(scope, timeout: nil)
|
|
60
|
+
return {} unless scope
|
|
61
|
+
|
|
62
|
+
records = scope.to_a
|
|
63
|
+
states = states_for(records.map(&:id), timeout: timeout)
|
|
64
|
+
records.to_h { |record| [record.id, build_member(record, states[record.id])] }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# === Cable message builders — identical shapes across every delivery mode
|
|
68
|
+
# so the client reducer never forks ===
|
|
69
|
+
|
|
70
|
+
def snapshot_message(members, mode:)
|
|
71
|
+
message = { type: "roster_snapshot", mode: mode.to_s, members: members }
|
|
72
|
+
message[:poll_interval] = WhereIsWaldo.config.roster_poll_interval if %i[poll nudge].include?(mode.to_sym)
|
|
73
|
+
message[:nudge_jitter] = WhereIsWaldo.config.roster_nudge_jitter if mode.to_sym == :nudge
|
|
74
|
+
message
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def delta_message(member)
|
|
78
|
+
{ type: "roster_delta", member: member }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Content-free "roster changed, re-poll" trigger (:nudge mode). Carries no
|
|
82
|
+
# identity/state, so it leaks nothing beyond "activity happened".
|
|
83
|
+
def nudge_message
|
|
84
|
+
{ type: "roster_nudge" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Tells the client to drop a member who left the viewer's visible scope
|
|
88
|
+
# (distinct from going offline, which stays present with status "offline").
|
|
89
|
+
def removed_message(subject_id)
|
|
90
|
+
{ type: "roster_delta", member: { id: subject_id, _removed: true } }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Aggregate presence state for a single subject across all their sessions.
|
|
94
|
+
# @return [Hash] { status:, devices: { platform => status } }
|
|
95
|
+
def state_for(subject_id, timeout: nil)
|
|
96
|
+
states_for([subject_id], timeout: timeout).fetch(subject_id, offline_state)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Presence status for a subject on a specific device/platform, e.g.
|
|
100
|
+
# WhereIsWaldo::Roster.device_status(user.id, :mobile) # => "idle"
|
|
101
|
+
# Answers "is the user active on mobile?" vs the overall state_for.
|
|
102
|
+
# @return [String] "active" | "idle" | "background" | "offline"
|
|
103
|
+
def device_status(subject_id, platform, timeout: nil)
|
|
104
|
+
state_for(subject_id, timeout: timeout)[:devices][platform.to_s] || "offline"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Broadcast a compact delta for one subject to their org roster stream.
|
|
108
|
+
# Intended to be called ONLY on transitions (connect/disconnect/active
|
|
109
|
+
# flip). No-ops safely when the roster is unconfigured or the subject has
|
|
110
|
+
# no org. Recomputes the subject's aggregate so multi-session state (e.g.
|
|
111
|
+
# a second tab still active) is always correct.
|
|
112
|
+
def publish(subject_id, timeout: nil)
|
|
113
|
+
return false if subject_id.blank?
|
|
114
|
+
|
|
115
|
+
subject = find_subject(subject_id)
|
|
116
|
+
stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
|
|
117
|
+
return false unless stream
|
|
118
|
+
|
|
119
|
+
member = build_member_by_id(subject, subject_id, timeout: timeout)
|
|
120
|
+
ActionCable.server.broadcast(stream, delta_message(member))
|
|
121
|
+
true
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
Rails.logger&.warn("[WhereIsWaldo::Roster] publish failed for #{subject_id}: #{e.class}: #{e.message}")
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Per-viewer stream (:fanout mode). Each viewer streams only their own.
|
|
128
|
+
def viewer_stream(viewer_id)
|
|
129
|
+
"where_is_waldo:roster:viewer:#{viewer_id}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Push a subject's delta to every viewer allowed to see them (:fanout
|
|
133
|
+
# mode). Directional audience (config.roster_viewers_of) makes this correct
|
|
134
|
+
# for arbitrary/asymmetric visibility, at O(audience) broadcasts.
|
|
135
|
+
def publish_fanout(subject_id, timeout: nil)
|
|
136
|
+
return false if subject_id.blank?
|
|
137
|
+
|
|
138
|
+
subject = find_subject(subject_id)
|
|
139
|
+
audience = subject && WhereIsWaldo.config.resolve_viewers_of(subject)
|
|
140
|
+
return false unless audience
|
|
141
|
+
|
|
142
|
+
message = delta_message(build_member_by_id(subject, subject_id, timeout: timeout))
|
|
143
|
+
audience.pluck(:id).each { |viewer_id| ActionCable.server.broadcast(viewer_stream(viewer_id), message) }
|
|
144
|
+
true
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
Rails.logger&.warn("[WhereIsWaldo::Roster] fanout failed for #{subject_id}: #{e.class}: #{e.message}")
|
|
147
|
+
false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Broadcast a content-free nudge to a subject's org roster stream (:nudge
|
|
151
|
+
# mode). Tells watchers to re-poll; carries no protected data.
|
|
152
|
+
def publish_nudge(subject_id)
|
|
153
|
+
return false if subject_id.blank?
|
|
154
|
+
|
|
155
|
+
subject = find_subject(subject_id)
|
|
156
|
+
stream = stream_name(WhereIsWaldo.config.resolve_roster_org(subject))
|
|
157
|
+
return false unless stream
|
|
158
|
+
|
|
159
|
+
ActionCable.server.broadcast(stream, nudge_message)
|
|
160
|
+
true
|
|
161
|
+
rescue StandardError => e
|
|
162
|
+
Rails.logger&.warn("[WhereIsWaldo::Roster] nudge failed for #{subject_id}: #{e.class}: #{e.message}")
|
|
163
|
+
false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# Aggregate state for many subjects in one query, keyed by subject id.
|
|
169
|
+
def states_for(subject_ids, timeout: nil)
|
|
170
|
+
ids = Array(subject_ids).compact.uniq
|
|
171
|
+
return {} if ids.empty?
|
|
172
|
+
|
|
173
|
+
threshold = (timeout || WhereIsWaldo.config.timeout).seconds.ago
|
|
174
|
+
subject_col = Presence.subject_column
|
|
175
|
+
|
|
176
|
+
rows = Presence.where(subject_col => ids)
|
|
177
|
+
.where("last_heartbeat > ?", threshold)
|
|
178
|
+
.to_a
|
|
179
|
+
|
|
180
|
+
rows.group_by { |row| row[subject_col] }
|
|
181
|
+
.transform_values { |sessions| aggregate(sessions) }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Reduce a subject's live sessions to per-device statuses plus an overall
|
|
185
|
+
# roll-up. Sessions are grouped by platform (so several browser tabs form
|
|
186
|
+
# one "web" status); the overall status is the highest across platforms.
|
|
187
|
+
# @return [Hash] { status: "active", devices: { "web" => "active", ... } }
|
|
188
|
+
def aggregate(sessions)
|
|
189
|
+
devices = sessions.group_by { |s| platform(s) }
|
|
190
|
+
.transform_values { |sess| platform_level(sess) }
|
|
191
|
+
overall = devices.values.max_by { |level| RANK[level] } || :offline
|
|
192
|
+
{
|
|
193
|
+
status: overall.to_s,
|
|
194
|
+
devices: devices.transform_values(&:to_s)
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Aggregate activity level for the sessions on one platform.
|
|
199
|
+
def platform_level(sessions)
|
|
200
|
+
sessions.map { |s| session_level(s) }.max_by { |level| RANK[level] } || :offline
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Activity level of one live session. Uniform across web and mobile:
|
|
204
|
+
# a hidden tab / backgrounded app is :background; a visible/foreground
|
|
205
|
+
# session is :active when working, else :idle.
|
|
206
|
+
def session_level(session)
|
|
207
|
+
return :background unless session.tab_visible
|
|
208
|
+
|
|
209
|
+
session.subject_active ? :active : :idle
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def platform(session)
|
|
213
|
+
meta = session.metadata
|
|
214
|
+
value = meta && (meta["platform"] || meta[:platform])
|
|
215
|
+
(value.presence || DEFAULT_PLATFORM).to_s
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Build a member hash from an already-loaded state map entry.
|
|
219
|
+
def build_member(record, state)
|
|
220
|
+
merge_member(record, state || offline_state)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Build a member hash by recomputing the subject's aggregate state.
|
|
224
|
+
def build_member_by_id(record, subject_id, timeout: nil)
|
|
225
|
+
merge_member(record, state_for(subject_id, timeout: timeout), id: subject_id)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def merge_member(record, state, id: nil)
|
|
229
|
+
data = record ? WhereIsWaldo.config.build_subject_data(record) : {}
|
|
230
|
+
data.merge(
|
|
231
|
+
id: id || record&.id,
|
|
232
|
+
status: state[:status],
|
|
233
|
+
devices: state[:devices]
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def offline_state
|
|
238
|
+
{ status: "offline", devices: {} }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def find_subject(subject_id)
|
|
242
|
+
WhereIsWaldo.config.subject_class_constant&.find_by(id: subject_id)
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|