livechat 0.1.0
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 +11 -0
- data/MIT-LICENSE +20 -0
- data/README.md +160 -0
- data/Rakefile +11 -0
- data/app/controllers/livechat/application_controller.rb +76 -0
- data/app/controllers/livechat/conversations_controller.rb +57 -0
- data/app/controllers/livechat/messages_controller.rb +21 -0
- data/app/controllers/livechat/visitor_controller.rb +108 -0
- data/app/controllers/livechat/widgets_controller.rb +36 -0
- data/app/helpers/livechat/widget_helper.rb +37 -0
- data/app/mailers/livechat/mailer.rb +51 -0
- data/app/models/livechat/application_record.rb +7 -0
- data/app/models/livechat/conversation.rb +108 -0
- data/app/models/livechat/message.rb +53 -0
- data/app/views/layouts/livechat/application.html.erb +97 -0
- data/app/views/livechat/conversations/index.html.erb +62 -0
- data/app/views/livechat/conversations/show.html.erb +73 -0
- data/app/views/livechat/mailer/new_agent_reply.text.erb +7 -0
- data/app/views/livechat/mailer/new_visitor_message.text.erb +7 -0
- data/config/locales/livechat.ar.yml +44 -0
- data/config/locales/livechat.bg.yml +44 -0
- data/config/locales/livechat.bn.yml +44 -0
- data/config/locales/livechat.de.yml +44 -0
- data/config/locales/livechat.el.yml +44 -0
- data/config/locales/livechat.en.yml +44 -0
- data/config/locales/livechat.es.yml +44 -0
- data/config/locales/livechat.fr.yml +44 -0
- data/config/locales/livechat.hi.yml +44 -0
- data/config/locales/livechat.hr.yml +44 -0
- data/config/locales/livechat.id.yml +44 -0
- data/config/locales/livechat.it.yml +44 -0
- data/config/locales/livechat.ja.yml +44 -0
- data/config/locales/livechat.ko.yml +44 -0
- data/config/locales/livechat.lb.yml +44 -0
- data/config/locales/livechat.nl.yml +44 -0
- data/config/locales/livechat.pl.yml +44 -0
- data/config/locales/livechat.pt.yml +44 -0
- data/config/locales/livechat.ro.yml +44 -0
- data/config/locales/livechat.ru.yml +44 -0
- data/config/locales/livechat.th.yml +44 -0
- data/config/locales/livechat.tr.yml +44 -0
- data/config/locales/livechat.uk.yml +44 -0
- data/config/locales/livechat.ur.yml +44 -0
- data/config/locales/livechat.vi.yml +44 -0
- data/config/locales/livechat.zh-CN.yml +44 -0
- data/config/routes.rb +27 -0
- data/lib/generators/livechat/install/install_generator.rb +42 -0
- data/lib/generators/livechat/install/templates/create_livechat_tables.rb.tt +36 -0
- data/lib/generators/livechat/install/templates/initializer.rb +59 -0
- data/lib/livechat/configuration.rb +103 -0
- data/lib/livechat/dashboard.js +58 -0
- data/lib/livechat/engine.rb +13 -0
- data/lib/livechat/notifications.rb +41 -0
- data/lib/livechat/version.rb +5 -0
- data/lib/livechat/widget.js +563 -0
- data/lib/livechat/widget.rb +115 -0
- data/lib/livechat.rb +56 -0
- metadata +125 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1f475396740f88ad5b62ea7f30889dd7e3b36699834f061d2a7d09cdf341b88a
|
|
4
|
+
data.tar.gz: cb58318b0d8ac30a316d59bca48e8eca3bbe63b697c0d744a2404820d9327344
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e261a7ad6751071cafb5546af26cc27fe2191ff7425c8aa001d52dc7ce996d0a7176cce69aecae2b7f2e09dccf38b6536dabc1c71af06f790f80b536c45de37a
|
|
7
|
+
data.tar.gz: 443fe470e3d66f164a4f970ea4d0e3fdfdd41e42d98d5a05674ac9783e97f4c128ea5dfe97866ad034e4a71eb3cc412e4ec8cabfaf3449c7a727e903925328c2
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
- Initial release: floating chat widget (guests + signed-in visitors, one
|
|
6
|
+
thread per visitor, email capture), team inbox with signed replies,
|
|
7
|
+
resolve/reopen with system messages, unread tracking on both sides,
|
|
8
|
+
polling transport (no Action Cable required), built-in notification
|
|
9
|
+
emails in both directions (one per unread stretch), `on_visitor_message` /
|
|
10
|
+
`on_agent_message` hooks, per-IP rate limiting, 26 languages, RTL, strict
|
|
11
|
+
CSP support.
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright 2026 Yaroslav Shmarov
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# livechat
|
|
2
|
+
|
|
3
|
+
[](https://rubygems.org/gems/livechat)
|
|
4
|
+
[](https://github.com/yshmarov/livechat/actions/workflows/ci.yml)
|
|
5
|
+
[](MIT-LICENSE)
|
|
6
|
+
|
|
7
|
+
Open-source live chat for Rails. Self-hosted alternative to Crisp, Intercom
|
|
8
|
+
and Chatwoot — as a gem, not another service to deploy.
|
|
9
|
+
|
|
10
|
+
Your users already have questions. `livechat` gives them a chat bubble and
|
|
11
|
+
gives you an inbox, inside the app you already run. Visitors write — signed
|
|
12
|
+
in or not. Your team answers — together, every reply signed with its author.
|
|
13
|
+
Nobody is around? Visitors leave an email and the conversation continues
|
|
14
|
+
there. Every word stays in your database.
|
|
15
|
+
|
|
16
|
+
- **A gem, not a platform.** `bundle add livechat`, one migration, one line
|
|
17
|
+
in your layout. No second app to deploy, no third-party script on your
|
|
18
|
+
pages, no per-seat pricing. Ever.
|
|
19
|
+
- **Zero UI dependencies.** The widget is plain JavaScript and styles itself.
|
|
20
|
+
No Tailwind, no Stimulus, no importmap, no build step, no websockets to
|
|
21
|
+
configure. Works with Turbo Drive and strict nonce-based CSPs out of the box.
|
|
22
|
+
- **Honest about response time.** The widget says "We usually reply within a
|
|
23
|
+
few hours" (you choose the words), not a fake "we're online". Email
|
|
24
|
+
notifications — both directions — keep slow conversations alive.
|
|
25
|
+
- **A team sport.** Any authorized teammate answers any thread; each message
|
|
26
|
+
carries its author. You decide what visitors see — full names, first
|
|
27
|
+
names, or an anonymous "Support team".
|
|
28
|
+
- **Real attribution.** Signed-in visitors are identified server-side against
|
|
29
|
+
your user records and keep their thread across devices. Guests get a
|
|
30
|
+
cookie, and keep their history when they sign up.
|
|
31
|
+
- **26 languages.** The widget follows your app's locale, RTL included.
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
1. Add `<%= livechat_tag %>` to your layout. A chat bubble appears
|
|
36
|
+
bottom-right (or open the panel from any element with
|
|
37
|
+
`data-livechat-open`, or `window.Livechat.open()`).
|
|
38
|
+
2. A visitor writes. The message lands in `livechat_conversations` in your
|
|
39
|
+
database, and — if you configured it — in your team's email.
|
|
40
|
+
3. Your team answers at the mount path (`/livechat`). Several people can
|
|
41
|
+
work the same thread; resolve it when done. A visitor writing again
|
|
42
|
+
reopens it — one thread per visitor, like a conversation, not tickets.
|
|
43
|
+
4. The visitor sees the reply in the widget, or by email when they're gone.
|
|
44
|
+
|
|
45
|
+
Realtime is polling, on purpose: ~4s while the panel is open, ~30s in the
|
|
46
|
+
background, nothing at all for visitors who never wrote. No Action Cable,
|
|
47
|
+
no Redis, no infrastructure. At support-chat volume you will not notice;
|
|
48
|
+
your ops person will notice there is nothing new to run.
|
|
49
|
+
|
|
50
|
+
## Requirements
|
|
51
|
+
|
|
52
|
+
- Ruby >= 3.2
|
|
53
|
+
- Rails >= 7.1
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
# Gemfile
|
|
59
|
+
gem "livechat"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bundle install
|
|
64
|
+
bin/rails generate livechat:install
|
|
65
|
+
bin/rails db:migrate
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The generator writes `config/initializers/livechat.rb`, creates the
|
|
69
|
+
migration, and mounts the engine at `/livechat`. Then add the widget to your
|
|
70
|
+
layout:
|
|
71
|
+
|
|
72
|
+
```erb
|
|
73
|
+
<%= livechat_tag %>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
That's it. Visit any page, click the bubble, say hi. Answer yourself at
|
|
77
|
+
`/livechat`.
|
|
78
|
+
|
|
79
|
+
## Configuration
|
|
80
|
+
|
|
81
|
+
Everything lives in `config/initializers/livechat.rb`; every option has a
|
|
82
|
+
working default. The essentials:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
Livechat.configure do |config|
|
|
86
|
+
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
87
|
+
config.authorize_agent = ->(request) { request.env["warden"]&.user&.admin? }
|
|
88
|
+
config.mailer_from = "chat@example.com"
|
|
89
|
+
config.agent_emails = -> { User.where(admin: true).pluck(:email) }
|
|
90
|
+
config.reply_time_text = "We usually reply within an hour."
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Brand color
|
|
95
|
+
|
|
96
|
+
```ruby
|
|
97
|
+
config.accent_color = "#7c3aed"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
One hex value restyles the launcher, header, visitor bubbles and send
|
|
101
|
+
button; the widget picks black or white text automatically for contrast,
|
|
102
|
+
in light and dark mode alike.
|
|
103
|
+
|
|
104
|
+
### Who visitors talk to
|
|
105
|
+
|
|
106
|
+
Replies are signed. What visitors see is up to you:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
config.agent_display_name = ->(label) { label.split.first } # "Ada"
|
|
110
|
+
config.agent_display_name = ->(_label) { "Support team" } # anonymous
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Email, both directions
|
|
114
|
+
|
|
115
|
+
When a visitor writes and nobody has read it, the team gets one email — one
|
|
116
|
+
per unread stretch, not one per message. When an agent replies and the
|
|
117
|
+
visitor is away, the visitor gets one email (signed-in visitors
|
|
118
|
+
automatically, guests once they leave an address — the widget asks, gently).
|
|
119
|
+
Requires `config.mailer_from`; team notifications also need
|
|
120
|
+
`config.agent_emails`.
|
|
121
|
+
|
|
122
|
+
For anything else, hook in:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
config.on_visitor_message = ->(message) { SlackNotifier.ping(message) }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## The inbox
|
|
129
|
+
|
|
130
|
+
Browse at the mount path: open and resolved tabs, unread badges, one click
|
|
131
|
+
into a thread. Reply (Cmd/Ctrl+Enter sends), resolve, reopen. The thread
|
|
132
|
+
updates itself while you watch — and never reloads over a half-written
|
|
133
|
+
reply. Gated by `config.authorize_agent` (development-only until you set it).
|
|
134
|
+
|
|
135
|
+
## Widget API
|
|
136
|
+
|
|
137
|
+
- `window.Livechat.open()` / `window.Livechat.close()`
|
|
138
|
+
- Any element with `data-livechat-open` opens the panel on click
|
|
139
|
+
- `<%= livechat_button %>` renders a plain, unstyled opener button
|
|
140
|
+
- `config.show_launcher = false` hides the bubble entirely — bring your own
|
|
141
|
+
entry point
|
|
142
|
+
|
|
143
|
+
## What it doesn't do
|
|
144
|
+
|
|
145
|
+
No AI bots, no canned responses, no omnichannel (WhatsApp, Messenger…), no
|
|
146
|
+
visitor tracking, no "powered by" badge. If you need a support platform,
|
|
147
|
+
[Chatwoot](https://github.com/chatwoot/chatwoot) is excellent. If you need
|
|
148
|
+
your users to be able to reach you from inside your Rails app — this is a
|
|
149
|
+
gem's worth of exactly that.
|
|
150
|
+
|
|
151
|
+
## Testing
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
bundle exec rake test
|
|
155
|
+
bundle exec rubocop
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
class ApplicationController < ActionController::Base
|
|
5
|
+
protect_from_forgery with: :exception
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def current_visitor
|
|
10
|
+
return @current_visitor if defined?(@current_visitor)
|
|
11
|
+
|
|
12
|
+
@current_visitor = Livechat.config.current_user.call(request)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def current_visitor_id
|
|
16
|
+
current_visitor.respond_to?(:id) ? current_visitor.id.to_s : nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def require_enabled
|
|
20
|
+
head :forbidden unless Livechat.enabled?(request)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Server-side gate for the inbox. Default: development only.
|
|
24
|
+
def require_agent
|
|
25
|
+
return if Livechat.agent?(request)
|
|
26
|
+
|
|
27
|
+
render plain: 'Forbidden. Set Livechat.config.authorize_agent to grant access.',
|
|
28
|
+
status: :forbidden
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Replies are attributed to whoever config.current_user resolves. When an
|
|
32
|
+
# inbox is protected by something user-less (HTTP basic, a VPN), replies
|
|
33
|
+
# still work — anonymously, as "Support".
|
|
34
|
+
def current_agent_id
|
|
35
|
+
current_visitor_id || '0'
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def current_agent_label
|
|
39
|
+
label = (Livechat.config.agent_label.call(current_visitor).presence if current_visitor)
|
|
40
|
+
label || I18n.t(:team, scope: :livechat, default: 'Support')
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render_rate_limited
|
|
44
|
+
message = I18n.t('livechat.error_rate_limited',
|
|
45
|
+
default: 'Too many messages. Please wait a moment and try again.')
|
|
46
|
+
render json: { errors: [message] }, status: :too_many_requests
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Guests get a permanent random token — their key to the conversation
|
|
50
|
+
# across visits. Signed-in visitors are keyed by id and only need the
|
|
51
|
+
# cookie to carry a guest history into their account.
|
|
52
|
+
def visitor_token
|
|
53
|
+
cookies[:livechat_vid]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def ensure_visitor_token
|
|
57
|
+
visitor_token.presence || begin
|
|
58
|
+
token = SecureRandom.base58(24)
|
|
59
|
+
cookies.permanent[:livechat_vid] = { value: token, httponly: true, same_site: :lax }
|
|
60
|
+
token
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A guest who signed in keeps their thread: adopt cookie-token
|
|
65
|
+
# conversations into the account, then the cookie no longer matters.
|
|
66
|
+
def claim_guest_conversations
|
|
67
|
+
return unless current_visitor_id && visitor_token
|
|
68
|
+
|
|
69
|
+
Conversation.claim!(
|
|
70
|
+
visitor_token: visitor_token,
|
|
71
|
+
visitor_id: current_visitor_id,
|
|
72
|
+
visitor_label: Livechat.config.visitor_label.call(current_visitor).presence
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# The inbox. Every action is gated by config.authorize_agent; any
|
|
5
|
+
# authorized teammate can read and answer any thread — it's a shared line,
|
|
6
|
+
# not an assignment queue.
|
|
7
|
+
class ConversationsController < ApplicationController
|
|
8
|
+
before_action :require_agent
|
|
9
|
+
before_action :set_conversation, except: :index
|
|
10
|
+
layout 'livechat/application'
|
|
11
|
+
|
|
12
|
+
PER_PAGE = 50
|
|
13
|
+
|
|
14
|
+
def index
|
|
15
|
+
@status = Conversation::STATUSES.include?(params[:status]) ? params[:status] : 'open'
|
|
16
|
+
@counts = Conversation.group(:status).count
|
|
17
|
+
@offset = params[:offset].to_i.clamp(0, 1_000_000)
|
|
18
|
+
|
|
19
|
+
scope = Conversation.where(status: @status).recent_first
|
|
20
|
+
page = scope.offset(@offset).limit(PER_PAGE + 1).to_a
|
|
21
|
+
@more = page.size > PER_PAGE
|
|
22
|
+
@conversations = page.first(PER_PAGE)
|
|
23
|
+
|
|
24
|
+
# Unread badges for the whole page in one query.
|
|
25
|
+
@unread = Message.from_visitor.unread
|
|
26
|
+
.where(conversation_id: @conversations.map(&:id))
|
|
27
|
+
.group(:conversation_id).count
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def show
|
|
31
|
+
@messages = @conversation.messages.chronological.to_a
|
|
32
|
+
@conversation.mark_read_for_agent!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Polled by dashboard.js: has anything new arrived in this thread?
|
|
36
|
+
def poll
|
|
37
|
+
render json: { latest: @conversation.messages.maximum(:id).to_i,
|
|
38
|
+
status: @conversation.status }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def resolve
|
|
42
|
+
@conversation.resolve_by!(current_agent_label)
|
|
43
|
+
redirect_to conversation_path(@conversation)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reopen
|
|
47
|
+
@conversation.reopen_by!(current_agent_label)
|
|
48
|
+
redirect_to conversation_path(@conversation)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def set_conversation
|
|
54
|
+
@conversation = Conversation.find(params[:id])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
class MessagesController < ApplicationController
|
|
5
|
+
before_action :require_agent
|
|
6
|
+
|
|
7
|
+
def create
|
|
8
|
+
conversation = Conversation.find(params[:conversation_id])
|
|
9
|
+
message = conversation.post_agent_message!(
|
|
10
|
+
body: params[:body].to_s.strip,
|
|
11
|
+
agent_id: current_agent_id,
|
|
12
|
+
agent_label: current_agent_label
|
|
13
|
+
)
|
|
14
|
+
Notifications.agent_message(message)
|
|
15
|
+
|
|
16
|
+
redirect_to conversation_path(conversation, anchor: "message-#{message.id}")
|
|
17
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
18
|
+
redirect_to conversation_path(conversation), alert: e.record.errors.full_messages.first
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# The widget's API. Every action is scoped to the requesting visitor — the
|
|
5
|
+
# signed-in user's id, or the guest cookie token — so there is nothing to
|
|
6
|
+
# enumerate and no id to leak. Gated by config.enabled and rate-limited.
|
|
7
|
+
class VisitorController < ApplicationController
|
|
8
|
+
before_action :require_enabled
|
|
9
|
+
before_action :claim_guest_conversations
|
|
10
|
+
|
|
11
|
+
if respond_to?(:rate_limit) && Livechat.config.rate_limit
|
|
12
|
+
rate_limit(**Livechat.config.rate_limit,
|
|
13
|
+
only: %i[create email],
|
|
14
|
+
with: -> { render_rate_limited })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# GET widget/conversation(?after=<id>) — the widget's single source of
|
|
18
|
+
# truth: thread status, unread count for the launcher badge, and messages
|
|
19
|
+
# (all of them, or only those after the given id when polling).
|
|
20
|
+
def show
|
|
21
|
+
conversation = find_conversation
|
|
22
|
+
return render json: empty_state unless conversation
|
|
23
|
+
|
|
24
|
+
messages = conversation.messages.chronological
|
|
25
|
+
messages = messages.after_id(params[:after]) if params[:after].present?
|
|
26
|
+
|
|
27
|
+
render json: {
|
|
28
|
+
status: conversation.status,
|
|
29
|
+
unread: conversation.messages.from_agent.unread.count,
|
|
30
|
+
email: conversation.visitor_email.present?,
|
|
31
|
+
messages: messages.map(&:as_widget_json)
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# POST widget/messages — first message starts the thread; writing into a
|
|
36
|
+
# resolved thread reopens it.
|
|
37
|
+
def create
|
|
38
|
+
message = nil
|
|
39
|
+
# One transaction, so an invalid first message never leaves an empty
|
|
40
|
+
# conversation behind.
|
|
41
|
+
ActiveRecord::Base.transaction do
|
|
42
|
+
conversation = find_conversation || start_conversation
|
|
43
|
+
message = conversation.post_visitor_message!(params[:body].to_s.strip)
|
|
44
|
+
end
|
|
45
|
+
refresh_context(message.conversation)
|
|
46
|
+
Notifications.visitor_message(message)
|
|
47
|
+
|
|
48
|
+
render json: { message: message.as_widget_json }, status: :created
|
|
49
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
50
|
+
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# POST widget/email — a guest leaves an address so replies reach them
|
|
54
|
+
# when they're gone.
|
|
55
|
+
def email
|
|
56
|
+
conversation = find_conversation
|
|
57
|
+
return head :not_found unless conversation
|
|
58
|
+
|
|
59
|
+
if conversation.update(visitor_email: params[:email].to_s.strip.presence)
|
|
60
|
+
head :no_content
|
|
61
|
+
else
|
|
62
|
+
render json: { errors: conversation.errors.full_messages }, status: :unprocessable_entity
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# POST widget/read — the visitor opened the panel and saw the replies.
|
|
67
|
+
def read
|
|
68
|
+
find_conversation&.mark_read_for_visitor!
|
|
69
|
+
head :no_content
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def find_conversation
|
|
75
|
+
Conversation.for_visitor(visitor_id: current_visitor_id, visitor_token: visitor_token)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def start_conversation
|
|
79
|
+
Conversation.create!(
|
|
80
|
+
visitor_id: current_visitor_id,
|
|
81
|
+
visitor_token: current_visitor_id ? visitor_token : ensure_visitor_token,
|
|
82
|
+
visitor_label: visitor_label,
|
|
83
|
+
visitor_email: (current_visitor.try(:email).presence if current_visitor),
|
|
84
|
+
page_url: params[:page_url].to_s.first(255).presence,
|
|
85
|
+
locale: params[:locale].to_s.first(10).presence
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def visitor_label
|
|
90
|
+
return unless current_visitor
|
|
91
|
+
|
|
92
|
+
Livechat.config.visitor_label.call(current_visitor).presence
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# page_url and locale track the visitor's LAST message, not the first —
|
|
96
|
+
# "where are they stuck right now" is what an answering agent needs.
|
|
97
|
+
def refresh_context(conversation)
|
|
98
|
+
updates = {}
|
|
99
|
+
updates[:page_url] = params[:page_url].to_s.first(255) if params[:page_url].present?
|
|
100
|
+
updates[:locale] = params[:locale].to_s.first(10) if params[:locale].present?
|
|
101
|
+
conversation.update_columns(updates) if updates.any?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def empty_state
|
|
105
|
+
{ status: nil, unread: 0, email: false, messages: [] }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# Serves the widget JavaScript as a plain same-origin script. Same-origin
|
|
5
|
+
# matters: under a `script-src 'self'` (or nonce-based) CSP, an external
|
|
6
|
+
# script from the app's own host is always allowed — including when Turbo
|
|
7
|
+
# Drive swaps the <body> and re-runs body scripts under the *original*
|
|
8
|
+
# page's CSP header, where a freshly minted inline nonce would be refused.
|
|
9
|
+
class WidgetsController < ApplicationController
|
|
10
|
+
# The script is static and carries no user data; without this, Rails'
|
|
11
|
+
# cross-origin JavaScript guard refuses to serve it to a plain
|
|
12
|
+
# <script src> request.
|
|
13
|
+
skip_forgery_protection
|
|
14
|
+
|
|
15
|
+
# The script URLs carry a content fingerprint (?v=<md5>), so stale code
|
|
16
|
+
# is structurally impossible: new code means a new URL. The canonical
|
|
17
|
+
# fingerprinted URL is immutable and gets long-lived caching; anything
|
|
18
|
+
# else only ETag-revalidates.
|
|
19
|
+
def show
|
|
20
|
+
serve(Widget.javascript, Widget.fingerprint)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def dashboard
|
|
24
|
+
serve(Widget.dashboard_javascript, Widget.dashboard_fingerprint)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def serve(source, fingerprint)
|
|
30
|
+
expires_in 1.year, public: true if params[:v] == fingerprint
|
|
31
|
+
return unless stale?(etag: [Livechat::VERSION, fingerprint])
|
|
32
|
+
|
|
33
|
+
render plain: source, content_type: 'text/javascript'
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# Included into the host's ActionView. Drop `<%= livechat_tag %>` before
|
|
5
|
+
# </body> in your layout; it renders nothing unless chat is enabled for the
|
|
6
|
+
# request. The widget shows a floating launcher (unless show_launcher is
|
|
7
|
+
# off) and also opens from any element carrying `data-livechat-open`, or
|
|
8
|
+
# from `window.Livechat.open()`.
|
|
9
|
+
module WidgetHelper
|
|
10
|
+
def livechat_tag
|
|
11
|
+
return unless Livechat.enabled?(request)
|
|
12
|
+
|
|
13
|
+
Widget.snippet(
|
|
14
|
+
locale: I18n.locale,
|
|
15
|
+
authenticated: livechat_visitor.present?,
|
|
16
|
+
nonce: (content_security_policy_nonce if respond_to?(:content_security_policy_nonce))
|
|
17
|
+
).html_safe
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A plain, unstyled <button> that opens the chat — it picks up the host's
|
|
21
|
+
# own styles. Put it anywhere on a page that also renders livechat_tag.
|
|
22
|
+
def livechat_button(label: nil, **)
|
|
23
|
+
return unless Livechat.enabled?(request)
|
|
24
|
+
|
|
25
|
+
label ||= I18n.t(:launcher, scope: :livechat, default: 'Chat with us')
|
|
26
|
+
tag.button(label, type: 'button', 'data-livechat-open': '', **)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def livechat_visitor
|
|
32
|
+
return @livechat_visitor if defined?(@livechat_visitor)
|
|
33
|
+
|
|
34
|
+
@livechat_visitor = Livechat.config.current_user.call(request)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Livechat
|
|
4
|
+
# The two built-in notification emails. Plain text, no branding — they are
|
|
5
|
+
# nudges back into the conversation, not newsletters. Both require
|
|
6
|
+
# config.mailer_from; the callers in Livechat::Notifications enforce the
|
|
7
|
+
# rest of the conditions (recipients present, first unread message).
|
|
8
|
+
class Mailer < ActionMailer::Base
|
|
9
|
+
# To the team: a visitor wrote and nobody has read it.
|
|
10
|
+
def new_visitor_message(message)
|
|
11
|
+
@message = message
|
|
12
|
+
@conversation = message.conversation
|
|
13
|
+
@inbox_url = inbox_url(@conversation)
|
|
14
|
+
|
|
15
|
+
mail from: Livechat.config.mailer_from,
|
|
16
|
+
to: Livechat.config.agent_email_list,
|
|
17
|
+
subject: I18n.t('livechat.mail.visitor_subject',
|
|
18
|
+
name: @conversation.display_name, app: Livechat.app_name,
|
|
19
|
+
default: '%{name} wrote to you on %{app}')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# To the visitor: an agent replied while they were away.
|
|
23
|
+
def new_agent_reply(message)
|
|
24
|
+
@message = message
|
|
25
|
+
@conversation = message.conversation
|
|
26
|
+
|
|
27
|
+
mail from: Livechat.config.mailer_from,
|
|
28
|
+
to: @conversation.visitor_email,
|
|
29
|
+
subject: I18n.t('livechat.mail.reply_subject',
|
|
30
|
+
name: message.public_label, app: Livechat.app_name,
|
|
31
|
+
default: '%{name} replied to you on %{app}')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# An absolute link to the inbox needs a host. Reuse the one hosts already
|
|
37
|
+
# configure for Action Mailer (Devise needs it too); without one, the
|
|
38
|
+
# email simply carries no link.
|
|
39
|
+
def inbox_url(conversation)
|
|
40
|
+
host = default_url_options[:host] || ActionMailer::Base.default_url_options[:host]
|
|
41
|
+
return unless host
|
|
42
|
+
|
|
43
|
+
Livechat::Engine.routes.url_helpers.conversation_url(
|
|
44
|
+
conversation, host: host, **default_url_options.except(:host),
|
|
45
|
+
script_name: Livechat.config.mount_path.chomp('/')
|
|
46
|
+
)
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|