Class: PresenceChannel

Inherits:
Object
  • Object
show all
Defined in:
lib/presence_channel.rb

Overview

The server-side implementation of PresenceChannels. See also PresenceController and app/assets/javascripts/discourse/app/services/presence.js

Defined Under Namespace

Classes: Config, ConfigNotLoaded, InvalidAccess, InvalidConfig, NotFound, State

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name, raise_not_found: true, use_cache: true) ⇒ PresenceChannel

Returns a new instance of PresenceChannel.



86
87
88
89
90
91
92
93
94
95
96
97
98
# File 'lib/presence_channel.rb', line 86

def initialize(name, raise_not_found: true, use_cache: true)
  @name = name
  @message_bus_channel_name = "/presence#{name}"

  begin
    @config = fetch_config(use_cache: use_cache)
  rescue PresenceChannel::NotFound
    raise if raise_not_found
    @config = Config.new
  end

  @timeout = config.timeout || DEFAULT_TIMEOUT
end

Instance Attribute Details

#configObject (readonly)

Returns the value of attribute config.



84
85
86
# File 'lib/presence_channel.rb', line 84

def config
  @config
end

#message_bus_channel_nameObject (readonly)

Returns the value of attribute message_bus_channel_name.



84
85
86
# File 'lib/presence_channel.rb', line 84

def message_bus_channel_name
  @message_bus_channel_name
end

#nameObject (readonly)

Returns the value of attribute name.



84
85
86
# File 'lib/presence_channel.rb', line 84

def name
  @name
end

#timeoutObject (readonly)

Returns the value of attribute timeout.



84
85
86
# File 'lib/presence_channel.rb', line 84

def timeout
  @timeout
end

Class Method Details

.auto_leave_allObject

Designed to be run periodically. Checks the channel list for channels with expired members, and runs auto_leave for each eligible channel



220
221
222
223
224
# File 'lib/presence_channel.rb', line 220

def self.auto_leave_all
  channels_with_expiring_members =
    PresenceChannel.redis.zrangebyscore(redis_key_channel_list, "-inf", Time.zone.now.to_i)
  channels_with_expiring_members.each { |name| new(name, raise_not_found: false).auto_leave }
end

.clear_all!Object

Clear all known channels. This is intended for debugging/development only



227
228
229
230
231
232
233
234
235
236
237
# File 'lib/presence_channel.rb', line 227

def self.clear_all!
  channels = PresenceChannel.redis.zrangebyscore(redis_key_channel_list, "-inf", "+inf")
  channels.each { |name| new(name, raise_not_found: false).clear }

  config_cache_keys =
    PresenceChannel
      .redis
      .scan_each(match: Discourse.redis.namespace_key("_presence_*_config"))
      .to_a
  PresenceChannel.redis.del(*config_cache_keys) if config_cache_keys.present?
end

.redisObject

Shortcut to access a redis client for all PresenceChannel activities. PresenceChannel must use the same Redis server as MessageBus, so that actions can be applied atomically. For the vast majority of Discourse installations, this is the same Redis server as ‘Discourse.redis`.



243
244
245
246
247
248
249
250
251
# File 'lib/presence_channel.rb', line 243

def self.redis
  if MessageBus.backend == :redis
    MessageBus.backend_instance.send(:pub_redis) # TODO: avoid a private API?
  elsif Rails.env.test?
    Discourse.redis.without_namespace
  else
    raise "PresenceChannel is unable to access MessageBus's Redis instance"
  end
end

.redis_eval(key, *args) ⇒ Object



253
254
255
# File 'lib/presence_channel.rb', line 253

def self.redis_eval(key, *args)
  LUA_SCRIPTS[key].eval(redis, *args)
end

.register_prefix(prefix, &block) ⇒ Object

Register a callback to configure channels with a given prefix Prefix must match [a-zA-Z0-9_-]+

For example, this registration will be used for all channels starting /topic-reply/…:

register_prefix("topic-reply") do |channel_name|
  PresenceChannel::Config.new(public: true)
end

At runtime, the block will be passed a full channel name. If the channel should not exist, the block should return ‘nil`. If the channel should exist, the block should return a PresenceChannel::Config object.

Return values may be cached for up to 10 seconds.

Plugins should use the Plugin::Instance#register_presence_channel_prefix API instead



274
275
276
277
278
279
280
281
282
# File 'lib/presence_channel.rb', line 274

def self.register_prefix(prefix, &block)
  unless prefix.match? /[a-zA-Z0-9_-]+/
    raise "PresenceChannel prefix #{prefix} must match [a-zA-Z0-9_-]+"
  end
  if @@configuration_blocks&.[](prefix)
    raise "PresenceChannel prefix #{prefix} already registered"
  end
  @@configuration_blocks[prefix] = block
end

.unregister_prefix(prefix) ⇒ Object

For use in a test environment only



285
286
287
288
# File 'lib/presence_channel.rb', line 285

def self.unregister_prefix(prefix)
  raise "Only allowed in test environment" if !Rails.env.test?
  @@configuration_blocks&.delete(prefix)
end

Instance Method Details

#auto_leaveObject

Automatically expire all users which have not been ‘present’ for more than DEFAULT_TIMEOUT



193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
# File 'lib/presence_channel.rb', line 193

def auto_leave
  mutex_value = SecureRandom.hex
  left_user_ids =
    retry_on_mutex_error do
      PresenceChannel.redis_eval(:auto_leave, redis_keys, [name, Time.zone.now.to_i, mutex_value])
    end

  if !left_user_ids.empty?
    begin
      publish_message(leaving_user_ids: left_user_ids)
    ensure
      release_mutex(mutex_value)
    end
  end
end

#can_enter?(user_id: nil, group_ids: nil) ⇒ Boolean

Is a user allowed to enter this channel? Currently equal to the the can_view? permission

Returns:

  • (Boolean)


116
117
118
119
# File 'lib/presence_channel.rb', line 116

def can_enter?(user_id: nil, group_ids: nil)
  return false if user_id.nil?
  can_view?(user_id: user_id, group_ids: group_ids)
end

#can_view?(user_id: nil, group_ids: nil) ⇒ Boolean

Is this user allowed to view this channel? Pass ‘nil` for anonymous viewers

Returns:

  • (Boolean)


102
103
104
105
106
107
108
109
110
111
112
# File 'lib/presence_channel.rb', line 102

def can_view?(user_id: nil, group_ids: nil)
  return true if config.public
  return true if user_id && config.allowed_user_ids&.include?(user_id)

  if user_id && config.allowed_group_ids.present?
    return true if config.allowed_group_ids.include?(Group::AUTO_GROUPS[:everyone])
    group_ids ||= GroupUser.where(user_id: user_id).pluck("group_id")
    return true if (group_ids & config.allowed_group_ids).present?
  end
  false
end

#clearObject

Clear all members of the channel. This is intended for debugging/development only



210
211
212
213
214
215
216
# File 'lib/presence_channel.rb', line 210

def clear
  PresenceChannel.redis.del(redis_key_zlist)
  PresenceChannel.redis.del(redis_key_hash)
  PresenceChannel.redis.del(redis_key_config)
  PresenceChannel.redis.del(redis_key_mutex)
  PresenceChannel.redis.zrem(self.class.redis_key_channel_list, name)
end

#countObject



188
189
190
# File 'lib/presence_channel.rb', line 188

def count
  state(count_only: true).count
end

#leave(user_id:, client_id:) ⇒ Object

Immediately mark a user’s client as leaving the channel



147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'lib/presence_channel.rb', line 147

def leave(user_id:, client_id:)
  mutex_value = SecureRandom.hex
  result =
    retry_on_mutex_error do
      PresenceChannel.redis_eval(:leave, redis_keys, [name, user_id, client_id, nil, mutex_value])
    end

  if result == 1
    begin
      publish_message(leaving_user_ids: [user_id])
    ensure
      release_mutex(mutex_value)
    end
  end
end

#present(user_id:, client_id:) ⇒ Object

Mark a user’s client as present in this channel. The client_id should be unique per browser tab. This method should be called repeatedly (at least once every DEFAULT_TIMEOUT) while the user is present in the channel.



124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
# File 'lib/presence_channel.rb', line 124

def present(user_id:, client_id:)
  raise PresenceChannel::InvalidAccess if !can_enter?(user_id: user_id)

  mutex_value = SecureRandom.hex
  result =
    retry_on_mutex_error do
      PresenceChannel.redis_eval(
        :present,
        redis_keys,
        [name, user_id, client_id, (Time.zone.now + timeout).to_i, mutex_value],
      )
    end

  if result == 1
    begin
      publish_message(entering_user_ids: [user_id])
    ensure
      release_mutex(mutex_value)
    end
  end
end

#state(count_only: config.count_only) ⇒ Object

Fetch a State instance representing the current state of this

Parameters:

  • count_only (Boolean) (defaults to: config.count_only)

    set true to skip fetching the list of user ids from redis



166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
# File 'lib/presence_channel.rb', line 166

def state(count_only: config.count_only)
  if count_only
    last_id, count = retry_on_mutex_error { PresenceChannel.redis_eval(:count, redis_keys) }
  else
    last_id, ids = retry_on_mutex_error { PresenceChannel.redis_eval(:user_ids, redis_keys) }
  end
  count ||= ids&.count
  last_id = nil if last_id == -1

  if Rails.env.test? && MessageBus.backend == :memory
    # Doing it this way is not atomic, but we have no other option when
    # messagebus is not using the redis backend
    last_id = MessageBus.last_id(message_bus_channel_name)
  end

  State.new(message_bus_last_id: last_id, user_ids: ids, count: count)
end

#user_idsObject



184
185
186
# File 'lib/presence_channel.rb', line 184

def user_ids
  state.user_ids
end