Is there any way in action cable to modify the message being sent based on the user who established the connection (the conneciton is identified_by the current user).
Specifically, I have a subscription based on a particular model so I can write code like:
ProjectChannel.broadcast_to(project, { action: action, data: project.to_json })
However, I'd like to send different messages to subscribers based on whether or not they are owners of the project or merely are authorized to view the project. Is this possible? Or does the underlying pubsub model make this impossible?
So one can setup multiple streams inside a single channel. Thus, if one had two types of users (say admin and non-admin) you could subscribe only the admin users to an admin stream like so:
def subscribed
stream_from "all_user_stream"
stream_from "admin_stream" if current_user.is_admin?
stream_from "non_admin_stream" unless current_user.is_admin?
end
Now you can send a message to just the admin users via
ActionCable.server.broadcast "admin_stream", msg
And to all users via
ActionCable.server.broadcast "all_user_stream", msg
So if you want a different message to go to the admin users and the non-admin users you just create a method that sends one message to the admin_stream and a different message to the non_admin_stream. For instance, somewhere in your app you could write:
def send_user_msg(msg, secret) #assume msg is a hash
ActionCable.server.broadcast "non_admin_stream", msg
msg['secret'] = secret
ActionCable.server.broadcast "admin_stream", msg
end
Now only admin users will get the secret in their message and the rest of the users will get the same message without the secret.
Note that, if you are using stream_for with a model you'll probably want to retrieve the actual name of the stream (stream_for is just a wrapper to stream_from that encodes the active record object into a unique string) and then modify that.
For instance, if you are working in a channel named DocumentsChannel then stream_for doc essentially does:
stream_from DocumentsChannel.broadcasting_for(doc)
So if you want multiple streams you could stream_from "#{DocumentsChannel.broadcasting_for(doc)}:admin" and "#{DocumentsChannel.broadcasting_for(doc)}:non_admin"
One note of caution here. This doesn't update if the attribute changes so if you have a user whose admin status is removed they will keep receiving the admin messages until either they disconnect or you explicitly use stop_stream_from to end that streaming. Since that later code has to be called from the Channel instance opened for that user's connection this isn't necessarily a good option for properties that might be changed by outside factors (i.e. in response to anything other than a message on that channel from the connection you want to modify).
Related
Question
Is there a use case where stream_from has an advantage over stream_for?
Example
In this contrived example, I create a ChatChannel which can connect to a different rooms. Each version of the code also includes the method for broadcasting a message to that room and a comment in the subscribed method showing the room name should room == '123'.
Using stream_from
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_#{params[:room]}" #=> "chat_123"
end
end
ActionCable.server.broadcast("chat_#{room}", data)
Using stream_for
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_for params[:room] #=> "chat:123"
end
end
ChatChannel.broadcast_to(room, data)
One interesting thing to point out is that using the stream_for approach, you can still use ActionCable.server.broadcast("chat:#{room}", data) (note the change of _ to :) but you cannot use ChatChannel.broadcast_to with the stream_from approach.
Reasoning
stream_from is more "dangerous" as it sets a global room which requires a convention maintained by the author(s). Additionally, broadcasting can only be done directly from ActionCable.server.
stream_for namespaces the room and broadcasting can be accessed from the channel's class method broadcast_to. While a global room is still created, the naming convention is maintained by Rails.
Best Guess
The only difference that I've found so far is that stream_from would allow multiple channels to transmit the same message with a single call. E.g. If I had both a ChatChannel and NotificationChannel and both contain stream_from 'common', I'd be able to broadcast on both channels at once with ActionCable.server.broadcast('common', data). This seems like a code smell to me though.
A quick correction on your example with the stream_for approach. stream_for requires a model so your example should be
class ChatChannel < ApplicationCable::Channel
def subscribed
room = Room.find params[:room]
stream_for room
end
end
Note:
Your approach will still work, the only difference is the resulting named broadcasting for the stream. This will make more sense as you will soon see.
Are there any differences between stream_for and stream_from? I'll say not much but to answer that question factually, let us look at how they both work under the hood.
stream_for
Takes a model and inturn calls stream_from. The result is a serializable string that is used as the named broadcasting. So when you call stream_from on a
model, the named broadcasting becomes the model global id
hash, the named broadcasting becomes a query parameter
string, the name broadcasting becomes the string itself
So stream_for works like this stream_for -> broadcasting_for -> stream_from
broadcasting_for is the mechanism that ActionCable uses to generate unique named broadcastings for objects in your application. This ensures every object has a unique identifier in your application lifecycle.
For ActiveRecord models, rails call to_gid_param on them which returns a unique identifier for that model instance. If you update the model, change an attribute, reload the model, calling to_gid_param on that model will always return the same string. You can try this in your rails console, call to_gid_param on an AR model instance.
For other types of objects, rails call to_param which converts the object to URL safe query parameters. One special case is Array, see how rails handle that here.
stream_from
The fundamental difference here is that you don't need to pass a model, just pass a string as the named broadcasting and you are good to go. If you pass anything other than a string, it still gets converted to a string.
Clarifying your doubts
One interesting thing to point out is that using the stream_for approach, you can still use ActionCable.server.broadcast("chat:#{room}", data) (note the change of _ to :) but you cannot use ChatChannel.broadcast_to with the stream_from approach.
Well, broadcast_to still ends up calling ActionCable.server.broadcast if you want this sort of behavior in all of your channels, you can just add a broadcast class method to your base class typically ApplicationCable::Channel
module ApplicationCable
class Channel < ActionCable::Channel::Base
def self.broadcast(broadcasting, data)
ActionCable.server.broadcast(broadcasting, data)
end
end
end
You can even take it a step further by unifying broadcast_to here, since you have access to braodcasting_for in your channel. Try ChatChannel.broadcasting_for(arg) in your rails console passing different value including models to see what I mean.
stream_from is more "dangerous" as it sets a global room that requires a convention maintained by the author(s). Additionally, broadcasting can only be done directly from ActionCable.server.
Like I said earlier you can circumvent this, see the easy solution above. As for stream_for being more dangerous how would you define dangerous? If you mean because you have to maintain the named broadcasting yourself, I will provide you with instances where this makes sense.
In your example, you said stream_from sets a global room.
Note that a subscription will not be established, except your client code explicitly subscribe to your channel. So even if you use stream_for "chat_#{params[:room]}" a connection will never be established unless your client-side code subscribes to the room.
If I had both a ChatChannel and NotificationChannel and both contain stream_from 'common', I'd be able to broadcast on both channels at once with ActionCable.server.broadcast('common', data). This seems like a code smell to me though.
The ActionCable docs recommend that at the very least, a consumer should be subscribed to one channel.
How you choose to use stream_from is what results in a code smell, this is not a problem with ActionCable/Rails.
When should one use stream_from?
If you plan to craft complex named broadcasting to route messages to a client based on roles or some criteria, using stream_from can help you here. This will ensure only users that meet those defined criteria gets the broadcast.
Example
I want to broadcast a message to everyone in a room
class ChatChannel < ApplicationCable::Channel
def subscribed
room = Room.find_by_name params[:room]
stream_for room
end
end
The client will end up subscribing to this channel like this
consumer.subscriptions.create({ channel: "ChatChannel", room: "gaming" })
Every client will get this message.
What if you want to enforce some sort of security without reaching for a complex solution? We can use stream_from to achieve some sort of privacy. Say you want to send a message to a particular user in the Gaming room, we can craft a named broadcasting like so
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_from "chat_#{params[:room]}_#{params[:user]}"
end
end
The client will end up subscribing to the above channel like so
consumer.subscriptions.create({ channel: "ChatChannel", room: "gaming", user: "1" })
This way if you broadcast a message to the above-named broadcasting, only the user with ID 1 will get the message.
Basically I am trying to add live chatrooms to my Rails website. In my app there are 2 models: Client and Professionnel. Clients can ask quotes to professionels. (Edit: Quote is a model too)
For each quote I want to have a live chat available between client and professionnel.
I have already done it with a classic Message channel, appending the right message to the right quote. Yet it doesn't fulfill privacy principle as every Client and every Professionnel receive all chat messages.
At server level, I can easily subscrible the user (Client or Professionnel) to a quote specific stream like :
class QuoteChannel < ApplicationCable::Channel
def subscribed
if current_user
if current_user.quotes.any?
current_user.quotes.each do |quote|
stream_from "#{quote.hashed_id.to_s}_channel"
end
end
end
end
def unsubscribed
end
end
Though I am a bit stuck for the client side. My quote.coffee.erb doesn't accept that I use current_user (as defined in Actioncable connection file) or any Devise identifier current_client or current_professionnel.
So I am not sure how I can personnalize my subscriptions on the client side. I have read that it is possible to catch an element broadcast in the message but I am not sure how I can do with my current Coffee.erb file :
App.quote = App.cable.subscriptions.create "QuoteChannel",
connected: ->
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
received: (data) ->
$('#cell'+data.quoteid).append '<div>'+'<span>'+data.message+'</span>'+'</div>'
$('.sendmessageinputtext').val("")
quoteid is passed to the received function but I need to create as many streams as the user owns quotes. In this thread http://www.thegreatcodeadventure.com/rails-5-action-cable-with-multiple-chatroom-subscriptions/ the tutor iterates across all available Chatrooms, I could do the same but it is stupid as there may be thousands of live quotes at a certain time, of which only a few are owned by the current_user which has been allowed an Actioncable connection.
I have an ActionCable method that subscribes the user. If a new convo is started, I want to subscribe the user to the new channel as well. I can't figure out the proper syntax for calling a channel method in a controller.
UPDATE: The issue is that the messages are appended to the chatbox when sent, but when the first message, is sent, the websocket connection is not established yet, and therefore it looks to the user as if the message was not sent (because it's not being appended).
channel/msgs_channel.rb
class MsgsChannel < ApplicationCable::Channel
#This function subscribes the user to all existing convos
def subscribed
#convos = Convo.where("sender_id = ? OR recipient_id = ?", current_user, current_user)
#convos.each do |convo|
stream_from "msg_channel_#{convo.id}"
end
end
#This is a new function I wrote to subscribe the user to a new convo that is started during their session.
def subscribe(convo_id)
stream_from "msg_channel_#{convo_id}"
end
end
In my convos controller, create method, I have tried several things:
convos_controller.rb
def create
#convo = Convo.create!({sender_id: #sender_id, recipient_id: #recipient_id})
ActionCable.server.subscribe(#convo.id)
end
ActionCable.subscribe(#convo.id)
error:
NoMethodError (undefined methodsubscribe' for ActionCable:Module)`
ActionCable.msgs.subscribe(#convo.id)
error:
NoMethodError (undefined methodmsgs' for ActionCable:Module):`
App.msgs.subscribe(#convo.id)
error:NameError (uninitialized constant ConvosController::App):
MsgsChannel.subscribe(#convo.id)
error:NoMethodError (undefined methodsubscribe' for MsgsChannel:Class`
ActionCable.server.subscribe(#convo.id)
error:NoMethodError (undefined methodsubscribe' for #):`
Retrieve a Specific Channel Instance from a Controller
Channel instances are associated with the user's Connection. The following will work to get a hash of the Channels for a Connection:
conn = ActionCable.server.connections.first { |c| c.current_user == user_id }
# subs is a hash where the keys are json identifiers and the values are Channels
subs = conn.subscriptions.instance_variable_get("#subscriptions")
(If Connection#current_user does not exist then follow the instructions at the top of section 3.1.1 from the rails ActionCable overview to add it.)
You can then get a Channel based on whatever criteria you want. For instance you could search by type which in your case would be MsgsChannel. The json key in subs could also be used but might be more complicated.
Once you have this instance you can then call whatever method on it you want to (in your case MsgsChannel#subscribe).
Concerns About This Approach
A Channel just encapsulates server side work that will be kicked off in response to messages from the client side of the app. Having the server call a Channel method effectively amounts to having the server pretend that a consumer sent a message that it didn't send.
I would recommend instead of doing this putting whatever logic that you want to use both from a Controller and a Channel into a method in a shared location. The Controller and Channel can then directly call this method as needed.
That being said there is one painful caveat to this which is that you need the Channel to call stream_from. Unfortunately managing streams is specific to Channels even though fundamentally it is just updating pubsub information for the Server.
I think that stream management should really just require the appropriate connection and the Server with its pubsub. If that were the case you wouldn't need to worry about calling Channel methods from the server side of an application.
You should be able to effectively run stream_for more directly if you get a Channel, any Channel (you could use subs from the top of this answer as well) and call stream_for on it
conn = ActionCable.server.connections.first { |c| c.current_user == user_id }
MsgsChannel.new(conn, "made up id").stream_from "your_stream_name"
I haven't tried this myself but it looks like it should work.
So you should not be subscribing the user to the channel in the controller on create. It should be based on when they visit the page. You should change where users are connected by adding in a js/coffe file to do this for you based on who is connected. A good example/tutorial for this when I was learn was this video here.
What the video leaves out is how to connect to an individual conversation. So I struggled with it and found a way to grab the conversation id from the url, probably not the best way but it works. Hope this helps
conversation_id = parseInt(document.URL.match(new RegExp("conversations/"+ "(.*)"))[1]) # gets the conversation id from the url of the page
App.conversation = App.cable.subscriptions.create { channel: "ConversationChannel", conversation_id: conversation_id },
connected: ->
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
received: (data) ->
$('#messages').append data['message']
speak: (message) ->
#perform 'speak', message: message
Is there a way to terminate the subscription to a particular channel for any particular consumer from the server side (controller) so that disconnected callback in my coffee script file can be invoked?
http://api.rubyonrails.org/classes/ActionCable/Channel/Base.html#class-ActionCable::Channel::Base-label-Rejecting+subscription+requests
class ChatChannel < ApplicationCable::Channel
def subscribed
#room = Chat::Room[params[:room_number]]
reject unless current_user.can_access?(#room)
end
end
Before calling reject you can also inform the subscriber of the reject's reason:
class ChatChannel < ApplicationCable::Channel
def subscribed
if params["answerer"]
answerer = params["answerer"]
answerer_user = User.find_by email: answerer
if answerer_user
stream_from "chat_#{answerer_user}_channel"
else
connection.transmit identifier: params, error: "The user #{answerer} not found."
# http://api.rubyonrails.org/classes/ActionCable/Channel/Base.html#class-ActionCable::Channel::Base-label-Rejecting+subscription+requests
reject
end
else
connection.transmit identifier: params, error: "No params specified."
# http://api.rubyonrails.org/classes/ActionCable/Channel/Base.html#class-ActionCable::Channel::Base-label-Rejecting+subscription+requests
reject
end
end
end
The previous answers allow you to reject an attempt to subscribe to a channel. But they don't let you forcibly unsubscribe a connection after it's subscribed. A user might be ejected from a chatroom, say, so you need to cancel their subscription to the chatroom channel. I came up with this Pull Request to Rails to support this.
Essentially it adds an unsubscribe method to remote_connections so you can call:
subscription_identifier = "{\"channel\":\"ChatChannel\", \"chat_id\":1}"
remote_connection = ActionCable.server.remote_connections.where(current_user: User.find(1))
remote_connection.unsubscribe(subscription_identifier)
That sends a message on the internal_channel (which all connections are subscribed to) that the relevant connection responds to by removing its subscription to the specified channel.
Like Ollie's answer correctly pointed out, the other answers here are rejecting the ActionCable connection before it succeeds, but the question asks about disconnecting a subscription after it was already subscribed.
This question is very important because it deals with the scenario of an user being kicked out of a chatroom he was previously in. Unless you disconnect him from that subscription, he will continue to receive messages for that channel through the WebSocket until he closes his window/tab or reloads the page (because then a new subscription will be started and the server will not subscribe him to the chat he doesn't have permission anymore).
Ollie's answer points to a great pull request he made, because it allows to disconnect a specific stream, and not all open WebSockets connections a user has; the problem is it's not merged in Rails yet.
My solution is to use a documented API feature that already exists. Even tough it doesn't let you choose which stream you want to disconnect, you can disconnect all open websocket connects from a User.
In my tests this works fine because as soon as the disconnection happens, all tabs will try to resubscribe in a couple of seconds, and it will trigger the subscribed method in each ActionCable channel, thereby restarting the connections, but now based on the most up-to-date permissions from the server (which, of course, will not resubscribe him to the chat he was kicked out of).
The solution goes like this, assuming you have a join record ChatroomUser that is used to track if a specific user can read the chat in a specific chatroom:
class ChatroomUser < ApplicationRecord
belongs_to :chatroom
belongs_to :user
after_destroy :disconnect_action_cable_connections
private
def disconnect_action_cable_connections
ActionCable.server.remote_connections.where(current_user: self.user).disconnect
end
end
This uses this API (https://api.rubyonrails.org/classes/ActionCable/RemoteConnections.html), and assumes you have current_user set up in your ApplicationCable::Connection, as most people do (per tutorials).
You can do something like this.
class YourChannel < ApplicationCable::Channel
#your code
def your_custom_action
if something
reject_subscription
end
end
end
I'm building a messenger application using Rails 5.0.0.rc1 + ActionCable + Redis.
I've single channel ApiChannel and a number of actions in it. There are some "unicast" actions -> ask for something, get something back, and "broadcast" actions -> do something, broadcast the payload to some connected clients.
From time to time I'm getting RuntimeError exception from here: https://github.com/rails/rails/blob/master/actioncable/lib/action_cable/connection/subscriptions.rb#L70 Unable to find subscription with identifier (...).
What can be a reason of this? In what situation can I get such exception? I spent quite a lot of time on investigating the issue (and will continue to do so) and any hints would be greatly appreciated!
It looks like it's related to this issue: https://github.com/rails/rails/issues/25381
Some kind of race conditions when Rails reply the subscription has been created but in fact it hasn't been done yet.
As a temporary solution adding a small timeout after establishing the subscription has solved the issue.
More investigation needs to be done, though.
The reason for this error might be the difference of the identifiers you subscribe to and messaging to. I use ActionCable in Rails 5 API mode (with gem 'devise_token_auth') and I faced the same error too:
SUBSCRIBE (ERROR):
{"command":"subscribe","identifier":"{\"channel\":\"UnreadChannel\"}"}
SEND MESSAGE (ERROR):
{"command":"message","identifier":"{\"channel\":\"UnreadChannel\",\"correspondent\":\"client2#example.com\"}","data":"{\"action\":\"process_unread_on_server\"}"}
For some reason ActionCable requires your client instance to apply the same identifier twice - while subscribing and while messaging:
/var/lib/gems/2.3.0/gems/actioncable-5.0.1/lib/action_cable/connection/subscriptions.rb:74
def find(data)
if subscription = subscriptions[data['identifier']]
subscription
else
raise "Unable to find subscription with identifier: #{data['identifier']}"
end
end
This is a live example: I implement a messaging subsystem where users get the unread messages notifications in the real-time mode. At the time of the subscription, I don't really need a correspondent, but at the messaging time - I do.
So the solution is to move the correspondent from identifier hash to the data hash:
SEND MESSAGE (CORRECT):
{"command":"message","identifier":"{\"channel\":\"UnreadChannel\"}","data":"{\"correspondent\":\"client2#example.com\",\"action\":\"process_unread_on_server\"}"}
This way the error is gone.
Here's my UnreadChannel code:
class UnreadChannel < ApplicationCable::Channel
def subscribed
if current_user
unread_chanel_token = signed_token current_user.email
stream_from "unread_#{unread_chanel_token}_channel"
else
# http://api.rubyonrails.org/classes/ActionCable/Channel/Base.html#class-ActionCable::Channel::Base-label-Rejecting+subscription+requests
reject
end
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
def process_unread_on_server param_message
correspondent = param_message["correspondent"]
correspondent_user = User.find_by email: correspondent
if correspondent_user
unread_chanel_token = signed_token correspondent
ActionCable.server.broadcast "unread_#{unread_chanel_token}_channel",
sender_id: current_user.id
end
end
end
helper: (you shouldn't expose plain identifiers - encode them the same way Rails encodes plain cookies to signed ones)
def signed_token string1
token = string1
# http://vesavanska.com/2013/signing-and-encrypting-data-with-tools-built-in-to-rails
secret_key_base = Rails.application.secrets.secret_key_base
verifier = ActiveSupport::MessageVerifier.new secret_key_base
signed_token1 = verifier.generate token
pos = signed_token1.index('--') + 2
signed_token1.slice pos..-1
end
To summarize it all you must first call SUBSCRIBE command if you want later call MESSAGE command. Both commands must have the same identifier hash (here "channel"). What is interesting here, the subscribed hook is not required (!) - even without it you can still send messages (after SUBSCRIBE) (but nobody would receive them - without the subscribed hook).
Another interesting point here is that inside the subscribed hook I use this code:
stream_from "unread_#{unread_chanel_token}_channel"
and obviously the unread_chanel_token could be whatever - it applies only to the "receiving" direction.
So the subscription identifier (like \"channel\":\"UnreadChannel\") has to be considered as a "password" for the future message-sending operations (e.g. it applies only to the "sending" direction) - if you want to send a message, (first send subscribe, and then) provide the same "pass" again, or you'll get the described error.
And more of it - it's really just a "password" - as you can see, you can actually send a message to whereever you want:
ActionCable.server.broadcast "unread_#{unread_chanel_token}_channel", sender_id: current_user.id
Weird, right?
This all is pretty complicated. Why is it not described in the official documentation?