Compare commits

..
Author SHA1 Message Date
Shivam Mishra 648b9c9507 chore: add logs for debug 2025-05-22 10:08:19 +05:30
611fc82847 feat: Add components to show steps in the copilot thinking process (#11530)
This PR adds the components for new Copilot UI
- Added a Header component
- Added a thinking block.
- Update the outline on copilot input

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-05-22 10:08:19 +05:30
ffad8458cb feat: Delete a contact from the contacts page (#11529)
# Pull Request Template

## Description


**This PR includes:**

1. Adds the ability to delete a contact from the contacts list accordion
section.
2. Improves the expand/collapse transition for the accordion.


Fixes
[CW-4375](https://linear.app/chatwoot/issue/CW-4375/allow-users-to-delete-a-contact-from-the-contacts-page)

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Loom video

https://www.loom.com/share/8c897d24737f40f6b8b29fef76ba18e2?sid=70910b9d-f3db-4d54-8bfc-820db680e537


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
2025-05-22 10:08:19 +05:30
5869a98e6e fix: account email validation during signup (#11307)
- Refactor email validation logic to be a service
- Use the service for both email/pass signup and Google SSO
- fix account email validation during signup
- Use `blocked_domain` setting for both email/pass signup and Google
Sign In [`BLOCKED_DOMAIN` via GlobalConfig]
- add specs for `account_builder`
- add specs for the new service

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2025-05-22 10:08:19 +05:30
dde08b0bd9 feat: Add support for search_conversations in copilot (#11520)
Earlier, we were manually checking if a user was an agent and filtering
their conversations based on inboxes. This logic should have been part
of the conversation permissions service.

This PR moves the check to the right place and updates the logic
accordingly.

Other updates:
- Add support for search_conversations service for copilot.
- Use PermissionFilterService in contacts/conversations, conversations,
copilot search_conversations.

---------

Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-05-22 10:08:19 +05:30
Sivin VargheseandShivam Mishra 5a4201ae58 chore: Display Agent Bot token after creation (#11488)
This PR includes:

- Displaying the Agent Bot token after creation
- Updating the avatar icon when an avatar image is not present

Fixes: https://linear.app/chatwoot/issue/CW-4337/agent-bot-token-not-visible
2025-05-22 10:08:19 +05:30
Sivin VargheseandShivam Mishra fc8fe9a8d1 feat: Prevent saving preferences and status when impersonating (#11164)
This PR will prevent saving user preferences and online status when impersonating. Previously, these settings could be updated during impersonation, causing the user to see a different view or UI settings.

Fixes https://linear.app/chatwoot/issue/CW-4163/impersonation-improvements
2025-05-22 10:08:19 +05:30
Muhsin KelothandShivam Mishra 2fe0761fd5 fix: Display message content for CSAT messages in non-widget inboxes (#11528)
We made so many improvements for CSAT via https://github.com/chatwoot/chatwoot/pull/11485. However, we missed showing message content in the dashboard for CSAT URLs created in non-widget inboxes. This PR fixes the issue by ensuring that CSAT-configured messages are passed along with CSAT responses, otherwise defaulting to the translation.
2025-05-22 10:08:19 +05:30
Sivin VargheseandShivam Mishra 9dd2d14655 fix: Status not updating when creating a Linear issue (#11523) 2025-05-22 10:08:19 +05:30
1943 changed files with 20568 additions and 78695 deletions
+3 -3
View File
@@ -73,15 +73,15 @@ jobs:
libvips
- run:
name: Install RVM and Ruby 3.4.4
name: Install RVM and Ruby 3.3.3
command: |
sudo apt-get install -y gpg
gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB
\curl -sSL https://get.rvm.io | bash -s stable
echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV
source ~/.rvm/scripts/rvm
rvm install "3.4.4"
rvm use 3.4.4 --default
rvm install "3.3.3"
rvm use 3.3.3 --default
gem install bundler -v 2.5.16
- run:
+1 -11
View File
@@ -4,15 +4,5 @@ FROM ghcr.io/chatwoot/chatwoot_codespace:latest
# Do the set up required for chatwoot app
WORKDIR /workspace
# Copy dependency files first for better caching
COPY package.json pnpm-lock.yaml ./
COPY Gemfile Gemfile.lock ./
# Install dependencies (will be cached if files don't change)
RUN pnpm install --frozen-lockfile && \
gem install bundler && \
bundle install --jobs=$(nproc)
# Copy source code after dependencies are installed
COPY . /workspace
RUN yarn && gem install bundler && bundle install
+42 -65
View File
@@ -1,16 +1,12 @@
ARG VARIANT="ubuntu-22.04"
ARG VARIANT
FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT}
ENV DEBIAN_FRONTEND=noninteractive
ARG NODE_VERSION
ARG RUBY_VERSION
ARG USER_UID
ARG USER_GID
ARG PNPM_VERSION="10.2.0"
ENV PNPM_VERSION ${PNPM_VERSION}
ENV RUBY_CONFIGURE_OPTS=--disable-install-doc
# Update args in docker-compose.yaml to set the UID/GID of the "vscode" user.
RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
@@ -19,80 +15,61 @@ RUN if [ "$USER_GID" != "1000" ] || [ "$USER_UID" != "1000" ]; then \
&& chmod -R $USER_UID:$USER_GID /home/vscode; \
fi
RUN NODE_MAJOR=$(echo $NODE_VERSION | cut -d. -f1) \
&& curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get update \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg \
tar \
tzdata \
postgresql-client \
libpq-dev \
git \
imagemagick \
libyaml-dev \
curl \
ca-certificates \
tmux \
nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends \
build-essential \
libssl-dev \
zlib1g-dev \
gnupg2 \
tar \
tzdata \
postgresql-client \
libpq-dev \
yarn \
git \
imagemagick \
tmux \
zsh \
git-flow \
npm \
libyaml-dev
# Install rbenv and ruby for root user first
RUN git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv \
# Install rbenv and ruby
RUN git clone https://github.com/rbenv/rbenv.git ~/.rbenv \
&& echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc \
&& echo 'eval "$(rbenv init -)"' >> ~/.bashrc
ENV PATH "/root/.rbenv/bin/:/root/.rbenv/shims/:$PATH"
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git && \
RUN git clone https://github.com/rbenv/ruby-build.git && \
PREFIX=/usr/local ./ruby-build/install.sh
RUN rbenv install $RUBY_VERSION && \
rbenv global $RUBY_VERSION && \
rbenv versions
# Set up rbenv for vscode user
RUN su - vscode -c "git clone --depth 1 https://github.com/rbenv/rbenv.git ~/.rbenv" \
&& su - vscode -c "echo 'export PATH=\"\$HOME/.rbenv/bin:\$PATH\"' >> ~/.bashrc" \
&& su - vscode -c "echo 'eval \"\$(rbenv init -)\"' >> ~/.bashrc" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv install $RUBY_VERSION" \
&& su - vscode -c "PATH=\"/home/vscode/.rbenv/bin:\$PATH\" rbenv global $RUBY_VERSION"
# Install overmind and gh in single layer
# Install overmind
RUN curl -L https://github.com/DarthSim/overmind/releases/download/v2.1.0/overmind-v2.1.0-linux-amd64.gz > overmind.gz \
&& gunzip overmind.gz \
&& mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
&& sudo mv overmind /usr/local/bin \
&& chmod +x /usr/local/bin/overmind
# Install gh
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh
# Do the set up required for chatwoot app
WORKDIR /workspace
RUN chown vscode:vscode /workspace
COPY . /workspace
# set up node js, pnpm and claude code in single layer
RUN npm install -g pnpm@${PNPM_VERSION} @anthropic-ai/claude-code \
&& npm cache clean --force
# set up ruby
COPY Gemfile Gemfile.lock ./
RUN gem install bundler && bundle install
# Switch to vscode user
USER vscode
ENV PATH="/home/vscode/.rbenv/bin:/home/vscode/.rbenv/shims:$PATH"
# Copy dependency files first for better caching
COPY --chown=vscode:vscode Gemfile Gemfile.lock package.json pnpm-lock.yaml ./
# Install dependencies as vscode user
RUN eval "$(rbenv init -)" \
&& gem install bundler -N \
&& bundle install --jobs=$(nproc) \
&& pnpm install --frozen-lockfile
# Copy source code after dependencies are installed
COPY --chown=vscode:vscode . /workspace
# set up node js
RUN npm install n -g && \
n $NODE_VERSION
RUN npm install --global yarn
RUN yarn
+8 -17
View File
@@ -4,26 +4,17 @@
"dockerComposeFile": "docker-compose.yml",
"settings": {
"terminal.integrated.shell.linux": "/bin/zsh",
"extensions.showRecommendationsOnlyOnDemand": true,
"editor.formatOnSave": true,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"search.exclude": {
"**/node_modules": true,
"**/tmp": true,
"**/log": true,
"**/coverage": true,
"**/public/packs": true
}
"terminal.integrated.shell.linux": "/bin/zsh"
},
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"Shopify.ruby-lsp",
"rebornix.Ruby",
"misogi.ruby-rubocop",
"wingrunr21.vscode-ruby",
"davidpallinder.rails-test-runner",
"eamodio.gitlens",
"github.copilot",
"mrmlnc.vscode-duplicate"
],
@@ -32,15 +23,15 @@
// 5432 postgres
// 6379 redis
// 1025,8025 mailhog
"forwardPorts": [8025, 3000, 3036],
"forwardPorts": [8025, 3000, 3035],
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && pnpm install",
"postCreateCommand": ".devcontainer/scripts/setup.sh && POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rake db:chatwoot_prepare && yarn",
"portsAttributes": {
"3000": {
"label": "Rails Server"
},
"3036": {
"label": "Vite Dev Server"
"3035": {
"label": "Webpack Dev Server"
},
"8025": {
"label": "Mailhog UI"
-18
View File
@@ -1,18 +0,0 @@
# Docker Compose file for building the base image in GitHub Actions
# Usage: docker-compose -f .devcontainer/docker-compose.base.yml build base
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: ghcr.io/chatwoot/chatwoot_codespace:latest
+14 -1
View File
@@ -5,6 +5,19 @@
version: '3'
services:
base:
build:
context: ..
dockerfile: .devcontainer/Dockerfile.base
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
image: base:latest
app:
build:
context: ..
@@ -12,7 +25,7 @@ services:
args:
VARIANT: 'ubuntu-22.04'
NODE_VERSION: '23.7.0'
RUBY_VERSION: '3.4.4'
RUBY_VERSION: '3.3.3'
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: '1000'
USER_GID: '1000'
+7 -10
View File
@@ -2,15 +2,12 @@ cp .env.example .env
sed -i -e '/REDIS_URL/ s/=.*/=redis:\/\/localhost:6379/' .env
sed -i -e '/POSTGRES_HOST/ s/=.*/=localhost/' .env
sed -i -e '/SMTP_ADDRESS/ s/=.*/=localhost/' .env
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.app.github.dev/" .env
# Setup Claude Code API key if available
if [ -n "$CLAUDE_CODE_API_KEY" ]; then
mkdir -p ~/.claude
echo '{"apiKeyHelper": "~/.claude/anthropic_key.sh"}' > ~/.claude/settings.json
echo "echo \"$CLAUDE_CODE_API_KEY\"" > ~/.claude/anthropic_key.sh
chmod +x ~/.claude/anthropic_key.sh
fi
sed -i -e "/FRONTEND_URL/ s/=.*/=https:\/\/$CODESPACE_NAME-3000.githubpreview.dev/" .env
sed -i -e "/WEBPACKER_DEV_SERVER_PUBLIC/ s/=.*/=https:\/\/$CODESPACE_NAME-3035.githubpreview.dev/" .env
# uncomment the webpacker env variable
sed -i -e '/WEBPACKER_DEV_SERVER_PUBLIC/s/^# //' .env
# fix the error with webpacker
echo 'export NODE_OPTIONS=--openssl-legacy-provider' >> ~/.zshrc
# codespaces make the ports public
gh codespace ports visibility 3000:public 3036:public 8025:public -c $CODESPACE_NAME
gh codespace ports visibility 3000:public 3035:public 8025:public -c $CODESPACE_NAME
-1
View File
@@ -103,7 +103,6 @@ module.exports = {
'⌘',
'📄',
'🎉',
'🚀',
'💬',
'👥',
'📥',
-5
View File
@@ -6,11 +6,6 @@ name: Deploy Check
on:
pull_request:
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
deployment_check:
name: Check Deployment
@@ -5,11 +5,6 @@ on:
branches:
- develop
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
log_lines_check:
runs-on: ubuntu-latest
@@ -19,5 +19,6 @@ jobs:
- name: Build the Codespace Base Image
run: |
docker compose -f .devcontainer/docker-compose.base.yml build base
docker-compose -f .devcontainer/docker-compose.yml build base
docker tag base:latest ghcr.io/chatwoot/chatwoot_codespace:latest
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
-5
View File
@@ -5,11 +5,6 @@ on:
branches:
- develop
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
concurrency:
group: pr-${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-22.04
+8 -179
View File
@@ -1,10 +1,7 @@
plugins:
require:
- rubocop-performance
- rubocop-rails
- rubocop-rspec
- rubocop-factory_bot
require:
- ./rubocop/use_from_email.rb
- ./rubocop/custom_cop_location.rb
@@ -16,61 +13,44 @@ Metrics/ClassLength:
Exclude:
- 'app/models/message.rb'
- 'app/models/conversation.rb'
Metrics/MethodLength:
Max: 19
Exclude:
- 'enterprise/lib/captain/agent.rb'
RSpec/ExampleLength:
Max: 25
Style/Documentation:
Enabled: false
Style/ExponentialNotation:
Enabled: false
Style/FrozenStringLiteralComment:
Enabled: false
Style/SymbolArray:
Enabled: false
Style/OpenStructUse:
Enabled: false
Style/OptionalBooleanParameter:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
- 'app/dispatchers/dispatcher.rb'
Style/GlobalVars:
Exclude:
- 'config/initializers/01_redis.rb'
- 'config/initializers/rack_attack.rb'
- 'lib/redis/alfred.rb'
- 'lib/global_config.rb'
Style/ClassVars:
Exclude:
- 'app/services/email_templates/db_resolver_service.rb'
Lint/MissingSuper:
Exclude:
- 'app/drops/base_drop.rb'
Lint/SymbolConversion:
Enabled: false
Lint/EmptyBlock:
Exclude:
- 'app/views/api/v1/accounts/conversations/toggle_status.json.jbuilder'
Lint/OrAssignmentToConstant:
Exclude:
- 'lib/redis/config.rb'
Metrics/BlockLength:
Max: 30
Exclude:
@@ -78,16 +58,10 @@ Metrics/BlockLength:
- '**/routes.rb'
- 'config/environments/*'
- db/schema.rb
Metrics/ModuleLength:
Exclude:
- lib/seeders/message_seeder.rb
- spec/support/slack_stubs.rb
Rails/HelperInstanceVariable:
Exclude:
- enterprise/app/helpers/captain/chat_helper.rb
Rails/ApplicationController:
Exclude:
- 'app/controllers/api/v1/widget/messages_controller.rb'
@@ -97,101 +71,74 @@ Rails/ApplicationController:
- 'app/controllers/platform_controller.rb'
- 'app/controllers/public_controller.rb'
- 'app/controllers/survey/responses_controller.rb'
Rails/FindEach:
Enabled: true
Include:
- 'app/**/*.rb'
Rails/CompactBlank:
Enabled: false
Rails/EnvironmentVariableAccess:
Enabled: false
Rails/TimeZoneAssignment:
Enabled: false
Rails/RedundantPresenceValidationOnBelongsTo:
Enabled: false
Rails/InverseOf:
Exclude:
- enterprise/app/models/captain/assistant.rb
Rails/UniqueValidationWithoutIndex:
Exclude:
- app/models/canned_response.rb
- app/models/telegram_bot.rb
- enterprise/app/models/captain_inbox.rb
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
Style/ClassAndModuleChildren:
EnforcedStyle: compact
Exclude:
- 'config/application.rb'
- 'config/initializers/monkey_patches/*'
Style/MapToHash:
Enabled: false
Style/HashSyntax:
Enabled: true
EnforcedStyle: no_mixed_keys
EnforcedShorthandSyntax: never
RSpec/NestedGroups:
Enabled: true
Max: 4
RSpec/MessageSpies:
Enabled: false
RSpec/StubbedMock:
Enabled: false
RSpec/FactoryBot/SyntaxMethods:
Enabled: false
Naming/VariableNumber:
Enabled: false
Naming/MemoizedInstanceVariableName:
Exclude:
- 'app/models/message.rb'
Style/GuardClause:
Exclude:
- 'app/builders/account_builder.rb'
- 'app/models/attachment.rb'
- 'app/models/message.rb'
Metrics/AbcSize:
Max: 26
Exclude:
- 'app/controllers/concerns/auth_helper.rb'
Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/channel/twitter_profile.rb'
- 'app/models/webhook.rb'
- 'app/models/contact.rb'
- 'app/models/integrations/hook.rb'
- 'app/models/canned_response.rb'
- 'app/models/telegram_bot.rb'
Rails/RenderInline:
Exclude:
- 'app/controllers/swagger_controller.rb'
Rails/ThreeStateBooleanColumn:
Exclude:
- 'db/migrate/20230503101201_create_sla_policies.rb'
RSpec/IndexedLet:
Enabled: false
RSpec/NamedSubject:
Enabled: false
# we should bring this down
RSpec/MultipleExpectations:
Max: 7
RSpec/MultipleMemoizedHelpers:
Max: 14
@@ -219,121 +166,3 @@ AllCops:
- 'tmp/**/*'
- 'storage/**/*'
- 'db/migrate/20230426130150_init_schema.rb'
FactoryBot/SyntaxMethods:
Enabled: false
# Disable new rules causing errors
Layout/LeadingCommentSpace:
Enabled: false
Style/ReturnNilInPredicateMethodDefinition:
Enabled: false
Style/RedundantParentheses:
Enabled: false
Performance/StringIdentifierArgument:
Enabled: false
Layout/EmptyLinesAroundExceptionHandlingKeywords:
Enabled: false
Lint/LiteralAsCondition:
Enabled: false
Style/RedundantReturn:
Enabled: false
Layout/SpaceAroundOperators:
Enabled: false
Rails/EnvLocal:
Enabled: false
Rails/WhereRange:
Enabled: false
Lint/UselessConstantScoping:
Enabled: false
Style/MultipleComparison:
Enabled: false
Bundler/OrderedGems:
Enabled: false
RSpec/ExampleWording:
Enabled: false
RSpec/ReceiveMessages:
Enabled: false
FactoryBot/AssociationStyle:
Enabled: false
Rails/EnumSyntax:
Enabled: false
Lint/RedundantTypeConversion:
Enabled: false
# Additional rules to disable
Rails/RedundantActiveRecordAllMethod:
Enabled: false
Layout/TrailingEmptyLines:
Enabled: false
Style/SafeNavigationChainLength:
Enabled: false
Lint/SafeNavigationConsistency:
Enabled: false
Lint/CopDirectiveSyntax:
Enabled: false
# Final set of rules to disable
FactoryBot/ExcessiveCreateList:
Enabled: false
RSpec/MissingExpectationTargetMethod:
Enabled: false
Performance/InefficientHashSearch:
Enabled: false
Style/RedundantSelfAssignmentBranch:
Enabled: false
Style/YAMLFileRead:
Enabled: false
Layout/ExtraSpacing:
Enabled: false
Style/RedundantFilterChain:
Enabled: false
Performance/MapMethodChain:
Enabled: false
Rails/RootPathnameMethods:
Enabled: false
Style/SuperArguments:
Enabled: false
# Final remaining rules to disable
Rails/Delegate:
Enabled: false
Style/CaseLikeIf:
Enabled: false
FactoryBot/RedundantFactoryOption:
Enabled: false
FactoryBot/FactoryAssociationWithStrategy:
Enabled: false
+1 -1
View File
@@ -1 +1 @@
3.4.4
3.3.3
+6 -6
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org'
ruby '3.4.4'
ruby '3.3.3'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.1'
gem 'rails', '~> 7.0.8.4'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -33,8 +33,6 @@ gem 'liquid'
gem 'commonmarker'
# Validate Data against JSON Schema
gem 'json_schemer'
# used in swagger build
gem 'json_refs'
# Rack middleware for blocking & throttling abusive requests
gem 'rack-attack', '>= 6.7.0'
# a utility tool for streaming, flexible and safe downloading of remote files
@@ -89,7 +87,7 @@ gem 'wisper', '2.0.0'
##--- gems for channels ---##
gem 'facebook-messenger'
gem 'line-bot-api'
gem 'twilio-ruby'
gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty', '~> 0.1.5'
@@ -198,6 +196,9 @@ group :development do
gem 'scss_lint', require: false
gem 'web-console', '>= 4.2.1'
# used in swagger build
gem 'json_refs'
# When we want to squash migrations
gem 'squasher'
@@ -236,7 +237,6 @@ group :development, :test do
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
gem 'rubocop-rspec', require: false
gem 'rubocop-factory_bot', require: false
gem 'seed_dump'
gem 'shoulda-matchers'
gem 'simplecov', '0.17.1', require: false
+153 -179
View File
@@ -25,89 +25,76 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.5.1)
actionpack (= 7.1.5.1)
activesupport (= 7.1.5.1)
actioncable (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.5.1)
actionpack (= 7.1.5.1)
activejob (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
actionmailbox (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.5.1)
actionpack (= 7.1.5.1)
actionview (= 7.1.5.1)
activejob (= 7.1.5.1)
activesupport (= 7.1.5.1)
actionmailer (7.0.8.7)
actionpack (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activesupport (= 7.0.8.7)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2)
actionpack (7.1.5.1)
actionview (= 7.1.5.1)
activesupport (= 7.1.5.1)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rails-dom-testing (~> 2.0)
actionpack (7.0.8.7)
actionview (= 7.0.8.7)
activesupport (= 7.0.8.7)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.5.1)
actionpack (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.7)
actionpack (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.5.1)
activesupport (= 7.1.5.1)
actionview (7.0.8.7)
activesupport (= 7.0.8.7)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8)
activejob (7.1.5.1)
activesupport (= 7.1.5.1)
activejob (7.0.8.7)
activesupport (= 7.0.8.7)
globalid (>= 0.3.6)
activemodel (7.1.5.1)
activesupport (= 7.1.5.1)
activerecord (7.1.5.1)
activemodel (= 7.1.5.1)
activesupport (= 7.1.5.1)
timeout (>= 0.4.0)
activerecord-import (2.1.0)
activemodel (7.0.8.7)
activesupport (= 7.0.8.7)
activerecord (7.0.8.7)
activemodel (= 7.0.8.7)
activesupport (= 7.0.8.7)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.1.5.1)
actionpack (= 7.1.5.1)
activejob (= 7.1.5.1)
activerecord (= 7.1.5.1)
activesupport (= 7.1.5.1)
activestorage (7.0.8.7)
actionpack (= 7.0.8.7)
activejob (= 7.0.8.7)
activerecord (= 7.0.8.7)
activesupport (= 7.0.8.7)
marcel (~> 1.0)
activesupport (7.1.5.1)
base64
benchmark (>= 0.3)
bigdecimal
mini_mime (>= 1.1.0)
activesupport (7.0.8.7)
concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
securerandom (>= 0.3)
tzinfo (~> 2.0)
acts-as-taggable-on (12.0.0)
activerecord (>= 7.1, < 8.1)
zeitwerk (>= 2.4, < 3.0)
acts-as-taggable-on (9.0.1)
activerecord (>= 6.0, < 7.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
administrate (0.20.1)
@@ -129,7 +116,7 @@ GEM
annotate (3.2.0)
activerecord (>= 3.2, < 8.0)
rake (>= 10.4, < 14.0)
ast (2.4.3)
ast (2.4.2)
attr_extras (7.1.0)
audited (5.4.1)
activerecord (>= 5.0, < 7.7)
@@ -155,15 +142,14 @@ GEM
statsd-ruby (~> 1.1)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.4.0)
bigdecimal (3.1.9)
bigdecimal (3.1.8)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
browser (5.3.1)
builder (3.3.0)
bullet (8.0.7)
bullet (7.0.7)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundle-audit (0.1.0)
@@ -172,13 +158,11 @@ GEM
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
byebug (11.1.3)
childprocess (5.1.0)
logger (~> 1.5)
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
rexml
@@ -192,10 +176,16 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.4.1)
ddtrace (0.48.0)
ffi (~> 1.0)
datadog-ci (0.8.3)
msgpack
date (3.4.1)
ddtrace (1.23.2)
datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1)
libdatadog (~> 7.0.0.1.0)
libddwaf (~> 1.14.0.0.0)
msgpack
debase-ruby_core_source (3.3.1)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -206,10 +196,10 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise_token_auth (1.2.5)
devise_token_auth (1.2.3)
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 8.1)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1)
digest-crc (0.6.5)
rake (>= 12.0.0, < 14.0.0)
@@ -222,7 +212,6 @@ GEM
railties (>= 6.1)
down (5.4.0)
addressable (~> 2.8)
drb (2.2.3)
dry-cli (1.1.0)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
@@ -246,10 +235,8 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-mashify (0.1.1)
@@ -257,8 +244,8 @@ GEM
hashie
faraday-multipart (1.0.4)
multipart-post (~> 2)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http (3.1.0)
net-http
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
@@ -267,10 +254,7 @@ GEM
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.17.2)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
ffi (1.16.3)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
@@ -331,13 +315,16 @@ GEM
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.25.7)
google-protobuf (3.25.5)
google-protobuf (3.25.5-arm64-darwin)
google-protobuf (3.25.5-x86_64-darwin)
google-protobuf (3.25.5-x86_64-linux)
googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41)
googleapis-common-protos-types (1.20.0)
google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.11.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.1)
@@ -347,17 +334,17 @@ GEM
signet (>= 0.16, < 2.a)
groupdate (6.2.1)
activesupport (>= 5.2)
grpc (1.72.0)
google-protobuf (>= 3.25, < 5.0)
grpc (1.62.0)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-arm64-darwin)
google-protobuf (>= 3.25, < 5.0)
grpc (1.62.0-arm64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-x86_64-darwin)
google-protobuf (>= 3.25, < 5.0)
grpc (1.62.0-x86_64-darwin)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
grpc (1.72.0-x86_64-linux)
google-protobuf (>= 3.25, < 5.0)
grpc (1.62.0-x86_64-linux)
google-protobuf (~> 3.25)
googleapis-common-protos-types (~> 1.0)
haikunator (1.1.1)
hairtrigger (1.0.0)
@@ -383,7 +370,7 @@ GEM
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
httpclient (2.8.3)
i18n (1.14.7)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
@@ -401,7 +388,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.12.0)
json (2.6.3)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -416,7 +403,7 @@ GEM
judoscale-sidekiq (1.8.2)
judoscale-ruby (= 1.8.2)
sidekiq (>= 5.0)
jwt (2.10.1)
jwt (2.8.1)
base64
kaminari (1.2.2)
activesupport (>= 4.1.0)
@@ -436,15 +423,21 @@ GEM
faraday-multipart
json (>= 1.8)
rexml
language_server-protocol (3.17.0.5)
launchy (3.1.1)
launchy (2.5.2)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
libdatadog (7.0.0.1.0)
libdatadog (7.0.0.1.0-x86_64-linux)
libddwaf (1.14.0.0.0)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
lint_roller (1.1.0)
liquid (5.4.0)
listen (3.8.0)
rb-fsevent (~> 0.10, >= 0.10.3)
@@ -452,7 +445,7 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.7.0)
logger (1.6.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@@ -478,17 +471,17 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.5)
minitest (5.25.4)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.8.0)
msgpack (1.7.0)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.3.0)
mutex_m (0.3.0)
neighbor (0.2.3)
activerecord (>= 5.2)
net-http (0.6.0)
net-http (0.4.1)
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
@@ -552,16 +545,14 @@ GEM
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.1)
parallel (1.27.0)
parser (3.3.8.0)
parallel (1.23.0)
parser (3.2.2.1)
ast (~> 2.4.1)
racc
pg (1.5.3)
pg_search (2.3.6)
activerecord (>= 5.2)
activesupport (>= 5.2)
pgvector (0.1.1)
prism (1.4.0)
procore-sift (1.0.0)
activerecord (>= 6.1)
pry (0.14.2)
@@ -569,14 +560,14 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.2)
public_suffix (6.0.0)
puma (6.4.3)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.15)
rack (2.2.14)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
@@ -590,28 +581,23 @@ GEM
rack (~> 2.2, >= 2.2.4)
rack-proxy (0.7.7)
rack
rack-session (1.0.2)
rack (< 3)
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rackup (1.0.1)
rack (< 3)
webrick
rails (7.1.5.1)
actioncable (= 7.1.5.1)
actionmailbox (= 7.1.5.1)
actionmailer (= 7.1.5.1)
actionpack (= 7.1.5.1)
actiontext (= 7.1.5.1)
actionview (= 7.1.5.1)
activejob (= 7.1.5.1)
activemodel (= 7.1.5.1)
activerecord (= 7.1.5.1)
activestorage (= 7.1.5.1)
activesupport (= 7.1.5.1)
rails (7.0.8.7)
actioncable (= 7.0.8.7)
actionmailbox (= 7.0.8.7)
actionmailer (= 7.0.8.7)
actionpack (= 7.0.8.7)
actiontext (= 7.0.8.7)
actionview (= 7.0.8.7)
activejob (= 7.0.8.7)
activemodel (= 7.0.8.7)
activerecord (= 7.0.8.7)
activestorage (= 7.0.8.7)
activesupport (= 7.0.8.7)
bundler (>= 1.15.0)
railties (= 7.1.5.1)
railties (= 7.0.8.7)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -619,14 +605,13 @@ GEM
rails-html-sanitizer (1.6.1)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (7.1.5.1)
actionpack (= 7.1.5.1)
activesupport (= 7.1.5.1)
irb
rackup (>= 1.0.0)
railties (7.0.8.7)
actionpack (= 7.0.8.7)
activesupport (= 7.0.8.7)
method_source
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
thor (~> 1.0)
zeitwerk (~> 2.5)
rainbow (3.1.1)
rake (13.2.1)
rb-fsevent (0.11.2)
@@ -638,7 +623,7 @@ GEM
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
regexp_parser (2.10.0)
regexp_parser (2.8.0)
reline (0.3.6)
io-console (~> 0.5)
representable (3.2.0)
@@ -658,7 +643,7 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.1)
rexml (3.3.9)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.2)
@@ -678,36 +663,30 @@ GEM
rspec-support (3.13.1)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.75.6)
rubocop (1.50.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
parser (>= 3.2.0.0)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.44.0, < 2.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.44.1)
parser (>= 3.3.7.2)
prism (~> 1.4)
rubocop-factory_bot (2.27.1)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-performance (1.25.0)
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.32.0)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.28.1)
parser (>= 3.2.1.0)
rubocop-capybara (2.18.0)
rubocop (~> 1.41)
rubocop-performance (1.17.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.19.1)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.6.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.21.0)
rubocop (~> 1.33)
rubocop-capybara (~> 2.17)
ruby-openai (7.3.1)
event_stream_parser (>= 0.3.0, < 2.0.0)
faraday (>= 1)
@@ -821,7 +800,7 @@ GEM
i18n
timeout (0.4.3)
trailblazer-option (0.1.2)
twilio-ruby (7.6.0)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, < 3.0)
nokogiri (>= 1.6, < 2.0)
@@ -837,10 +816,8 @@ GEM
unf (0.1.4)
unf_ext
unf_ext (0.0.8.2)
unicode-display_width (3.1.4)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uniform_notifier (1.17.0)
unicode-display_width (2.4.2)
uniform_notifier (1.16.0)
uri (1.0.3)
uri_template (0.7.0)
valid_email2 (5.2.6)
@@ -868,9 +845,7 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.9.1)
websocket-driver (0.7.7)
base64
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
@@ -976,7 +951,7 @@ DEPENDENCIES
rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.1)
rails (~> 7.0.8.4)
redis
redis-namespace
responders (>= 3.1.1)
@@ -985,7 +960,6 @@ DEPENDENCIES
rspec-rails (>= 6.1.5)
rspec_junit_formatter
rubocop
rubocop-factory_bot
rubocop-performance
rubocop-rails
rubocop-rspec
@@ -1010,7 +984,7 @@ DEPENDENCIES
telephone_number
test-prof
time_diff
twilio-ruby
twilio-ruby (~> 5.66)
twitty (~> 0.1.5)
tzinfo-data
uglifier
@@ -1023,7 +997,7 @@ DEPENDENCIES
working_hours
RUBY VERSION
ruby 3.4.4p34
ruby 3.3.3p89
BUNDLED WITH
2.5.16
+1 -8
View File
@@ -41,15 +41,8 @@ run:
force_run:
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.dev
force_run_tunnel:
lsof -ti:3000 | xargs kill -9 2>/dev/null || true
rm -f ./.overmind.sock
rm -f tmp/pids/*.pid
overmind start -f Procfile.tunnel
debug:
overmind connect backend
@@ -59,4 +52,4 @@ debug_worker:
docker:
docker build -t $(APP_NAME) -f ./docker/Dockerfile .
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run force_run_tunnel debug debug_worker
.PHONY: setup db_create db_migrate db_seed db_reset db console server burn docker run force_run debug debug_worker
-4
View File
@@ -1,4 +0,0 @@
backend: DISABLE_MINI_PROFILER=true bin/rails s -p 3000
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
vite: bin/vite build --watch
+1 -12
View File
@@ -10,8 +10,7 @@ function toggleSecretField(e) {
if (!textElement) return;
if (textElement.dataset.secretMasked === 'false') {
const maskedLength = secretField.dataset.secretText?.length || 10;
textElement.textContent = '•'.repeat(maskedLength);
textElement.textContent = '•'.repeat(10);
textElement.dataset.secretMasked = 'true';
toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show');
@@ -33,13 +32,3 @@ function copySecretField(e) {
navigator.clipboard.writeText(secretField.dataset.secretText);
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.cell-data__secret-field').forEach(field => {
const span = field.querySelector('[data-secret-masked]');
if (span && span.dataset.secretMasked === 'true') {
const len = field.dataset.secretText?.length || 10;
span.textContent = '•'.repeat(len);
}
});
});
@@ -46,25 +46,17 @@
.cell-data__secret-field {
align-items: center;
color: $hint-grey;
display: flex;
span {
flex: 0 0 auto;
flex: 1;
}
[data-secret-toggler],
[data-secret-copier] {
background: transparent;
border: 0;
color: inherit;
margin-left: 0.5rem;
padding: 0;
button {
margin-left: 5px;
svg {
fill: currentColor;
height: 1.25rem;
width: 1.25rem;
}
}
}
+4 -13
View File
@@ -21,8 +21,6 @@ class ContactInboxBuilder
email_source_id
when 'Channel::Sms'
phone_source_id
when 'Channel::Voice'
phone_source_id # Voice uses phone number as source ID
when 'Channel::Api', 'Channel::WebWidget'
SecureRandom.uuid
else
@@ -37,12 +35,7 @@ class ContactInboxBuilder
end
def phone_source_id
unless @contact.phone_number.present?
# For voice channels, we'll create a fallback source ID if phone number is missing
return SecureRandom.uuid if @inbox.channel_type == 'Channel::Voice'
raise ActionController::ParameterMissing, 'contact phone number'
end
raise ActionController::ParameterMissing, 'contact phone number' unless @contact.phone_number
@contact.phone_number
end
@@ -66,13 +59,11 @@ class ContactInboxBuilder
end
def create_contact_inbox
attrs = {
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
contact_id: @contact.id,
inbox_id: @inbox.id,
source_id: @source_id
}
::ContactInbox.where(attrs).first_or_create!(hmac_verified: hmac_verified || false)
)
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("[ContactInboxBuilder] RecordNotUnique #{@source_id} #{@contact.id} #{@inbox.id}")
update_old_contact_inbox
@@ -107,6 +98,6 @@ class ContactInboxBuilder
end
def allowed_channels?
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? || @inbox.channel_type == 'Channel::Voice'
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
end
end
@@ -152,13 +152,11 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
end
def message_already_exists?
find_message_by_source_id(@messaging[:message][:mid]).present?
end
cw_message = conversation.messages.where(
source_id: @messaging[:message][:mid]
).first
def find_message_by_source_id(source_id)
return unless source_id
@message = Message.find_by(source_id: source_id)
cw_message.present?
end
def all_unsupported_files?
+10 -13
View File
@@ -7,7 +7,7 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
@message_type = params[:message_type].to_s || 'outgoing'
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
return unless params.instance_of?(ActionController::Parameters)
@@ -33,6 +33,11 @@ class Messages::MessageBuilder
def content_attributes
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
return parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
# Converts the given object to a hash.
@@ -100,9 +105,8 @@ class Messages::MessageBuilder
end
def message_type
# Allow incoming messages in both API and Voice channels
if !['Channel::Api', 'Channel::Voice'].include?(@conversation.inbox.channel_type) && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api and Voice inboxes'
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
end
@message_type
@@ -135,7 +139,7 @@ class Messages::MessageBuilder
end
def message_params
message_attrs = {
{
account_id: @conversation.account_id,
inbox_id: @conversation.inbox_id,
message_type: message_type,
@@ -148,12 +152,5 @@ class Messages::MessageBuilder
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
# Directly add content_attributes from params if present
if @params[:content_attributes].present?
message_attrs[:content_attributes] = content_attributes
end
message_attrs
end
end
end
@@ -1,103 +0,0 @@
class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
attr_reader :account, :params
# rubocop:disable Lint/MissingSuper
# the parent class has no initialize
def initialize(account:, params:)
@account = account
@params = params
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]&.name
end
# rubocop:enable Lint/MissingSuper
def build
labels = account.labels.to_a
return [] if labels.empty?
report_data = collect_report_data
labels.map { |label| build_label_report(label, report_data) }
end
private
def collect_report_data
conversation_filter = build_conversation_filter
use_business_hours = use_business_hours?
{
conversation_counts: fetch_conversation_counts(conversation_filter),
resolved_counts: fetch_resolved_counts(conversation_filter),
resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours)
}
end
def build_label_report(label, report_data)
{
id: label.id,
name: label.title,
conversations_count: report_data[:conversation_counts][label.title] || 0,
avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
avg_reply_time: report_data[:reply_metrics][label.title] || 0,
resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
}
end
def use_business_hours?
ActiveModel::Type::Boolean.new.cast(params[:business_hours])
end
def build_conversation_filter
conversation_filter = { account_id: account.id }
conversation_filter[:created_at] = range if range.present?
conversation_filter
end
def fetch_conversation_counts(conversation_filter)
fetch_counts(conversation_filter)
end
def fetch_resolved_counts(conversation_filter)
# since the base query is ActsAsTaggableOn,
# the status :resolved won't automatically be converted to integer status
fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved]))
end
def fetch_counts(conversation_filter)
ActsAsTaggableOn::Tagging
.joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
taggable_type: 'Conversation',
context: 'labels',
conversations: conversation_filter
)
.select('tags.name, COUNT(taggings.*) AS count')
.group('tags.name')
.each_with_object({}) { |record, hash| hash[record.name] = record.count }
end
def fetch_metrics(conversation_filter, event_name, use_business_hours)
ReportingEvent
.joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
.joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
.joins('INNER JOIN tags ON taggings.tag_id = tags.id')
.where(
conversations: conversation_filter,
name: event_name,
taggings: { taggable_type: 'Conversation', context: 'labels' }
)
.group('tags.name')
.order('tags.name')
.select(
'tags.name',
use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
)
.each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
end
end
@@ -29,11 +29,6 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
head :ok
end
def reset_access_token
@agent_bot.access_token.regenerate_token
@agent_bot.reload
end
private
def agent_bot
@@ -68,7 +68,7 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def article_params
params.require(:article).permit(
:title, :slug, :position, :content, :description, :category_id, :author_id, :associated_article_id, :status,
:title, :slug, :position, :content, :description, :position, :category_id, :author_id, :associated_article_id, :status,
:locale, meta: [:title,
:description,
{ tags: [] }]
@@ -1,152 +0,0 @@
class Api::V1::Accounts::Channels::Voice::WebhooksController < Api::V1::Accounts::BaseController
skip_before_action :authenticate_user!, :set_current_user, only: [:incoming, :conference_status]
protect_from_forgery with: :null_session, only: [:incoming, :conference_status]
before_action :validate_twilio_signature, only: [:incoming]
before_action :handle_options_request, only: [:incoming, :conference_status]
# Handle CORS preflight OPTIONS requests
def handle_options_request
if request.method == "OPTIONS"
set_cors_headers
head :ok
return true
end
false
end
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS'
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
headers['Access-Control-Max-Age'] = '86400' # 24 hours
end
# Handle incoming calls from Twilio
def incoming
# Set CORS headers first to ensure they're included
set_cors_headers
# Log basic request info
Rails.logger.info("🔔 INCOMING CALL WEBHOOK: CallSid=#{params['CallSid']} From=#{params['From']} To=#{params['To']}")
# Process incoming call using service
begin
# Ensure account is set properly
if !Current.account && params[:account_id].present?
Current.account = Account.find(params[:account_id])
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
end
# Validate required parameters
validate_incoming_params
# Process the call
service = Voice::IncomingCallService.new(
account: Current.account,
params: params.to_unsafe_h.merge(host_with_port: request.host_with_port)
)
twiml_response = service.process
# Return TwiML response
Rails.logger.info("✅ INCOMING CALL: Successfully processed")
render xml: twiml_response
rescue StandardError => e
# Log the error with detailed information
Rails.logger.error("❌ INCOMING CALL ERROR: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
# Return friendly error message to caller
render_error("We're sorry, but we're experiencing technical difficulties. Please try your call again later.")
end
end
# Handle conference status updates
def conference_status
# Set CORS headers first to ensure they're always included
set_cors_headers
# Return immediately for OPTIONS requests
if request.method == "OPTIONS"
return head :ok
end
# Log basic request info
Rails.logger.info("🎧 CONFERENCE STATUS WEBHOOK: ConferenceSid=#{params['ConferenceSid']} Event=#{params['StatusCallbackEvent']}")
# Process conference status updates using service
begin
# Set account for local development if needed
if !Current.account && params[:account_id].present?
Current.account = Account.find(params[:account_id])
Rails.logger.info("👑 Set Current.account to #{Current.account.id}")
end
# Validate required parameters
if params['ConferenceSid'].blank? && params['CallSid'].blank?
Rails.logger.error("❌ MISSING REQUIRED PARAMS: Need either ConferenceSid or CallSid")
end
# Use service to process conference status
service = Voice::ConferenceStatusService.new(account: Current.account, params: params)
service.process
Rails.logger.info("✅ CONFERENCE STATUS: Successfully processed")
rescue StandardError => e
# Log errors but don't affect the response
Rails.logger.error("❌ CONFERENCE STATUS ERROR: #{e.message}")
Rails.logger.error("❌ BACKTRACE: #{e.backtrace[0..5].join("\n")}")
end
# Always return a successful response for Twilio
head :ok
end
private
def validate_incoming_params
if params['CallSid'].blank?
raise "Missing required parameter: CallSid"
end
if params['From'].blank?
raise "Missing required parameter: From"
end
if params['To'].blank?
raise "Missing required parameter: To"
end
if Current.account.nil?
raise "Current account not set"
end
end
def validate_twilio_signature
begin
validator = Voice::TwilioValidatorService.new(
account: Current.account,
params: params,
request: request
)
if !validator.valid?
Rails.logger.error("❌ INVALID TWILIO SIGNATURE")
render_error('Invalid Twilio signature')
return false
end
return true
rescue StandardError => e
Rails.logger.error("❌ TWILIO VALIDATION ERROR: #{e.message}")
render_error('Error validating Twilio request')
return false
end
end
def render_error(message)
response = Twilio::TwiML::VoiceResponse.new
response.say(message: message)
response.hangup
render xml: response.to_s
end
end
@@ -1,39 +0,0 @@
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
before_action :fetch_contact
def create
# Validate that contact has a phone number
if @contact.phone_number.blank?
render json: { error: 'Contact has no phone number' }, status: :unprocessable_entity
return
end
begin
# Use the outgoing call service to handle the entire process
service = Voice::OutgoingCallService.new(
account: Current.account,
contact: @contact,
user: Current.user
)
# Process the call - this handles all the steps
conversation = service.process
# Assign to @conversation so jbuilder template can access it
@conversation = conversation
# Use the conversation jbuilder template to ensure consistent representation
# This will ensure only display_id is used as the id, not the internal database id
render 'api/v1/accounts/conversations/show'
rescue StandardError => e
Rails.logger.error("Error initiating call: #{e.message}")
render json: { error: e.message }, status: :unprocessable_entity
end
end
private
def fetch_contact
@contact = Current.account.contacts.find(params[:contact_id])
end
end
@@ -14,7 +14,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :set_current_page, only: [:index, :active, :search, :filter]
before_action :fetch_contact, only: [:show, :update, :destroy, :avatar, :contactable_inboxes, :destroy_custom_attributes]
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
before_action :set_include_contact_inboxes, only: [:index, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@@ -56,7 +56,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
@contacts = contacts.page(@current_page)
end
def show; end
@@ -124,12 +124,6 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
@conversation.save!
end
def destroy
authorize @conversation, :destroy?
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
private
def permitted_update_params
@@ -1,23 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/google/callback",
scope: scope,
scope: 'email profile https://mail.google.com/',
response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online'
state: state,
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
}
)
if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -81,15 +81,11 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type])
account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type))
end
def allowed_channel_types
%w[web_widget api email line telegram whatsapp sms]
end
def update_inbox_working_hours
@inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours]
end
@@ -163,8 +159,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
'line' => Channel::Line,
'telegram' => Channel::Telegram,
'whatsapp' => Channel::Whatsapp,
'sms' => Channel::Sms,
'voice' => Channel::Voice
'sms' => Channel::Sms
}[permitted_params[:channel][:type]]
end
@@ -1,6 +1,7 @@
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController
include InstagramConcern
include Instagram::IntegrationHelper
before_action :check_authorization
def create
# https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization
@@ -20,4 +21,10 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -1,9 +1,8 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues]
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
before_action :fetch_hook, only: [:destroy]
def destroy
revoke_linear_token
@hook.destroy!
head :ok
end
@@ -28,16 +27,10 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params, Current.user)
issue = linear_processor_service.create_issue(permitted_params)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_created,
issue_data: { id: issue[:data][:identifier] },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -45,34 +38,21 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def link_issue
issue_id = permitted_params[:issue_id]
title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title, Current.user)
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_linked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue_id = permitted_params[:issue_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
Linear::ActivityMessageService.new(
conversation: @conversation,
action_type: :issue_unlinked,
issue_data: { id: issue_id },
user: Current.user
).perform
render json: issue[:data], status: :ok
end
end
@@ -121,15 +101,4 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'linear')
end
def revoke_linear_token
return unless @hook&.access_token
begin
linear_client = Linear.new(@hook.access_token)
linear_client.revoke_token
rescue StandardError => e
Rails.logger.error "Failed to revoke Linear token: #{e.message}"
end
end
end
@@ -1,14 +0,0 @@
class Api::V1::Accounts::Integrations::NotionController < Api::V1::Accounts::BaseController
before_action :fetch_hook, only: [:destroy]
def destroy
@hook.destroy!
head :ok
end
private
def fetch_hook
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'notion')
end
end
@@ -1,19 +1,28 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: scope,
state: state,
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
prompt: 'consent'
}
)
if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -1,21 +0,0 @@
class Api::V1::Accounts::Notion::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include NotionConcern
def create
redirect_url = notion_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/notion/callback",
response_type: 'code',
owner: 'user',
state: state,
client_id: GlobalConfigService.load('NOTION_CLIENT_ID', nil)
}
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end
@@ -1,23 +0,0 @@
class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController
before_action :check_authorization
protected
def scope
''
end
def state
Current.account.to_sgid(expires_in: 15.minutes).to_s
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -15,10 +15,6 @@ class Api::V1::Accounts::SearchController < Api::V1::Accounts::BaseController
@result = search('Message')
end
def articles
@result = search('Article')
end
private
def search(search_type)
@@ -1,80 +0,0 @@
class Api::V1::Accounts::Voice::TokensController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox
def create
render json: build_response
rescue StandardError => e
Rails.logger.error("Voice::TokensController#create: #{e.class} - #{e.message}\n#{e.backtrace.first(5).join("\n")}")
render json: { error: 'Failed to generate token', details: e.message }, status: :internal_server_error
end
private
def build_response
{
token: twilio_token.to_jwt,
identity: client_identity,
voice_enabled: true,
account_sid: twilio_config[:account_sid],
agent_id: Current.user.id,
account_id: Current.account.id,
inbox_id: @voice_inbox.id,
phone_number: twilio_config[:phone_number],
twiml_endpoint: twilio_config[:twiml_url],
has_twiml_app: twilio_config[:outgoing_app_sid].present?
}
end
def twilio_token
Twilio::JWT::AccessToken.new(
*twilio_credentials,
identity: client_identity,
ttl: 1.hour.to_i
).tap { |t| t.add_grant(voice_grant) }
end
def twilio_credentials
twilio_config.values_at(:account_sid, :api_key_sid, :api_key_secret)
end
def voice_grant
Twilio::JWT::AccessToken::VoiceGrant.new.tap do |grant|
grant.incoming_allow = true
grant.outgoing_application_sid = twilio_config[:outgoing_app_sid]
grant.outgoing_application_params = outgoing_params
end
end
def outgoing_params
{
account_id: Current.account.id,
agent_id: Current.user.id,
identity: client_identity,
client_name: client_identity,
accountSid: twilio_config[:account_sid],
is_agent: 'true'
}
end
def twilio_config
@twilio_config ||= begin
cfg = @voice_inbox.channel.provider_config_hash || {}
{
account_sid: cfg['account_sid'],
api_key_sid: cfg['api_key_sid'],
api_key_secret: cfg['api_key_secret'],
outgoing_app_sid: cfg['outgoing_application_sid'],
phone_number: @voice_inbox.channel.phone_number,
twiml_url: "#{ENV.fetch('FRONTEND_URL', '')}/api/v1/accounts/#{Current.account.id}/voice/twiml_for_client"
}.with_indifferent_access.merge(client_identity:)
end
end
def client_identity
@client_identity ||= "agent-#{Current.user.id}-account-#{Current.account.id}"
end
def set_voice_inbox
@voice_inbox = Current.account.inboxes.find(params[:inbox_id])
end
end
@@ -1,230 +0,0 @@
require 'twilio-ruby'
class Api::V1::Accounts::VoiceController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: %i[end_call join_call reject_call]
skip_before_action :authenticate_user!, only: :twiml_for_client
protect_from_forgery with: :null_session, only: :twiml_for_client
before_action :render_options, if: -> { request.options? }
after_action :set_cors_headers, if: -> { action_name == 'twiml_for_client' }
# ---------- PUBLIC ACTIONS --------------------------------------------------
def end_call
call_sid = params[:call_sid] || convo_attr('call_sid')
return render_not_found('active call') unless call_sid
twilio_client.calls(call_sid).update(status: 'completed') if in_progress?(call_sid)
Voice::CallStatus::Manager.new(conversation: @conversation,
call_sid: call_sid,
provider: :twilio)
.process_status_update('completed', nil, false, "Call ended by #{current_user.name}")
broadcast_status(call_sid, 'completed')
render_success('Call successfully ended')
rescue StandardError => e
render_error("Failed to end call: #{e.message}")
end
def join_call
call_sid = params[:call_sid] || convo_attr('call_sid')
outbound = convo_attr('requires_agent_join') == true
return render_not_found('active call') unless call_sid || outbound
conference_sid = convo_attr('conference_sid') || create_conference_sid!
update_join_metadata!(call_sid)
broadcast_status(call_sid, 'in-progress')
render json: {
status: 'success',
message: 'Agent joining call via WebRTC',
conference_sid: conference_sid,
using_webrtc: true,
conversation_id: @conversation.display_id,
account_id: Current.account.id
}
rescue StandardError => e
render_error("Failed to join call: #{e.message}")
end
def reject_call
call_sid = params[:call_sid] || convo_attr('call_sid')
return render_not_found('active call') unless call_sid
@conversation.update!(additional_attributes: convo_attrs.merge(
'agent_rejected' => true,
'rejected_at' => Time.current.to_i,
'rejected_by' => user_meta
))
Voice::CallStatus::Manager.new(conversation: @conversation,
call_sid: call_sid,
provider: :twilio)
.create_activity_message("#{current_user.name} declined to answer",
rejected_by: current_user.name,
rejected_at: Time.current.to_i)
render_success('Call rejected by agent')
end
def call_status
call_sid = params[:call_sid]
return render_not_found('active call') unless call_sid
call = twilio_client.calls(call_sid).fetch
render json: call.slice(:status, :duration, :direction, :from, :to, :start_time, :end_time)
rescue StandardError => e
render_error("Failed to fetch call status: #{e.message}")
end
# TwiML for agent WebRTC dialin
def twiml_for_client
to = params[:To] || params[:to]
return render_twiml_error('Missing conference ID parameter') if to.blank?
render xml: build_twiml(to), content_type: 'text/xml'
rescue StandardError => e
render_twiml_error(e.message)
end
# ---------- PRIVATE ---------------------------------------------------------
private
# ---- Helpers ---------------------------------------------------------------
def render_options
head :ok
end
def set_cors_headers
headers['Content-Type'] ||= 'text/xml; charset=utf-8'
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, OPTIONS'
headers['Access-Control-Allow-Headers'] = 'Content-Type, X-Twilio-Signature'
headers['Access-Control-Max-Age'] = '86400'
end
def render_success(msg) = render json: { status: 'success', message: msg }
def render_not_found(resource) = render json: { error: "No #{resource} found" }, status: :not_found
def render_error(msg) = render json: { error: msg }, status: :internal_server_error
def fetch_conversation
@conversation = Current.account.conversations.find_by(display_id: params[:conversation_id])
end
def twilio_client
@twilio_client ||= begin
cfg = @conversation.inbox.channel.provider_config_hash
Twilio::REST::Client.new(cfg['account_sid'], cfg['auth_token'])
end
end
def in_progress?(call_sid)
%w[in-progress ringing].include?(twilio_client.calls(call_sid).fetch.status)
end
def convo_attrs
@conversation.additional_attributes || {}
end
def convo_attr(key)
convo_attrs[key]
end
def user_meta
{ id: current_user.id, name: current_user.name }
end
def create_conference_sid!
sid = "conf_account_#{Current.account.id}_conv_#{@conversation.display_id}"
@conversation.update!(additional_attributes: convo_attrs.merge('conference_sid' => sid))
sid
end
def update_join_metadata!(call_sid)
@conversation.update!(additional_attributes: convo_attrs.merge(
'agent_joined' => true,
'joined_at' => Time.current.to_i,
'joined_by' => user_meta,
'call_status' => 'in-progress'
))
Voice::CallStatus::Manager.new(conversation: @conversation,
call_sid: call_sid,
provider: :twilio)
.process_status_update('in-progress', nil, false, "#{current_user.name} joined the call")
end
def broadcast_status(call_sid, status)
ActionCable.server.broadcast "account_#{@conversation.account_id}", {
event_name: 'call_status_changed',
data: {
call_sid: call_sid,
status: status,
conversation_id: @conversation.display_id,
inbox_id: @conversation.inbox_id,
timestamp: Time.current.to_i
}
}
end
# ---- TwiML -----------------------------------------------------------------
def build_twiml(conference_name)
# For agent legs, we need to add transcription too
account_id = params[:account_id] || Current.account&.id
agent_id = params[:agent_id] || current_user&.id
transcription_url = "#{base_url}/twilio/transcription_callback?account_id=#{account_id}&conference_sid=#{conference_name}&speaker_type=agent&agent_id=#{agent_id}"
Twilio::TwiML::VoiceResponse.new do |r|
# Add transcription for the agent leg too
r.start do |start|
start.transcription(
status_callback_url: transcription_url,
status_callback_method: 'POST',
track: 'inbound_track', # Use inbound_track consistently for conference calls
language_code: 'en-US'
)
end
r.dial do |dial|
dial.conference(
conference_name,
startConferenceOnEnter: true,
endConferenceOnExit: true,
muted: false,
beep: false,
waitUrl: '',
earlyMedia: true,
statusCallback: conference_callback_url,
statusCallbackEvent: 'start end join leave',
statusCallbackMethod: 'POST',
participantLabel: "agent-#{params[:agent_id] || current_user&.id}"
)
end
end.to_s
end
def conference_callback_url
account_id = params[:account_id] || Current.account&.id
"#{base_url.chomp('/')}/api/v1/accounts/#{account_id}/channels/voice/webhooks/conference_status"
end
def base_url
ENV.fetch('FRONTEND_URL', '')
end
# ---- TwiML Error -----------------------------------------------------------
def render_twiml_error(message)
response = Twilio::TwiML::VoiceResponse.new do |r|
r.say(message: "Error: #{message}")
r.hangup
end
set_cors_headers
render xml: response.to_s, content_type: 'text/xml'
end
end
@@ -92,7 +92,7 @@ class Api::V1::AccountsController < Api::BaseController
end
def settings_params
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label)
params.permit(:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting)
end
def check_signup_enabled
@@ -38,11 +38,6 @@ class Api::V1::ProfilesController < Api::BaseController
head :ok
end
def reset_access_token
@user.access_token.regenerate_token
@user.reload
end
private
def set_user
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
before_action :prepare_builder_params, only: [:agent, :team, :inbox]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -14,10 +14,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::InboxSummaryBuilder)
end
def label
render_report_with(V2::Reports::LabelSummaryBuilder)
end
private
def check_authorization
+2 -2
View File
@@ -14,7 +14,7 @@ module GoogleConcern
private
def scope
'email profile https://mail.google.com/'
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -15,7 +15,7 @@ module MicrosoftConcern
private
def scope
'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email'
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -1,21 +0,0 @@
module NotionConcern
extend ActiveSupport::Concern
def notion_client
app_id = GlobalConfigService.load('NOTION_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('NOTION_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://api.notion.com',
authorize_url: 'https://api.notion.com/v1/oauth/authorize',
token_url: 'https://api.notion.com/v1/oauth/token',
auth_scheme: :basic_auth
})
end
private
def scope
''
end
end
+1 -1
View File
@@ -15,7 +15,7 @@ class DashboardController < ActionController::Base
private
def ensure_html_format
render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json?
head :not_acceptable unless request.format.html?
end
def set_global_config
@@ -1,36 +0,0 @@
class Notion::CallbacksController < OauthCallbackController
include NotionConcern
private
def provider_name
'notion'
end
def oauth_client
notion_client
end
def handle_response
hook = account.hooks.new(
access_token: parsed_body['access_token'],
status: 'enabled',
app_id: 'notion',
settings: {
token_type: parsed_body['token_type'],
workspace_name: parsed_body['workspace_name'],
workspace_id: parsed_body['workspace_id'],
workspace_icon: parsed_body['workspace_icon'],
bot_id: parsed_body['bot_id'],
owner: parsed_body['owner']
}
)
hook.save!
redirect_to notion_redirect_uri
end
def notion_redirect_uri
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/notion"
end
end
+8 -9
View File
@@ -6,6 +6,7 @@ class OauthCallbackController < ApplicationController
)
handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
@@ -63,10 +64,13 @@ class OauthCallbackController < ApplicationController
raise NotImplementedError
end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
@@ -81,17 +85,12 @@ class OauthCallbackController < ApplicationController
decoded_token[0]
end
def account_from_signed_id
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
account = GlobalID::Locator.locate_signed(params[:state])
raise 'Invalid or expired state' if account.nil?
account
def account_id
::Redis::Alfred.get(cache_key)
end
def account
@account ||= account_from_signed_id
@account ||= Account.find(account_id)
end
# Fallback name, for when name field is missing from users_data
@@ -1,9 +1,9 @@
class Platform::Api::V1::UsersController < PlatformController
# ref: https://stackoverflow.com/a/45190318/939299
# set resource is called for other actions already in platform controller
# we want to add login and token to that chain as well
before_action(only: [:login, :token]) { set_resource }
before_action(only: [:login, :token]) { validate_platform_app_permissible }
# we want to add login to that chain as well
before_action(only: [:login]) { set_resource }
before_action(only: [:login]) { validate_platform_app_permissible }
def show; end
@@ -18,8 +18,6 @@ class Platform::Api::V1::UsersController < PlatformController
render json: { url: @resource.generate_sso_link }
end
def token; end
def update
@resource.assign_attributes(user_update_params)
@@ -1,40 +1,19 @@
class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::BaseController
before_action :ensure_custom_domain_request, only: [:show, :index]
before_action :portal
before_action :set_category, except: [:index, :show, :tracking_pixel]
before_action :set_category, except: [:index, :show]
before_action :set_article, only: [:show]
layout 'portal'
def index
@articles = @portal.articles.published.includes(:category, :author)
@articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present?
@articles_count = @articles.count
search_articles
order_by_sort_param
limit_results
end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @article.title)
end
def tracking_pixel
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
return head :not_found unless @article
@article.increment_view_count if @article.published?
# Serve the 1x1 tracking pixel with 24-hour private cache
# Private cache bypasses CDN but allows browser caching to prevent duplicate views from same user
expires_in 24.hours, public: false
response.headers['Content-Type'] = 'image/png'
pixel_path = Rails.public_path.join('assets/images/tracking-pixel.png')
send_file pixel_path, type: 'image/png', disposition: 'inline'
end
def show; end
private
@@ -60,6 +39,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
def set_article
@article = @portal.articles.find_by(slug: permitted_params[:article_slug])
@article.increment_view_count if @article.published?
@parsed_content = render_article_content(@article.content)
end
@@ -8,9 +8,7 @@ class Public::Api::V1::Portals::CategoriesController < Public::Api::V1::Portals:
@categories = @portal.categories.order(position: :asc)
end
def show
@og_image_url = helpers.set_og_image_url(@portal.name, @category.name)
end
def show; end
private
@@ -4,9 +4,7 @@ class Public::Api::V1::PortalsController < Public::Api::V1::Portals::BaseControl
before_action :redirect_to_portal_with_locale, only: [:show]
layout 'portal'
def show
@og_image_url = helpers.set_og_image_url('', @portal.header_text)
end
def show; end
def sitemap
@help_center_url = @portal.custom_domain || ChatwootApp.help_center_root
@@ -2,13 +2,6 @@ class SuperAdmin::AccountUsersController < SuperAdmin::ApplicationController
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
# Since account/user page - account user role attribute links to the show page
# Handle with a redirect to the user show page
def show
redirect_to super_admin_user_path(requested_resource.user)
end
def create
resource = resource_class.new(resource_params)
authorize_resource(resource)
@@ -32,18 +32,22 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
end
def allowed_configs
mapping = {
'facebook' => %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT],
'shopify' => %w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET],
'microsoft' => %w[AZURE_APP_ID AZURE_APP_SECRET],
'email' => ['MAILER_INBOUND_EMAIL_DOMAIN'],
'linear' => %w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET],
'slack' => %w[SLACK_CLIENT_ID SLACK_CLIENT_SECRET],
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
}
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'shopify'
%w[SHOPIFY_CLIENT_ID SHOPIFY_CLIENT_SECRET]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
when 'linear'
%w[LINEAR_CLIENT_ID LINEAR_CLIENT_SECRET]
when 'instagram'
%w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT]
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end
end
end
@@ -27,10 +27,7 @@ class Twilio::CallbackController < ApplicationController
*Array.new(10) { |i| :"MediaUrl#{i}" },
*Array.new(10) { |i| :"MediaContentType#{i}" },
:MessagingServiceSid,
:NumMedia,
:Latitude,
:Longitude,
:MessageType
:NumMedia
)
end
end
@@ -1,81 +0,0 @@
# frozen_string_literal: true
class Twilio::RecordingController < ActionController::Base
skip_forgery_protection
# POST /twilio/recording_callback
# This endpoint is called by Twilio when a call recording is available
def recording_callback
conference_sid = params['conference_sid']
call_sid = params['CallSid']
recording_url = params['RecordingUrl']
recording_sid = params['RecordingSid']
account_id = params['account_id']
Rails.logger.info("[Twilio::RecordingController] Incoming recording_callback with params: #{params.inspect}")
unless recording_url && account_id && (conference_sid || call_sid)
Rails.logger.warn("[Twilio::RecordingController] Missing required params. recording_url: #{recording_url}, account_id: #{account_id}, conference_sid: #{conference_sid}, call_sid: #{call_sid}")
return head :bad_request
end
# Find the account
account = Account.find_by(id: account_id)
unless account
Rails.logger.warn("[Twilio::RecordingController] Account not found for id: #{account_id}")
return head :not_found
end
# Prefer lookup by conference_sid (most robust for conference recordings)
conversation = if conference_sid
account.conversations.find_by("additional_attributes ->> 'conference_sid' = ?", conference_sid)
elsif call_sid
account.conversations.find_by("additional_attributes ->> 'call_sid' = ?", call_sid)
end
unless conversation
Rails.logger.warn("[Twilio::RecordingController] Conversation not found for conference_sid: #{conference_sid} or call_sid: #{call_sid}")
return head :not_found
end
# Find the original voice call message (should be unique per conference)
message = conversation.messages.voice_call.order(:created_at).first
unless message
Rails.logger.warn("[Twilio::RecordingController] No voice_call message found in conversation_id: #{conversation.id}")
return head :not_found
end
# Download the recording from Twilio
begin
Rails.logger.info("[Twilio::RecordingController] Downloading recording from: #{recording_url}.mp3")
file = URI.open(recording_url + '.mp3')
rescue => e
Rails.logger.error("[Twilio::RecordingController] Failed to download recording: #{e.message}")
return head :internal_server_error
end
# Attach the audio file to the message as an audio attachment
begin
att = message.attachments.create!(
account_id: account.id,
file: {
io: file,
filename: "twilio_recording_#{recording_sid}.mp3",
content_type: 'audio/mpeg'
},
file_type: :audio,
external_url: recording_url + '.mp3',
meta: { recording_sid: recording_sid, conference_sid: conference_sid, call_sid: call_sid }
)
Rails.logger.info("[Twilio::RecordingController] Successfully attached recording to message_id: #{message.id}, attachment_id: #{att.id}")
rescue => e
Rails.logger.error("[Twilio::RecordingController] Failed to attach recording: #{e.message}")
return head :internal_server_error
end
# Optionally, update message content_attributes to indicate recording is attached
content_attributes = message.content_attributes || {}
content_attributes['recording_attached'] = true
content_attributes['conference_sid'] = conference_sid if conference_sid
message.update!(content_attributes: content_attributes)
head :ok
end
end
@@ -1,62 +0,0 @@
class Twilio::TranscriptionController < ActionController::Base
skip_forgery_protection
# Receives real-time transcription updates from Twilio
def transcription_callback
# Set Current.account
Current.account = Account.find_by(id: params[:account_id])
# Only process transcription content events
if params['TranscriptionEvent'] == 'transcription-content'
process_transcription_content
end
head :ok
end
private
def process_transcription_content
# Extract transcript content from JSON
data = JSON.parse(params['TranscriptionData'])
transcript_content = data['transcript']
confidence = data['confidence']
# Find conversation by conference_sid from our standard format
display_id = params[:conference_sid].match(/^conf_account_\d+_conv_(\d+)$/)[1]
conversation = Current.account.conversations.find_by(display_id: display_id)
# Create message based on speaker_type
create_message(conversation, transcript_content, confidence)
end
def create_message(conversation, content, confidence)
if params[:speaker_type] == 'contact'
# Contact message (incoming)
sender = conversation.contact
message_type = :incoming
else
# Agent message (outgoing)
sender = User.find_by(id: params[:agent_id])
message_type = :outgoing
end
# Create the message
Messages::MessageBuilder.new(
sender,
conversation,
content: content,
message_type: message_type,
private: false,
additional_attributes: {
transcription: true,
call_sid: params['CallSid'],
conference_sid: params[:conference_sid],
speaker_type: params[:speaker_type],
confidence: confidence,
track: params['Track']
}
).perform
end
end
-189
View File
@@ -1,189 +0,0 @@
class Twilio::VoiceController < ActionController::Base
skip_forgery_protection
before_action :set_call_details, only: %i[status_callback simple_twiml]
before_action :set_inbox, only: %i[status_callback simple_twiml]
def status_callback
return head :ok unless @inbox
conversation = Voice::ConversationFinderService.new(
account: @inbox.account,
call_sid: @call_sid,
phone_number: incoming_number,
is_outbound: outbound?,
inbox: @inbox
).perform
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: @call_sid,
provider: :twilio
).process_status_update(params[:CallStatus], params[:CallDuration]&.to_i, first_status_response?)
head :ok
end
def simple_twiml
return fallback_twiml unless @inbox
conversation = Voice::ConversationFinderService.new(
account: @inbox.account,
call_sid: @call_sid,
phone_number: incoming_number,
is_outbound: outbound?,
inbox: @inbox
).perform
Voice::CallStatus::Manager.new(
conversation: conversation,
call_sid: @call_sid,
provider: :twilio
).process_status_update('in-progress', nil, true)
conference_name = ensure_conference_name(conversation, params[:conference_name])
conversation.update!(
additional_attributes: conversation.additional_attributes.merge(
'conference_sid' => conference_name,
'call_direction' => outbound? ? 'outbound' : 'inbound',
'requires_agent_join' => true
)
)
render_twiml do |r|
r.say(message: 'Please wait while we connect you to an agent')
# Enable real-time transcription for this call leg
# For outbound calls, we're connecting to the contact, so this track is for the contact
contact_id = conversation.contact_id
callback_url = "#{base_url}/twilio/transcription_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}&speaker_type=contact&contact_id=#{contact_id}"
Rails.logger.info("📞 VoiceController: Setting transcription callback to: #{callback_url}")
r.start do |start|
start.transcription(
status_callback_url: callback_url,
status_callback_method: 'POST',
track: 'inbound_track',
language_code: 'en-US'
)
end
# Set up the conference
conference_callback_url = "#{base_url}/api/v1/accounts/#{@inbox.account_id}/channels/voice/webhooks/conference_status"
Rails.logger.info("📞 VoiceController: Setting conference callback to: #{conference_callback_url}")
r.dial do |d|
d.conference(
conference_name,
startConferenceOnEnter: false,
endConferenceOnExit: true,
beep: false,
muted: false,
waitUrl: '',
earlyMedia: true,
statusCallback: conference_callback_url,
statusCallbackMethod: 'POST',
statusCallbackEvent: 'start end join leave',
participantLabel: "caller-#{@call_sid.last(8)}",
record: 'record-from-start',
recording_status_callback: "#{base_url}/twilio/recording_callback?account_id=#{@inbox.account_id}&conference_sid=#{conference_name}",
recording_status_callback_method: 'POST'
)
end
end
rescue StandardError => e
Rails.logger.error("Error creating voice conversation: #{e.message}")
fallback_twiml
end
private
def set_call_details
@call_sid = params[:CallSid]
@direction = params[:Direction]
end
def set_inbox
@inbox = find_inbox(outbound? ? params[:From] : params[:To])
end
def outbound?
@direction == 'outbound-api'
end
def incoming_number
outbound? ? params[:To] : params[:From]
end
def first_status_response?
params[:IsFirstResponseForStatus] == 'true'
end
def render_twiml(status: :ok)
response = Twilio::TwiML::VoiceResponse.new
yield response
render xml: response.to_s, status: status
end
def build_message(conversation, content)
Messages::MessageBuilder.new(
nil,
conversation,
content: content,
message_type: :activity,
additional_attributes: { call_sid: @call_sid, call_status: 'in-progress', user_input: true }
).perform
end
def input_text
return "Caller pressed #{params[:Digits]}" if params[:Digits].present?
return "Caller said: \"#{params[:SpeechResult]}\"" if params[:SpeechResult].present?
'Caller responded'
end
def ensure_conference_name(conversation, supplied)
name = supplied.presence ||
conversation.additional_attributes['conference_sid'] ||
conversation.additional_attributes['conference_name']
return name if name&.match?(/^conf_account_\d+_conv_\d+$/)
"conf_account_#{@inbox.account_id}_conv_#{conversation.display_id}"
end
def fallback_twiml
render_twiml do |r|
r.say(message: 'Hello from Chatwoot. This is a courtesy call to check on your recent signup.')
r.pause(length: 1)
r.say(message: 'We will connect you with an agent shortly.')
r.hangup
end
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
def find_inbox(phone_number)
return nil if phone_number.blank?
Inbox.joins('INNER JOIN channel_voice ON channel_voice.account_id = inboxes.account_id AND inboxes.channel_id = channel_voice.id')
.find_by('channel_voice.phone_number = ?', phone_number)
end
def find_or_create_conversation(inbox, phone_number, call_sid)
Voice::ConversationFinderService.new(
account: inbox.account,
call_sid: call_sid,
phone_number: phone_number,
is_outbound: false,
inbox: inbox
).perform
rescue StandardError => e
Rails.logger.error("find_or_create_conversation error: #{e.message}")
nil
end
end
-1
View File
@@ -15,7 +15,6 @@ class AsyncDispatcher < BaseDispatcher
CsatSurveyListener.instance,
HookListener.instance,
InstallationWebhookListener.instance,
MessageListener.instance,
NotificationListener.instance,
ParticipationListener.instance,
ReportingEventListener.instance,
+1 -2
View File
@@ -107,8 +107,7 @@ module Api::V1::InboxesHelper
'line' => Current.account.line_channels,
'telegram' => Current.account.telegram_channels,
'whatsapp' => Current.account.whatsapp_channels,
'sms' => Current.account.sms_channels,
'voice' => Current.account.voice_channels
'sms' => Current.account.sms_channels
}[permitted_params[:channel][:type]]
end
@@ -36,13 +36,9 @@ module Api::V2::Accounts::ReportsHelper
end
def generate_labels_report
reports = V2::Reports::LabelSummaryBuilder.new(
account: Current.account,
params: build_params({})
).build
reports.map do |report|
[report[:name]] + generate_readable_report_metrics(report)
Current.account.labels.map do |label|
label_report = report_builder({ type: :label, id: label.id }).short_summary
[label.title] + generate_readable_report_metrics(label_report)
end
end
+3 -7
View File
@@ -1,13 +1,9 @@
module MessageFormatHelper
include RegexHelper
def transform_user_mention_content(message_content)
# attachment message without content, message_content is nil
return '' unless message_content.presence
# Use CommonMarker to convert markdown to plain text for notifications
# This handles all markdown formatting (links, bold, italic, etc.) not just mentions
# Converts: [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support)
# To: @👍 customer support
CommonMarker.render_doc(message_content).to_plaintext.strip
message_content.presence ? message_content.gsub(MENTION_REGEX, '\1') : ''
end
def render_message_content(message_content)
-17
View File
@@ -1,21 +1,4 @@
module PortalHelper
def set_og_image_url(portal_name, title)
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
return if cdn_url.blank?
client_ref = GlobalConfig.get('OG_IMAGE_CLIENT_REF')['OG_IMAGE_CLIENT_REF']
uri = URI.parse(cdn_url)
uri.path = '/og'
uri.query = URI.encode_www_form(
clientRef: client_ref,
title: title,
portalName: portal_name
)
uri.to_s
end
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
+1 -123
View File
@@ -1,12 +1,11 @@
<script>
import { mapGetters } from 'vuex';
import AddAccountModal from './components/app/AddAccountModal.vue';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import FloatingCallWidget from './components/widgets/FloatingCallWidget.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
@@ -15,8 +14,6 @@ import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import { useAccount } from 'dashboard/composables/useAccount';
import { useFontSize } from 'dashboard/composables/useFontSize';
import { useAlert } from 'dashboard/composables';
import VoiceAPI from 'dashboard/api/channels/voice';
import {
registerSubscription,
verifyServiceWorkerExistence,
@@ -28,7 +25,6 @@ export default {
components: {
AddAccountModal,
FloatingCallWidget,
LoadingState,
NetworkNotification,
UpdateBanner,
@@ -55,7 +51,6 @@ export default {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
showCallWidget: false, // Will be set to true when calls are active
};
},
computed: {
@@ -65,10 +60,6 @@ export default {
currentUser: 'getCurrentUser',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
activeCall: 'calls/getActiveCall',
hasActiveCall: 'calls/hasActiveCall',
incomingCall: 'calls/getIncomingCall',
hasIncomingCall: 'calls/hasIncomingCall',
}),
hasAccounts() {
const { accounts = [] } = this.currentUser || {};
@@ -93,34 +84,11 @@ export default {
}
},
},
hasIncomingCall: {
immediate: true,
handler(newVal) {
if (newVal) {
this.showCallWidget = true;
} else {
this.showCallWidget = false;
}
}
},
hasActiveCall: {
immediate: true,
handler(newVal) {
if (newVal) {
this.showCallWidget = true;
} else {
this.showCallWidget = false;
}
}
},
},
mounted() {
this.initializeColorTheme();
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
// Make app instance available globally for direct call widget updates
window.app = this;
},
unmounted() {
if (this.reconnectService) {
@@ -138,77 +106,6 @@ export default {
setLocale(locale) {
this.$root.$i18n.locale = locale;
},
handleCallEnded() {
this.showCallWidget = false;
this.$store.dispatch('calls/clearActiveCall');
this.$store.dispatch('calls/clearIncomingCall');
// Clear the activeCallConversation state in all ContactInfo components
this.$nextTick(() => {
const clearContactInfoCallState = (components) => {
if (!components) return;
components.forEach(component => {
if (component.$options && component.$options.name === 'ContactInfo') {
if (component.activeCallConversation) {
component.activeCallConversation = null;
component.$forceUpdate();
}
}
if (component.$children && component.$children.length) {
clearContactInfoCallState(component.$children);
}
});
};
clearContactInfoCallState(this.$children);
});
},
handleCallJoined() {
this.showCallWidget = true;
},
handleCallRejected() {
this.showCallWidget = false;
this.$store.dispatch('calls/clearIncomingCall');
},
forceEndCall() {
this.showCallWidget = false;
if (window.forceEndCallHandlers) {
window.forceEndCallHandlers.forEach(handler => {
try {
handler();
} catch (e) {
// Optionally log error in production
}
});
}
if (this.activeCall && this.activeCall.callSid) {
const { callSid, conversationId } = this.activeCall;
const savedCallSid = callSid;
const savedConversationId = conversationId;
this.$store.dispatch('calls/clearActiveCall');
if (savedConversationId) {
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(() => {
useAlert({ message: 'Call ended successfully', type: 'success' });
})
.catch(() => {
setTimeout(() => {
VoiceAPI.endCall(savedCallSid, savedConversationId)
.then(() => {
})
.catch(() => {
});
}, 1000);
useAlert({ message: 'Call UI has been reset', type: 'info' });
});
} else {
useAlert({ message: 'Call ended', type: 'success' });
}
} else {
this.$store.dispatch('calls/clearActiveCall');
}
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
this.$store.dispatch('setActiveAccount', {
@@ -256,25 +153,6 @@ export default {
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox />
<NetworkNotification />
<!-- Floating call widget that appears during active calls -->
<FloatingCallWidget
v-if="showCallWidget || hasActiveCall || hasIncomingCall"
:key="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : 'no-call')"
:call-sid="activeCall ? activeCall.callSid : (incomingCall ? incomingCall.callSid : '')"
:inbox-name="activeCall ? (activeCall.inboxName || 'Primary') : (incomingCall ? incomingCall.inboxName : 'Primary')"
:conversation-id="activeCall ? activeCall.conversationId : (incomingCall ? incomingCall.conversationId : null)"
:contact-name="activeCall ? activeCall.contactName : (incomingCall ? incomingCall.contactName : '')"
:contact-id="activeCall ? activeCall.contactId : (incomingCall ? incomingCall.contactId : null)"
:inbox-id="activeCall ? activeCall.inboxId : (incomingCall ? incomingCall.inboxId : null)"
:inbox-avatar-url="activeCall ? activeCall.inboxAvatarUrl : (incomingCall ? incomingCall.inboxAvatarUrl : '')"
:inbox-phone-number="activeCall ? activeCall.inboxPhoneNumber : (incomingCall ? incomingCall.inboxPhoneNumber : '')"
:avatar-url="activeCall ? activeCall.avatarUrl : (incomingCall ? incomingCall.avatarUrl : '')"
:phone-number="activeCall ? activeCall.phoneNumber : (incomingCall ? incomingCall.phoneNumber : '')"
use-web-rtc
@callEnded="handleCallEnded"
@callJoined="handleCallJoined"
@callRejected="handleCallRejected"
/>
</div>
<LoadingState v-else />
</template>
@@ -21,10 +21,6 @@ class AgentBotsAPI extends ApiClient {
deleteAgentBotAvatar(botId) {
return axios.delete(`${this.url}/${botId}/avatar`);
}
resetAccessToken(botId) {
return axios.post(`${this.url}/${botId}/reset_access_token`);
}
}
export default new AgentBotsAPI();
+11 -15
View File
@@ -38,7 +38,13 @@ export default {
}
return false;
},
profileUpdate({ displayName, avatar, ...profileAttributes }) {
profileUpdate({
password,
password_confirmation,
displayName,
avatar,
...profileAttributes
}) {
const formData = new FormData();
Object.keys(profileAttributes).forEach(key => {
const hasValue = profileAttributes[key] === undefined;
@@ -47,22 +53,16 @@ export default {
}
});
formData.append('profile[display_name]', displayName || '');
if (password && password_confirmation) {
formData.append('profile[password]', password);
formData.append('profile[password_confirmation]', password_confirmation);
}
if (avatar) {
formData.append('profile[avatar]', avatar);
}
return axios.put(endPoints('profileUpdate').url, formData);
},
profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) {
return axios.put(endPoints('profileUpdate').url, {
profile: {
current_password: currentPassword,
password,
password_confirmation: passwordConfirmation,
},
});
},
updateUISettings({ uiSettings }) {
return axios.put(endPoints('profileUpdate').url, {
profile: { ui_settings: uiSettings },
@@ -102,8 +102,4 @@ export default {
const urlData = endPoints('resendConfirmation');
return axios.post(urlData.url);
},
resetAccessToken() {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
};
@@ -1,18 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class CopilotMessages extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
get(threadId) {
return axios.get(`${this.url}/${threadId}/copilot_messages`);
}
create({ threadId, ...rest }) {
return axios.post(`${this.url}/${threadId}/copilot_messages`, rest);
}
}
export default new CopilotMessages();
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class CopilotThreads extends ApiClient {
constructor() {
super('captain/copilot_threads', { accountScoped: true });
}
}
export default new CopilotThreads();
@@ -1,856 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class VoiceAPI extends ApiClient {
constructor() {
// Use 'voice' as the resource with accountScoped: true
super('voice', { accountScoped: true });
// Client-side Twilio device
this.device = null;
this.activeConnection = null;
this.initialized = false;
}
// Initiate a call to a contact
initiateCall(contactId) {
if (!contactId) {
throw new Error('Contact ID is required to initiate a call');
}
// Based on the route definition, the correct URL path is /api/v1/accounts/{accountId}/contacts/{contactId}/call
// The endpoint is defined in the contacts namespace, not voice namespace
return axios.post(`${this.baseUrl().replace('/voice', '')}/contacts/${contactId}/call`);
}
// End an active call
endCall(callSid, conversationId) {
if (!conversationId) {
throw new Error('Conversation ID is required to end a call');
}
if (!callSid) {
throw new Error('Call SID is required to end a call');
}
// Validate call SID format - Twilio call SID starts with 'CA' or 'TJ'
if (!callSid.startsWith('CA') && !callSid.startsWith('TJ')) {
throw new Error(
'Invalid call SID format. Expected Twilio call SID starting with CA or TJ.'
);
}
return axios.post(`${this.url}/end_call`, {
call_sid: callSid,
conversation_id: conversationId,
id: conversationId,
});
}
// Get call status
getCallStatus(callSid) {
if (!callSid) {
throw new Error('Call SID is required to get call status');
}
return axios.get(`${this.url}/call_status`, {
params: { call_sid: callSid },
});
}
// Join an incoming call as an agent (join the conference)
// This is used for the WebRTC client-side setup, not for phone calls anymore
joinCall(params) {
// Check if we have individual parameters or a params object
const conversationId = params.conversation_id || params.conversationId;
const callSid = params.call_sid || params.callSid;
const accountId = params.account_id;
if (!conversationId) {
throw new Error('Conversation ID is required to join a call');
}
if (!callSid) {
throw new Error('Call SID is required to join a call');
}
// Build request payload with proper naming convention
const payload = {
call_sid: callSid,
conversation_id: conversationId,
};
// Add account_id if provided
if (accountId) {
payload.account_id = accountId;
}
console.log('Calling join_call API endpoint with payload:', payload);
return axios.post(`${this.url}/join_call`, payload);
}
// Reject an incoming call as an agent (don't join the conference)
rejectCall(callSid, conversationId) {
if (!conversationId) {
throw new Error('Conversation ID is required to reject a call');
}
if (!callSid) {
throw new Error('Call SID is required to reject a call');
}
return axios.post(`${this.url}/reject_call`, {
call_sid: callSid,
conversation_id: conversationId,
});
}
// Client SDK methods
// Get a capability token for the Twilio Client
getToken(inboxId) {
console.log(`Requesting token for inbox ID: ${inboxId} at URL: ${this.url}/tokens`);
// Log the base URL for debugging
console.log(`Base URL: ${this.baseUrl()}`);
// Check if inboxId is valid
if (!inboxId) {
console.error('No inbox ID provided for token request');
return Promise.reject(new Error('Inbox ID is required'));
}
// Add more request details to help debugging
return axios.post(`${this.url}/tokens`, { inbox_id: inboxId }, {
headers: { 'Content-Type': 'application/json' },
}).catch(error => {
// Extract useful error details for debugging
const errorInfo = {
status: error.response?.status,
statusText: error.response?.statusText,
data: error.response?.data,
url: `${this.url}/tokens`,
inboxId,
};
console.error('Token request error details:', errorInfo);
// Try to extract a more useful error message from the HTML response if it's a 500 error
if (error.response?.status === 500 && typeof error.response.data === 'string') {
// Look for specific error patterns in the HTML
const htmlData = error.response.data;
// Check for common Ruby/Rails error patterns
const nameMatchResult = htmlData.match(/<h2>(.*?)<\/h2>/);
const detailsMatchResult = htmlData.match(/<pre>([\s\S]*?)<\/pre>/);
const errorName = nameMatchResult ? nameMatchResult[1] : null;
const errorDetails = detailsMatchResult ? detailsMatchResult[1] : null;
if (errorName || errorDetails) {
const enhancedError = new Error(`Server error: ${errorName || 'Internal Server Error'}`);
enhancedError.details = errorDetails;
enhancedError.originalError = error;
throw enhancedError;
}
}
throw error;
});
}
// Initialize the Twilio Device
async initializeDevice(inboxId) {
// If already initialized, return the existing device after checking its health
if (this.initialized && this.device) {
const deviceState = this.device.state;
console.log('Device already initialized, current state:', deviceState);
// If the device is in a bad state, destroy and reinitialize
if (deviceState === 'error' || deviceState === 'unregistered') {
console.log('Device is in a bad state, destroying and reinitializing...');
try {
this.device.destroy();
} catch (e) {
console.log('Error destroying device:', e);
}
this.device = null;
this.initialized = false;
} else {
// Device is in a good state, return it
return this.device;
}
}
// Device needs to be initialized or reinitialized
try {
console.log(`Starting Twilio Device initialization for inbox: ${inboxId}`);
// Import the Twilio Voice SDK
let Device;
try {
// We know the package is installed via package.json
const { Device: TwilioDevice } = await import('@twilio/voice-sdk');
Device = TwilioDevice;
console.log('✓ Twilio Voice SDK imported successfully');
} catch (importError) {
console.error('✗ Failed to import Twilio Voice SDK:', importError);
throw new Error(`Failed to load Twilio Voice SDK: ${importError.message}`);
}
// Validate inbox ID
if (!inboxId) {
throw new Error('Inbox ID is required to initialize the Twilio Device');
}
// Step 1: Get a token from the server
console.log(`Requesting Twilio token for inbox: ${inboxId}`);
let response;
try {
response = await this.getToken(inboxId);
console.log(`✓ Token response received with status: ${response.status}`);
} catch (tokenError) {
console.error('✗ Token request failed:', tokenError);
// Enhanced error handling for token requests
if (tokenError.details) {
// If we already have extracted details from the error, include those
console.error('Token error details:', tokenError.details);
throw new Error(`Failed to get token: ${tokenError.message}`);
}
// Check for specific HTTP error status codes
if (tokenError.response) {
const status = tokenError.response.status;
const data = tokenError.response.data;
if (status === 401) {
throw new Error('Authentication error: Please check your Twilio credentials');
} else if (status === 403) {
throw new Error('Permission denied: You don\'t have access to this inbox');
} else if (status === 404) {
throw new Error('Inbox not found or does not have voice capability');
} else if (status === 500) {
throw new Error('Server error: The server encountered an error processing your request. Check your Twilio configuration.');
} else if (data && data.error) {
throw new Error(`Server error: ${data.error}`);
}
}
throw new Error(`Failed to get token: ${tokenError.message}`);
}
// Validate token response
if (!response.data || !response.data.token) {
console.error('✗ Invalid token response data:', response.data);
// Check if we have an error message in the response
if (response.data && response.data.error) {
throw new Error(`Server did not return a valid token: ${response.data.error}`);
} else {
throw new Error('Server did not return a valid token');
}
}
// Check for warnings about missing TwiML App SID
if (response.data.warning) {
console.warn('⚠️ Twilio Voice Warning:', response.data.warning);
if (!response.data.has_twiml_app) {
console.error(
'🚨 IMPORTANT: Missing TwiML App SID. Browser-based calling requires a ' +
'TwiML App configured in Twilio Console. Set the Voice Request URL to: ' +
response.data.twiml_endpoint
);
}
}
// Extract token data
const { token, identity, voice_enabled, account_sid } = response.data;
// Log diagnostic information
console.log(`✓ Token data received for identity: ${identity}`);
console.log(`✓ Voice enabled: ${voice_enabled}`);
console.log(`✓ Twilio Account SID available: ${!!account_sid}`);
// Log the TwiML endpoint that will be used
if (response.data.twiml_endpoint) {
console.log(`✓ TwiML endpoint: ${response.data.twiml_endpoint}`);
} else {
console.warn('⚠️ No TwiML endpoint found in token response');
}
// Check if voice is enabled
if (!voice_enabled) {
throw new Error('Voice is not enabled for this inbox. Check your Twilio configuration.');
}
// Store the TwiML endpoint URL for later use
this.twimlEndpoint = response.data.twiml_endpoint;
// Step 2: Create Twilio Device with better options
const deviceOptions = {
// Use absolute minimal options - less is more for audio compatibility
allowIncomingWhileBusy: true, // Allow incoming calls while already on a call
debug: true, // Enable debug logging
warnings: true, // Show warnings in console
disableAudioContextSounds: true, // Disable browser audio context for sounds
// Add explicit edge parameter - this helps avoid connectivity issues
edge: ['ashburn', 'sydney', 'roaming'],
// Explicitly set codec preferences
codecPreferences: ['opus', 'pcmu'],
// Add the account ID to any calls made by this device
appParams: {
account_id: response.data.account_id,
}
};
console.log('Creating Twilio Device with options:', deviceOptions);
try {
this.device = new Device(token, deviceOptions);
console.log('✓ Twilio Device created successfully');
} catch (deviceError) {
console.error('✗ Failed to create Twilio Device:', deviceError);
throw new Error(`Failed to create Twilio Device: ${deviceError.message}`);
}
// Step 3: Set up event listeners with enhanced error handling
this._setupDeviceEventListeners(inboxId);
// Step 4: Register the device with Twilio
console.log('Registering Twilio Device...');
try {
await this.device.register();
console.log('✓ Twilio Device registered successfully');
this.initialized = true;
return this.device;
} catch (registerError) {
console.error('✗ Failed to register Twilio Device:', registerError);
// Handle specific registration errors
if (registerError.message && registerError.message.includes('token')) {
throw new Error('Invalid Twilio token. Check your account credentials.');
} else if (registerError.message && registerError.message.includes('permission')) {
throw new Error('Missing microphone permission. Please allow microphone access.');
}
throw new Error(`Failed to register device: ${registerError.message}`);
}
} catch (error) {
// Clear device and initialized flag in case of error
this.device = null;
this.initialized = false;
console.error('Failed to initialize Twilio Device:', error);
// Create a detailed error with context for debugging
const enhancedError = new Error(`Twilio Device initialization failed: ${error.message}`);
enhancedError.originalError = error;
enhancedError.inboxId = inboxId;
enhancedError.timestamp = new Date().toISOString();
enhancedError.browserInfo = {
userAgent: navigator.userAgent,
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
};
// Add specific advice for known error cases
if (error.message.includes('permission')) {
enhancedError.advice = 'Please ensure your browser allows microphone access.';
} else if (error.message.includes('token')) {
enhancedError.advice = 'Check your Twilio credentials in the Voice channel settings.';
} else if (error.message.includes('TwiML')) {
enhancedError.advice = 'Set up a valid TwiML app in your Twilio console and configure it in the inbox settings.';
} else if (error.message.includes('configuration')) {
enhancedError.advice = 'Review your Voice inbox configuration to ensure all required fields are completed.';
}
throw enhancedError;
}
}
// Helper method to set up device event listeners
_setupDeviceEventListeners(inboxId) {
if (!this.device) return;
// Remove any existing listeners to prevent duplicates
this.device.removeAllListeners();
// Add standard event listeners
this.device.on('registered', () => {
console.log('✓ Twilio Device registered with Twilio servers');
});
this.device.on('unregistered', () => {
console.log('⚠️ Twilio Device unregistered from Twilio servers');
});
this.device.on('tokenWillExpire', () => {
console.log('⚠️ Twilio token is about to expire, refreshing...');
this.getToken(inboxId)
.then(newTokenResponse => {
if (newTokenResponse.data && newTokenResponse.data.token) {
console.log('✓ Successfully obtained new token');
this.device.updateToken(newTokenResponse.data.token);
} else {
console.error('✗ Failed to get a valid token for renewal');
}
})
.catch(tokenError => {
console.error('✗ Error refreshing token:', tokenError);
});
});
this.device.on('incoming', connection => {
console.log('📞 Incoming call received via Twilio Device');
this.activeConnection = connection;
// Set up connection-specific events
this._setupConnectionEventListeners(connection);
});
this.device.on('error', error => {
// Enhanced error logging with full details
const errorDetails = {
code: error.code,
message: error.message,
description: error.description || 'No description',
twilioErrorObject: error,
connectionInfo: this.activeConnection ? {
parameters: this.activeConnection.parameters,
status: this.activeConnection.status && this.activeConnection.status(),
direction: this.activeConnection.direction,
} : 'No active connection',
deviceState: this.device.state,
browserInfo: {
userAgent: navigator.userAgent,
platform: navigator.platform
},
timestamp: new Date().toISOString()
};
console.error('❌ DETAILED Twilio Device Error:', errorDetails);
// Provide helpful troubleshooting tips based on error code
switch (error.code) {
case 31000:
console.error('⚠️ Error 31000: General Error. This could be an authentication, configuration, or network issue.');
console.error('31000 Error Details:', {
sdp: error.sdp || 'No SDP data',
callState: error.call ? error.call.state : 'No call state',
connectionState: error.connection ? error.connection.state : 'No connection state',
peerConnectionState: error.peerConnection ? error.peerConnection.iceConnectionState : 'No ICE state',
message: error.message,
twilioError: error,
info: error.info || 'No additional info',
solution: 'Check Twilio account status, SDP negotiations, and network connectivity'
});
// Create a network diagnostic to check connectivity
fetch('https://status.twilio.com/api/v2/status.json')
.then(response => response.json())
.then(data => {
console.log('Twilio service status check:', data);
})
.catch(statusError => {
console.error('Failed to check Twilio status:', statusError);
});
break;
case 31002:
console.error('⚠️ Error 31002: Permission Denied. Your browser microphone is blocked or unavailable.');
break;
case 31003:
console.error('⚠️ Error 31003: TwiML App Error. Your TwiML application does not exist or is misconfigured.');
break;
case 31005:
console.error('⚠️ Error 31005: Error sent from gateway in HANGUP. This usually means the TwiML endpoint is not reachable or returning invalid TwiML.');
console.error('Additional details for 31005:', {
activeConnection: this.activeConnection ? 'Yes' : 'No',
deviceState: this.device ? this.device.state : 'No device',
params: this.activeConnection ? this.activeConnection.parameters : 'No params',
twimlEndpoint: this.activeConnection && this.activeConnection.parameters ?
this.activeConnection.parameters.To : 'Unknown endpoint',
hangupReason: error.hangupReason || 'Unknown', // Capture hangup reason
message: error.message,
description: error.description,
customMessage: error.customMessage,
originalError: error.originalError ? JSON.stringify(error.originalError) : 'None'
});
break;
case 31008:
console.error('⚠️ Error 31008: Connection Error. The call could not be established.');
break;
case 31204:
console.error('⚠️ Error 31204: ICE Connection Failed. WebRTC connection failure, check firewall settings.');
break;
default:
console.error(`⚠️ Unspecified error with code ${error.code}: ${error.message}`);
}
});
this.device.on('connect', connection => {
console.log('📞 Call connected');
this.activeConnection = connection;
this._setupConnectionEventListeners(connection);
});
this.device.on('disconnect', () => {
console.log('📞 Call disconnected');
this.activeConnection = null;
});
}
// Set up event listeners for the active connection with enhanced audio diagnostic logging
_setupConnectionEventListeners(connection) {
if (!connection) return;
// Add advanced audio debug data
const getAudioDiagnostics = () => {
const audioContext = window.AudioContext || window.webkitAudioContext;
let audioInfo = { supported: !!audioContext };
try {
if (audioContext) {
const context = new audioContext();
audioInfo = {
...audioInfo,
sampleRate: context.sampleRate,
state: context.state,
baseLatency: context.baseLatency,
outputLatency: context.outputLatency,
destination: {
maxChannelCount: context.destination.maxChannelCount,
numberOfInputs: context.destination.numberOfInputs,
numberOfOutputs: context.destination.numberOfOutputs
}
};
context.close();
}
} catch (e) {
audioInfo.error = e.message;
}
// Check if microphone is accessible
let microphoneInfo = { detected: false, active: false, tracks: [] };
if (window.activeAudioStream) {
const tracks = window.activeAudioStream.getAudioTracks();
microphoneInfo = {
detected: true,
active: tracks.some(track => track.enabled && track.readyState === 'live'),
tracks: tracks.map(track => ({
id: track.id,
label: track.label,
enabled: track.enabled,
muted: track.muted,
readyState: track.readyState,
constraints: track.getConstraints()
}))
};
}
return {
audioContext: audioInfo,
microphone: microphoneInfo,
speakersMuted: typeof window.speechSynthesis !== 'undefined' ?
window.speechSynthesis.speaking === false : 'unknown'
};
};
connection.on('error', error => {
// Significantly enhanced connection error logging with audio diagnostics
const diagnostics = getAudioDiagnostics();
const connectionErrorDetails = {
code: error.code,
message: error.message,
description: error.description || 'No description',
twilioErrorObject: error,
connectionInfo: {
parameters: connection.parameters,
status: connection.status && connection.status(),
direction: connection.direction,
},
deviceState: this.device ? this.device.state : 'No device',
timestamp: new Date().toISOString(),
// Audio diagnostics for troubleshooting
audioDiagnostics: diagnostics,
// Browser media permissions
mediaPermissions: {
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
activeAudioStream: !!window.activeAudioStream,
activeAudioTracks: window.activeAudioStream ?
window.activeAudioStream.getAudioTracks().length : 0
}
};
console.error('❌ DETAILED Connection Error with Audio Diagnostics:', connectionErrorDetails);
});
connection.on('mute', isMuted => {
console.log(`📞 Call ${isMuted ? 'muted' : 'unmuted'}`);
});
connection.on('accept', () => {
// Enhanced logging for accept event with audio diagnostics
const diagnostics = getAudioDiagnostics();
console.log('📞 Call accepted with audio diagnostics:', {
connectionParameters: connection.parameters,
status: connection.status && connection.status(),
audioDiagnostics: diagnostics,
activeAudioStream: window.activeAudioStream ? {
active: window.activeAudioStream.active,
id: window.activeAudioStream.id,
trackCount: window.activeAudioStream.getTracks().length
} : 'No active stream'
});
// AUDIO HEALTH CHECK AFTER CONNECTION
setTimeout(() => {
console.log('🔊 AUDIO HEALTH CHECK:', {
connectionActive: this.activeConnection === connection,
connectionState: connection.status && connection.status(),
audioTracks: window.activeAudioStream ?
window.activeAudioStream.getAudioTracks().map(track => ({
label: track.label,
enabled: track.enabled,
readyState: track.readyState,
muted: track.muted
})) : 'No active stream',
// Device state after 5 seconds
deviceState: this.device ? this.device.state : 'No device'
});
}, 5000);
});
connection.on('disconnect', () => {
console.log('📞 Call disconnected', {
disconnectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
finalStatus: connection.status && connection.status(),
audioDiagnostics: getAudioDiagnostics()
});
this.activeConnection = null;
});
connection.on('reject', () => {
console.log('📞 Call rejected', {
rejectCause: connection.parameters ? connection.parameters.DisconnectCause : 'Unknown',
audioDiagnostics: getAudioDiagnostics()
});
this.activeConnection = null;
});
// Additional event for warning messages
connection.on('warning', warning => {
console.warn('⚠️ Connection Warning:', warning);
});
// Listen for TwiML processing events
connection.on('twiml-processing', twiml => {
console.log('📄 Processing TwiML:', twiml);
});
// Enhanced audio events for debugging
if (typeof connection.on === 'function') {
try {
// Check for volume events
connection.on('volume', (inputVolume, outputVolume) => {
// Log only significant volume changes to avoid console spam
if (Math.abs(inputVolume) > 50 || Math.abs(outputVolume) > 50) {
console.log(`🔊 Volume change - Input: ${inputVolume}, Output: ${outputVolume}`);
}
});
// Check for media stream events if supported
if (typeof connection.getRemoteStream === 'function') {
const remoteStream = connection.getRemoteStream();
if (remoteStream) {
console.log('✅ Remote audio stream available:', {
active: remoteStream.active,
id: remoteStream.id,
tracks: remoteStream.getTracks().map(t => ({
kind: t.kind,
enabled: t.enabled,
readyState: t.readyState
}))
});
} else {
console.warn('⚠️ No remote audio stream available');
}
}
} catch (e) {
console.warn('Error setting up enhanced audio events:', e);
}
}
}
// Make a call using the Twilio Client
makeClientCall(params) {
if (!this.device || !this.initialized) {
throw new Error('Twilio Device not initialized');
}
this.activeConnection = this.device.connect(params);
return this.activeConnection;
}
// Join a conference call using the Twilio Client
joinClientCall(conferenceParams) {
if (!this.device || !this.initialized) {
throw new Error('Twilio Device not initialized');
}
try {
// IMPORTANT: Do NOT try to register if already registered
// Only check state is ready
if (this.device.state !== 'ready' && this.device.state !== 'registered') {
// Don't try to register again if already registered
}
// This is CRITICAL for Twilio - params must be formatted exactly right
// and passed directly in the format Twilio expects
const params = {
// REQUIRED: Twilio Voice JS SDK expects 'To' parameter to be a properly formatted string
To: `${conferenceParams.To}`,
// Additional params for our server
account_id: conferenceParams.account_id,
is_agent: 'true'
};
// Check To parameter exists - fail if missing
if (!params.To) {
throw new Error('Missing To parameter for conference');
}
// Make sure 'To' is explicitly a string
const stringifiedTo = String(params.To);
console.log('🎯 CRITICAL CONFERENCE CONNECTION: Connecting agent to conference with To=', stringifiedTo);
// Follow Twilio documentation format - params should be nested under 'params' property
console.log('🎯 TRYING CONNECTION: Using documented format with params property');
// Just use the minimal required parameters
const connection = this.device.connect({
params: {
To: stringifiedTo, // Conference ID
is_agent: 'true' // Flag to indicate agent is joining
}
});
console.log('🎯 CONFERENCE CONNECTION RESULT:', connection ? 'Success' : 'Failed');
this.activeConnection = connection;
if (connection && typeof connection.then === 'function') {
// It's a Promise - newer Twilio SDK version
connection.then(resolvedConnection => {
this.activeConnection = resolvedConnection;
try {
if (typeof resolvedConnection.on === 'function') {
resolvedConnection.on('accept', () => {
// Connection accepted
});
}
} catch (listenerError) {
// Could not add listeners to Promise connection
}
}).catch(connError => {
// WebRTC Promise connection error
});
} else {
// It's a synchronous connection - older Twilio SDK
}
return connection;
} catch (error) {
// Error joining conference
}
}
// Get the status of the device with additional diagnostic info
getDeviceStatus() {
if (!this.device) {
return 'not_initialized';
}
const deviceState = this.device.state;
// Append a recommended action based on the state
switch (deviceState) {
case 'registered':
return 'ready';
case 'unregistered':
return 'disconnected';
case 'destroyed':
return 'terminated';
case 'busy':
return 'busy';
case 'error':
return 'error';
default:
return deviceState;
}
}
// Get comprehensive diagnostic information about the device and connection
getDiagnosticInfo() {
const browserInfo = {
userAgent: navigator.userAgent,
platform: navigator.platform,
vendor: navigator.vendor,
hasMediaDevices: !!navigator.mediaDevices,
hasGetUserMedia: !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia),
};
const deviceInfo = this.device ? {
state: this.device.state,
isInitialized: this.initialized,
capabilities: this.device.capabilities || {},
isBusy: this.device.isBusy || false,
audio: {
isAudioSelectionSupported: this.device.isAudioSelectionSupported || false
}
} : { state: 'not_initialized' };
const connectionInfo = this.activeConnection ? {
status: this.activeConnection.status(),
isMuted: this.activeConnection.isMuted(),
direction: this.activeConnection.direction,
parameters: this.activeConnection.parameters,
} : { status: 'no_connection' };
return {
timestamp: new Date().toISOString(),
browser: browserInfo,
device: deviceInfo,
connection: connectionInfo
};
}
// Get the status of the active connection
getConnectionStatus() {
if (!this.activeConnection) {
return 'no_connection';
}
const status = this.activeConnection.status();
// Translate connection statuses to more user-friendly terms
switch (status) {
case 'pending':
return 'connecting';
case 'open':
return 'connected';
case 'connecting':
return 'connecting';
case 'ringing':
return 'ringing';
case 'closed':
return 'ended';
default:
return status;
}
}
}
export default new VoiceAPI();
-5
View File
@@ -61,11 +61,6 @@ class ContactAPI extends ApiClient {
return axios.get(requestURL);
}
active(page = 1, sortAttr = 'name') {
let requestURL = `${this.url}/active?${buildContactParams(page, sortAttr)}`;
return axios.get(requestURL);
}
// eslint-disable-next-line default-param-last
filter(page = 1, sortAttr = 'name', queryPayload) {
let requestURL = `${this.url}/filter?${buildContactParams(page, sortAttr)}`;
@@ -51,10 +51,6 @@ const endPoints = {
resendConfirmation: {
url: '/api/v1/profile/resend_confirmation',
},
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
};
export default page => {
@@ -134,12 +134,12 @@ class ConversationApi extends ApiClient {
return axios.get(`${this.url}/${conversationId}/attachments`);
}
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
requestCopilot(conversationId, body) {
return axios.post(`${this.url}/${conversationId}/copilot`, body);
}
delete(conversationId) {
return axios.delete(`${this.url}/${conversationId}`);
getInboxAssistant(conversationId) {
return axios.get(`${this.url}/${conversationId}/inbox_assistant`);
}
}
@@ -33,11 +33,9 @@ class LinearAPI extends ApiClient {
);
}
unlinkIssue(linkId, issueIdentifier, conversationId) {
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
issue_id: issueIdentifier,
conversation_id: conversationId,
});
}
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from './ApiClient';
class NotionOAuthClient extends ApiClient {
constructor() {
super('notion', { accountScoped: true });
}
generateAuthorization() {
return axios.post(`${this.url}/authorization`);
}
}
export default new NotionOAuthClient();
-9
View File
@@ -40,15 +40,6 @@ class SearchAPI extends ApiClient {
},
});
}
articles({ q, page = 1 }) {
return axios.get(`${this.url}/articles`, {
params: {
q,
page: page,
},
});
}
}
export default new SearchAPI();
@@ -9,6 +9,5 @@ describe('#AgentBotsAPI', () => {
expect(AgentBotsAPI).toHaveProperty('create');
expect(AgentBotsAPI).toHaveProperty('update');
expect(AgentBotsAPI).toHaveProperty('delete');
expect(AgentBotsAPI).toHaveProperty('resetAccessToken');
});
});
@@ -91,19 +91,6 @@ describe('#linearAPI', () => {
issueData
);
});
it('creates a valid request with conversation_id', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
conversation_id: 123,
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
@@ -133,18 +120,6 @@ describe('#linearAPI', () => {
}
);
});
it('creates a valid request with title', () => {
LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue');
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 'ENG-123',
conversation_id: 1,
title: 'Sample Issue',
}
);
});
});
describe('getLinkedIssue', () => {
@@ -189,26 +164,12 @@ describe('#linearAPI', () => {
window.axios = originalAxios;
});
it('creates a valid request with link_id only', () => {
LinearAPIClient.unlinkIssue('link123');
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: undefined,
conversation_id: undefined,
}
);
});
it('creates a valid request with all parameters', () => {
LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 'link123',
issue_id: 'ENG-456',
conversation_id: 789,
link_id: 1,
}
);
});
@@ -35,16 +35,6 @@ class SummaryReportsAPI extends ApiClient {
},
});
}
getLabelReports({ since, until, businessHours } = {}) {
return axios.get(`${this.url}/label`, {
params: {
since,
until,
business_hours: businessHours,
},
});
}
}
export default new SummaryReportsAPI();
@@ -0,0 +1,101 @@
.slide-fade-enter-active {
transition: all 0.3s var(--ease-in-cubic);
}
.slide-fade-leave-active {
transition: all 0.3s var(--ease-out-cubic);
}
.slide-fade-enter,
.slide-fade-leave-to {
opacity: 0;
transform: translateX(10px);
}
.slide-fade-enter {
transform: translateX($space-micro);
}
.slide-fade-leave-to {
transform: translateX($space-medium);
}
.conversations-list-enter-active,
.conversations-list-leave-active {
transition: all 0.25s var(--ease-out-cubic);
}
.conversations-list-enter,
.conversations-list-leave-to {
opacity: 0;
transform: translateX($space-medium);
}
.slide-up-enter-active {
transition: all 0.3s var(--ease-in-cubic);
}
.slide-up-leave-active {
transition: all 0.3s var(--ease-out-cubic);
}
.slide-up-enter,
.slide-up-leave-to {
opacity: 0;
transform: translateY(-$space-medium);
}
.menu-slide-enter-active,
.menu-slide-leave-active {
transform: translateY(0);
transition:
transform 0.25s var(--ease-in-cubic),
opacity 0.15s var(--ease-in-cubic);
}
.menu-slide-enter,
.menu-slide-leave-to {
opacity: 0;
transform: translateY($space-small);
}
.toast-fade-enter-active {
transition: all 0.3s var(--ease-in-sine);
}
.toast-fade-leave-active {
transition: all 0.1s var(--ease-out-sine);
}
.toast-fade-enter,
.toast-fade-leave-to {
opacity: 0;
transform: translateY(-$space-small);
}
.modal-fade-enter-active {
transition: all 0.3s var(--ease-in-sine);
}
.modal-fade-leave-active {
transition: all 0.1s var(--ease-out-sine);
}
.modal-fade-enter,
.modal-fade-leave-to {
opacity: 0;
}
.network-notification-fade-enter-active {
transition: all 0.1s var(--ease-in-sine);
}
.network-notification-fade-leave-active {
transition: all 0.1s var(--ease-out-sine);
}
.network-notification-fade-enter,
.network-notification-fade-leave-to {
opacity: 0;
transform: translateY(-$space-small);
}
@@ -30,11 +30,11 @@
.mx-input:disabled,
.mx-input[readonly] {
@apply bg-n-background cursor-pointer;
@apply bg-white dark:bg-slate-900 cursor-pointer;
}
.mx-icon-calendar {
@apply text-n-slate-10;
@apply dark:text-slate-500;
}
}
@@ -43,17 +43,17 @@
.cell {
&.disabled {
@apply bg-n-slate-2 dark:bg-n-background text-n-slate-10;
@apply bg-slate-25 dark:bg-slate-900 text-slate-200 dark:text-slate-300;
}
&:hover,
&.hover-in-range,
&.in-range {
@apply bg-n-slate-3 dark:bg-n-solid-3 text-n-slate-12;
@apply bg-slate-75 dark:bg-slate-700 text-slate-900 dark:text-slate-100;
}
}
.mx-calendar + .mx-calendar {
.mx-calendar+.mx-calendar {
@apply border-l border-n-weak;
}
@@ -62,7 +62,7 @@
}
.mx-time {
@apply border-0 bg-n-background dark:bg-n-solid-2;
@apply border-0 bg-white dark:bg-slate-800;
.mx-time-header {
@apply border-0;
@@ -70,11 +70,11 @@
.mx-time-item {
&.disabled {
@apply bg-n-slate-2 dark:bg-n-background;
@apply bg-slate-25 dark:bg-slate-900;
}
&:hover {
@apply bg-n-slate-3 dark:bg-n-solid-3;
@apply bg-slate-75 dark:bg-slate-700;
}
}
}
@@ -0,0 +1,38 @@
@import 'dashboard/assets/scss/variables';
.formulate-input {
.formulate-input-errors {
list-style-type: none;
margin: 0;
padding: 0;
}
.formulate-input-error {
color: var(--r-400);
display: block;
font-size: var(--font-size-small);
font-weight: $font-weight-normal;
margin-bottom: $space-one;
width: 100%;
}
}
.integration-hooks {
.formulate-input[data-type='checkbox'] {
.formulate-input-wrapper {
@apply flex;
.formulate-input-element {
@apply pr-2;
input {
@apply mb-0;
}
}
}
.formulate-input-element-decorator {
@apply hidden;
}
}
}
@@ -0,0 +1,21 @@
// loader class
.spinner {
@include color-spinner();
@apply inline-block h-6 py-0 px-6 relative align-middle w-6;
&.message {
@apply bg-white dark:bg-slate-800 rounded-full left-0 my-3 mx-auto p-4 top-0;
&::before {
@apply -ml-3 -mt-3;
}
}
&.small {
@apply h-4 w-4;
&::before {
@apply h-4 -mt-2 w-4;
}
}
}
@@ -0,0 +1,48 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'shared/assets/fonts/inter';
// Inter,
html,
body {
font-family:
'Inter',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif !important;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
margin: 0;
padding: 0;
width: 100%;
}
.app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
}
}
.banner + .app-wrapper {
// Reduce the height of the dashboard to make room for the banner.
// And causing the top right green-action button to be pushed down when scrolling.
@apply h-[calc(100%-48px)];
.button--fixed-top {
@apply top-14;
}
.off-canvas-content {
.button--fixed-top {
@apply top-2;
}
}
}
@@ -0,0 +1,101 @@
@import 'dashboard/assets/scss/variables';
$spinner-before-border-color: rgba(255, 255, 255, 0.7);
// input form
@mixin ghost-input() {
box-shadow: none;
border-color: transparent;
&:active,
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
}
}
@mixin color-spinner() {
@keyframes spinner {
to {
transform: rotate(360deg);
}
}
&::before {
animation: spinner .9s linear infinite;
border: 2px solid $spinner-before-border-color;
border-radius: 50%;
border-top-color: lighten($color-woot, 10%);
box-sizing: border-box;
content: '';
height: $space-medium;
left: 50%;
margin-left: -$space-one;
margin-top: -$space-one;
position: absolute;
top: 50%;
width: $space-medium;
}
}
// --------------------------------------------------------
// arrows
// --------------------------------------------------------
// $direction: top, left, right, bottom, top-left, top-right, bottom-left, bottom-right
// $color: hex, rgb or rbga
// $size: px or em
// @example
// .element{
// @include arrow(top, #000, 50px);
// }
@mixin arrow($direction, $color, $size) {
display: block;
height: 0;
width: 0;
content: '';
@if $direction == 'top' {
border-bottom: $size solid $color;
border-left: $size solid transparent;
border-right: $size solid transparent;
}
@else if $direction == 'right' {
border-bottom: $size solid transparent;
border-left: $size solid $color;
border-top: $size solid transparent;
}
@else if $direction == 'bottom' {
border-left: $size solid transparent;
border-right: $size solid transparent;
border-top: $size solid $color;
}
@else if $direction == 'left' {
border-bottom: $size solid transparent;
border-right: $size solid $color;
border-top: $size solid transparent;
}
@else if $direction == 'top-left' {
border-right: $size solid transparent;
border-top: $size solid $color;
}
@else if $direction == 'top-right' {
border-left: $size solid transparent;
border-top: $size solid $color;
}
@else if $direction == 'bottom-left' {
border-bottom: $size solid $color;
border-right: $size solid transparent;
}
@else if $direction == 'bottom-right' {
border-bottom: $size solid $color;
border-left: $size solid transparent;
}
}
@@ -0,0 +1,204 @@
.app-rtl--wrapper {
direction: rtl;
// Woot Tabs
.tabs-title {
&:first-child {
margin-left: var(--space-small);
margin-right: unset;
}
&:last-child {
margin-left: unset;
margin-right: var(--space-small);
}
}
// woot tables
table,
thead,
th {
text-align: right;
}
// Table footer
.footer {
.page-meta {
direction: initial;
}
}
// Wizard box
.wizard-box {
direction: initial;
}
// Conversation details
.conversation-details-wrap {
.conversation-panel {
// Message text
.text-content {
p {
unicode-bidi: plaintext;
}
ul {
padding-left: unset;
padding-right: var(--space-two);
}
li {
text-align: right;
}
}
// Message items and actions
li {
&.right {
.sender--info {
padding: var(--space-small) var(--space-smaller)
var(--space-smaller) 0;
}
.context-menu-wrap {
margin-left: 0;
margin-right: auto;
}
}
}
}
// Conversation footer
.conversation-footer {
.preview-item {
direction: initial;
}
}
// Custom attributes section in conversation sidebar
.conversation-sidebar-wrap .checkbox-wrap {
.checkbox {
margin-left: var(--space-small);
}
}
}
// Conversation list
.conversations-list-wrap {
border-right: 0;
.conversation {
.conversation--meta {
left: $space-normal;
right: unset;
.unread {
margin-left: unset;
margin-right: auto;
}
}
.assignee-label {
margin-left: 0;
margin-right: var(--space-one);
}
.show-more--button {
margin: unset;
transform: rotate(180deg);
}
}
// Basic filter dropdown
.basic-filter {
left: 0;
right: unset;
}
// Bulk actions
.bulk-action__container {
.triangle {
left: var(--triangle-position);
right: unset;
}
.bulk-action__agents {
left: var(--space-small);
right: unset;
}
.labels-container {
left: var(--space-small);
right: unset;
.label-checkbox {
margin: 0 0 0 var(--space-one);
}
}
.actions-container {
left: var(--space-small);
right: unset;
}
.bulk-action__teams {
left: var(--space-small);
right: unset;
}
}
}
// Contact notes
.card.note-wrap {
.time-stamp {
unicode-bidi: plaintext;
}
}
// Toggle switch
.toggle-button {
&.small {
span {
&.active {
transform: translate(var(--space-minus-small), var(--space-zero));
}
}
}
span {
--minus-space-one-point-five: -0.9375rem;
&.active {
transform: translate(
var(--minus-space-one-point-five),
var(--space-zero)
);
}
}
}
// Modal
.modal-container {
text-align: right;
.modal-footer {
button {
margin-left: 0;
margin-right: var(--space-small);
}
}
}
// Other changes
.colorpicker--chrome {
direction: initial;
}
.mention--box {
direction: initial;
}
.contact--form .input-group {
direction: initial;
}
}
@@ -0,0 +1,97 @@
// Font sizes
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
// spaces
$zero: 0;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
// font-weight
$font-weight-feather: 100;
$font-weight-light: 300;
$font-weight-normal: 400;
$font-weight-medium: 500;
$font-weight-bold: 600;
$font-weight-black: 700;
//Navbar
$nav-bar-width: 14.375rem;
$header-height: 3.5rem;
$woot-logo-padding: $space-large $space-two;
// Colors
$color-woot: #1f93ff;
$color-gray: #6e6f73;
$color-light-gray: #999a9b;
$color-border: var(--s-75);
$color-border-light: var(--s-50);
$color-border-dark: var(--s-100);
$color-background: var(--s-50);
$color-background-light: var(--s-25);
$color-white: #fff;
$color-body: #3c4858;
$color-heading: #1f2d3d;
$color-extra-light-blue: #f5f7f9;
$primary-color: $color-woot;
$secondary-color: #5d7592;
$success-color: #44ce4b;
$warning-color: #ffc532;
$alert-color: #ff382d;
$masked-bg: rgba(0, 0, 0, .4);
// Color-palettes
$color-primary-light: #c7e3ff;
$color-primary-dark: darken($color-woot, 20%);
// Thumbnail
$thumbnail-radius: 2.5rem;
// chat-header
$conv-header-height: 2.5rem;
// Inbox List
$inbox-thumb-size: 3rem;
// Snackbar default
$woot-snackbar-bg: #323232;
$woot-snackbar-button: #ffeb3b;
$swift-ease-out-duration: .4s !default;
$swift-ease-out-function: cubic-bezier(0.37, 0, 0.63, 1) !default;
$swift-ease-out: all $swift-ease-out-duration $swift-ease-out-function !default;
// Transitions
$transition-ease-in: all 0.250s ease-in;
:root {
--dashboard-app-tabs-height: 2.4375rem;
}
+383 -51
View File
@@ -1,4 +1,3 @@
// scss-lint:disable SpaceAfterPropertyColon
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
@@ -9,66 +8,54 @@
// Next Colors
@import 'next-colors';
// Base styles for elements
@import 'base';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@import 'shared/assets/stylesheets/font-size';
@import 'shared/assets/stylesheets/font-weights';
@import 'shared/assets/stylesheets/shadows';
@import 'shared/assets/stylesheets/border-radius';
@import 'shared/assets/stylesheets/z-index';
@import 'variables';
@import 'mixins';
@import 'helper-classes';
@import 'formulate';
@import 'date-picker';
@import 'layout';
@import 'animations';
@import 'rtl';
@import 'widgets/base';
@import 'widgets/conversation-view';
@import 'widgets/tabs';
@import 'widgets/woot-tables';
// Plugins
@import 'plugins/multiselect';
@import 'plugins/date-picker';
html,
body {
font-family:
'Inter',
-apple-system,
system-ui,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Tahoma,
Arial,
sans-serif !important;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
height: 100%;
margin: 0;
padding: 0;
width: 100%;
}
.app-wrapper {
@apply h-screen flex-grow-0 min-h-0 w-full;
.button--fixed-top {
@apply fixed ltr:right-2 rtl:left-2 top-2 flex flex-row;
}
}
.banner + .app-wrapper {
// Reduce the height of the dashboard to make room for the banner.
// And causing the top right green-action button to be pushed down when scrolling.
@apply h-[calc(100%-48px)];
.button--fixed-top {
@apply top-14;
}
.off-canvas-content {
.button--fixed-top {
@apply top-2;
}
}
}
@import 'plugins/dropdown';
.tooltip {
@apply bg-n-solid-2 text-n-slate-12 py-1 px-2 z-40 text-xs rounded-md max-w-96;
@apply bg-slate-900 text-white py-1 px-2 z-40 text-xs rounded-md dark:bg-slate-200 dark:text-slate-900 max-w-96;
}
#app {
@apply h-full w-full;
}
.hide {
@apply hidden;
}
.n-blue-border {
@apply border-n-blue-border;
}
.n-blue-text {
@apply text-n-blue-text;
}
.custom-dashed-border {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23E2E3E7' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
background-position: center;
@@ -80,6 +67,351 @@ body {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100%25' height='100%25'%3E%3Crect x='0' y='0' width='100%25' height='100%25' fill='none' rx='16' ry='16' stroke='%23343434' stroke-width='2' stroke-dasharray='6, 8' stroke-dashoffset='0' stroke-linecap='round'/%3E%3C/svg%3E");
}
// scss-lint:disable PropertySortOrder
@layer base {
:root {
--color-amber-25: 254 253 251;
--color-amber-50: 255 249 237;
--color-amber-75: 255 243 208;
--color-amber-100: 255 236 183;
--color-amber-200: 255 224 161;
--color-amber-300: 245 208 140;
--color-amber-400: 228 187 120;
--color-amber-500: 214 163 92;
--color-amber-600: 214 163 92;
--color-amber-700: 255 186 26;
--color-amber-800: 145 89 48;
--color-amber-900: 79 52 34;
--color-ash-100: 235 235 239;
--color-ash-200: 228 228 233;
--color-ash-25: 252 252 253;
--color-ash-300: 221 221 227;
--color-ash-400: 211 212 219;
--color-ash-50: 249 249 251;
--color-ash-500: 185 187 198;
--color-ash-600: 139 141 152;
--color-ash-700: 126 128 138;
--color-ash-75: 242 242 245;
--color-ash-800: 96 100 108;
--color-ash-900: 28 32 36;
--color-primary-25: 251 253 255;
--color-primary-50: 245 249 255;
--color-primary-75: 233 243 255;
--color-primary-100: 218 236 255;
--color-primary-200: 201 226 255;
--color-primary-300: 181 213 255;
--color-primary-400: 155 195 252;
--color-primary-500: 117 171 247;
--color-primary-600: 39 129 246;
--color-primary-700: 16 115 233;
--color-primary-800: 8 109 224;
--color-primary-900: 11 50 101;
--color-ruby-100: 255 220 225;
--color-ruby-200: 255 206 214;
--color-ruby-25: 255 252 253;
--color-ruby-300: 248 191 200;
--color-ruby-400: 239 172 184;
--color-ruby-50: 255 247 248;
--color-ruby-500: 229 146 163;
--color-ruby-600: 229 70 102;
--color-ruby-700: 220 59 93;
--color-ruby-75: 254 234 237;
--color-ruby-800: 202 36 77;
--color-ruby-900: 100 23 43;
--color-teal-100: 224 248 243;
--color-teal-200: 204 243 234;
--color-teal-25: 250 254 253;
--color-teal-300: 184 234 224;
--color-teal-400: 161 222 210;
--color-teal-50: 243 251 249;
--color-teal-500: 83 185 171;
--color-teal-600: 18 165 148;
--color-teal-700: 13 155 138;
--color-teal-75: 236 249 255;
--color-teal-800: 0 133 115;
--color-teal-900: 13 61 56;
--color-green-25: 251 254 252;
--color-green-50: 244 251 246;
--color-green-75: 230 246 235;
--color-green-100: 214 241 223;
--color-green-200: 196 232 209;
--color-green-300: 173 221 192;
--color-green-400: 142 206 170;
--color-green-500: 91 185 139;
--color-green-600: 48 164 108;
--color-green-700: 43 154 102;
--color-green-800: 33 131 88;
--color-green-900: 25 59 45;
--color-mint-25: 249 254 253;
--color-mint-50: 242 251 249;
--color-mint-75: 221 249 242;
--color-mint-100: 200 244 233;
--color-mint-200: 179 236 222;
--color-mint-300: 156 224 208;
--color-mint-400: 126 207 189;
--color-mint-500: 76 187 165;
--color-mint-600: 134 234 212;
--color-mint-700: 125 224 203;
--color-mint-800: 2 120 100;
--color-mint-900: 22 67 60;
--color-sky-25: 249 254 255;
--color-sky-50: 241 250 253;
--color-sky-75: 225 246 253;
--color-sky-100: 209 240 250;
--color-sky-200: 190 231 245;
--color-sky-300: 169 218 237;
--color-sky-400: 141 202 227;
--color-sky-500: 96 179 215;
--color-sky-600: 124 226 254;
--color-sky-700: 116 218 248;
--color-sky-800: 0 116 158;
--color-sky-900: 29 62 86;
--color-indigo-25: 253 253 254;
--color-indigo-50: 247 249 255;
--color-indigo-75: 237 242 254;
--color-indigo-100: 225 233 255;
--color-indigo-200: 210 222 255;
--color-indigo-300: 193 208 255;
--color-indigo-400: 171 189 249;
--color-indigo-500: 141 164 239;
--color-indigo-600: 62 99 221;
--color-indigo-700: 51 88 212;
--color-indigo-800: 58 91 199;
--color-indigo-900: 31 45 92;
--color-iris-25: 253 253 255;
--color-iris-50: 248 248 255;
--color-iris-75: 240 241 254;
--color-iris-100: 230 231 255;
--color-iris-200: 218 220 255;
--color-iris-300: 203 205 255;
--color-iris-400: 184 186 248;
--color-iris-500: 155 158 240;
--color-iris-600: 91 91 214;
--color-iris-700: 81 81 205;
--color-iris-800: 87 83 198;
--color-iris-900: 39 41 98;
--color-violet-25: 253 252 254;
--color-violet-50: 250 248 255;
--color-violet-75: 244 240 254;
--color-violet-100: 235 228 255;
--color-violet-200: 225 217 255;
--color-violet-300: 212 202 254;
--color-violet-400: 194 181 245;
--color-violet-500: 170 153 236;
--color-violet-600: 110 86 207;
--color-violet-700: 101 77 196;
--color-violet-800: 101 80 185;
--color-violet-900: 47 38 95;
--color-pink-25: 255 252 254;
--color-pink-50: 254 247 251;
--color-pink-75: 254 233 245;
--color-pink-100: 251 220 239;
--color-pink-200: 246 206 231;
--color-pink-300: 239 191 221;
--color-pink-400: 231 172 208;
--color-pink-500: 221 147 194;
--color-pink-600: 214 64 159;
--color-pink-700: 207 56 151;
--color-pink-800: 194 41 138;
--color-pink-900: 101 18 73;
--color-orange-25: 254 252 251;
--color-orange-50: 255 247 237;
--color-orange-75: 255 239 214;
--color-orange-100: 255 223 181;
--color-orange-200: 255 209 154;
--color-orange-300: 255 193 130;
--color-orange-400: 245 174 115;
--color-orange-500: 236 148 85;
--color-orange-600: 247 107 21;
--color-orange-700: 239 95 0;
--color-orange-800: 204 78 0;
--color-orange-900: 88 45 29;
}
// scss-lint:disable QualifyingElement
body.dark {
--color-amber-25: 31 19 0;
--color-amber-50: 37 24 4;
--color-amber-75: 48 32 11;
--color-amber-100: 57 39 15;
--color-amber-200: 67 46 18;
--color-amber-300: 83 57 22;
--color-amber-400: 111 77 29;
--color-amber-500: 169 118 42;
--color-amber-600: 169 118 42;
--color-amber-700: 255 203 71;
--color-amber-800: 255 204 77;
--color-amber-900: 255 231 179;
--color-ash-100: 46 48 53;
--color-ash-200: 53 55 60;
--color-ash-25: 24 24 26;
--color-ash-300: 60 63 68;
--color-ash-400: 70 75 80;
--color-ash-50: 27 27 31;
--color-ash-500: 90 97 101;
--color-ash-600: 105 110 119;
--color-ash-700: 120 127 133;
--color-ash-75: 39 40 45;
--color-ash-800: 173 177 184;
--color-ash-900: 237 238 240;
--color-primary-25: 10 17 28;
--color-primary-50: 15 24 38;
--color-primary-75: 15 39 72;
--color-primary-100: 10 49 99;
--color-primary-200: 18 61 117;
--color-primary-300: 29 74 134;
--color-primary-400: 40 89 156;
--color-primary-500: 48 106 186;
--color-primary-600: 39 129 246;
--color-primary-700: 21 116 231;
--color-primary-800: 126 182 255;
--color-primary-900: 205 227 255;
--color-ruby-100: 78 19 37;
--color-ruby-200: 94 26 46;
--color-ruby-25: 25 17 19;
--color-ruby-300: 111 37 57;
--color-ruby-400: 136 52 71;
--color-ruby-50: 30 21 23;
--color-ruby-500: 179 68 90;
--color-ruby-600: 229 70 102;
--color-ruby-700: 236 90 114;
--color-ruby-75: 58 20 30;
--color-ruby-800: 255 148 157;
--color-ruby-900: 254 210 225;
--color-teal-100: 2 59 55;
--color-teal-200: 8 72 67;
--color-teal-25: 13 21 20;
--color-teal-300: 28 105 97;
--color-teal-400: 28 105 97;
--color-teal-50: 17 28 27;
--color-teal-500: 32 126 115;
--color-teal-600: 41 163 131;
--color-teal-700: 14 179 158;
--color-teal-75: 13 45 42;
--color-teal-800: 11 216 182;
--color-teal-900: 173 240 221;
--color-green-25: 14 21 18;
--color-green-50: 18 27 23;
--color-green-75: 19 45 33;
--color-green-100: 17 59 41;
--color-green-200: 23 73 51;
--color-green-300: 32 87 62;
--color-green-400: 40 104 74;
--color-green-500: 47 124 87;
--color-green-600: 48 164 108;
--color-green-700: 51 176 116;
--color-green-800: 61 214 140;
--color-green-900: 177 241 203;
--color-mint-25: 14 21 21;
--color-mint-50: 15 27 27;
--color-mint-75: 9 44 43;
--color-mint-100: 0 58 56;
--color-mint-200: 0 71 68;
--color-mint-300: 16 86 80;
--color-mint-400: 30 104 95;
--color-mint-500: 39 127 112;
--color-mint-600: 134 234 212;
--color-mint-700: 168 245 229;
--color-mint-800: 88 213 186;
--color-mint-900: 196 245 225;
--color-sky-25: 14 21 21;
--color-sky-50: 15 27 27;
--color-sky-75: 9 44 43;
--color-sky-100: 0 58 56;
--color-sky-200: 0 71 68;
--color-sky-300: 16 86 80;
--color-sky-400: 30 104 95;
--color-sky-500: 39 127 112;
--color-sky-600: 134 234 212;
--color-sky-700: 168 245 229;
--color-sky-800: 88 213 186;
--color-sky-900: 196 245 225;
--color-indigo-25: 17 19 31;
--color-indigo-50: 20 23 38;
--color-indigo-75: 24 36 73;
--color-indigo-100: 29 46 98;
--color-indigo-200: 37 57 116;
--color-indigo-300: 48 67 132;
--color-indigo-400: 58 79 151;
--color-indigo-500: 67 93 177;
--color-indigo-600: 62 99 221;
--color-indigo-700: 84 114 228;
--color-indigo-800: 158 177 255;
--color-indigo-900: 214 225 255;
--color-iris-25: 19 19 30;
--color-iris-50: 23 22 37;
--color-iris-75: 32 34 72;
--color-iris-100: 38 42 101;
--color-iris-200: 48 51 116;
--color-iris-300: 61 62 130;
--color-iris-400: 74 74 149;
--color-iris-500: 89 88 177;
--color-iris-600: 91 91 214;
--color-iris-700: 110 106 222;
--color-iris-800: 177 169 255;
--color-iris-900: 224 223 254;
--color-violet-25: 20 18 31;
--color-violet-50: 27 21 37;
--color-violet-75: 41 31 67;
--color-violet-100: 51 37 91;
--color-violet-200: 60 46 105;
--color-violet-300: 71 56 118;
--color-violet-400: 86 70 139;
--color-violet-500: 105 88 173;
--color-violet-600: 110 86 207;
--color-violet-700: 125 102 217;
--color-violet-800: 186 167 255;
--color-violet-900: 226 221 254;
--color-pink-25: 25 17 23;
--color-pink-50: 33 18 29;
--color-pink-75: 55 23 47;
--color-pink-100: 75 20 61;
--color-pink-200: 89 28 71;
--color-pink-300: 105 41 85;
--color-pink-400: 131 56 105;
--color-pink-500: 168 72 133;
--color-pink-600: 214 64 159;
--color-pink-700: 222 81 168;
--color-pink-800: 255 141 204;
--color-pink-900: 253 209 234;
--color-orange-25: 23 18 14;
--color-orange-50: 30 22 15;
--color-orange-75: 51 30 11;
--color-orange-100: 70 33 0;
--color-orange-200: 86 40 0;
--color-orange-300: 102 53 12;
--color-orange-400: 126 69 29;
--color-orange-500: 163 88 41;
--color-orange-600: 247 107 21;
--color-orange-700: 255 128 31;
--color-orange-800: 255 160 87;
--color-orange-900: 255 224 194;
}
}
@layer utilities {
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
@@ -0,0 +1,7 @@
.dropdown-pane {
@apply border rounded-lg hidden relative invisible shadow-lg border-n-strong dark:border-n-strong box-content p-2 w-fit z-[9999];
&.dropdown-pane--open {
@apply bg-n-alpha-3 backdrop-blur-[100px] absolute block visible;
}
}
@@ -17,10 +17,6 @@
@apply mb-4;
}
&.invalid .multiselect__tags {
@apply border-0 outline outline-1 outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 disabled:outline-n-ruby-8 dark:disabled:outline-n-ruby-8;
}
&.multiselect--disabled {
@apply opacity-50 rounded-lg cursor-not-allowed pointer-events-auto;
@@ -51,7 +47,7 @@
@apply max-w-full;
.multiselect__option {
@apply text-sm font-normal flex justify-between items-center;
@apply text-sm font-normal;
span {
@apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit;
@@ -62,7 +58,7 @@
}
&::after {
@apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight;
@apply bottom-0 flex items-center justify-center text-center;
}
&.multiselect__option--highlight {
@@ -78,7 +74,7 @@
}
&.multiselect__option--highlight::after {
@apply bg-transparent text-n-slate-12;
@apply bg-transparent;
}
&.multiselect__option--selected {
@@ -128,7 +124,8 @@
}
.multiselect__input {
@apply text-sm h-[2.875rem] mb-0 p-0 shadow-none border-transparent hover:border-transparent hover:shadow-none focus:border-transparent focus:shadow-none active:border-transparent active:shadow-none;
@include ghost-input;
@apply text-sm h-[2.875rem] mb-0 p-0;
}
.multiselect__single {
@@ -0,0 +1 @@
// to be removed
@@ -0,0 +1 @@
// to be removed
@@ -101,7 +101,7 @@ select {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' version='1.1' width='32' height='24' viewBox='0 0 32 24'><polygon points='0,0 32,0 16,24' style='fill: rgb%28110, 111, 115%29'></polygon></svg>");
background-size: 9px 6px;
@apply field-base h-10 bg-origin-content bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
@apply field-base h-10 bg-origin-content focus-visible:outline-none bg-no-repeat py-2 ltr:bg-[right_-1rem_center] rtl:bg-[left_-1rem_center] ltr:pr-6 rtl:pl-6 rtl:pr-3 ltr:pl-3;
&[disabled] {
@apply field-disabled;
@@ -201,8 +201,3 @@ code {
}
}
}
// Table
table {
@apply border-spacing-0 text-sm w-full;
}
@@ -0,0 +1,261 @@
// scss-lint:disable MergeableSelector
@tailwind utilities;
@layer utilities {
.custom-gradient {
background-image: linear-gradient(
-180deg,
transparent 3%,
rgb(76 81 85) 130%
);
}
.bubble-with-types {
@apply py-2 text-sm font-normal bg-woot-500 dark:bg-woot-500 relative px-4 m-0 text-white dark:text-white;
.message-text__wrap {
@apply relative;
.link {
@apply text-white dark:text-white underline;
}
}
.image,
.video {
@apply cursor-pointer relative;
.modal-container {
@apply text-center;
}
.modal-image {
@apply max-h-[76vh] max-w-[76vw];
}
.modal-video {
@apply max-h-[76vh] max-w-[76vw];
}
&::before {
@apply custom-gradient bottom-0 h-[20%] content-[''] left-0 absolute w-full opacity-80;
}
}
}
}
.conversation-panel {
@apply flex-shrink flex-grow basis-px flex flex-col overflow-y-auto relative h-full m-0 pb-4;
}
.conversation-panel > li {
@apply flex flex-shrink-0 flex-grow-0 flex-auto max-w-full mt-0 mr-0 mb-1 ml-0 relative first:mt-auto last:mb-0;
&.unread--toast {
+ .right {
@apply mb-1;
}
+ .left {
@apply mb-0;
}
span {
@apply shadow-lg rounded-full bg-woot-500 dark:bg-woot-500 text-white dark:text-white text-xs font-medium my-2.5 mx-auto px-2.5 py-1.5;
}
}
.bubble {
@apply bubble-with-types text-left break-words;
.aplayer {
@apply shadow-none;
font-family: inherit;
}
}
&.left {
.bubble {
@apply rounded-r-lg rounded-l mr-auto break-words;
&:not(.is-unsupported) {
@apply border border-slate-50 dark:border-slate-700 bg-white dark:bg-slate-700 text-black-900 dark:text-slate-50;
}
&.is-image {
@apply rounded-lg;
}
.link {
@apply text-woot-600 dark:text-woot-600;
}
.file {
.attachment-name {
@apply text-slate-700 dark:text-woot-300;
}
.icon-wrap {
@apply text-woot-600 dark:text-woot-600;
}
.download {
@apply text-woot-600 dark:text-woot-600;
}
}
}
+ .right {
@apply mt-2.5;
.bubble {
@apply rounded-tr-lg;
}
}
+ .unread--toast {
+ .right {
@apply mt-2.5;
.bubble {
@apply rounded-tr-lg;
}
}
+ .left {
@apply mt-0;
}
}
}
&.right {
@apply justify-end;
.wrap {
@apply flex items-end mr-4 text-right;
.sender--info {
@apply pt-2 pb-1 pr-0 pl-2;
}
}
.bubble {
@apply ml-auto break-words rounded-l-lg rounded-r;
&.is-private {
@apply text-black-900 dark:text-white relative border border-solid bg-yellow-100 dark:bg-yellow-700 border-yellow-200 dark:border-yellow-600/25;
blockquote {
@apply border-slate-400 dark:border-slate-400 text-slate-800 dark:text-slate-300;
p {
@apply text-slate-600 dark:text-slate-300;
}
}
}
&.is-image {
@apply rounded-lg;
.message__mail-head {
@apply px-4 py-2;
}
}
}
+ .left {
@apply mt-2.5;
.bubble {
@apply rounded-tl-lg;
}
}
+ .unread--toast {
+ .left {
@apply rounded-lg;
.bubble {
@apply rounded-tl-lg;
}
}
+ .right {
@apply mt-0;
}
}
}
&.center {
@apply items-center justify-center;
}
.wrap {
max-width: Min(31rem, 84%);
@apply my-0 mx-4;
.sender--name {
@apply text-xs mb-1;
}
}
.sender--thumbnail {
@apply h-3 mr-3 mt-0.5 w-3 rounded-full;
}
.activity-wrap {
@apply flex justify-center text-sm my-1 mx-0 py-1 pr-0.5 pl-2.5 bg-slate-50 dark:bg-slate-600 text-slate-800 dark:text-slate-100 rounded-md border border-slate-100 dark:border-slate-600 border-solid;
.is-text {
@apply inline-flex items-center text-start 2xl:flex;
}
}
}
.activity-wrap .message-text__wrap {
.text-content p {
@apply mb-0;
}
}
.conversation-footer {
@apply flex relative flex-col;
}
.left .bubble .text-content {
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-slate-800 dark:text-slate-100;
}
a {
@apply text-woot-500 dark:text-woot-500 underline;
}
p:last-child {
@apply mb-0;
}
}
.right .bubble .text-content {
h1,
h2,
h3,
h4,
h5,
h6 {
@apply text-white dark:text-white;
}
a {
@apply text-white dark:text-white underline;
}
p:last-child {
@apply mb-0;
}
}
@@ -0,0 +1,77 @@
.tabs--container {
@apply flex;
}
.tabs--container--with-border {
@apply border-b border-n-weak;
}
.tabs--container--compact.tab--chat-type {
.tabs-title {
a {
@apply py-2 text-sm;
}
}
}
.tabs {
@apply border-r-0 border-l-0 border-t-0 flex min-w-[6.25rem] py-0 px-4 list-none mb-0;
}
.tabs--with-scroll {
@apply overflow-hidden py-0 px-1;
max-width: calc(100% - 64px);
}
.tabs--scroll-button {
@apply items-center rounded-none cursor-pointer flex h-auto justify-center min-w-[2rem];
}
// Tab chat type
.tab--chat-type {
@apply flex;
.tabs-title {
a {
@apply text-base font-medium py-3;
}
}
}
.tabs-title {
@apply flex-shrink-0 my-0 mx-2;
.badge {
@apply bg-n-alpha-black2 dark:bg-n-solid-3 rounded-md text-n-slate-11 h-5 flex items-center justify-center text-xxs font-semibold my-0 mx-1 px-1 py-0;
}
&:first-child {
@apply ml-0;
}
&:last-child {
@apply mr-0;
}
&:hover,
&:focus {
a {
@apply text-n-slate-12;
}
}
a {
@apply flex items-center flex-row border-b py-2.5 select-none cursor-pointer border-transparent text-n-slate-11 text-sm top-[1px] relative;
transition: border-color 0.15s $swift-ease-out-function;
}
&.is-active {
a {
@apply border-b border-n-brand text-n-blue-text;
}
.badge {
@apply bg-n-brand/10 dark:bg-n-brand/20 text-n-blue-text;
}
}
}
@@ -0,0 +1,90 @@
table {
@apply border-spacing-0 text-sm w-full;
}
.woot-table {
thead {
th {
@apply font-semibold tracking-[1px] text-left px-2.5 uppercase text-slate-900 dark:text-slate-200;
}
}
tbody {
tr {
@apply border-b border-slate-50 dark:border-slate-800/30;
}
td {
@apply p-2.5 text-slate-700 dark:text-slate-100;
}
}
tr {
.show-if-hover {
transition: opacity 0.2s $swift-ease-out-function;
@apply opacity-0;
}
&:hover {
.show-if-hover {
@apply opacity-100;
}
}
}
.agent-name {
@apply block font-medium capitalize;
}
.woot-thumbnail {
@apply rounded-full h-[3.125rem] w-[3.125rem];
}
.button-wrapper {
@apply flex justify-start flex-row min-w-[12.5rem] gap-1;
}
.button {
margin: 0;
}
}
.ve-table {
.ve-table-container.ve-table-border-around {
@apply border-slate-200 dark:border-slate-700;
}
.ve-table-content {
.ve-table-header .ve-table-header-tr .ve-table-header-th {
@apply bg-slate-50 dark:bg-slate-800 text-slate-800 dark:text-slate-100 border-slate-100 dark:border-slate-700/50;
}
.ve-table-body .ve-table-body-tr .ve-table-body-td {
@apply bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100 border-slate-75 dark:border-slate-800;
}
.ve-table-body.ve-table-row-hover .ve-table-body-tr:hover td {
@apply bg-slate-50 dark:bg-slate-700 text-slate-800 dark:text-slate-100;
}
}
}
.table-pagination {
.ve-pagination-total {
@apply text-slate-600 dark:text-slate-200;
}
.ve-pagination-goto {
@apply text-slate-600 dark:text-slate-200;
.ve-pagination-goto-input {
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200;
}
}
.ve-pagination-li {
@apply bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-200 border-slate-75 dark:border-slate-700;
}
}
@@ -1,27 +0,0 @@
<template>
<div>
<Button
icon="i-ri-phone-fill"
color="slate"
size="sm"
:tooltip="$t('CALL_BUTTON.TOOLTIP')"
class="!h-7 !bg-n-solid-3 dark:!bg-n-black/30 !outline-n-weak !text-n-slate-11"
@click="openCallModal"
/>
<div v-if="showCallModal" class="fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0">
<CallModal @close="showCallModal = false" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CallModal from './CallModal.vue';
const showCallModal = ref(false);
const openCallModal = () => {
showCallModal.value = true;
};
</script>
@@ -1,306 +0,0 @@
<template>
<div class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl">
<div class="px-4 py-3 flex items-center">
<h3 class="text-base font-medium">{{ $t('CALL_MODAL.START_CALL') }}</h3>
</div>
<!-- Inbox Selector (First) -->
<div class="flex items-center flex-1 w-full gap-3 px-4 py-3 overflow-y-visible">
<label class="mb-0.5 text-sm font-medium text-n-slate-11 whitespace-nowrap">
{{ $t('CALL_MODAL.VIA') }}
</label>
<div
v-if="selectedInbox"
class="flex items-center gap-1.5 rounded-md bg-n-alpha-2 truncate ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 h-7 min-w-0"
>
<span class="text-sm truncate text-n-slate-12 flex items-center gap-2">
<span class="i-ri-phone-fill text-n-slate-11"></span>
{{ selectedInbox.name }} - {{ selectedInbox.phoneNumber }}
</span>
<Button
variant="ghost"
icon="i-lucide-x"
color="slate"
size="xs"
class="flex-shrink-0"
@click="selectedInbox = null"
/>
</div>
<div
v-else
v-on-click-outside="() => showInboxDropdown = false"
class="relative flex items-center h-7"
>
<Button
:label="$t('CALL_MODAL.SELECT_INBOX')"
variant="link"
size="sm"
color="slate"
class="hover:!no-underline"
@click="showInboxDropdown = !showInboxDropdown"
/>
<DropdownMenu
v-if="voiceInboxesList.length > 0 && showInboxDropdown"
:menu-items="voiceInboxesList"
class="left-0 z-[100] top-8 overflow-y-auto max-h-60 w-fit max-w-sm dark:!outline-n-slate-5"
@action="selectInbox($event)"
/>
</div>
</div>
<!-- Contact Selector -->
<ContactSelector
:contacts="contacts"
:selected-contact="selectedContact"
:show-contacts-dropdown="showContactsDropdown"
:is-loading="isSearching"
:is-creating-contact="false"
:contact-id="null"
:contactable-inboxes-list="[]"
:show-inboxes-dropdown="false"
:has-errors="false"
@search-contacts="handleContactSearch"
@set-selected-contact="handleSelectedContact"
@clear-selected-contact="clearSelectedContact"
@update-dropdown="handleDropdownUpdate"
/>
<!-- Action buttons -->
<div class="flex items-center justify-end w-full h-[3.25rem] gap-2 px-4 py-3">
<Button
:label="$t('CALL_MODAL.CANCEL')"
variant="faded"
color="slate"
size="sm"
class="!text-xs font-medium"
@click="$emit('close')"
/>
<Button
:label="$t('CALL_MODAL.CALL')"
icon="i-ri-phone-fill"
size="sm"
class="!text-xs font-medium"
:disabled="!selectedInbox || !selectedContact || isLoading"
:is-loading="isLoading"
@click="makeCall"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import { debounce } from '@chatwoot/utils';
import { vOnClickOutside } from '@vueuse/components';
import ContactAPI from 'dashboard/api/contacts';
import VoiceAPI from 'dashboard/api/channels/voice';
import camelcaseKeys from 'camelcase-keys';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import ContactSelector from 'dashboard/components-next/NewConversation/components/ContactSelector.vue';
import axios from 'axios';
const { t } = useI18n();
const store = useStore();
const emit = defineEmits(['close']);
const selectedContact = ref(null);
const selectedInbox = ref(null);
const showContactsDropdown = ref(false);
const showInboxDropdown = ref(false);
const contacts = ref([]);
const isSearching = ref(false);
const isLoading = ref(false);
const inboxes = useMapGetter('inboxes/getInboxes');
const voiceInboxesList = computed(() => {
return inboxes.value
.filter(inbox => inbox.channel_type === INBOX_TYPES.VOICE)
.map(inbox => ({
id: inbox.id,
title: `${inbox.name}`,
subtitle: inbox.phone_number,
label: `${inbox.name} - ${inbox.phone_number}`,
action: 'select-inbox',
value: inbox.id,
sourceId: inbox.id,
phoneNumber: inbox.phone_number,
name: inbox.name,
icon: 'i-ri-phone-fill',
}));
});
// Auto-select the first available voice inbox
watch(voiceInboxesList, (newList) => {
if (newList.length > 0 && !selectedInbox.value) {
selectedInbox.value = newList[0];
}
}, { immediate: true });
const selectInbox = item => {
const inbox = voiceInboxesList.value.find(i => i.value === item.value);
if (inbox) {
selectedInbox.value = inbox;
showInboxDropdown.value = false;
}
};
const handleSelectedContact = ({ value, action, ...rest }) => {
// If this is a direct call to a phone number
if (action === 'create' && value.match(/^\+?[0-9\s\-()]+$/)) {
selectedContact.value = {
id: 'direct-call',
name: t('CALL_MODAL.CALL_DIRECTLY'),
sourceId: 'direct-call',
phoneNumber: value,
action: 'contact',
};
} else {
// For existing contacts, make sure we're capturing their ID properly
console.log('Contact selected from dropdown:', { value, action, ...rest });
selectedContact.value = {
...rest,
sourceId: rest.id || rest.value || value // Make sure we have the ID in sourceId
};
}
showContactsDropdown.value = false;
};
const handleDropdownUpdate = (type, value) => {
showContactsDropdown.value = value;
};
const clearSelectedContact = () => {
selectedContact.value = null;
};
// This function gets called from the ContactSelector component
const handleContactSearch = value => {
showContactsDropdown.value = true;
// Pass all the needed keys for search when using the value sent directly
debouncedSearchContacts(value);
};
const debouncedSearchContacts = debounce(async query => {
if (!query || query.length < 2) {
contacts.value = [];
return;
}
isSearching.value = true;
try {
// Use the simple search endpoint since it's more reliable for this use case
const { data } = await ContactAPI.search(query);
console.log('Search response:', data); // Log the search response
// Ensure contacts.value is an array and convert to camelCase
const searchResults = data?.payload ? camelcaseKeys(data.payload, { deep: true }) : [];
// Filter to only include contacts with phone numbers
const contactsWithPhone = searchResults.filter(contact => contact.phoneNumber);
// Map the contacts to ensure they have sourceId set to ID for consistency
contacts.value = contactsWithPhone.map(contact => ({
...contact,
sourceId: contact.id, // Make sure sourceId is set
value: contact.id // Make sure value is set for TagInput
}));
// If it looks like a phone number, add option to call directly
if (query.match(/^\+?[0-9\s\-()]+$/) && !contacts.value.some(c => c.phoneNumber === query)) {
contacts.value.push({
id: 'direct-call',
name: t('CALL_MODAL.CALL_DIRECTLY'),
phoneNumber: query,
sourceId: 'direct-call',
value: 'direct-call'
});
}
console.log('Processed contacts for dropdown:', contacts.value);
} catch (error) {
console.error('Error searching contacts:', error);
contacts.value = []; // Ensure this is always an array
useAlert(t('CALL_MODAL.CONTACT_SEARCH_ERROR'));
} finally {
isSearching.value = false;
}
}, 300);
const makeCall = async () => {
if (!selectedInbox.value || !selectedContact.value) {
useAlert(t('CALL_MODAL.VALIDATION_ERROR'));
return;
}
isLoading.value = true;
try {
const isDirect = selectedContact.value.sourceId === 'direct-call';
const contactId = isDirect ? null : (selectedContact.value.sourceId || selectedContact.value.id);
if (contactId) {
console.log('Making call to contact ID:', contactId, 'with full contact:', selectedContact.value);
// Use VoiceAPI.initiateCall instead of direct axios call
await VoiceAPI.initiateCall(contactId);
} else {
// For direct phone number calls
const phoneNumber = selectedContact.value.phoneNumber;
if (!phoneNumber) {
throw new Error('Phone number is required for direct calls');
}
// First create a contact with this phone number
console.log('Creating new contact with phone number:', phoneNumber);
const contactPayload = {
phone_number: phoneNumber,
inbox_id: selectedInbox.value.sourceId,
name: `Phone: ${phoneNumber}`,
};
const contactResponse = await ContactAPI.create(contactPayload);
console.log('Created contact:', contactResponse.data);
// Then initiate call to the newly created contact
if (contactResponse.data && contactResponse.data.payload && contactResponse.data.payload.contact) {
const newContactId = contactResponse.data.payload.contact.id;
console.log('Using new contact ID:', newContactId);
await VoiceAPI.initiateCall(newContactId);
} else {
throw new Error('Failed to create contact for direct call');
}
}
useAlert(t('CALL_MODAL.SUCCESS_MESSAGE'));
emit('close');
} catch (error) {
console.error('Error making call:', error);
let errorMessage = t('CALL_MODAL.ERROR_MESSAGE');
// Simple error handling - just show server message if available
if (error.response && error.response.data && error.response.data.error) {
errorMessage = error.response.data.error;
}
useAlert(errorMessage);
} finally {
isLoading.value = false;
}
};
onMounted(() => {
// The first inbox will be selected automatically via the watch
// This ensures it works even if voiceInboxesList is populated after mounting
});
</script>
@@ -8,7 +8,6 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
import SMSCampaignDetails from './SMSCampaignDetails.vue';
import VoiceCampaignDetails from './VoiceCampaignDetails.vue';
const props = defineProps({
title: {
@@ -23,10 +22,6 @@ const props = defineProps({
type: Boolean,
default: false,
},
isVoiceType: {
type: Boolean,
default: false,
},
isEnabled: {
type: Boolean,
default: false,
@@ -72,12 +67,6 @@ const campaignStatus = computed(() => {
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
if (props.isVoiceType) {
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.VOICE.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.VOICE.CARD.STATUS.SCHEDULED');
}
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
@@ -119,12 +108,6 @@ const inboxIcon = computed(() => {
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<VoiceCampaignDetails
v-else-if="isVoiceType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
@@ -135,7 +118,7 @@ const inboxIcon = computed(() => {
</div>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType || isVoiceType"
v-if="isLiveChatType"
variant="faded"
size="sm"
color="slate"

Some files were not shown because too many files have changed in this diff Show More