How to terminate subscription to an actioncable channel from server? - ruby-on-rails

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

Related

Rails: Broadcast message based on current_user (connection identifier)

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).

Actioncable with infinite channel streams

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.

How can I call a channel method in a rails controller?

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

Actioncable broadcasts not hitting Received JS function

I have been trying to get a rails app together to replace a nastily coded php monstrosity. The current incarnation is pulling data from a non-Rails db, putting it into the Rails db and then displaying it in the views. The db is mainly populated with temperature readings that are added every few seconds. I can display a static-ish page in Rails with no problems but, trying to add ActionCable/realtime data has proven problematic. MOST things seem to be working properly but, when I broadcast to my channel, it does not seem to hit the Received function in the mychannel.coffee.
My Setup:
Server - passenger (bundle exec passenger start)
Jobs - Resque
ActionCable - Redis
Data is imported from the legacydb by a job that grabs the raw SQL and creates new records. After this, another job broadcasts to the channel.
The problems are coming from ActionCable, I think. All examples that I can find require user input to trigger the JS, it seems. However, I am trying to trigger things strictly from the server side. This job:
class DatabroadcastJob < ApplicationJob
queue_as :default
self.queue_adapter = :resque
def perform
ActionCable.server.broadcast 'dashboard_channel', content: render_thedata
end
private
def render_thedata
dataArr =[Data1.last, Data2.last, Data3.last]
ApplicationController.renderer.render(partial:
'dashboard/data_tables', locals: {item: dataArr})
end
end
Works. It works. I see the broadcast hitting the dashboard_channel. However, nothing in the dashboard.coffee gets triggered by the broadcast. This is incredibly confusing.
Dashboard.coffee
App.dashboard = App.cable.subscriptions.create "DashboardChannel",
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) ->
# Called when there's incoming data on the websocket for this channel
alert data['content']
Nothing happens. The logs show the broadcast but nothing hits dashboard.coffee and raises an alert in browser. Am I thinking about this the wrong way because of all of the chat examples? Is there another place where I grab the broadcast and push it to subscribers when only making server side changes?
If any other info is needed to address this, please let me know. This issue has been driving me mental for days now.
First, check your frames. Are you sure you're getting the messages you want?
Then, in your channel you should set an ID to your subs. If you have a stream that is related to a model, then the broadcasting used can be generated from the model and channel.
class DashboardChannel < ApplicationCable::Channel
def subscribed
post = Post.find(params[:id])
stream_for post
end
end
Then you can broadcast to your channel like so
DashboardChannel.broadcast_to(#post, #comment)
Otherwise, you should do the following:
class DashboardChannel < ApplicationCable::Channel
def subscribed
stream_from 'dashboard_channel'
end
end
But this is a bad practice, because you won't be able to define which user transmits to your server.
One thing I would add for troubleshooting and testing the coffee/javascript is that console.log is your friend. Adding console.log "First step complete" and so on throughout really helped to trackdown where the errors were occurring.

What can be the reason of "Unable to find subscription with identifier" in Rails ActionCable?

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?

Resources