Repo cleanup
This commit is contained in:
parent
59872f579a
commit
e941353b64
444 changed files with 1485 additions and 21978 deletions
155
packages/zammad-addon-link/README.md
Normal file
155
packages/zammad-addon-link/README.md
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
# CDR Link Zammad Addon
|
||||
|
||||
## Overview
|
||||
|
||||
The CDR Link addon integrates external communication channels (Signal, WhatsApp, Voice) into Zammad, supporting both the classic UI and the new Vue-based desktop/mobile interfaces.
|
||||
|
||||
## Features
|
||||
|
||||
### Signal Channel Integration
|
||||
|
||||
- Reply button on customer Signal messages
|
||||
- "Add Signal message" button in ticket reply area
|
||||
- 10,000 character limit with warning at 5,000
|
||||
- Plain text format with attachment support
|
||||
- Full integration with both classic and new Vue-based UI
|
||||
|
||||
### WhatsApp Channel Integration
|
||||
|
||||
- Reply button on customer WhatsApp messages
|
||||
- "Add WhatsApp message" button in ticket reply area
|
||||
- 4,096 character limit with warning at 3,000
|
||||
- Plain text format with attachment support
|
||||
- Full integration with both classic and new Vue-based UI
|
||||
|
||||
### Voice Channel Support
|
||||
|
||||
- Classic UI implementation maintained
|
||||
- New UI support ready for future implementation
|
||||
|
||||
### Channel Restriction Settings (NEW)
|
||||
|
||||
- Control which reply channels appear in the UI
|
||||
- Configurable via `cdr_link_allowed_channels` setting
|
||||
- Acts as a whitelist while preserving contextual logic
|
||||
- Empty setting falls back to default Zammad behavior
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Zammad 6.0+ (for new UI support)
|
||||
- CDR Bridge backend services configured
|
||||
- Signal/WhatsApp/Voice services running
|
||||
|
||||
### Installation Steps
|
||||
|
||||
1. Build the addon package:
|
||||
|
||||
```bash
|
||||
cd packages/zammad-addon-link
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
2. Install in Zammad:
|
||||
|
||||
```bash
|
||||
# Copy the generated .zpm file to your Zammad installation
|
||||
cp dist/bridge-vX.X.X.zpm /opt/zammad/
|
||||
|
||||
# Install using Zammad package manager
|
||||
zammad run rails r "Package.install(file: '/opt/zammad/bridge-vX.X.X.zpm')"
|
||||
|
||||
# Restart Zammad
|
||||
systemctl restart zammad
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Channel Restriction Settings
|
||||
|
||||
Control which reply channels are available in the ticket interface:
|
||||
|
||||
```ruby
|
||||
# Rails console
|
||||
Setting.set('cdr_link_allowed_channels', 'note,signal message') # Signal only
|
||||
Setting.set('cdr_link_allowed_channels', 'note,whatsapp message') # WhatsApp only
|
||||
Setting.set('cdr_link_allowed_channels', 'note,signal message,whatsapp message') # Both
|
||||
Setting.set('cdr_link_allowed_channels', '') # Default behavior (all channels)
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
|
||||
- The setting acts as a whitelist of allowed channels
|
||||
- Channels must be both in the whitelist AND contextually appropriate
|
||||
- For example, Signal replies only appear for tickets that originated from Signal
|
||||
- Empty or unset falls back to default Zammad behavior
|
||||
- Changes take effect immediately (browser refresh required)
|
||||
|
||||
## Development
|
||||
|
||||
### Adding New Channels
|
||||
|
||||
1. Create TypeScript plugin in `app/frontend/shared/entities/ticket-article/action/plugins/`
|
||||
2. Add desktop UI plugin in `app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/article-type/plugins/`
|
||||
3. Add corresponding backend implementation
|
||||
4. Create database migrations in `src/db/addon/link/`
|
||||
|
||||
### Building the Package
|
||||
|
||||
```bash
|
||||
# Update version and changelog in bridge-skeleton.szpm
|
||||
# Build the package
|
||||
make
|
||||
# Output: dist/bridge-vX.X.X.szpm
|
||||
```
|
||||
|
||||
### Create a New Migration
|
||||
|
||||
Helper script to create new migrations (requires python `inflection` library):
|
||||
|
||||
```bash
|
||||
# Install dependency
|
||||
apt install python3-inflection # Debian/Ubuntu
|
||||
# Or: pip install --user inflection
|
||||
|
||||
# Create migration
|
||||
make new-migration
|
||||
```
|
||||
|
||||
## Compatibility
|
||||
|
||||
- **Zammad 6.0+**: Both Classic and New UI
|
||||
- **Browser Support**: All modern browsers
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Voice Channel
|
||||
|
||||
POST to submission endpoint with `Authorization: SUBMISSION_TOKEN` header:
|
||||
|
||||
```json
|
||||
{
|
||||
"startTime": "ISO date string",
|
||||
"endTime": "ISO date string",
|
||||
"to": "fully qualified phone number",
|
||||
"from": "fully qualified phone number",
|
||||
"duration": "recording duration string",
|
||||
"callSid": "unique call identifier",
|
||||
"recording": "base64 encoded binary",
|
||||
"mimeType": "binary mime-type string"
|
||||
}
|
||||
```
|
||||
|
||||
### Signal/WhatsApp Channels
|
||||
|
||||
Handled via CDR Bridge backend services - see bridge documentation for API details.
|
||||
|
||||
## License
|
||||
|
||||
[](https://gitlab.com/digiresilience/link/link-stack/blob/main/LICENSE.md)
|
||||
|
||||
This is a free software project licensed under the GNU Affero General
|
||||
Public License v3.0 (GNU AGPLv3) by [The Center for Digital
|
||||
Resilience](https://digiresilience.org) and [Guardian
|
||||
Project](https://guardianproject.info).
|
||||
18
packages/zammad-addon-link/package.json
Normal file
18
packages/zammad-addon-link/package.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "@link-stack/zammad-addon-link",
|
||||
"displayName": "Link",
|
||||
"version": "3.5.0-beta.1",
|
||||
"description": "CDR Link addon for Zammad with Signal, WhatsApp, and voice channel support.",
|
||||
"scripts": {
|
||||
"build": "tsx scripts/build.ts",
|
||||
"migrate": "tsx scripts/migrate.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.7.0",
|
||||
"glob": "^11.0.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"author": "",
|
||||
"license": "AGPL-3.0-or-later"
|
||||
}
|
||||
100
packages/zammad-addon-link/scripts/build.ts
Normal file
100
packages/zammad-addon-link/scripts/build.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import { glob } from "glob";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const log = (msg: string, data?: Record<string, any>) => {
|
||||
console.log(JSON.stringify({ msg, ...data, timestamp: new Date().toISOString() }));
|
||||
};
|
||||
|
||||
const packageFile = async (actualPath: string): Promise<any> => {
|
||||
log('Packaging file', { actualPath });
|
||||
const packagePath = actualPath.slice(4);
|
||||
const data = await fs.readFile(actualPath, "utf-8");
|
||||
const content = Buffer.from(data, "utf-8").toString("base64");
|
||||
const fileStats = await fs.stat(actualPath);
|
||||
const permission = parseInt(
|
||||
(fileStats.mode & 0o777).toString(8).slice(-3),
|
||||
10,
|
||||
);
|
||||
return {
|
||||
location: packagePath,
|
||||
permission,
|
||||
encode: "base64",
|
||||
content,
|
||||
};
|
||||
};
|
||||
|
||||
const packageFiles = async () => {
|
||||
const packagedFiles: any[] = [];
|
||||
const ignoredPatterns = [
|
||||
/\.gitkeep/,
|
||||
/Gemfile/,
|
||||
/Gemfile.lock/,
|
||||
/\.ruby-version/,
|
||||
];
|
||||
|
||||
const processDir = async (dir: string) => {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await processDir(entryPath);
|
||||
} else if (entry.isFile()) {
|
||||
if (!ignoredPatterns.some((pattern) => pattern.test(entry.name))) {
|
||||
packagedFiles.push(await packageFile(entryPath));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await processDir("./src/");
|
||||
return packagedFiles;
|
||||
};
|
||||
|
||||
export const createZPM = async ({
|
||||
name,
|
||||
displayName,
|
||||
version,
|
||||
}: Record<string, string>) => {
|
||||
const files = await packageFiles();
|
||||
const skeleton = {
|
||||
name: displayName,
|
||||
version,
|
||||
vendor: "Center for Digital Resilience",
|
||||
license: "AGPL-v3+",
|
||||
url: `https://gitlab.com/digiresilience/link/link-stack/packages/${name}`,
|
||||
buildhost: os.hostname(),
|
||||
builddate: new Date().toISOString(),
|
||||
files,
|
||||
};
|
||||
const pkg = JSON.stringify(skeleton, null, 2);
|
||||
|
||||
try {
|
||||
const oldFiles = await glob(`../../docker/zammad/addons/${name}-v*.zpm`, {});
|
||||
|
||||
for (const file of oldFiles) {
|
||||
await fs.unlink(file);
|
||||
log('File was deleted', { file });
|
||||
}
|
||||
} catch (err) {
|
||||
log('Error removing old addon files', { error: String(err) });
|
||||
}
|
||||
await fs.writeFile(
|
||||
`../../docker/zammad/addons/${name}-v${version}.zpm`,
|
||||
pkg,
|
||||
"utf-8",
|
||||
);
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const packageJSON = JSON.parse(await fs.readFile("./package.json", "utf-8"));
|
||||
const { name: fullName, displayName, version } = packageJSON;
|
||||
log('Building addon', { displayName, version });
|
||||
const name = fullName.split("/").pop();
|
||||
await createZPM({ name, displayName, version });
|
||||
};
|
||||
|
||||
main();
|
||||
53
packages/zammad-addon-link/scripts/migrate.ts
Normal file
53
packages/zammad-addon-link/scripts/migrate.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
|
||||
const underscore = (str: string) => {
|
||||
return str
|
||||
.replace(/([a-z\d])([A-Z])/g, "$1_$2")
|
||||
.replace(/([A-Z]+)([A-Z][a-z\d]+)/g, "$1_$2")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
const camelize = (str: string): string => {
|
||||
const camelizedStr = str.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
||||
|
||||
return camelizedStr.charAt(0).toUpperCase() + camelizedStr.slice(1);
|
||||
}
|
||||
|
||||
export const createMigration = async ({ displayName }: Record<string, string>) => {
|
||||
const rawName: string = await new Promise((resolve) => {
|
||||
process.stdin.setEncoding("utf-8");
|
||||
process.stdout.write("Enter migration name: ");
|
||||
process.stdin.once("data", (data: string) => {
|
||||
resolve(data.trim());
|
||||
});
|
||||
});
|
||||
|
||||
const migrationBaseName = `${displayName}_${underscore(rawName)}`;
|
||||
const migrationName = camelize(migrationBaseName);
|
||||
const migrationTemplate = `class MIGRATION_NAME < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
# add your code here
|
||||
end
|
||||
|
||||
def self.down
|
||||
# add your code here
|
||||
end
|
||||
end`;
|
||||
const contents = migrationTemplate.replace("MIGRATION_NAME", migrationName);
|
||||
const time = new Date().toISOString().replace(/[-:.]/g, "").slice(0, 14);
|
||||
const migrationFileName = `${time}_${migrationBaseName}.rb`;
|
||||
const addonDir = path.join("src", "db", "addon", displayName);
|
||||
await fs.mkdir(addonDir, { recursive: true });
|
||||
await fs.writeFile(path.join(addonDir, migrationFileName), contents);
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const packageJSON = JSON.parse(await fs.readFile("./package.json", "utf-8"));
|
||||
const { displayName } = packageJSON;
|
||||
await createMigration({ displayName });
|
||||
}
|
||||
|
||||
main();
|
||||
1
packages/zammad-addon-link/src/.ruby-version
Normal file
1
packages/zammad-addon-link/src/.ruby-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.1.3
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
class ChannelCdrSignal extends App.ControllerSubContent
|
||||
requiredPermission: 'admin.channel_cdr_signal'
|
||||
events:
|
||||
'click .js-new': 'new'
|
||||
'click .js-edit': 'edit'
|
||||
'click .js-delete': 'delete'
|
||||
'click .js-disable': 'disable'
|
||||
'click .js-enable': 'enable'
|
||||
'click .js-rotate-token': 'rotateToken'
|
||||
'click .js-set-notification': 'setNotification'
|
||||
'click .js-unset-notification': 'unsetNotification'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
#@interval(@load, 60000)
|
||||
@load()
|
||||
|
||||
load: =>
|
||||
@startLoading()
|
||||
@ajax(
|
||||
id: 'cdr_signal_index'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/channels_cdr_signal"
|
||||
processData: true
|
||||
success: (data) =>
|
||||
@stopLoading()
|
||||
App.Collection.loadAssets(data.assets)
|
||||
@render(data)
|
||||
)
|
||||
|
||||
render: (data) =>
|
||||
|
||||
channels = []
|
||||
for channel_id in data.channel_ids
|
||||
channel = App.Channel.find(channel_id)
|
||||
if channel && channel.options
|
||||
displayName = '-'
|
||||
if channel.group_id
|
||||
group = App.Group.find(channel.group_id)
|
||||
displayName = group.displayName()
|
||||
channel.options.groupName = displayName
|
||||
channels.push channel
|
||||
@html App.view('cdr_signal/index')(
|
||||
channels: channels
|
||||
notificationEnabled: data.notification_enabled
|
||||
notificationChannelId: data.notification_channel_id
|
||||
)
|
||||
|
||||
new: (e) =>
|
||||
e.preventDefault()
|
||||
new FormAdd(
|
||||
container: @el.parents('.content')
|
||||
load: @load
|
||||
)
|
||||
|
||||
edit: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
channel = App.Channel.find(id)
|
||||
new FormEdit(
|
||||
channel: channel
|
||||
container: @el.parents('.content')
|
||||
load: @load
|
||||
)
|
||||
|
||||
delete: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
new App.ControllerConfirm(
|
||||
message: 'Sure?'
|
||||
callback: =>
|
||||
@ajax(
|
||||
id: 'cdr_signal_delete'
|
||||
type: 'DELETE'
|
||||
url: "#{@apiPath}/channels_cdr_signal"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
container: @el.closest('.content')
|
||||
)
|
||||
|
||||
rotateToken: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
|
||||
new App.ControllerConfirm(
|
||||
message: 'This will break the submission form!'
|
||||
buttonSubmit: 'Reset token'
|
||||
head: 'Reset the submission token?'
|
||||
callback: =>
|
||||
@ajax(
|
||||
id: 'cdr_signal_disable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_signal_rotate_token"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
container: @el.closest('.content')
|
||||
)
|
||||
disable: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@ajax(
|
||||
id: 'cdr_signal_disable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_signal_disable"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
enable: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@ajax(
|
||||
id: 'cdr_signal_enable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_signal_enable"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
setNotification: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@ajax(
|
||||
id: 'cdr_signal_set_notification'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_signal_set_notification"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
unsetNotification: (e) =>
|
||||
e.preventDefault()
|
||||
@ajax(
|
||||
id: 'cdr_signal_unset_notification'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_signal_unset_notification"
|
||||
data: JSON.stringify({})
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
class FormAdd extends App.ControllerModal
|
||||
head: 'Add Web Form'
|
||||
shown: true
|
||||
button: 'Add'
|
||||
buttonCancel: true
|
||||
small: true
|
||||
|
||||
content: ->
|
||||
content = $(App.view('cdr_signal/form_add')())
|
||||
createOrgSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'organization_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Organization'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
createGroupSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'group_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Group'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
|
||||
content.find('.js-select').on('click', (e) =>
|
||||
@selectAll(e)
|
||||
)
|
||||
content.find('.js-messagesGroup').replaceWith createGroupSelection(1)
|
||||
content.find('.js-organization').replaceWith createOrgSelection(null)
|
||||
content
|
||||
|
||||
onClosed: =>
|
||||
return if !@isChanged
|
||||
@isChanged = false
|
||||
@load()
|
||||
|
||||
onSubmit: (e) =>
|
||||
@formDisable(e)
|
||||
@ajax(
|
||||
id: 'cdr_signal_app_verify'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_signal"
|
||||
data: JSON.stringify(@formParams())
|
||||
processData: true
|
||||
success: =>
|
||||
@isChanged = true
|
||||
@close()
|
||||
error: (xhr) =>
|
||||
data = JSON.parse(xhr.responseText)
|
||||
@formEnable(e)
|
||||
error_message = App.i18n.translateContent(data.error || 'Unable to save Web Form.')
|
||||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
class FormEdit extends App.ControllerModal
|
||||
head: 'Web Form Info'
|
||||
shown: true
|
||||
buttonCancel: true
|
||||
|
||||
content: ->
|
||||
content = $(App.view('cdr_signal/form_edit')(channel: @channel))
|
||||
|
||||
createOrgSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'organization_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Organization'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
createGroupSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'group_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Group'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
|
||||
content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id)
|
||||
content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id)
|
||||
content
|
||||
|
||||
onClosed: =>
|
||||
return if !@isChanged
|
||||
@isChanged = false
|
||||
@load()
|
||||
|
||||
onSubmit: (e) =>
|
||||
@formDisable(e)
|
||||
params = @formParams()
|
||||
@channel.options = params
|
||||
@ajax(
|
||||
id: 'channel_cdr_signal_update'
|
||||
type: 'PUT'
|
||||
url: "#{@apiPath}/channels_cdr_signal/#{@channel.id}"
|
||||
data: JSON.stringify(@formParams())
|
||||
processData: true
|
||||
success: =>
|
||||
@isChanged = true
|
||||
@close()
|
||||
error: (xhr) =>
|
||||
data = JSON.parse(xhr.responseText)
|
||||
@formEnable(e)
|
||||
error_message = App.i18n.translateContent(data.error || 'Unable to save changes.')
|
||||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
App.Config.set('cdr_signal', { prio: 5100, name: 'Signal', parent: '#channels', target: '#channels/cdr_signal', controller: ChannelCdrSignal, permission: ['admin.channel_cdr_signal'] }, 'NavBarAdmin')
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
class ChannelCdrVoice extends App.ControllerSubContent
|
||||
requiredPermission: 'admin.channel_cdr_voice'
|
||||
events:
|
||||
'click .js-new': 'new'
|
||||
'click .js-edit': 'edit'
|
||||
'click .js-delete': 'delete'
|
||||
'click .js-disable': 'disable'
|
||||
'click .js-enable': 'enable'
|
||||
'click .js-rotate-token': 'rotateToken'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
#@interval(@load, 60000)
|
||||
@load()
|
||||
|
||||
load: =>
|
||||
@startLoading()
|
||||
@ajax(
|
||||
id: 'cdr_voice_index'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/channels_cdr_voice"
|
||||
processData: true
|
||||
success: (data) =>
|
||||
@stopLoading()
|
||||
App.Collection.loadAssets(data.assets)
|
||||
@render(data)
|
||||
)
|
||||
|
||||
render: (data) =>
|
||||
|
||||
channels = []
|
||||
for channel_id in data.channel_ids
|
||||
channel = App.Channel.find(channel_id)
|
||||
if channel && channel.options
|
||||
displayName = '-'
|
||||
if channel.group_id
|
||||
group = App.Group.find(channel.group_id)
|
||||
displayName = group.displayName()
|
||||
channel.options.groupName = displayName
|
||||
channels.push channel
|
||||
@html App.view('cdr_voice/index')(
|
||||
channels: channels
|
||||
)
|
||||
|
||||
new: (e) =>
|
||||
e.preventDefault()
|
||||
new FormAdd(
|
||||
container: @el.parents('.content')
|
||||
load: @load
|
||||
)
|
||||
|
||||
edit: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
channel = App.Channel.find(id)
|
||||
new FormEdit(
|
||||
channel: channel
|
||||
container: @el.parents('.content')
|
||||
load: @load
|
||||
)
|
||||
|
||||
delete: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
new App.ControllerConfirm(
|
||||
message: 'Sure?'
|
||||
callback: =>
|
||||
@ajax(
|
||||
id: 'cdr_voice_delete'
|
||||
type: 'DELETE'
|
||||
url: "#{@apiPath}/channels_cdr_voice"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
container: @el.closest('.content')
|
||||
)
|
||||
|
||||
rotateToken: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
|
||||
new App.ControllerConfirm(
|
||||
message: 'This will break the submission form!'
|
||||
buttonSubmit: 'Reset token'
|
||||
head: 'Reset the submission token?'
|
||||
callback: =>
|
||||
@ajax(
|
||||
id: 'cdr_voice_disable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_voice_rotate_token"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
container: @el.closest('.content')
|
||||
)
|
||||
disable: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@ajax(
|
||||
id: 'cdr_voice_disable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_voice_disable"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
enable: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@ajax(
|
||||
id: 'cdr_voice_enable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_voice_enable"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
class FormAdd extends App.ControllerModal
|
||||
head: 'Add Web Form'
|
||||
shown: true
|
||||
button: 'Add'
|
||||
buttonCancel: true
|
||||
small: true
|
||||
|
||||
content: ->
|
||||
content = $(App.view('cdr_voice/form_add')())
|
||||
createOrgSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'organization_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Organization'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
createGroupSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'group_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Group'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
|
||||
content.find('.js-select').on('click', (e) =>
|
||||
@selectAll(e)
|
||||
)
|
||||
content.find('.js-messagesGroup').replaceWith createGroupSelection(1)
|
||||
content.find('.js-organization').replaceWith createOrgSelection(null)
|
||||
content
|
||||
|
||||
onClosed: =>
|
||||
return if !@isChanged
|
||||
@isChanged = false
|
||||
@load()
|
||||
|
||||
onSubmit: (e) =>
|
||||
@formDisable(e)
|
||||
@ajax(
|
||||
id: 'cdr_voice_app_verify'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_voice"
|
||||
data: JSON.stringify(@formParams())
|
||||
processData: true
|
||||
success: =>
|
||||
@isChanged = true
|
||||
@close()
|
||||
error: (xhr) =>
|
||||
data = JSON.parse(xhr.responseText)
|
||||
@formEnable(e)
|
||||
error_message = App.i18n.translateContent(data.error || 'Unable to save Web Form.')
|
||||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
class FormEdit extends App.ControllerModal
|
||||
head: 'Web Form Info'
|
||||
shown: true
|
||||
buttonCancel: true
|
||||
|
||||
content: ->
|
||||
content = $(App.view('cdr_voice/form_edit')(channel: @channel))
|
||||
|
||||
createOrgSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'organization_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Organization'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
createGroupSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'group_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Group'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
|
||||
content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id)
|
||||
content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id)
|
||||
content
|
||||
|
||||
onClosed: =>
|
||||
return if !@isChanged
|
||||
@isChanged = false
|
||||
@load()
|
||||
|
||||
onSubmit: (e) =>
|
||||
@formDisable(e)
|
||||
params = @formParams()
|
||||
@channel.options = params
|
||||
@ajax(
|
||||
id: 'channel_cdr_voice_update'
|
||||
type: 'PUT'
|
||||
url: "#{@apiPath}/channels_cdr_voice/#{@channel.id}"
|
||||
data: JSON.stringify(@formParams())
|
||||
processData: true
|
||||
success: =>
|
||||
@isChanged = true
|
||||
@close()
|
||||
error: (xhr) =>
|
||||
data = JSON.parse(xhr.responseText)
|
||||
@formEnable(e)
|
||||
error_message = App.i18n.translateContent(data.error || 'Unable to save changes.')
|
||||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
App.Config.set('cdr_voice', { prio: 5100, name: 'Voice', parent: '#channels', target: '#channels/cdr_voice', controller: ChannelCdrVoice, permission: ['admin.channel_cdr_voice'] }, 'NavBarAdmin')
|
||||
|
|
@ -0,0 +1,249 @@
|
|||
class ChannelCdrWhatsapp extends App.ControllerSubContent
|
||||
requiredPermission: 'admin.channel_cdr_whatsapp'
|
||||
events:
|
||||
'click .js-new': 'new'
|
||||
'click .js-edit': 'edit'
|
||||
'click .js-delete': 'delete'
|
||||
'click .js-disable': 'disable'
|
||||
'click .js-enable': 'enable'
|
||||
'click .js-rotate-token': 'rotateToken'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
#@interval(@load, 60000)
|
||||
@load()
|
||||
|
||||
load: =>
|
||||
@startLoading()
|
||||
@ajax(
|
||||
id: 'cdr_whatsapp_index'
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/channels_cdr_whatsapp"
|
||||
processData: true
|
||||
success: (data) =>
|
||||
@stopLoading()
|
||||
App.Collection.loadAssets(data.assets)
|
||||
@render(data)
|
||||
)
|
||||
|
||||
render: (data) =>
|
||||
|
||||
channels = []
|
||||
for channel_id in data.channel_ids
|
||||
channel = App.Channel.find(channel_id)
|
||||
if channel && channel.options
|
||||
displayName = '-'
|
||||
if channel.group_id
|
||||
group = App.Group.find(channel.group_id)
|
||||
displayName = group.displayName()
|
||||
channel.options.groupName = displayName
|
||||
channels.push channel
|
||||
@html App.view('cdr_whatsapp/index')(
|
||||
channels: channels
|
||||
)
|
||||
|
||||
new: (e) =>
|
||||
e.preventDefault()
|
||||
new FormAdd(
|
||||
container: @el.parents('.content')
|
||||
load: @load
|
||||
)
|
||||
|
||||
edit: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
channel = App.Channel.find(id)
|
||||
new FormEdit(
|
||||
channel: channel
|
||||
container: @el.parents('.content')
|
||||
load: @load
|
||||
)
|
||||
|
||||
delete: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
new App.ControllerConfirm(
|
||||
message: 'Sure?'
|
||||
callback: =>
|
||||
@ajax(
|
||||
id: 'cdr_whatsapp_delete'
|
||||
type: 'DELETE'
|
||||
url: "#{@apiPath}/channels_cdr_whatsapp"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
container: @el.closest('.content')
|
||||
)
|
||||
|
||||
rotateToken: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
|
||||
new App.ControllerConfirm(
|
||||
message: 'This will break the submission form!'
|
||||
buttonSubmit: 'Reset token'
|
||||
head: 'Reset the submission token?'
|
||||
callback: =>
|
||||
@ajax(
|
||||
id: 'cdr_whatsapp_disable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_whatsapp_rotate_token"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
container: @el.closest('.content')
|
||||
)
|
||||
disable: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@ajax(
|
||||
id: 'cdr_whatsapp_disable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_whatsapp_disable"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
enable: (e) =>
|
||||
e.preventDefault()
|
||||
id = $(e.target).closest('.action').data('id')
|
||||
@ajax(
|
||||
id: 'cdr_whatsapp_enable'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_whatsapp_enable"
|
||||
data: JSON.stringify(id: id)
|
||||
processData: true
|
||||
success: =>
|
||||
@load()
|
||||
)
|
||||
|
||||
class FormAdd extends App.ControllerModal
|
||||
head: 'Add Web Form'
|
||||
shown: true
|
||||
button: 'Add'
|
||||
buttonCancel: true
|
||||
small: true
|
||||
|
||||
content: ->
|
||||
content = $(App.view('cdr_whatsapp/form_add')())
|
||||
createOrgSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'organization_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Organization'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
createGroupSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'group_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Group'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
|
||||
content.find('.js-select').on('click', (e) =>
|
||||
@selectAll(e)
|
||||
)
|
||||
content.find('.js-messagesGroup').replaceWith createGroupSelection(1)
|
||||
content.find('.js-organization').replaceWith createOrgSelection(null)
|
||||
content
|
||||
|
||||
onClosed: =>
|
||||
return if !@isChanged
|
||||
@isChanged = false
|
||||
@load()
|
||||
|
||||
onSubmit: (e) =>
|
||||
@formDisable(e)
|
||||
@ajax(
|
||||
id: 'cdr_whatsapp_app_verify'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/channels_cdr_whatsapp"
|
||||
data: JSON.stringify(@formParams())
|
||||
processData: true
|
||||
success: =>
|
||||
@isChanged = true
|
||||
@close()
|
||||
error: (xhr) =>
|
||||
data = JSON.parse(xhr.responseText)
|
||||
@formEnable(e)
|
||||
error_message = App.i18n.translateContent(data.error || 'Unable to save Web Form.')
|
||||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
class FormEdit extends App.ControllerModal
|
||||
head: 'Web Form Info'
|
||||
shown: true
|
||||
buttonCancel: true
|
||||
|
||||
content: ->
|
||||
content = $(App.view('cdr_whatsapp/form_edit')(channel: @channel))
|
||||
|
||||
createOrgSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'organization_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Organization'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
createGroupSelection = (selected_id) ->
|
||||
return App.UiElement.select.render(
|
||||
name: 'group_id'
|
||||
multiple: false
|
||||
limit: 100
|
||||
null: false
|
||||
relation: 'Group'
|
||||
nulloption: true
|
||||
value: selected_id
|
||||
class: 'form-control--small'
|
||||
)
|
||||
|
||||
content.find('.js-messagesGroup').replaceWith createGroupSelection(@channel.group_id)
|
||||
content.find('.js-organization').replaceWith createOrgSelection(@channel.options.organization_id)
|
||||
content
|
||||
|
||||
onClosed: =>
|
||||
return if !@isChanged
|
||||
@isChanged = false
|
||||
@load()
|
||||
|
||||
onSubmit: (e) =>
|
||||
@formDisable(e)
|
||||
params = @formParams()
|
||||
@channel.options = params
|
||||
@ajax(
|
||||
id: 'channel_cdr_whatsapp_update'
|
||||
type: 'PUT'
|
||||
url: "#{@apiPath}/channels_cdr_whatsapp/#{@channel.id}"
|
||||
data: JSON.stringify(@formParams())
|
||||
processData: true
|
||||
success: =>
|
||||
@isChanged = true
|
||||
@close()
|
||||
error: (xhr) =>
|
||||
data = JSON.parse(xhr.responseText)
|
||||
@formEnable(e)
|
||||
error_message = App.i18n.translateContent(data.error || 'Unable to save changes.')
|
||||
@el.find('.alert').removeClass('hidden').text(error_message)
|
||||
)
|
||||
|
||||
App.Config.set('cdr_whatsapp', { prio: 5100, name: 'Whatsapp', parent: '#channels', target: '#channels/cdr_whatsapp', controller: ChannelCdrWhatsapp, permission: ['admin.channel_cdr_whatsapp'] }, 'NavBarAdmin')
|
||||
|
|
@ -0,0 +1,220 @@
|
|||
class ProfileNotification extends App.ControllerSubContent
|
||||
@include App.TicketNotificationMatrix
|
||||
|
||||
@requiredPermission: 'user_preferences.notifications+ticket.agent'
|
||||
header: __('Notifications')
|
||||
events:
|
||||
'submit form': 'update'
|
||||
'click .js-reset' : 'reset'
|
||||
'change .js-notificationSound': 'previewSound'
|
||||
'change #profile-groups-limit': 'didSwitchGroupsLimit'
|
||||
'change input[name=group_ids]': 'didChangeGroupIds'
|
||||
'change input[name$=".channel.signal"]': 'didChangeSignalCheckbox'
|
||||
|
||||
elements:
|
||||
'#profile-groups-limit': 'profileGroupsLimitInput'
|
||||
'.profile-groups-limit-settings-inner': 'groupsLimitSettings'
|
||||
'.profile-groups-all-unchecked': 'groupsAllUncheckedWarning'
|
||||
|
||||
sounds: [
|
||||
{
|
||||
name: 'Bell'
|
||||
file: 'Bell.mp3'
|
||||
},
|
||||
{
|
||||
name: 'Kalimba'
|
||||
file: 'Kalimba.mp3'
|
||||
},
|
||||
{
|
||||
name: 'Marimba'
|
||||
file: 'Marimba.mp3'
|
||||
},
|
||||
{
|
||||
name: 'Peep'
|
||||
file: 'Peep.mp3'
|
||||
},
|
||||
{
|
||||
name: 'Plop'
|
||||
file: 'Plop.mp3'
|
||||
},
|
||||
{
|
||||
name: 'Ring'
|
||||
file: 'Ring.mp3'
|
||||
},
|
||||
{
|
||||
name: 'Space'
|
||||
file: 'Space.mp3'
|
||||
},
|
||||
{
|
||||
name: 'Wood'
|
||||
file: 'Wood.mp3'
|
||||
},
|
||||
{
|
||||
name: 'Xylo'
|
||||
file: 'Xylo.mp3'
|
||||
}
|
||||
]
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
App.User.full(App.Session.get().id, @render, true, true)
|
||||
|
||||
render: =>
|
||||
|
||||
matrix =
|
||||
create:
|
||||
name: __('New Ticket')
|
||||
update:
|
||||
name: __('Ticket update')
|
||||
reminder_reached:
|
||||
name: __('Ticket reminder reached')
|
||||
escalation:
|
||||
name: __('Ticket escalation')
|
||||
|
||||
config =
|
||||
group_ids: []
|
||||
matrix: {}
|
||||
|
||||
user_config = @Session.get('preferences').notification_config
|
||||
if user_config
|
||||
config = $.extend(true, {}, config, user_config)
|
||||
|
||||
# groups
|
||||
user_group_config = true
|
||||
if !user_config || !user_config['group_ids'] || _.isEmpty(user_config['group_ids']) || user_config['group_ids'][0] is '-'
|
||||
user_group_config = false
|
||||
|
||||
groups = []
|
||||
group_ids = App.User.find(@Session.get('id')).allGroupIds()
|
||||
if group_ids
|
||||
for group_id in group_ids
|
||||
group = App.Group.find(group_id)
|
||||
groups.push group
|
||||
if !user_group_config
|
||||
if !config['group_ids']
|
||||
config['group_ids'] = []
|
||||
config['group_ids'].push group_id.toString()
|
||||
|
||||
groups = _.sortBy(groups, (item) -> return item.name)
|
||||
|
||||
for sound in @sounds
|
||||
sound.selected = sound.file is App.OnlineNotification.soundFile() ? true : false
|
||||
|
||||
signal_notification_enabled = App.Config.get('signal_notification_enabled')
|
||||
|
||||
signal_uid = config.signal_uid || ''
|
||||
|
||||
# Check if any signal checkbox is currently checked in the matrix
|
||||
signal_has_checked = false
|
||||
if signal_notification_enabled
|
||||
for key, val of config.matrix
|
||||
if val?.channel?.signal
|
||||
signal_has_checked = true
|
||||
break
|
||||
|
||||
@html App.view('profile/notification')
|
||||
matrixTableHTML: @renderNotificationMatrix(config.matrix)
|
||||
groups: groups
|
||||
config: config
|
||||
sounds: @sounds
|
||||
notificationSoundEnabled: App.OnlineNotification.soundEnabled()
|
||||
user_group_config: user_group_config
|
||||
signal_notification_enabled: signal_notification_enabled
|
||||
signal_uid: signal_uid
|
||||
signal_has_checked: signal_has_checked
|
||||
|
||||
update: (e) =>
|
||||
|
||||
#notification_config
|
||||
e.preventDefault()
|
||||
params = {}
|
||||
params.notification_config = {}
|
||||
|
||||
formParams = @formParam(e.target)
|
||||
|
||||
params.notification_config.matrix = @updatedNotificationMatrixValues(formParams)
|
||||
|
||||
if formParams.signal_uid?
|
||||
params.notification_config.signal_uid = formParams.signal_uid
|
||||
|
||||
if @profileGroupsLimitInput.is(':checked')
|
||||
params.notification_config.group_ids = formParams['group_ids']
|
||||
if typeof params.notification_config.group_ids isnt 'object'
|
||||
params.notification_config.group_ids = [params.notification_config.group_ids]
|
||||
|
||||
if _.isEmpty(params.notification_config.group_ids)
|
||||
delete params.notification_config.group_ids
|
||||
|
||||
@formDisable(e)
|
||||
|
||||
params.notification_sound = formParams.notification_sound
|
||||
if !params.notification_sound.enabled
|
||||
params.notification_sound.enabled = false
|
||||
else
|
||||
params.notification_sound.enabled = true
|
||||
|
||||
# get data
|
||||
@ajax(
|
||||
id: 'preferences'
|
||||
type: 'PUT'
|
||||
url: @apiPath + '/users/preferences'
|
||||
data: JSON.stringify(params)
|
||||
processData: true
|
||||
success: @success
|
||||
error: @error
|
||||
)
|
||||
|
||||
reset: (e) =>
|
||||
new App.ControllerConfirm(
|
||||
message: __('Are you sure? Your notifications settings will be reset to default.')
|
||||
buttonClass: 'btn--danger'
|
||||
callback: =>
|
||||
@ajax(
|
||||
id: 'preferences_notifications_reset'
|
||||
type: 'POST'
|
||||
url: "#{@apiPath}/users/preferences_notifications_reset"
|
||||
processData: true
|
||||
success: @success
|
||||
)
|
||||
container: @el.closest('.content')
|
||||
)
|
||||
|
||||
|
||||
success: (data, status, xhr) =>
|
||||
App.User.full(
|
||||
App.Session.get('id'),
|
||||
=>
|
||||
App.Event.trigger('ui:rerender')
|
||||
@notify(
|
||||
type: 'success'
|
||||
msg: __('Update successful.')
|
||||
)
|
||||
,
|
||||
true
|
||||
)
|
||||
|
||||
error: (xhr, status, error) =>
|
||||
@render()
|
||||
data = JSON.parse(xhr.responseText)
|
||||
@notify(
|
||||
type: 'error'
|
||||
msg: data.message
|
||||
)
|
||||
|
||||
previewSound: (e) =>
|
||||
params = @formParam(e.target)
|
||||
return if !params.notification_sound
|
||||
return if !params.notification_sound.file
|
||||
App.OnlineNotification.play(params.notification_sound.file)
|
||||
|
||||
didSwitchGroupsLimit: (e) =>
|
||||
@groupsLimitSettings.collapse('toggle')
|
||||
|
||||
didChangeGroupIds: (e) =>
|
||||
@groupsAllUncheckedWarning.toggleClass 'hide', @el.find('input[name=group_ids]:checked').length != 0
|
||||
|
||||
didChangeSignalCheckbox: (e) =>
|
||||
hasChecked = @el.find('input[name$=".channel.signal"]:checked').length > 0
|
||||
@el.find('.js-signal-phone-container').toggle(hasChecked)
|
||||
|
||||
App.Config.set('Notifications', { prio: 2600, name: __('Notifications'), parent: '#profile', target: '#profile/notifications', permission: ['user_preferences.notifications+ticket.agent'], controller: ProfileNotification }, 'NavBarProfile')
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# coffeelint: disable=camel_case_classes
|
||||
class App.UiElement.notification_matrix
|
||||
@render: (values, options = {}) ->
|
||||
|
||||
matrixYAxe =
|
||||
create:
|
||||
name: __('New Ticket')
|
||||
update:
|
||||
name: __('Ticket update')
|
||||
reminder_reached:
|
||||
name: __('Ticket reminder reached')
|
||||
escalation:
|
||||
name: __('Ticket escalation')
|
||||
|
||||
$( App.view('generic/notification_matrix')( matrixYAxe: matrixYAxe, values: values, signal_notification_enabled: options.signal_notification_enabled ) )
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
class CdrLinkChannelFilter
|
||||
# Required stub - we don't add any actions, just pass through
|
||||
@action: (actions, ticket, article, ui) ->
|
||||
actions
|
||||
|
||||
@articleTypes: (articleTypes, ticket, ui) ->
|
||||
return articleTypes if !ui.permissionCheck('ticket.agent')
|
||||
|
||||
# Check CDR Link allowed channels setting
|
||||
allowedChannels = ui.Config.get('cdr_link_allowed_channels')
|
||||
|
||||
# If no whitelist is configured, allow all types
|
||||
if !allowedChannels || !allowedChannels.trim()
|
||||
return articleTypes
|
||||
|
||||
# Parse the comma-separated whitelist
|
||||
whitelist = (channel.trim() for channel in allowedChannels.split(','))
|
||||
|
||||
# Filter article types to only those in the whitelist
|
||||
# Always keep 'note' for internal notes regardless of whitelist
|
||||
filteredTypes = articleTypes.filter (type) ->
|
||||
type.name is 'note' or type.name in whitelist
|
||||
|
||||
# Add email if it's in the whitelist but not in the array
|
||||
# (Email is only added by Zammad core for email tickets, not Signal tickets)
|
||||
if 'email' in whitelist
|
||||
hasEmail = filteredTypes.some (type) -> type.name is 'email'
|
||||
if !hasEmail
|
||||
# Add email with all the standard email attributes
|
||||
filteredTypes.push {
|
||||
name: 'email'
|
||||
icon: 'email'
|
||||
attributes: ['to', 'cc', 'subject']
|
||||
internal: false
|
||||
features: ['attachment']
|
||||
}
|
||||
|
||||
filteredTypes
|
||||
|
||||
App.Config.set('900-CdrLinkChannelFilter', CdrLinkChannelFilter, 'TicketZoomArticleAction')
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
class CdrSignalReply
|
||||
@action: (actions, ticket, article, ui) ->
|
||||
return actions if ui.permissionCheck('ticket.customer')
|
||||
|
||||
if article.sender.name is 'Customer' && article.type.name is 'cdr_signal'
|
||||
actions.push {
|
||||
name: 'reply'
|
||||
type: 'cdrSignalMessageReply'
|
||||
icon: 'reply'
|
||||
href: '#'
|
||||
}
|
||||
|
||||
actions
|
||||
|
||||
@perform: (articleContainer, type, ticket, article, ui) ->
|
||||
return true if type isnt 'cdrSignalMessageReply'
|
||||
|
||||
ui.scrollToCompose()
|
||||
|
||||
# get reference article
|
||||
type = App.TicketArticleType.find(article.type_id)
|
||||
|
||||
articleNew = {
|
||||
to: ''
|
||||
cc: ''
|
||||
body: ''
|
||||
in_reply_to: ''
|
||||
}
|
||||
|
||||
if article.message_id
|
||||
articleNew.in_reply_to = article.message_id
|
||||
|
||||
# get current body
|
||||
articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || ''
|
||||
|
||||
App.Event.trigger('ui::ticket::setArticleType', {
|
||||
ticket: ticket
|
||||
type: type
|
||||
article: articleNew
|
||||
position: 'end'
|
||||
})
|
||||
|
||||
true
|
||||
|
||||
@articleTypes: (articleTypes, ticket, ui) ->
|
||||
return articleTypes if !ui.permissionCheck('ticket.agent')
|
||||
|
||||
# Check if this ticket was created via Signal
|
||||
return articleTypes if !ticket || !ticket.create_article_type_id
|
||||
|
||||
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
|
||||
|
||||
# Only add cdr_signal type if ticket was created via Signal
|
||||
if articleTypeCreate isnt 'cdr_signal'
|
||||
return articleTypes
|
||||
|
||||
# Add the cdr_signal article type for Signal replies
|
||||
articleTypes.push {
|
||||
name: 'cdr_signal'
|
||||
icon: 'cdr-signal'
|
||||
attributes: []
|
||||
internal: false,
|
||||
features: ['attachment']
|
||||
maxTextLength: 10000
|
||||
warningTextLength: 5000
|
||||
}
|
||||
articleTypes
|
||||
|
||||
@setArticleTypePost: (type, ticket, ui) ->
|
||||
return if type isnt 'cdr_signal'
|
||||
rawHTML = ui.$('[data-name=body]').html()
|
||||
cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML)
|
||||
if cleanHTML && cleanHTML.html() != rawHTML
|
||||
ui.$('[data-name=body]').html(cleanHTML)
|
||||
|
||||
@params: (type, params, ui) ->
|
||||
if type is 'cdr_signal'
|
||||
App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false)
|
||||
params.content_type = 'text/plain'
|
||||
params.body = App.Utils.html2text(params.body, true)
|
||||
|
||||
params
|
||||
|
||||
App.Config.set('300-CdrSignalReply', CdrSignalReply, 'TicketZoomArticleAction')
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
class CdrWhatsappReply
|
||||
@action: (actions, ticket, article, ui) ->
|
||||
return actions if ui.permissionCheck('ticket.customer')
|
||||
|
||||
if article.sender.name is 'Customer' && article.type.name is 'cdr_whatsapp'
|
||||
actions.push {
|
||||
name: 'reply'
|
||||
type: 'cdrWhatsappMessageReply'
|
||||
icon: 'reply'
|
||||
href: '#'
|
||||
}
|
||||
|
||||
actions
|
||||
|
||||
@perform: (articleContainer, type, ticket, article, ui) ->
|
||||
return true if type isnt 'cdrWhatsappMessageReply'
|
||||
|
||||
ui.scrollToCompose()
|
||||
|
||||
# get reference article
|
||||
type = App.TicketArticleType.find(article.type_id)
|
||||
|
||||
articleNew = {
|
||||
to: ''
|
||||
cc: ''
|
||||
body: ''
|
||||
in_reply_to: ''
|
||||
}
|
||||
|
||||
if article.message_id
|
||||
articleNew.in_reply_to = article.message_id
|
||||
|
||||
# get current body
|
||||
articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || ''
|
||||
|
||||
App.Event.trigger('ui::ticket::setArticleType', {
|
||||
ticket: ticket
|
||||
type: type
|
||||
article: articleNew
|
||||
position: 'end'
|
||||
})
|
||||
|
||||
true
|
||||
|
||||
@articleTypes: (articleTypes, ticket, ui) ->
|
||||
return articleTypes if !ui.permissionCheck('ticket.agent')
|
||||
|
||||
return articleTypes if !ticket || !ticket.create_article_type_id
|
||||
|
||||
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
|
||||
|
||||
return articleTypes if articleTypeCreate isnt 'cdr_whatsapp'
|
||||
articleTypes.push {
|
||||
name: 'cdr_whatsapp'
|
||||
icon: 'cdr-whatsapp'
|
||||
attributes: []
|
||||
internal: false,
|
||||
features: ['attachment']
|
||||
maxTextLength: 10000
|
||||
warningTextLength: 5000
|
||||
}
|
||||
articleTypes
|
||||
|
||||
@setArticleTypePost: (type, ticket, ui) ->
|
||||
return if type isnt 'cdr_whatsapp'
|
||||
rawHTML = ui.$('[data-name=body]').html()
|
||||
cleanHTML = App.Utils.htmlRemoveRichtext(rawHTML)
|
||||
if cleanHTML && cleanHTML.html() != rawHTML
|
||||
ui.$('[data-name=body]').html(cleanHTML)
|
||||
|
||||
@params: (type, params, ui) ->
|
||||
if type is 'cdr_whatsapp'
|
||||
App.Utils.htmlRemoveRichtext(ui.$('[data-name=body]'), false)
|
||||
params.content_type = 'text/plain'
|
||||
params.body = App.Utils.html2text(params.body, true)
|
||||
|
||||
params
|
||||
|
||||
App.Config.set('300-CdrWhatsappReply', CdrWhatsappReply, 'TicketZoomArticleAction')
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Common handling for the notification matrix
|
||||
App.TicketNotificationMatrix =
|
||||
renderNotificationMatrix: (values) ->
|
||||
App.UiElement.notification_matrix.render(values, signal_notification_enabled: App.Config.get('signal_notification_enabled'))[0].outerHTML
|
||||
|
||||
updatedNotificationMatrixValues: (formParams) ->
|
||||
matrix = {}
|
||||
|
||||
for key, value of formParams
|
||||
area = key.split('.')
|
||||
|
||||
continue if area[0] isnt 'matrix'
|
||||
|
||||
if !matrix[area[1]]
|
||||
matrix[area[1]] = {}
|
||||
|
||||
switch area[2]
|
||||
when 'criteria'
|
||||
if !matrix[area[1]][area[2]]
|
||||
matrix[area[1]][area[2]] = {}
|
||||
|
||||
matrix[area[1]][area[2]][area[3]] = value is 'true'
|
||||
when 'channel'
|
||||
if !matrix[area[1]][area[2]]
|
||||
matrix[area[1]][area[2]] = { online: true }
|
||||
|
||||
matrix[area[1]][area[2]][area[3]] = value is 'true'
|
||||
|
||||
matrix
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<div class="alert alert--danger hidden" role="alert"></div>
|
||||
<fieldset>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Phone number') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="phone_number" type="text" name="phone_number" value="" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="bot_token" type="text" name="bot_token" value="" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="bot_endpoint" type="text" name="bot_endpoint" value="" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the group in which form submissions will get added to.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="js-messagesGroup"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the organization to which submitters will be added to when they submit via this form.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="profile-organization js-organization"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<div class="alert alert--danger hidden" role="alert"></div>
|
||||
|
||||
<fieldset>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Voice Line Number') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="phone_number" type="text" name="phone_number" value="<%= @channel.options.phone_number %>" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="bot_token" type="text" name="bot_token" value="<%= @channel.options.bot_token %>" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="bot_endpoint" type="text" name="bot_endpoint" value="<%= @channel.options.bot_endpoint %>" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the group in which incoming messages will be added.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="js-messagesGroup"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this number.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="profile-organization js-organization"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="token"><%- @T('Endpoint URL') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="token" type="text" value="<%= "#{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/channels_cdr_signal_webhook/#{@channel.options.token}" %>" class="form-control input js-select" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title">
|
||||
<h1><%- @T('Signal') %></h1>
|
||||
</div>
|
||||
|
||||
<div class="page-header-meta">
|
||||
<a class="btn btn--success js-new"><%- @T('Add Signal bot') %></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
|
||||
<% if _.isEmpty(@channels): %>
|
||||
<div class="page-description">
|
||||
<p><%- @T('You have no configured %s right now.', 'Signal numbers') %></p>
|
||||
</div>
|
||||
<% else: %>
|
||||
|
||||
<% for channel in @channels: %>
|
||||
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
|
||||
<div class="action-block action-row">
|
||||
<h2><%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %></h2>
|
||||
<% if @notificationEnabled and @notificationChannelId is channel.id: %>
|
||||
<span class="label label--success" style="margin-left: 10px;"><%- @T('Agent Notifications') %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="action-flow action-flow--row">
|
||||
<div class="action-block">
|
||||
<h3><%- @T('Group') %></h3>
|
||||
<% if channel.options: %>
|
||||
<%= channel.options.groupName %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="action-block">
|
||||
<h3><%- @T('Endpoint URL') %></h3>
|
||||
<%- @T('Click the edit button to view the endpoint details ') %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-controls">
|
||||
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
|
||||
<div class="btn btn--danger btn--secondary js-rotate-token"><%- @T('Reset Token') %></div>
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
<% if @notificationEnabled and @notificationChannelId is channel.id: %>
|
||||
<div class="btn btn--secondary js-unset-notification"><%- @T('Disable Agent Notifications') %></div>
|
||||
<% else if channel.active is true: %>
|
||||
<div class="btn btn--success btn--secondary js-set-notification"><%- @T('Use for Agent Notifications') %></div>
|
||||
<% end %>
|
||||
<div class="btn js-edit"><%- @T('Edit') %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<div class="alert alert--danger hidden" role="alert"></div>
|
||||
<fieldset>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Phone number') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="phone_number" type="text" name="phone_number" value="" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the group in which form submissions will get added to.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="js-messagesGroup"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the organization to which submitters will be added to when they submit via this form.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="profile-organization js-organization"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<div class="alert alert--danger hidden" role="alert"></div>
|
||||
|
||||
<fieldset>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Voice Line Number') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="phone_number" type="text" name="phone_number" value="<%= @channel.options.phone_number %>" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the group in which incoming calls will be added to.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="js-messagesGroup"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the organization to which users will be added to when they leave a recording to this voice line.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="profile-organization js-organization"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="token"><%- @T('Endpoint URL') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="token" type="text" value="<%= "#{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/channels_cdr_voice_webhook/#{@channel.options.token}" %>" class="form-control input js-select" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title">
|
||||
<h1><%- @T('Voice') %></h1>
|
||||
</div>
|
||||
|
||||
<div class="page-header-meta">
|
||||
<a class="btn btn--success js-new"><%- @T('Add Voice Line') %></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
|
||||
<% if _.isEmpty(@channels): %>
|
||||
<div class="page-description">
|
||||
<p><%- @T('You have no configured %s right now.', 'voice lines') %></p>
|
||||
</div>
|
||||
<% else: %>
|
||||
|
||||
<% for channel in @channels: %>
|
||||
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
|
||||
<div class="action-block action-row">
|
||||
<h2><%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %></h2>
|
||||
</div>
|
||||
<div class="action-flow action-flow--row">
|
||||
<div class="action-block">
|
||||
<h3><%- @T('Group') %></h3>
|
||||
<% if channel.options: %>
|
||||
<%= channel.options.groupName %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="action-block">
|
||||
<h3><%- @T('Endpoint URL') %></h3>
|
||||
<%- @T('Click the edit button to view the endpoint details ') %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-controls">
|
||||
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
|
||||
<div class="btn btn--danger btn--secondary js-rotate-token"><%- @T('Reset Token') %></div>
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
<div class="btn js-edit"><%- @T('Edit') %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<div class="alert alert--danger hidden" role="alert"></div>
|
||||
<fieldset>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Phone number') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="phone_number" type="text" name="phone_number" value="" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="bot_token" type="text" name="bot_token" value="" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="bot_endpoint" type="text" name="bot_endpoint" value="" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the group in which form submissions will get added to.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="js-messagesGroup"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the organization to which submitters will be added to when they submit via this form.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="profile-organization js-organization"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
<div class="alert alert--danger hidden" role="alert"></div>
|
||||
|
||||
<fieldset>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Whatsapp Number') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="phone_number" type="text" name="phone_number" value="<%= @channel.options.phone_number %>" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Bot Token') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="bot_token" type="text" name="bot_token" value="<%= @channel.options.bot_token %>" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="form_name"><%- @T('Bot Endpoint') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="bot_endpoint" type="text" name="bot_endpoint" value="<%= @channel.options.bot_endpoint %>" class="form-control" required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the group in which incoming messages will be added to.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="js-messagesGroup"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for=""><%- @T('Choose the organization to which users will be added to when they send a message to this number.') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="profile-organization js-organization"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="token"><%- @T('Endpoint URL') %> <span>*</span></label>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input id="token" type="text" value="<%= "#{App.Config.get('http_type')}://#{App.Config.get('fqdn')}/api/v1/channels_cdr_whatsapp_webhook/#{@channel.options.token}" %>" class="form-control input js-select" readonly>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title">
|
||||
<h1><%- @T('Whatsapp') %></h1>
|
||||
</div>
|
||||
|
||||
<div class="page-header-meta">
|
||||
<a class="btn btn--success js-new"><%- @T('Add Whatsapp bot') %></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
|
||||
<% if _.isEmpty(@channels): %>
|
||||
<div class="page-description">
|
||||
<p><%- @T('You have no configured %s right now.', 'Whatsapp numbers') %></p>
|
||||
</div>
|
||||
<% else: %>
|
||||
|
||||
<% for channel in @channels: %>
|
||||
<div class="action <% if channel.active isnt true: %>is-inactive<% end %>" data-id="<%= channel.id %>">
|
||||
<div class="action-block action-row">
|
||||
<h2><%- @Icon('status', 'supergood-color inline') %> <%= channel.options.phone_number %></h2>
|
||||
</div>
|
||||
<div class="action-flow action-flow--row">
|
||||
<div class="action-block">
|
||||
<h3><%- @T('Group') %></h3>
|
||||
<% if channel.options: %>
|
||||
<%= channel.options.groupName %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="action-block">
|
||||
<h3><%- @T('Endpoint URL') %></h3>
|
||||
<%- @T('Click the edit button to view the endpoint details ') %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-controls">
|
||||
<div class="btn btn--danger btn--secondary js-delete"><%- @T('Delete') %></div>
|
||||
<div class="btn btn--danger btn--secondary js-rotate-token"><%- @T('Reset Token') %></div>
|
||||
<% if channel.active is true: %>
|
||||
<div class="btn btn--secondary js-disable"><%- @T('Disable') %></div>
|
||||
<% else: %>
|
||||
<div class="btn btn--secondary js-enable"><%- @T('Enable') %></div>
|
||||
<% end %>
|
||||
<div class="btn js-edit"><%- @T('Edit') %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
<% if @signal_notification_enabled: %>
|
||||
<% colWidth = "13%" %>
|
||||
<% channelWidth = "100px" %>
|
||||
<% else: %>
|
||||
<% colWidth = "16%" %>
|
||||
<% channelWidth = "120px" %>
|
||||
<% end %>
|
||||
<table class="settings-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('My Tickets') %>
|
||||
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('Not Assigned') %>*
|
||||
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('Subscribed Tickets') %>
|
||||
<th width="<%= colWidth %>" style="text-align: center;"><%- @T('All Tickets') %>*
|
||||
<th width="<%= channelWidth %>" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via email') %>
|
||||
<% if @signal_notification_enabled: %>
|
||||
<th width="<%= channelWidth %>" class="settings-list-separator" style="text-align: center;"><%- @T('Also notify via Signal') %>
|
||||
<% end %>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if @matrixYAxe: %>
|
||||
<% for key, value of @matrixYAxe: %>
|
||||
<tr>
|
||||
<td>
|
||||
<%- @T(value.name) %>
|
||||
<% criteria = @values[key]?.criteria %>
|
||||
<% channel = @values[key]?.channel %>
|
||||
<td class="u-positionOrigin">
|
||||
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_me" value="true"<% if criteria && criteria.owned_by_me: %> checked<% end %> />
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</label>
|
||||
<td class="u-positionOrigin">
|
||||
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||
<input type="checkbox" name="matrix.<%= key %>.criteria.owned_by_nobody" value="true"<% if criteria && criteria.owned_by_nobody: %> checked<% end %> />
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</label>
|
||||
<td class="u-positionOrigin">
|
||||
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||
<input type="checkbox" name="matrix.<%= key %>.criteria.subscribed" value="true"<% if criteria && criteria.subscribed: %> checked<% end %> />
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</label>
|
||||
<td class="u-positionOrigin">
|
||||
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||
<input type="checkbox" name="matrix.<%= key %>.criteria.no" value="true"<% if criteria && criteria.no: %> checked<% end %> />
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</label>
|
||||
<td class="u-positionOrigin settings-list-separator">
|
||||
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||
<input type="checkbox" name="matrix.<%= key %>.channel.email" value="true"<% if channel && channel.email: %> checked<% end %> />
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</label>
|
||||
<% if @signal_notification_enabled: %>
|
||||
<td class="u-positionOrigin settings-list-separator">
|
||||
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||
<input type="checkbox" name="matrix.<%= key %>.channel.signal" value="true"<% if channel && channel.signal: %> checked<% end %> />
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</label>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title"><h1><%- @T('Notifications') %></h1></div>
|
||||
</div>
|
||||
|
||||
<form class="page-content form--flexibleWidth profile-settings-notifications-content">
|
||||
|
||||
<div class="settings-entry">
|
||||
<%- @matrixTableHTML %>
|
||||
</div>
|
||||
|
||||
<% if @signal_notification_enabled: %>
|
||||
<div class="js-signal-phone-container" style="<% if !@signal_has_checked: %>display: none;<% end %>">
|
||||
<h2><%- @T('Signal Phone Number') %></h2>
|
||||
<div class="form-group">
|
||||
<input type="text" name="signal_uid" class="form-control" value="<%= @signal_uid %>" placeholder="+1234567890">
|
||||
<p class="help-block"><%- @T('Use international format with country code (e.g., +1234567890)') %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @groups: %>
|
||||
<div class="zammad-switch zammad-switch--small" data-name="profile-groups-limit">
|
||||
<input type="checkbox" id="profile-groups-limit" <% if @user_group_config: %> checked <% end %>>
|
||||
<label for="profile-groups-limit"></label>
|
||||
</div>
|
||||
<h2>
|
||||
<%- @T('Limit Groups') %>
|
||||
</h2>
|
||||
|
||||
<div class="settings-entry profile-groups-limit-settings">
|
||||
<div class="profile-groups-limit-settings-inner collapse <% if @user_group_config: %>in<% end %>">
|
||||
<div class="alert alert--warning profile-groups-all-unchecked hide" role="alert">
|
||||
<%- @T('Disabling the notifications from all groups will turn off the limit. Instead, to disable the notifications use the settings above.') %>
|
||||
</div>
|
||||
|
||||
<table class="settings-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%- @T('Group') %>
|
||||
<th><%- @T('Not Assigned') %> & <%- @T('All Tickets') %>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for group in @groups: %>
|
||||
<tr>
|
||||
<td><%- @P(group, 'name') %>
|
||||
<td class="u-positionOrigin">
|
||||
<label class="checkbox-replacement checkbox-replacement--fullscreen">
|
||||
<input type="checkbox" name="group_ids" value="<%= group.id %>" <% if _.include(_.map(@config.group_ids, (group_id) -> group_id.toString()), group.id.toString()): %>checked<% end %>/>
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</label>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<h2><%- @T('Sounds') %></h2>
|
||||
<div class="form-group">
|
||||
<div class="formGroup-label">
|
||||
<label for="notification-sound"><%- @T('Notification Sound') %></label>
|
||||
</div>
|
||||
<div class="controls controls--select">
|
||||
<select class="form-control js-notificationSound" id="notification-sound" name="notification_sound::file">
|
||||
<% for sound in @sounds: %>
|
||||
<option value="<%= sound.file %>"<%= ' selected' if sound.selected %>><%= sound.name %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
<%- @Icon('arrow-down') %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="inline-label">
|
||||
<span class="checkbox-replacement checkbox-replacement--inline">
|
||||
<input type="checkbox" name="notification_sound::enabled" value="true" <% if @notificationSoundEnabled: %> checked<% end %> class="js-SoundEnableDisable">
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</span>
|
||||
<%- @T('Play user interface sound effects') %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn--primary"><%- @T( 'Submit' ) %></button>
|
||||
<input type="button" class="btn btn--danger js-reset" value="<%- @T( 'Reset to default settings' ) %>">
|
||||
</form>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.icon-cdr-signal {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.icon-cdr-whatsapp {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CdrSignalChannelsController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }
|
||||
|
||||
def index
|
||||
channels = Channel.where(area: 'Signal::Number', active: true).map do |channel|
|
||||
{
|
||||
id: channel.id,
|
||||
phone_number: channel.options['phone_number'],
|
||||
bot_endpoint: channel.options['bot_endpoint']
|
||||
# bot_token intentionally excluded - bridge-worker should look it up from cdr database
|
||||
}
|
||||
end
|
||||
|
||||
render json: channels
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CdrTicketArticleTypesController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }
|
||||
|
||||
def index
|
||||
types = Ticket::Article::Type.all.map do |type|
|
||||
{
|
||||
id: type.id,
|
||||
name: type.name,
|
||||
communication: type.communication
|
||||
}
|
||||
end
|
||||
|
||||
render json: types
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,574 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ChannelsCdrSignalController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }, except: [:webhook]
|
||||
skip_before_action :verify_csrf_token, only: [:webhook]
|
||||
|
||||
include CreatesTicketArticles
|
||||
|
||||
def index
|
||||
assets = {}
|
||||
channel_ids = []
|
||||
Channel.where(area: 'Signal::Number').order(:id).each do |channel|
|
||||
assets = channel.assets(assets)
|
||||
channel_ids.push channel.id
|
||||
end
|
||||
render json: {
|
||||
assets: assets,
|
||||
channel_ids: channel_ids,
|
||||
notification_enabled: Setting.get('signal_notification_enabled') == true,
|
||||
notification_channel_id: Setting.get('signal_notification_channel_id')
|
||||
}
|
||||
end
|
||||
|
||||
def set_notification_channel
|
||||
channel_id = params[:id].to_i
|
||||
channel = Channel.find_by(id: channel_id, area: 'Signal::Number')
|
||||
|
||||
unless channel
|
||||
render json: { error: 'Channel not found' }, status: :not_found
|
||||
return
|
||||
end
|
||||
|
||||
Setting.set('signal_notification_channel_id', channel_id)
|
||||
Setting.set('signal_notification_enabled', true)
|
||||
|
||||
render json: { success: true, notification_channel_id: channel_id }
|
||||
end
|
||||
|
||||
def unset_notification_channel
|
||||
Setting.set('signal_notification_enabled', false)
|
||||
Setting.set('signal_notification_channel_id', nil)
|
||||
|
||||
render json: { success: true }
|
||||
end
|
||||
|
||||
def add
|
||||
begin
|
||||
errors = {}
|
||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
channel = Channel.create(
|
||||
area: 'Signal::Number',
|
||||
options: {
|
||||
adapter: 'cdr_signal',
|
||||
phone_number: params[:phone_number],
|
||||
bot_token: params[:bot_token],
|
||||
bot_endpoint: params[:bot_endpoint],
|
||||
token: SecureRandom.urlsafe_base64(48),
|
||||
organization_id: params[:organization_id]
|
||||
},
|
||||
group_id: params[:group_id],
|
||||
active: true
|
||||
)
|
||||
rescue StandardError => e
|
||||
raise Exceptions::UnprocessableEntity, e.message
|
||||
end
|
||||
render json: channel
|
||||
end
|
||||
|
||||
def update
|
||||
errors = {}
|
||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
|
||||
begin
|
||||
channel.options[:phone_number] = params[:phone_number]
|
||||
channel.options[:bot_token] = params[:bot_token]
|
||||
channel.options[:bot_endpoint] = params[:bot_endpoint]
|
||||
channel.options[:organization_id] = params[:organization_id]
|
||||
channel.group_id = params[:group_id]
|
||||
channel.save!
|
||||
rescue StandardError => e
|
||||
raise Exceptions::UnprocessableEntity, e.message
|
||||
end
|
||||
render json: channel
|
||||
end
|
||||
|
||||
def rotate_token
|
||||
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
|
||||
channel.options[:token] = SecureRandom.urlsafe_base64(48)
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def enable
|
||||
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
|
||||
channel.active = true
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def disable
|
||||
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
|
||||
channel.active = false
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def destroy
|
||||
channel = Channel.find_by(id: params[:id], area: 'Signal::Number')
|
||||
channel.destroy
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def channel_for_token(token)
|
||||
return false unless token
|
||||
|
||||
Channel.where(area: 'Signal::Number').each do |channel|
|
||||
return channel if channel.options[:token] == token
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def webhook
|
||||
token = params['token']
|
||||
return render json: {}, status: 401 unless token
|
||||
|
||||
channel = channel_for_token(token)
|
||||
return render json: {}, status: 401 if !channel || !channel.active
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
return render json: {}, status: 401 unless ActiveSupport::SecurityUtils.secure_compare(
|
||||
channel.options[:token].to_s,
|
||||
token.to_s
|
||||
)
|
||||
|
||||
# Handle group creation events
|
||||
if params[:event] == 'group_created'
|
||||
return update_group
|
||||
end
|
||||
|
||||
# Handle group member joined events
|
||||
if params[:event] == 'group_member_joined'
|
||||
return handle_group_member_joined
|
||||
end
|
||||
|
||||
channel_id = channel.id
|
||||
|
||||
# validate input
|
||||
errors = {}
|
||||
|
||||
# %i[to
|
||||
# from
|
||||
# message_id
|
||||
# sent_at].each | field |
|
||||
# (errors[field] = 'required' if params[field].blank?)
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
message_id = params[:message_id]
|
||||
|
||||
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
||||
|
||||
receiver_phone_number = params[:to].strip
|
||||
sender_phone_number = params[:from].present? ? params[:from].strip : nil
|
||||
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
|
||||
|
||||
# Check if this is a group message using the is_group flag from bridge-worker
|
||||
# This flag is set when:
|
||||
# 1. The original message came from a Signal group
|
||||
# 2. Bridge-worker created a new group for the conversation
|
||||
is_group_message = params[:is_group].to_s == 'true'
|
||||
|
||||
# Lookup customer with fallback chain:
|
||||
# 1. Phone number in phone/mobile fields (preferred)
|
||||
# 2. Signal user ID in signal_uid field
|
||||
# 3. User ID in phone/mobile fields (legacy - we used to store UUIDs there)
|
||||
customer = nil
|
||||
if sender_phone_number.present?
|
||||
customer = User.find_by(phone: sender_phone_number)
|
||||
customer ||= User.find_by(mobile: sender_phone_number)
|
||||
end
|
||||
if customer.nil? && sender_user_id.present?
|
||||
customer = User.find_by(signal_uid: sender_user_id)
|
||||
# Legacy fallback: user ID might be stored in phone field
|
||||
customer ||= User.find_by(phone: sender_user_id)
|
||||
customer ||= User.find_by(mobile: sender_user_id)
|
||||
end
|
||||
|
||||
unless customer
|
||||
role_ids = Role.signup_role_ids
|
||||
customer = User.create(
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phone: sender_phone_number.presence || sender_user_id,
|
||||
signal_uid: sender_user_id,
|
||||
note: 'CDR Signal',
|
||||
active: true,
|
||||
role_ids: role_ids,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
end
|
||||
|
||||
# Update signal_uid if we have it and customer doesn't
|
||||
if sender_user_id.present? && customer.signal_uid.blank?
|
||||
customer.update(signal_uid: sender_user_id)
|
||||
end
|
||||
# Update phone if we have it and customer only has user_id in phone field
|
||||
if sender_phone_number.present? && customer.phone == sender_user_id
|
||||
customer.update(phone: sender_phone_number)
|
||||
end
|
||||
|
||||
# set current user
|
||||
UserInfo.current_user_id = customer.id
|
||||
current_user_set(customer, 'token_auth')
|
||||
|
||||
group = Group.find_by(id: channel.group_id)
|
||||
if group.blank?
|
||||
Rails.logger.error "Signal channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!"
|
||||
return render json: { error: 'There was an error during Signal submission' }, status: 500
|
||||
end
|
||||
|
||||
organization_id = channel.options['organization_id']
|
||||
if organization_id.present?
|
||||
organization = Organization.find_by(id: organization_id)
|
||||
unless organization.present?
|
||||
Rails.logger.error "Signal channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!"
|
||||
return render json: { error: 'There was an error during Signal submission' }, status: 500
|
||||
end
|
||||
unless customer.organization_id.present?
|
||||
customer.organization_id = organization.id
|
||||
customer.save!
|
||||
end
|
||||
end
|
||||
|
||||
message = params[:message] ||= 'No text content'
|
||||
sent_at = params[:sent_at]
|
||||
attachment_data_base64 = params[:attachment]
|
||||
attachment_filename = params[:filename]
|
||||
attachment_mimetype = params[:mime_type]
|
||||
sender_display = sender_phone_number.presence || sender_user_id
|
||||
title = "Message from #{sender_display} at #{sent_at}"
|
||||
body = message
|
||||
|
||||
# find ticket or create one
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
|
||||
if is_group_message
|
||||
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
|
||||
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
|
||||
Rails.logger.info "Customer ID: #{customer.id}"
|
||||
Rails.logger.info "Customer Phone: #{sender_display}"
|
||||
Rails.logger.info "Channel ID: #{channel.id}"
|
||||
|
||||
begin
|
||||
# Use text search on preferences YAML to efficiently find tickets without loading all into memory
|
||||
# This prevents DoS attacks from memory exhaustion
|
||||
ticket = Ticket.where.not(state_id: state_ids)
|
||||
.where("preferences LIKE ?", "%channel_id: #{channel.id}%")
|
||||
.where("preferences LIKE ?", "%chat_id: #{receiver_phone_number}%")
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
if ticket
|
||||
Rails.logger.info "=== FOUND MATCHING TICKET BY GROUP ID: ##{ticket.number} ==="
|
||||
# Update customer if different (handles duplicate phone numbers)
|
||||
if ticket.customer_id != customer.id
|
||||
Rails.logger.info "Updating ticket customer from #{ticket.customer_id} to #{customer.id}"
|
||||
ticket.customer_id = customer.id
|
||||
end
|
||||
else
|
||||
Rails.logger.info "=== NO MATCHING TICKET BY GROUP ID - CHECKING BY PHONE NUMBER ==="
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Error during group ticket lookup: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
end
|
||||
else
|
||||
Rails.logger.info "Not a group message or no group_id, finding most recent ticket"
|
||||
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||
end
|
||||
|
||||
if ticket
|
||||
# check if title need to be updated
|
||||
ticket.title = title if ticket.title == '-'
|
||||
new_state = Ticket::State.find_by(default_create: true)
|
||||
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||
else
|
||||
# Set up chat_id based on whether this is a group message
|
||||
# For direct messages, prefer UUID (more stable than phone numbers which can change)
|
||||
chat_id = is_group_message ? receiver_phone_number : (sender_user_id.presence || sender_phone_number)
|
||||
|
||||
# Build preferences with group_id included if needed
|
||||
cdr_signal_prefs = {
|
||||
bot_token: channel.options[:bot_token],
|
||||
chat_id: chat_id,
|
||||
user_id: sender_user_id
|
||||
}
|
||||
|
||||
# Store original recipient phone for group tickets to enable ticket splitting
|
||||
if is_group_message
|
||||
cdr_signal_prefs[:original_recipient] = sender_phone_number
|
||||
end
|
||||
|
||||
Rails.logger.info "=== CREATING NEW TICKET ==="
|
||||
Rails.logger.info "Preferences to be stored:"
|
||||
Rails.logger.info " - channel_id: #{channel.id}"
|
||||
Rails.logger.info " - cdr_signal: #{cdr_signal_prefs.inspect}"
|
||||
|
||||
ticket = Ticket.new(
|
||||
group_id: channel.group_id,
|
||||
title: title,
|
||||
customer_id: customer.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_signal: cdr_signal_prefs
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
ticket.save!
|
||||
|
||||
article_params = {
|
||||
from: sender_display,
|
||||
to: receiver_phone_number,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
subject: title,
|
||||
body: body,
|
||||
content_type: 'text/plain',
|
||||
message_id: "cdr_signal.#{message_id}",
|
||||
ticket_id: ticket.id,
|
||||
internal: params[:internal] == true,
|
||||
preferences: {
|
||||
cdr_signal: {
|
||||
timestamp: sent_at,
|
||||
message_id: message_id,
|
||||
from: sender_phone_number,
|
||||
user_id: sender_user_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if attachment_data_base64.present?
|
||||
article_params[:attachments] = [
|
||||
# i don't even...
|
||||
# this is necessary because of what's going on in controllers/concerns/creates_ticket_articles.rb
|
||||
# we need help from the ruby gods
|
||||
{
|
||||
'filename' => attachment_filename,
|
||||
:filename => attachment_filename,
|
||||
:data => attachment_data_base64,
|
||||
'data' => attachment_data_base64,
|
||||
'mime-type' => attachment_mimetype
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
ticket.with_lock do
|
||||
ta = article_create(ticket, article_params)
|
||||
ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id)
|
||||
end
|
||||
|
||||
ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id)
|
||||
|
||||
result = {
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
number: ticket.number
|
||||
}
|
||||
}
|
||||
|
||||
render json: result, status: :ok
|
||||
end
|
||||
|
||||
# Webhook endpoint for receiving group creation notifications from bridge-worker
|
||||
# This is called when a Signal group is created for a conversation
|
||||
# Expected payload:
|
||||
# {
|
||||
# "event": "group_created",
|
||||
# "conversation_id": "ticket_id_or_number",
|
||||
# "original_recipient": "+1234567890",
|
||||
# "group_id": "uuid-of-signal-group",
|
||||
# "timestamp": "ISO8601 timestamp"
|
||||
# }
|
||||
def update_group
|
||||
# Validate required parameters
|
||||
errors = {}
|
||||
errors['event'] = 'required' unless params[:event].present?
|
||||
errors['conversation_id'] = 'required' unless params[:conversation_id].present?
|
||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Only handle group_created events for now
|
||||
unless params[:event] == 'group_created'
|
||||
render json: { error: 'Unsupported event type' }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Find the ticket by ID or number
|
||||
# Try to find by both ID and number since ticket numbers can be numeric
|
||||
ticket = Ticket.find_by(id: params[:conversation_id]) ||
|
||||
Ticket.find_by(number: params[:conversation_id])
|
||||
|
||||
unless ticket
|
||||
Rails.logger.error "Signal group update: Ticket not found for conversation_id #{params[:conversation_id]}"
|
||||
render json: { error: 'Ticket not found' }, status: :not_found
|
||||
return
|
||||
end
|
||||
|
||||
# Idempotency check: if chat_id is already a group ID, don't overwrite it
|
||||
# This prevents race conditions where multiple group_created webhooks arrive
|
||||
# (e.g., due to retries after API timeouts during group creation)
|
||||
existing_chat_id = ticket.preferences&.dig(:cdr_signal, :chat_id) ||
|
||||
ticket.preferences&.dig('cdr_signal', 'chat_id')
|
||||
if existing_chat_id&.start_with?('group.')
|
||||
Rails.logger.info "Signal group update: Ticket #{ticket.id} already has group #{existing_chat_id}, ignoring new group #{params[:group_id]}"
|
||||
render json: {
|
||||
success: true,
|
||||
skipped: true,
|
||||
reason: 'Ticket already has a group assigned',
|
||||
existing_group_id: existing_chat_id,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.number
|
||||
}, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Update ticket preferences with the group information
|
||||
ticket.preferences ||= {}
|
||||
ticket.preferences[:cdr_signal] ||= {}
|
||||
ticket.preferences[:cdr_signal][:chat_id] = params[:group_id]
|
||||
ticket.preferences[:cdr_signal][:original_recipient] = params[:original_recipient] if params[:original_recipient].present?
|
||||
ticket.preferences[:cdr_signal][:group_created_at] = params[:timestamp] if params[:timestamp].present?
|
||||
|
||||
# Track whether user has joined the group (initially false)
|
||||
# This will be updated to true when we receive a group join event from Signal
|
||||
ticket.preferences[:cdr_signal][:group_joined] = params[:group_joined] if params.key?(:group_joined)
|
||||
|
||||
ticket.save!
|
||||
|
||||
Rails.logger.info "Signal group #{params[:group_id]} associated with ticket #{ticket.id}"
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.number
|
||||
}, status: :ok
|
||||
end
|
||||
|
||||
# Webhook endpoint for receiving group member joined notifications from bridge-worker
|
||||
# This is called when a user accepts the Signal group invitation
|
||||
# Expected payload:
|
||||
# {
|
||||
# "event": "group_member_joined",
|
||||
# "group_id": "group.base64encodedid",
|
||||
# "member_phone": "+1234567890",
|
||||
# "timestamp": "ISO8601 timestamp"
|
||||
# }
|
||||
def handle_group_member_joined
|
||||
# Validate required parameters
|
||||
errors = {}
|
||||
errors['event'] = 'required' unless params[:event].present?
|
||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||
errors['member_phone'] = 'required' unless params[:member_phone].present?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
# Find ticket(s) with this group_id in preferences
|
||||
# Use text search on preferences YAML for efficient lookup (prevents DoS from loading all tickets)
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
|
||||
ticket = Ticket.where.not(state_id: state_ids)
|
||||
.where("preferences LIKE ?", "%chat_id: #{params[:group_id]}%")
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
unless ticket
|
||||
Rails.logger.warn "Signal group member joined: Ticket not found for group_id #{params[:group_id]}"
|
||||
render json: { error: 'Ticket not found for this group' }, status: :not_found
|
||||
return
|
||||
end
|
||||
|
||||
# Idempotency check: if already marked as joined, skip update and return success
|
||||
# This prevents unnecessary database writes when the cron job sends duplicate notifications
|
||||
if ticket.preferences.dig('cdr_signal', 'group_joined') == true
|
||||
Rails.logger.debug "Signal group member #{params[:member_phone]} already marked as joined for group #{params[:group_id]} ticket #{ticket.id}, skipping update"
|
||||
render json: {
|
||||
success: true,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.number,
|
||||
group_joined: true,
|
||||
already_joined: true
|
||||
}, status: :ok
|
||||
return
|
||||
end
|
||||
|
||||
# Update group_joined flag
|
||||
member_phone = params[:member_phone]
|
||||
ticket.preferences[:cdr_signal][:group_joined] = true
|
||||
ticket.preferences[:cdr_signal][:group_joined_at] = params[:timestamp] if params[:timestamp].present?
|
||||
ticket.preferences[:cdr_signal][:group_joined_by] = member_phone
|
||||
|
||||
ticket.save!
|
||||
|
||||
Rails.logger.info "Signal group member #{member_phone} joined group #{params[:group_id]} for ticket #{ticket.id}"
|
||||
|
||||
# Check if any articles had a group_not_joined notification and add resolution note
|
||||
# Only add resolution note if we previously notified about the delivery issue
|
||||
articles_with_pending_notification = Ticket::Article.where(ticket_id: ticket.id)
|
||||
.where("preferences LIKE ?", "%group_not_joined_note_added: true%")
|
||||
|
||||
if articles_with_pending_notification.exists?
|
||||
# Check if we already added a resolution note for this ticket
|
||||
resolution_note_exists = Ticket::Article.where(ticket_id: ticket.id)
|
||||
.where("preferences LIKE ?", "%group_joined_resolution: true%")
|
||||
.exists?
|
||||
|
||||
unless resolution_note_exists
|
||||
Ticket::Article.create(
|
||||
ticket_id: ticket.id,
|
||||
content_type: 'text/plain',
|
||||
body: 'Recipient has now joined the Signal group. Pending messages will be delivered shortly.',
|
||||
internal: true,
|
||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||
preferences: {
|
||||
delivery_message: true,
|
||||
group_joined_resolution: true,
|
||||
},
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
Rails.logger.info "Ticket ##{ticket.number}: Added resolution note about customer joining Signal group"
|
||||
end
|
||||
end
|
||||
|
||||
render json: {
|
||||
success: true,
|
||||
ticket_id: ticket.id,
|
||||
ticket_number: ticket.number,
|
||||
group_joined: true
|
||||
}, status: :ok
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
# Copyright (C) 2012-2014 Zammad Foundation, http://zammad-foundation.org/
|
||||
|
||||
class ChannelsCdrVoiceController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }, except: [:webhook]
|
||||
skip_before_action :verify_csrf_token, only: [:webhook]
|
||||
|
||||
include CreatesTicketArticles
|
||||
|
||||
def index
|
||||
assets = {}
|
||||
channel_ids = []
|
||||
Channel.where(area: 'Voice::Number').order(:id).each do |channel|
|
||||
assets = channel.assets(assets)
|
||||
channel_ids.push channel.id
|
||||
end
|
||||
render json: {
|
||||
assets: assets,
|
||||
channel_ids: channel_ids
|
||||
}
|
||||
end
|
||||
|
||||
def add
|
||||
begin
|
||||
errors = {}
|
||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
channel = Channel.create(
|
||||
area: 'Voice::Number',
|
||||
options: {
|
||||
phone_number: params[:phone_number],
|
||||
token: SecureRandom.urlsafe_base64(48),
|
||||
organization_id: params[:organization_id]
|
||||
},
|
||||
group_id: params[:group_id],
|
||||
active: true
|
||||
)
|
||||
rescue StandardError => e
|
||||
raise Exceptions::UnprocessableEntity, e.message
|
||||
end
|
||||
render json: channel
|
||||
end
|
||||
|
||||
def update
|
||||
errors = {}
|
||||
errors['group_id'] = 'required' unless params[:group_id].present?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
channel = Channel.find_by(id: params[:id], area: 'Voice::Number')
|
||||
begin
|
||||
channel.options[:phone_number] = params[:phone_number]
|
||||
channel.options[:organization_id] = params[:organization_id]
|
||||
channel.group_id = params[:group_id]
|
||||
channel.save!
|
||||
rescue StandardError => e
|
||||
raise Exceptions::UnprocessableEntity, e.message
|
||||
end
|
||||
render json: channel
|
||||
end
|
||||
|
||||
def rotate_token
|
||||
channel = Channel.find_by(id: params[:id], area: 'Voice::Number')
|
||||
channel.options[:token] = SecureRandom.urlsafe_base64(48)
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def enable
|
||||
channel = Channel.find_by(id: params[:id], area: 'Voice::Number')
|
||||
channel.active = true
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def disable
|
||||
channel = Channel.find_by(id: params[:id], area: 'Voice::Number')
|
||||
channel.active = false
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def destroy
|
||||
channel = Channel.find_by(id: params[:id], area: 'Voice::Number')
|
||||
channel.destroy
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def channel_for_token(token)
|
||||
return false unless token
|
||||
|
||||
Channel.where(area: 'Voice::Number').each do |channel|
|
||||
return channel if channel.options[:token] == token
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def webhook
|
||||
token = params['token']
|
||||
return render json: {}, status: 401 unless token
|
||||
|
||||
channel = channel_for_token(token)
|
||||
return render json: {}, status: 401 if !channel || !channel.active
|
||||
return render json: {}, status: 401 if channel.options[:token] != token
|
||||
|
||||
channel_id = channel.id
|
||||
|
||||
# validate input
|
||||
errors = {}
|
||||
|
||||
%i[to
|
||||
from
|
||||
duration
|
||||
startTime
|
||||
endTime
|
||||
recording
|
||||
mimeType
|
||||
callSid].each do |field|
|
||||
errors[field] = 'required' if params[field].blank?
|
||||
end
|
||||
|
||||
valid_mimetypes = ['audio/mpeg']
|
||||
unless valid_mimetypes.include?(params[:mimeType])
|
||||
errors[:mimeType] = "invalid. must be one of #{valid_mimetypes.join(',')}"
|
||||
end
|
||||
|
||||
receiver_phone_number = params[:to]
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
caller_phone_number = params[:from].strip
|
||||
|
||||
customer = User.find_by(phone: caller_phone_number)
|
||||
customer ||= User.find_by(mobile: caller_phone_number)
|
||||
unless customer
|
||||
role_ids = Role.signup_role_ids
|
||||
customer = User.create(
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phone: caller_phone_number,
|
||||
active: true,
|
||||
role_ids: role_ids,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
end
|
||||
|
||||
# set current user
|
||||
UserInfo.current_user_id = customer.id
|
||||
current_user_set(customer, 'token_auth')
|
||||
|
||||
group = Group.find_by(id: channel.group_id)
|
||||
unless group.present?
|
||||
Rails.logger.error "Voice channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!"
|
||||
return render json: { error: 'There was an error during voice submission' }, status: 500
|
||||
end
|
||||
|
||||
organization_id = channel.options['organization_id']
|
||||
if organization_id.present?
|
||||
organization = Organization.find_by(id: organization_id)
|
||||
unless organization.present?
|
||||
Rails.logger.error "Voice channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!"
|
||||
return render json: { error: 'There was an error during voice submission' }, status: 500
|
||||
end
|
||||
unless customer.organization_id.present?
|
||||
customer.organization_id = organization.id
|
||||
customer.save!
|
||||
end
|
||||
end
|
||||
|
||||
call_id = params[:calLSid]
|
||||
duration = params[:duration]
|
||||
start_time = params[:startTime]
|
||||
end_time = params[:endTime]
|
||||
recording_data_base64 = params[:recording]
|
||||
recording_filename = "phone-call-#{start_time}-#{call_id}.mp3"
|
||||
recording_mimetype = params[:mimeType]
|
||||
|
||||
title = "Call from #{caller_phone_number} at #{start_time}"
|
||||
body = %(
|
||||
<ul>
|
||||
<li>Caller: #{caller_phone_number}</li>
|
||||
<li>Service Number: #{receiver_phone_number}</li>
|
||||
<li>Call Duration: #{duration} seconds</li>
|
||||
<li>Start Time: #{start_time}</li>
|
||||
<li>End Time: #{end_time}</li>
|
||||
</ul>
|
||||
<p>See the attached recording.</p>
|
||||
)
|
||||
|
||||
ticket_params = {
|
||||
group_id: group.id,
|
||||
customer_id: customer.id,
|
||||
title: title,
|
||||
preferences: {},
|
||||
note: 'This ticket was created from a recorded voice message.'
|
||||
}
|
||||
|
||||
article_params = {
|
||||
sender: 'Customer',
|
||||
subject: title,
|
||||
body: body,
|
||||
content_type: 'text/html',
|
||||
type: 'note',
|
||||
attachments: [
|
||||
# i don't even...
|
||||
# this is necessary because of what's going on in controllers/concerns/creates_ticket_articles.rb
|
||||
# we need help from the ruby gods
|
||||
{
|
||||
'filename' => recording_filename,
|
||||
:filename => recording_filename,
|
||||
:data => recording_data_base64,
|
||||
'data' => recording_data_base64,
|
||||
'mime-type' => recording_mimetype
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
clean_params = Ticket.param_cleanup(ticket_params, true)
|
||||
ticket = Ticket.new(clean_params)
|
||||
|
||||
ticket.save!
|
||||
ticket.with_lock do
|
||||
article_params[:sender] = 'Customer'
|
||||
article_create(ticket, article_params)
|
||||
end
|
||||
|
||||
result = {
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
number: ticket.number
|
||||
}
|
||||
}
|
||||
|
||||
render json: result, status: :ok
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,339 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ChannelsCdrWhatsappController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }, except: [:webhook, :bot_webhook]
|
||||
skip_before_action :verify_csrf_token, only: [:webhook, :bot_webhook]
|
||||
|
||||
include CreatesTicketArticles
|
||||
|
||||
def index
|
||||
assets = {}
|
||||
channel_ids = []
|
||||
Channel.where(area: 'Whatsapp::Number').order(:id).each do |channel|
|
||||
assets = channel.assets(assets)
|
||||
channel_ids.push channel.id
|
||||
end
|
||||
render json: {
|
||||
assets: assets,
|
||||
channel_ids: channel_ids
|
||||
}
|
||||
end
|
||||
|
||||
def add
|
||||
begin
|
||||
errors = {}
|
||||
errors['group_id'] = 'required' if params[:group_id].blank?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
channel = Channel.create(
|
||||
area: 'Whatsapp::Number',
|
||||
options: {
|
||||
adapter: 'cdr_whatsapp',
|
||||
phone_number: params[:phone_number],
|
||||
bot_token: params[:bot_token],
|
||||
bot_endpoint: params[:bot_endpoint],
|
||||
token: SecureRandom.urlsafe_base64(48),
|
||||
organization_id: params[:organization_id]
|
||||
},
|
||||
group_id: params[:group_id],
|
||||
active: true
|
||||
)
|
||||
rescue StandardError => e
|
||||
raise Exceptions::UnprocessableEntity, e.message
|
||||
end
|
||||
render json: channel
|
||||
end
|
||||
|
||||
def update
|
||||
errors = {}
|
||||
errors['group_id'] = 'required' if params[:group_id].blank?
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number')
|
||||
begin
|
||||
channel.options[:phone_number] = params[:phone_number]
|
||||
channel.options[:bot_token] = params[:bot_token]
|
||||
channel.options[:bot_endpoint] = params[:bot_endpoint]
|
||||
channel.options[:organization_id] = params[:organization_id]
|
||||
channel.group_id = params[:group_id]
|
||||
channel.save!
|
||||
rescue StandardError => e
|
||||
raise Exceptions::UnprocessableEntity, e.message
|
||||
end
|
||||
render json: channel
|
||||
end
|
||||
|
||||
def rotate_token
|
||||
channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number')
|
||||
channel.options[:token] = SecureRandom.urlsafe_base64(48)
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def enable
|
||||
channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number')
|
||||
channel.active = true
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def disable
|
||||
channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number')
|
||||
channel.active = false
|
||||
channel.save!
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def destroy
|
||||
channel = Channel.find_by(id: params[:id], area: 'Whatsapp::Number')
|
||||
channel.destroy
|
||||
render json: {}
|
||||
end
|
||||
|
||||
def channel_for_token(token)
|
||||
return false unless token
|
||||
|
||||
Channel.where(area: 'Whatsapp::Number').each do |channel|
|
||||
return channel if channel.options[:token] == token
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def channel_for_bot_token(bot_token)
|
||||
return false unless bot_token
|
||||
|
||||
Channel.where(area: 'Whatsapp::Number').each do |channel|
|
||||
return channel if channel.options[:bot_token] == bot_token
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
def bot_webhook
|
||||
bot_token = params['bot_token']
|
||||
return render json: {}, status: :unauthorized unless bot_token
|
||||
|
||||
channel = channel_for_bot_token(bot_token)
|
||||
return render json: { error: 'Channel not found' }, status: :not_found if !channel || !channel.active
|
||||
|
||||
# Normalize parameter names from bridge-whatsapp (camelCase) to Zammad (snake_case)
|
||||
normalized_params = {
|
||||
to: params[:to],
|
||||
from: params[:from],
|
||||
user_id: params[:userId] || params[:user_id],
|
||||
message_id: params[:messageId] || params[:message_id],
|
||||
sent_at: params[:sentAt] || params[:sent_at],
|
||||
message: params[:message],
|
||||
attachment: params[:attachment],
|
||||
filename: params[:filename],
|
||||
mime_type: params[:mimeType] || params[:mime_type]
|
||||
}
|
||||
|
||||
# Use the channel's webhook token to reuse existing logic
|
||||
params[:token] = channel.options[:token]
|
||||
normalized_params.each { |k, v| params[k] = v if v.present? }
|
||||
|
||||
webhook
|
||||
end
|
||||
|
||||
def webhook
|
||||
token = params['token']
|
||||
return render json: {}, status: :unauthorized unless token
|
||||
|
||||
channel = channel_for_token(token)
|
||||
return render json: {}, status: :unauthorized if !channel || !channel.active
|
||||
return render json: {}, status: :unauthorized if channel.options[:token] != token
|
||||
|
||||
channel_id = channel.id
|
||||
|
||||
# validate input
|
||||
errors = {}
|
||||
|
||||
%i[to
|
||||
message_id
|
||||
sent_at].each do |field|
|
||||
errors[field] = 'required' if params[field].blank?
|
||||
end
|
||||
|
||||
# At least one of from (phone) or user_id must be present
|
||||
if params[:from].blank? && params[:user_id].blank?
|
||||
errors[:from] = 'required (or user_id)'
|
||||
end
|
||||
|
||||
if errors.present?
|
||||
render json: {
|
||||
errors: errors
|
||||
}, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
message_id = params[:message_id]
|
||||
|
||||
return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}")
|
||||
|
||||
receiver_phone_number = params[:to].strip
|
||||
sender_phone_number = params[:from].present? ? params[:from].strip : nil
|
||||
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
|
||||
|
||||
# Lookup customer with fallback chain:
|
||||
# 1. Phone number in phone/mobile fields (preferred)
|
||||
# 2. WhatsApp user ID in whatsapp_uid field
|
||||
# 3. User ID in phone/mobile fields (legacy - we used to store LIDs there)
|
||||
customer = nil
|
||||
if sender_phone_number.present?
|
||||
customer = User.find_by(phone: sender_phone_number)
|
||||
customer ||= User.find_by(mobile: sender_phone_number)
|
||||
end
|
||||
if customer.nil? && sender_user_id.present?
|
||||
customer = User.find_by(whatsapp_uid: sender_user_id)
|
||||
# Legacy fallback: user ID might be stored in phone field
|
||||
customer ||= User.find_by(phone: sender_user_id)
|
||||
customer ||= User.find_by(mobile: sender_user_id)
|
||||
end
|
||||
|
||||
unless customer
|
||||
role_ids = Role.signup_role_ids
|
||||
customer = User.create(
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phone: sender_phone_number.presence || sender_user_id,
|
||||
whatsapp_uid: sender_user_id,
|
||||
note: 'CDR Whatsapp',
|
||||
active: true,
|
||||
role_ids: role_ids,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
end
|
||||
|
||||
# Update whatsapp_uid if we have it and customer doesn't
|
||||
if sender_user_id.present? && customer.whatsapp_uid.blank?
|
||||
customer.update(whatsapp_uid: sender_user_id)
|
||||
end
|
||||
# Update phone if we have it and customer only has user_id in phone field
|
||||
if sender_phone_number.present? && customer.phone == sender_user_id
|
||||
customer.update(phone: sender_phone_number)
|
||||
end
|
||||
|
||||
# set current user
|
||||
UserInfo.current_user_id = customer.id
|
||||
current_user_set(customer, 'token_auth')
|
||||
|
||||
group = Group.find_by(id: channel.group_id)
|
||||
if group.blank?
|
||||
Rails.logger.error "Whatsapp channel #{channel_id} paired with Group #{channel.group_id}, but group does not exist!"
|
||||
return render json: { error: 'There was an error during Whatsapp submission' }, status: :internal_server_error
|
||||
end
|
||||
|
||||
organization_id = channel.options['organization_id']
|
||||
if organization_id.present?
|
||||
organization = Organization.find_by(id: organization_id)
|
||||
if organization.blank?
|
||||
Rails.logger.error "Whatsapp channel #{channel_id} paired with Organization #{organization_id}, but organization does not exist!"
|
||||
return render json: { error: 'There was an error during Whatsapp submission' }, status: :internal_server_error
|
||||
end
|
||||
if customer.organization_id.blank?
|
||||
customer.organization_id = organization.id
|
||||
customer.save!
|
||||
end
|
||||
end
|
||||
|
||||
message = params[:message] ||= 'No text content'
|
||||
sent_at = params[:sent_at]
|
||||
attachment_data_base64 = params[:attachment]
|
||||
attachment_filename = params[:filename]
|
||||
attachment_mimetype = params[:mime_type]
|
||||
sender_display = sender_phone_number.presence || sender_user_id
|
||||
title = "Message from #{sender_display} at #{sent_at}"
|
||||
body = message
|
||||
|
||||
# find ticket or create one
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
ticket = Ticket.where(customer_id: customer.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||
if ticket
|
||||
# check if title need to be updated
|
||||
ticket.title = title if ticket.title == '-'
|
||||
new_state = Ticket::State.find_by(default_create: true)
|
||||
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||
else
|
||||
ticket = Ticket.new(
|
||||
group_id: channel.group_id,
|
||||
title: title,
|
||||
customer_id: customer.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_whatsapp: {
|
||||
bot_token: channel.options[:bot_token],
|
||||
chat_id: sender_phone_number.presence || sender_user_id,
|
||||
user_id: sender_user_id
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
ticket.save!
|
||||
|
||||
article_params = {
|
||||
from: sender_display,
|
||||
to: receiver_phone_number,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
subject: title,
|
||||
body: body,
|
||||
content_type: 'text/plain',
|
||||
message_id: "cdr_whatsapp.#{message_id}",
|
||||
ticket_id: ticket.id,
|
||||
internal: false,
|
||||
preferences: {
|
||||
cdr_whatsapp: {
|
||||
timestamp: sent_at,
|
||||
message_id: message_id,
|
||||
from: sender_phone_number,
|
||||
user_id: sender_user_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if attachment_data_base64.present?
|
||||
article_params[:attachments] = [
|
||||
# i don't even...
|
||||
# this is necessary because of what's going on in controllers/concerns/creates_ticket_articles.rb
|
||||
# we need help from the ruby gods
|
||||
{
|
||||
'filename' => attachment_filename,
|
||||
:filename => attachment_filename,
|
||||
:data => attachment_data_base64,
|
||||
'data' => attachment_data_base64,
|
||||
'mime-type' => attachment_mimetype
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
# setting the article type after saving seems to be the only way to get it to stick
|
||||
ticket.with_lock do
|
||||
ta = article_create(ticket, article_params)
|
||||
ta.update!(type_id: Ticket::Article::Type.find_by(name: 'cdr_whatsapp').id)
|
||||
end
|
||||
|
||||
ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_whatsapp').id)
|
||||
|
||||
result = {
|
||||
ticket: {
|
||||
id: ticket.id,
|
||||
number: ticket.number
|
||||
}
|
||||
}
|
||||
|
||||
render json: result, status: :ok
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# FormstackController handles webhook requests from Formstack form submissions.
|
||||
# It creates tickets in Zammad with support for Signal channel integration.
|
||||
#
|
||||
# Replaces bridge-worker formstack/create-ticket-from-form.ts
|
||||
|
||||
class FormstackController < ApplicationController
|
||||
skip_before_action :verify_csrf_token
|
||||
skip_before_action :authenticate_with_current_session
|
||||
skip_before_action :authenticate_with_token
|
||||
|
||||
# POST /api/v1/formstack/webhook
|
||||
def webhook
|
||||
# Validate handshake key
|
||||
handshake_key = Setting.get('formstack_handshake_key')
|
||||
provided_key = params[:HandshakeKey] || request.headers['X-Formstack-Handshake-Key']
|
||||
|
||||
unless handshake_key.present? && provided_key.present? &&
|
||||
ActiveSupport::SecurityUtils.secure_compare(handshake_key.to_s, provided_key.to_s)
|
||||
Rails.logger.warn 'Formstack: Invalid or missing handshake key'
|
||||
render json: { error: 'Unauthorized' }, status: :unauthorized
|
||||
return
|
||||
end
|
||||
|
||||
# Parse form data
|
||||
form_data = params.to_unsafe_h.except(:controller, :action, :HandshakeKey)
|
||||
|
||||
Rails.logger.info "Formstack: Received form submission with #{form_data.keys.length} fields"
|
||||
|
||||
# Queue the job to create the ticket
|
||||
CreateTicketFromFormJob.perform_later(form_data.to_json, Time.current.iso8601)
|
||||
|
||||
render json: { success: true, message: 'Form submission received' }, status: :ok
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Formstack: Error processing webhook: #{e.message}"
|
||||
render json: { error: 'Internal server error' }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# OpenSearchController provides access to OpenSearch Dashboards URL and status.
|
||||
# This allows the frontend to link directly to OpenSearch Dashboards for analytics.
|
||||
|
||||
class OpensearchController < ApplicationController
|
||||
prepend_before_action -> { authentication_check && authorize! }
|
||||
|
||||
# GET /api/v1/opensearch
|
||||
def index
|
||||
enabled = Setting.get('opensearch_dashboards_enabled') == true
|
||||
url = Setting.get('opensearch_dashboards_url')
|
||||
|
||||
render json: {
|
||||
enabled: enabled,
|
||||
url: enabled ? url : nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useActiveElement, useLocalStorage, useWindowSize } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch, type MaybeRef } from 'vue'
|
||||
|
||||
import type { TicketById } from '#shared/entities/ticket/types'
|
||||
import type { AppSpecificTicketArticleType } from '#shared/entities/ticket-article/action/plugins/types.ts'
|
||||
import { useApplicationStore } from '#shared/stores/application.ts'
|
||||
import { useSessionStore } from '#shared/stores/session.ts'
|
||||
import type { ButtonVariant } from '#shared/types/button.ts'
|
||||
|
||||
import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
|
||||
import ResizeLine from '#desktop/components/ResizeLine/ResizeLine.vue'
|
||||
import { useResizeLine } from '#desktop/components/ResizeLine/useResizeLine.ts'
|
||||
import { useElementScroll } from '#desktop/composables/useElementScroll.ts'
|
||||
|
||||
interface Props {
|
||||
ticket: TicketById
|
||||
newArticlePresent?: boolean
|
||||
createArticleType?: string | null
|
||||
ticketArticleTypes: AppSpecificTicketArticleType[]
|
||||
isTicketCustomer?: boolean
|
||||
hasInternalArticle?: boolean
|
||||
parentReachedBottomScroll: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
'show-article-form': [
|
||||
articleType: string,
|
||||
performReply: AppSpecificTicketArticleType['performReply'],
|
||||
]
|
||||
'discard-form': []
|
||||
}>()
|
||||
|
||||
const currentTicketArticleType = computed(() => {
|
||||
if (props.isTicketCustomer) return 'web'
|
||||
if (props.createArticleType && ['phone', 'web'].includes(props.createArticleType)) {
|
||||
return 'email'
|
||||
}
|
||||
|
||||
return props.createArticleType
|
||||
})
|
||||
|
||||
const allowedArticleTypes = computed(() => {
|
||||
return ['note', 'phone', currentTicketArticleType.value]
|
||||
})
|
||||
|
||||
const availableArticleTypes = computed(() => {
|
||||
// Get the channels that would normally be available
|
||||
let availableArticleTypes = props.ticketArticleTypes.filter((type) =>
|
||||
allowedArticleTypes.value.includes(type.value),
|
||||
)
|
||||
|
||||
// Check for CDR Link channel whitelist
|
||||
const application = useApplicationStore()
|
||||
const cdrAllowedChannels = application.config.cdr_link_allowed_channels as string | undefined
|
||||
|
||||
if (cdrAllowedChannels && cdrAllowedChannels.trim()) {
|
||||
// Parse the whitelist
|
||||
const whitelist = cdrAllowedChannels.split(',').map(c => c.trim())
|
||||
|
||||
// Filter to only channels in the whitelist
|
||||
availableArticleTypes = availableArticleTypes.filter(type => whitelist.includes(type.value))
|
||||
}
|
||||
|
||||
const hasEmail = availableArticleTypes.some((type) => type.value === 'email')
|
||||
|
||||
let primaryTicketArticleType = currentTicketArticleType.value
|
||||
if (availableArticleTypes.length === 2) {
|
||||
primaryTicketArticleType = props.createArticleType
|
||||
}
|
||||
|
||||
return availableArticleTypes.map((type) => {
|
||||
return {
|
||||
articleType: type.value,
|
||||
label:
|
||||
primaryTicketArticleType === type.value && hasEmail ? __('Add reply') : type.buttonLabel,
|
||||
icon: type.icon,
|
||||
variant:
|
||||
primaryTicketArticleType === type.value ||
|
||||
(type.value === 'phone' && !hasEmail && availableArticleTypes.length === 2)
|
||||
? 'primary'
|
||||
: 'secondary',
|
||||
performReply: (() =>
|
||||
type.performReply?.(props.ticket)) as AppSpecificTicketArticleType['performReply'],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const pinned = defineModel<boolean>('pinned')
|
||||
|
||||
const togglePinned = () => {
|
||||
pinned.value = !pinned.value
|
||||
}
|
||||
|
||||
const articlePanel = ref<HTMLElement>()
|
||||
|
||||
// Scroll the new article panel into view whenever:
|
||||
// - an article is being added
|
||||
// - the panel is being unpinned
|
||||
watch(
|
||||
() => [props.newArticlePresent, pinned.value],
|
||||
([newArticlePresent, newPinned]) => {
|
||||
if (!newArticlePresent || newPinned) return
|
||||
|
||||
nextTick(() => {
|
||||
// NB: Give editor a chance to initialize its height.
|
||||
setTimeout(() => {
|
||||
articlePanel.value?.scrollIntoView?.(true)
|
||||
}, 300)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// Reset the pinned state whenever the article is removed.
|
||||
watch(
|
||||
() => props.newArticlePresent,
|
||||
(newArticlePresent) => {
|
||||
if (newArticlePresent) return
|
||||
|
||||
pinned.value = false
|
||||
},
|
||||
)
|
||||
|
||||
const DEFAULT_ARTICLE_PANEL_HEIGHT = 290
|
||||
const MINIMUM_ARTICLE_PANEL_HEIGHT = 150
|
||||
|
||||
const { userId } = useSessionStore()
|
||||
|
||||
const articlePanelHeight = useLocalStorage(
|
||||
`${userId}-article-reply-height`,
|
||||
DEFAULT_ARTICLE_PANEL_HEIGHT,
|
||||
)
|
||||
|
||||
const { height: screenHeight } = useWindowSize()
|
||||
|
||||
const articlePanelMaxHeight = computed(() => screenHeight.value / 2)
|
||||
|
||||
const resizeLine = ref<InstanceType<typeof ResizeLine>>()
|
||||
|
||||
const resizeCallback = (valueY: number) => {
|
||||
if (valueY >= articlePanelMaxHeight.value || valueY < MINIMUM_ARTICLE_PANEL_HEIGHT) return
|
||||
|
||||
articlePanelHeight.value = valueY
|
||||
}
|
||||
|
||||
// a11y keyboard navigation
|
||||
const activeElement = useActiveElement()
|
||||
|
||||
const handleKeyStroke = (e: KeyboardEvent, adjustment: number) => {
|
||||
if (!articlePanelHeight.value || activeElement.value !== resizeLine.value?.resizeLine) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const newHeight = articlePanelHeight.value + adjustment
|
||||
|
||||
if (newHeight >= articlePanelMaxHeight.value) return
|
||||
|
||||
resizeCallback(newHeight)
|
||||
}
|
||||
|
||||
const { startResizing } = useResizeLine(
|
||||
resizeCallback,
|
||||
resizeLine.value?.resizeLine,
|
||||
handleKeyStroke,
|
||||
{ orientation: 'horizontal', offsetThreshold: 56 }, // bottom bar height in px
|
||||
)
|
||||
|
||||
const resetHeight = () => {
|
||||
articlePanelHeight.value = DEFAULT_ARTICLE_PANEL_HEIGHT
|
||||
}
|
||||
|
||||
const articleForm = ref<HTMLElement>()
|
||||
|
||||
const { reachedTop: articleFormReachedTop } = useElementScroll(articleForm as MaybeRef<HTMLElement>)
|
||||
|
||||
defineExpose({
|
||||
articlePanel,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="newArticlePresent"
|
||||
ref="articlePanel"
|
||||
class="relative mx-auto flex w-full flex-col"
|
||||
:class="{
|
||||
'max-w-6xl px-12 py-4': !pinned,
|
||||
'sticky bottom-0 z-20 overflow-hidden border-t border-t-neutral-300 bg-neutral-50 dark:border-t-gray-900 dark:bg-gray-500':
|
||||
pinned,
|
||||
}"
|
||||
:style="{
|
||||
height: pinned ? `${articlePanelHeight}px` : 'auto',
|
||||
}"
|
||||
aria-labelledby="article-reply-form-title"
|
||||
role="complementary"
|
||||
:aria-expanded="!pinned"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<ResizeLine
|
||||
v-if="pinned"
|
||||
ref="resizeLine"
|
||||
class="group absolute top-0 z-10 h-3 w-full"
|
||||
:label="$t('Resize article panel')"
|
||||
orientation="horizontal"
|
||||
:values="{
|
||||
max: articlePanelMaxHeight,
|
||||
min: MINIMUM_ARTICLE_PANEL_HEIGHT,
|
||||
current: articlePanelHeight,
|
||||
}"
|
||||
@mousedown-event="startResizing"
|
||||
@touchstart-event="startResizing"
|
||||
@dblclick="resetHeight"
|
||||
/>
|
||||
<div
|
||||
class="flex h-full grow flex-col"
|
||||
data-test-id="article-reply-stripes-panel"
|
||||
:class="{
|
||||
'bg-stripes relative z-0 rounded-xl outline-1 outline-blue-700 before:rounded-2xl':
|
||||
hasInternalArticle && !pinned,
|
||||
'border-stripes': hasInternalArticle && pinned,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="isolate flex h-full grow flex-col"
|
||||
:class="{
|
||||
'rounded-xl border border-neutral-300 bg-neutral-50 dark:border-gray-900 dark:bg-gray-500':
|
||||
!pinned,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="flex h-10 items-center p-3"
|
||||
:class="{
|
||||
'bg-neutral-50 dark:bg-gray-500': pinned,
|
||||
'border-b border-b-transparent': pinned && articleFormReachedTop,
|
||||
'border-b border-b-neutral-300 dark:border-b-gray-900':
|
||||
pinned && !articleFormReachedTop,
|
||||
}"
|
||||
>
|
||||
<CommonLabel
|
||||
id="article-reply-form-title"
|
||||
class="text-stone-200 ltr:mr-auto rtl:ml-auto dark:text-neutral-500"
|
||||
tag="h2"
|
||||
size="small"
|
||||
>
|
||||
{{ $t('Reply') }}
|
||||
</CommonLabel>
|
||||
<CommonButton
|
||||
v-tooltip="$t('Discard unsaved reply')"
|
||||
class="text-red-500 ltr:mr-2 rtl:ml-2"
|
||||
variant="none"
|
||||
icon="trash"
|
||||
@click="$emit('discard-form')"
|
||||
/>
|
||||
<CommonButton
|
||||
v-tooltip="pinned ? $t('Unpin this panel') : $t('Pin this panel')"
|
||||
:icon="pinned ? 'pin' : 'pin-angle'"
|
||||
variant="neutral"
|
||||
size="small"
|
||||
@click="togglePinned"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="ticketArticleReplyForm"
|
||||
ref="articleForm"
|
||||
class="grow px-3 pb-3"
|
||||
:class="{
|
||||
'overflow-y-auto': pinned,
|
||||
'my-[5px] px-4 pt-2': hasInternalArticle && pinned,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="newArticlePresent !== undefined"
|
||||
class="sticky bottom-0 z-20 flex w-full justify-center gap-2.5 border-t py-1.5"
|
||||
:class="{
|
||||
'border-t-neutral-100 bg-neutral-50 dark:border-t-gray-900 dark:bg-gray-500':
|
||||
parentReachedBottomScroll,
|
||||
'border-t-transparent': !parentReachedBottomScroll,
|
||||
}"
|
||||
>
|
||||
<CommonButton
|
||||
v-for="button in availableArticleTypes"
|
||||
:key="button.articleType"
|
||||
:prefix-icon="button.icon"
|
||||
:variant="button.variant as ButtonVariant"
|
||||
size="large"
|
||||
@click="$emit('show-article-form', button.articleType, button.performReply)"
|
||||
>
|
||||
{{ $t(button.label) }}
|
||||
</CommonButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.border-stripes {
|
||||
position: relative;
|
||||
z-index: -10;
|
||||
background-color: var(--color-neutral-50);
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 40px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
border: 5px solid transparent;
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--color-blue-400),
|
||||
var(--color-blue-400) 5px,
|
||||
var(--color-blue-700) 5px,
|
||||
var(--color-blue-700) 10px
|
||||
);
|
||||
background-position: -1px;
|
||||
background-attachment: fixed;
|
||||
mask:
|
||||
linear-gradient(white, white) padding-box,
|
||||
linear-gradient(white, white);
|
||||
mask-composite: exclude;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 40px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
outline: 1px solid var(--color-blue-700);
|
||||
outline-offset: -5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme='dark'] .border-stripes {
|
||||
background-color: var(--color-gray-500);
|
||||
|
||||
&::before {
|
||||
background-image: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--color-blue-700),
|
||||
var(--color-blue-700) 5px,
|
||||
var(--color-blue-900) 5px,
|
||||
var(--color-blue-900) 10px
|
||||
);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { ChannelModule } from "#desktop/pages/ticket/components/TicketDetailView/article-type/types.ts";
|
||||
|
||||
export default <ChannelModule>{
|
||||
name: "signal message",
|
||||
label: __("Signal Message"),
|
||||
icon: "cdr-signal",
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { ChannelModule } from "#desktop/pages/ticket/components/TicketDetailView/article-type/types.ts";
|
||||
|
||||
export default <ChannelModule>{
|
||||
name: "whatsapp message",
|
||||
label: __("WhatsApp Message"),
|
||||
icon: "whatsapp",
|
||||
};
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { EnumTicketArticleSenderName } from '#shared/graphql/types.ts'
|
||||
|
||||
import type { TicketArticleAction, TicketArticleActionPlugin, TicketArticleType } from './types.ts'
|
||||
|
||||
const actionPlugin: TicketArticleActionPlugin = {
|
||||
order: 350,
|
||||
|
||||
addActions(ticket, article) {
|
||||
const sender = article.sender?.name
|
||||
const type = article.type?.name
|
||||
|
||||
if (sender !== EnumTicketArticleSenderName.Customer || type !== 'signal message')
|
||||
return []
|
||||
|
||||
const action: TicketArticleAction = {
|
||||
apps: ['mobile', 'desktop'],
|
||||
label: __('Reply'),
|
||||
name: 'signal message',
|
||||
icon: 'cdr-signal',
|
||||
view: {
|
||||
agent: ['change'],
|
||||
},
|
||||
perform(ticket, article, { openReplyForm }) {
|
||||
const articleData = {
|
||||
articleType: type,
|
||||
inReplyTo: article.messageId,
|
||||
}
|
||||
|
||||
openReplyForm(articleData)
|
||||
},
|
||||
}
|
||||
return [action]
|
||||
},
|
||||
|
||||
addTypes(ticket) {
|
||||
const descriptionType = ticket.createArticleType?.name
|
||||
|
||||
if (descriptionType !== 'signal message') return []
|
||||
|
||||
const type: TicketArticleType = {
|
||||
apps: ['mobile', 'desktop'],
|
||||
value: 'signal message',
|
||||
label: __('Signal'),
|
||||
buttonLabel: __('Add Signal message'),
|
||||
icon: 'cdr-signal',
|
||||
view: {
|
||||
agent: ['change'],
|
||||
},
|
||||
internal: false,
|
||||
contentType: 'text/plain',
|
||||
fields: {
|
||||
body: {
|
||||
required: true,
|
||||
validation: 'length:1,10000',
|
||||
},
|
||||
attachments: {},
|
||||
},
|
||||
editorMeta: {
|
||||
footer: {
|
||||
maxlength: 10000,
|
||||
warningLength: 5000,
|
||||
},
|
||||
},
|
||||
}
|
||||
return [type]
|
||||
},
|
||||
}
|
||||
|
||||
export default actionPlugin
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { EnumTicketArticleSenderName } from "#shared/graphql/types.ts";
|
||||
|
||||
import type {
|
||||
TicketArticleAction,
|
||||
TicketArticleActionPlugin,
|
||||
TicketArticleType,
|
||||
} from "./types.ts";
|
||||
|
||||
const actionPlugin: TicketArticleActionPlugin = {
|
||||
order: 360,
|
||||
|
||||
addActions(ticket, article) {
|
||||
const sender = article.sender?.name;
|
||||
const type = article.type?.name;
|
||||
|
||||
if (
|
||||
sender !== EnumTicketArticleSenderName.Customer ||
|
||||
type !== "whatsapp message"
|
||||
)
|
||||
return [];
|
||||
|
||||
const action: TicketArticleAction = {
|
||||
apps: ["mobile", "desktop"],
|
||||
label: __("Reply"),
|
||||
name: "whatsapp message",
|
||||
icon: "cdr-whatsapp",
|
||||
view: {
|
||||
agent: ["change"],
|
||||
},
|
||||
perform(ticket, article, { openReplyForm }) {
|
||||
const articleData = {
|
||||
articleType: type,
|
||||
inReplyTo: article.messageId,
|
||||
};
|
||||
|
||||
openReplyForm(articleData);
|
||||
},
|
||||
};
|
||||
return [action];
|
||||
},
|
||||
|
||||
addTypes(ticket) {
|
||||
const descriptionType = ticket.createArticleType?.name;
|
||||
|
||||
if (descriptionType !== "whatsapp message") return [];
|
||||
|
||||
const type: TicketArticleType = {
|
||||
apps: ["mobile", "desktop"],
|
||||
value: "whatsapp message",
|
||||
label: __("WhatsApp"),
|
||||
buttonLabel: __("Add WhatsApp message"),
|
||||
icon: "cdr-whatsapp",
|
||||
view: {
|
||||
agent: ["change"],
|
||||
},
|
||||
internal: false,
|
||||
contentType: "text/plain",
|
||||
fields: {
|
||||
body: {
|
||||
required: true,
|
||||
validation: "length:1,4096",
|
||||
},
|
||||
attachments: {},
|
||||
},
|
||||
editorMeta: {
|
||||
footer: {
|
||||
maxlength: 4096,
|
||||
warningLength: 3000,
|
||||
},
|
||||
},
|
||||
};
|
||||
return [type];
|
||||
},
|
||||
};
|
||||
|
||||
export default actionPlugin;
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CommunicateCdrSignalJob < ApplicationJob
|
||||
retry_on StandardError, attempts: 4, wait: lambda { |executions|
|
||||
executions * 120.seconds
|
||||
}
|
||||
|
||||
def perform(article_id)
|
||||
article = Ticket::Article.find(article_id)
|
||||
|
||||
# set retry count
|
||||
article.preferences['delivery_retry'] ||= 0
|
||||
article.preferences['delivery_retry'] += 1
|
||||
|
||||
ticket = Ticket.lookup(id: article.ticket_id)
|
||||
unless ticket.preferences
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
unless ticket.preferences['cdr_signal']
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences['cdr_signal'] for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
unless ticket.preferences['cdr_signal']['bot_token']
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences['cdr_signal']['bot_token'] for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
# Only require chat_id if auto-groups is not enabled
|
||||
if ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase != 'true' && ticket.preferences['cdr_signal']['chat_id'].blank?
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences['cdr_signal']['chat_id'] for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
|
||||
# Check if this is a group chat and if the user has joined
|
||||
chat_id = ticket.preferences['cdr_signal']['chat_id']
|
||||
is_group_chat = chat_id&.start_with?('group.')
|
||||
group_joined = ticket.preferences.dig('cdr_signal', 'group_joined')
|
||||
|
||||
# If this is a group chat and user hasn't joined yet, don't send the message
|
||||
if is_group_chat && group_joined == false
|
||||
Rails.logger.info "Ticket ##{ticket.number}: User hasn't joined Signal group yet, skipping message delivery"
|
||||
|
||||
# Track group_not_joined retry attempts separately
|
||||
article.preferences['group_not_joined_retry'] ||= 0
|
||||
article.preferences['group_not_joined_retry'] += 1
|
||||
|
||||
# Mark article as pending delivery
|
||||
article.preferences['delivery_status'] = 'pending'
|
||||
article.preferences['delivery_status_message'] = 'Waiting for user to join Signal group'
|
||||
article.preferences['delivery_status_date'] = Time.zone.now
|
||||
|
||||
# After 3 failed attempts, add a note to inform the agent (only once)
|
||||
if article.preferences['group_not_joined_retry'] == 3 && !article.preferences['group_not_joined_note_added']
|
||||
Ticket::Article.create(
|
||||
ticket_id: ticket.id,
|
||||
content_type: 'text/plain',
|
||||
body: 'Unable to send Signal message: Recipient has not yet joined the Signal group. ' \
|
||||
'The message will be delivered automatically once they accept the group invitation.',
|
||||
internal: true,
|
||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||
preferences: {
|
||||
delivery_article_id_related: article.id,
|
||||
delivery_message: true,
|
||||
group_not_joined_notification: true,
|
||||
},
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
article.preferences['group_not_joined_note_added'] = true
|
||||
Rails.logger.info "Ticket ##{ticket.number}: Added notification note about pending group join"
|
||||
end
|
||||
|
||||
article.save!
|
||||
|
||||
# Retry later when user might have joined
|
||||
raise 'User has not joined Signal group yet'
|
||||
end
|
||||
channel = ::CdrSignal.bot_by_bot_token(ticket.preferences['cdr_signal']['bot_token'])
|
||||
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
|
||||
unless channel
|
||||
log_error(article,
|
||||
"No such channel for bot #{ticket.preferences['cdr_signal']['bot_token']} or channel id #{ticket.preferences['channel_id']}")
|
||||
end
|
||||
if channel.options[:bot_token].blank?
|
||||
log_error(article,
|
||||
"Channel.find(#{channel.id}) has no cdr signal api token!")
|
||||
end
|
||||
|
||||
has_error = false
|
||||
|
||||
begin
|
||||
result = channel.deliver(article)
|
||||
rescue StandardError => e
|
||||
log_error(article, e.message)
|
||||
has_error = true
|
||||
end
|
||||
|
||||
Rails.logger.debug { "send result: #{result}" }
|
||||
|
||||
if result.nil? || result[:error].present?
|
||||
log_error(article, 'Delivering signal message failed!')
|
||||
has_error = true
|
||||
end
|
||||
|
||||
return if has_error
|
||||
|
||||
article.to = result['result']['to']
|
||||
article.from = result['result']['from']
|
||||
|
||||
message_id = format('%<from>s@%<timestamp>s', from: result['result']['from'],
|
||||
timestamp: result['result']['timestamp'])
|
||||
article.preferences['cdr_signal'] = {
|
||||
timestamp: result['result']['timestamp'],
|
||||
message_id: message_id,
|
||||
from: result['result']['from'],
|
||||
to: result['result']['to']
|
||||
}
|
||||
|
||||
# set delivery status
|
||||
article.preferences['delivery_status_message'] = nil
|
||||
article.preferences['delivery_status'] = 'success'
|
||||
article.preferences['delivery_status_date'] = Time.zone.now
|
||||
|
||||
article.message_id = "cdr_signal.#{message_id}"
|
||||
|
||||
article.save!
|
||||
|
||||
Rails.logger.info "Sent signal message to: '#{article.to}' (from #{article.from})"
|
||||
|
||||
article
|
||||
end
|
||||
|
||||
def log_error(local_record, message)
|
||||
local_record.preferences['delivery_status'] = 'fail'
|
||||
local_record.preferences['delivery_status_message'] =
|
||||
message.encode!('UTF-8', 'UTF-8', invalid: :replace, replace: '?')
|
||||
local_record.preferences['delivery_status_date'] = Time.zone.now
|
||||
local_record.save
|
||||
Rails.logger.error message
|
||||
|
||||
if local_record.preferences['delivery_retry'] > 3
|
||||
Ticket::Article.create(
|
||||
ticket_id: local_record.ticket_id,
|
||||
content_type: 'text/plain',
|
||||
body: "Unable to send cdr signal message: #{message}",
|
||||
internal: true,
|
||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||
preferences: {
|
||||
delivery_article_id_related: local_record.id,
|
||||
delivery_message: true
|
||||
},
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
end
|
||||
|
||||
raise message
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CommunicateCdrWhatsappJob < ApplicationJob
|
||||
retry_on StandardError, attempts: 4, wait: lambda { |executions|
|
||||
executions * 120.seconds
|
||||
}
|
||||
|
||||
def perform(article_id)
|
||||
article = Ticket::Article.find(article_id)
|
||||
|
||||
# set retry count
|
||||
article.preferences['delivery_retry'] ||= 0
|
||||
article.preferences['delivery_retry'] += 1
|
||||
|
||||
ticket = Ticket.lookup(id: article.ticket_id)
|
||||
unless ticket.preferences
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
unless ticket.preferences['cdr_whatsapp']
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences['cdr_whatsapp'] for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
unless ticket.preferences['cdr_whatsapp']['bot_token']
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences['cdr_whatsapp']['bot_token'] for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
unless ticket.preferences['cdr_whatsapp']['chat_id']
|
||||
log_error(article,
|
||||
"Can't find ticket.preferences['cdr_whatsapp']['chat_id'] for Ticket.find(#{article.ticket_id})")
|
||||
end
|
||||
channel = ::CdrSignal.bot_by_bot_token(ticket.preferences['cdr_whatsapp']['bot_token'])
|
||||
channel ||= ::Channel.lookup(id: ticket.preferences['channel_id'])
|
||||
unless channel
|
||||
log_error(article,
|
||||
"No such channel for bot #{ticket.preferences['cdr_whatsapp']['bot_token']} or channel id #{ticket.preferences['channel_id']}")
|
||||
end
|
||||
if channel.options[:bot_token].blank?
|
||||
log_error(article,
|
||||
"Channel.find(#{channel.id}) has no cdr whatsapp api token!")
|
||||
end
|
||||
|
||||
has_error = false
|
||||
|
||||
begin
|
||||
result = channel.deliver(article)
|
||||
rescue StandardError => e
|
||||
log_error(article, e.message)
|
||||
has_error = true
|
||||
end
|
||||
|
||||
Rails.logger.debug { "send result: #{result}" }
|
||||
|
||||
if result.nil? || result[:error].present?
|
||||
log_error(article, 'Delivering whatsapp message failed!')
|
||||
has_error = true
|
||||
end
|
||||
|
||||
return if has_error
|
||||
|
||||
article.to = result['result']['to']
|
||||
article.from = result['result']['from']
|
||||
|
||||
message_id = format('%<from>s@%<timestamp>s', from: result['result']['from'],
|
||||
timestamp: result['result']['timestamp'])
|
||||
article.preferences['cdr_whatsapp'] = {
|
||||
timestamp: result['result']['timestamp'],
|
||||
message_id: message_id,
|
||||
from: result['result']['from'],
|
||||
to: result['result']['to']
|
||||
}
|
||||
|
||||
# set delivery status
|
||||
article.preferences['delivery_status_message'] = nil
|
||||
article.preferences['delivery_status'] = 'success'
|
||||
article.preferences['delivery_status_date'] = Time.zone.now
|
||||
|
||||
article.message_id = "cdr_whatsapp.#{message_id}"
|
||||
|
||||
article.save!
|
||||
|
||||
Rails.logger.info "Sent whatsapp message to: '#{article.to}' (from #{article.from})"
|
||||
|
||||
article
|
||||
end
|
||||
|
||||
def log_error(local_record, message)
|
||||
local_record.preferences['delivery_status'] = 'fail'
|
||||
local_record.preferences['delivery_status_message'] =
|
||||
message.encode!('UTF-8', 'UTF-8', invalid: :replace, replace: '?')
|
||||
local_record.preferences['delivery_status_date'] = Time.zone.now
|
||||
local_record.save
|
||||
Rails.logger.error message
|
||||
|
||||
if local_record.preferences['delivery_retry'] > 3
|
||||
Ticket::Article.create(
|
||||
ticket_id: local_record.ticket_id,
|
||||
content_type: 'text/plain',
|
||||
body: "Unable to send cdr whatsapp message: #{message}",
|
||||
internal: true,
|
||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||
preferences: {
|
||||
delivery_article_id_related: local_record.id,
|
||||
delivery_message: true
|
||||
},
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
end
|
||||
|
||||
raise message
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,319 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# CreateTicketFromFormJob creates Zammad tickets from Formstack form submissions.
|
||||
# Ported from bridge-worker formstack/create-ticket-from-form.ts
|
||||
#
|
||||
# Supports:
|
||||
# - Dynamic field mapping via FORMSTACK_FIELD_MAPPING environment variable
|
||||
# - Signal channel integration for Signal-based communication
|
||||
# - Customer lookup by phone number or email
|
||||
# - Custom Zammad fields mapping
|
||||
|
||||
class CreateTicketFromFormJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(form_data_json, received_at)
|
||||
form_data = JSON.parse(form_data_json)
|
||||
mapping = load_field_mapping
|
||||
|
||||
# Extract metadata
|
||||
form_id = get_field_value(form_data, 'formId', mapping)
|
||||
unique_id = get_field_value(form_data, 'uniqueId', mapping)
|
||||
|
||||
Rails.logger.info "Formstack: Processing form #{form_id}, submission #{unique_id}"
|
||||
|
||||
# Extract name fields
|
||||
name_field = get_field_value(form_data, 'name', mapping)
|
||||
first_name = extract_nested_value(name_field, mapping.dig('nestedFields', 'name', 'firstNamePath')) || ''
|
||||
last_name = extract_nested_value(name_field, mapping.dig('nestedFields', 'name', 'lastNamePath')) || ''
|
||||
full_name = [first_name, last_name].map(&:presence).compact.join(' ').presence || 'Unknown'
|
||||
|
||||
# Extract contact fields
|
||||
email = get_field_value(form_data, 'email', mapping)
|
||||
raw_phone = get_field_value(form_data, 'phone', mapping)
|
||||
raw_signal_account = get_field_value(form_data, 'signalAccount', mapping)
|
||||
organization = get_field_value(form_data, 'organization', mapping)
|
||||
type_of_support = get_field_value(form_data, 'typeOfSupport', mapping)
|
||||
description_of_issue = get_field_value(form_data, 'descriptionOfIssue', mapping)
|
||||
|
||||
# Sanitize phone numbers
|
||||
phone = sanitize_phone_number(raw_phone)
|
||||
signal_account = sanitize_phone_number(raw_signal_account)
|
||||
|
||||
# Validate contact info
|
||||
unless email.present? || phone.present? || signal_account.present?
|
||||
raise 'At least one contact method (email, phone, or signalAccount) is required'
|
||||
end
|
||||
|
||||
# Build ticket title
|
||||
title = build_ticket_title(mapping, {
|
||||
'name' => full_name,
|
||||
'organization' => format_field_value(organization),
|
||||
'typeOfSupport' => format_field_value(type_of_support)
|
||||
})
|
||||
|
||||
# Build article body
|
||||
body = build_article_body(form_data, mapping, full_name)
|
||||
|
||||
# Find or create customer
|
||||
customer = find_or_create_customer(
|
||||
phone: phone,
|
||||
email: email,
|
||||
first_name: first_name,
|
||||
last_name: last_name
|
||||
)
|
||||
|
||||
Rails.logger.info "Formstack: Using customer #{customer.id} for ticket"
|
||||
|
||||
# Find target group
|
||||
group_name = mapping.dig('ticket', 'group')
|
||||
group = Group.find_by(name: group_name)
|
||||
raise "Zammad group '#{group_name}' not found" unless group
|
||||
|
||||
# Look up article type
|
||||
article_type_name = mapping.dig('ticket', 'defaultArticleType') || 'note'
|
||||
article_type = Ticket::Article::Type.find_by(name: article_type_name)
|
||||
|
||||
# Check for Signal integration
|
||||
signal_article_type = nil
|
||||
signal_channel_id = nil
|
||||
signal_bot_token = nil
|
||||
|
||||
if signal_account.present?
|
||||
signal_channel = Channel.where(area: 'Signal::Number', active: true).first
|
||||
if signal_channel
|
||||
signal_channel_id = signal_channel.id
|
||||
signal_bot_token = signal_channel.options[:bot_token]
|
||||
signal_article_type = Ticket::Article::Type.find_by(name: 'cdr_signal')
|
||||
|
||||
Rails.logger.info "Formstack: Found Signal channel #{signal_channel_id} for Signal ticket"
|
||||
end
|
||||
end
|
||||
|
||||
# Build ticket data
|
||||
ticket_data = {
|
||||
group_id: group.id,
|
||||
title: title,
|
||||
customer_id: customer.id
|
||||
}
|
||||
|
||||
# Add custom Zammad fields
|
||||
zammad_fields = get_zammad_field_values(form_data, mapping)
|
||||
ticket_data.merge!(zammad_fields)
|
||||
|
||||
# Add Signal preferences if applicable
|
||||
if signal_channel_id && signal_bot_token && signal_article_type && signal_account
|
||||
ticket_data[:preferences] = {
|
||||
channel_id: signal_channel_id,
|
||||
cdr_signal: {
|
||||
bot_token: signal_bot_token,
|
||||
chat_id: signal_account
|
||||
}
|
||||
}
|
||||
Rails.logger.info "Formstack: Adding Signal preferences to ticket"
|
||||
end
|
||||
|
||||
# Create ticket
|
||||
UserInfo.current_user_id = customer.id
|
||||
ticket = Ticket.create!(ticket_data)
|
||||
|
||||
# Build article
|
||||
article_data = {
|
||||
ticket_id: ticket.id,
|
||||
subject: description_of_issue.presence || 'Support Request',
|
||||
body: body,
|
||||
content_type: 'text/html',
|
||||
internal: false
|
||||
}
|
||||
|
||||
# Use Signal article type if available
|
||||
if signal_article_type
|
||||
article_data[:type_id] = signal_article_type.id
|
||||
article_data[:sender_id] = Ticket::Article::Sender.find_by(name: 'Customer').id
|
||||
elsif article_type
|
||||
article_data[:type_id] = article_type.id
|
||||
end
|
||||
|
||||
Ticket::Article.create!(article_data)
|
||||
|
||||
# Set create_article_type_id for Signal tickets
|
||||
if signal_article_type && signal_channel_id
|
||||
ticket.update!(create_article_type_id: signal_article_type.id)
|
||||
end
|
||||
|
||||
Rails.logger.info "Formstack: Created ticket ##{ticket.number} for form #{form_id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_field_mapping
|
||||
config_json = ENV.fetch('FORMSTACK_FIELD_MAPPING', nil)
|
||||
raise 'FORMSTACK_FIELD_MAPPING environment variable is required' unless config_json
|
||||
|
||||
JSON.parse(config_json)
|
||||
end
|
||||
|
||||
def get_field_value(form_data, internal_key, mapping)
|
||||
source_field_name = mapping.dig('sourceFields', internal_key)
|
||||
return nil unless source_field_name
|
||||
|
||||
form_data[source_field_name]
|
||||
end
|
||||
|
||||
def extract_nested_value(field_value, path)
|
||||
return nil unless path && field_value.is_a?(Hash)
|
||||
|
||||
parts = path.split('.')
|
||||
current = field_value
|
||||
|
||||
parts.each do |part|
|
||||
return nil unless current.is_a?(Hash)
|
||||
|
||||
current = current[part]
|
||||
end
|
||||
|
||||
current
|
||||
end
|
||||
|
||||
def format_field_value(value)
|
||||
return nil if value.nil? || value == ''
|
||||
|
||||
if value.is_a?(Array)
|
||||
value.join(', ')
|
||||
elsif value.is_a?(Hash)
|
||||
value.to_json
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
end
|
||||
|
||||
def build_ticket_title(mapping, data)
|
||||
template = mapping.dig('ticket', 'titleTemplate') || '{name}'
|
||||
title = template.dup
|
||||
|
||||
data.each do |key, value|
|
||||
placeholder = "{#{key}}"
|
||||
if title.include?(placeholder)
|
||||
if value.present?
|
||||
title.gsub!(placeholder, value)
|
||||
else
|
||||
title.gsub!(" - #{placeholder}", '')
|
||||
title.gsub!("#{placeholder} - ", '')
|
||||
title.gsub!(placeholder, '')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
title.strip
|
||||
end
|
||||
|
||||
def build_article_body(form_data, mapping, full_name)
|
||||
html = ''
|
||||
|
||||
# Add formatted name first
|
||||
if full_name.present? && full_name != 'Unknown'
|
||||
html += "<strong>Name:</strong><br>#{ERB::Util.html_escape(full_name)}<br>"
|
||||
end
|
||||
|
||||
# Skip fields
|
||||
skip_fields = [
|
||||
mapping.dig('sourceFields', 'formId'),
|
||||
mapping.dig('sourceFields', 'uniqueId'),
|
||||
mapping.dig('sourceFields', 'name'),
|
||||
'HandshakeKey'
|
||||
].compact
|
||||
|
||||
form_data.each do |key, value|
|
||||
next if skip_fields.include?(key)
|
||||
next if value.nil? || value == ''
|
||||
|
||||
display_value = if value.is_a?(Array)
|
||||
value.join(', ')
|
||||
elsif value.is_a?(Hash)
|
||||
value.to_json
|
||||
else
|
||||
value.to_s
|
||||
end
|
||||
|
||||
html += "<strong>#{ERB::Util.html_escape(key)}:</strong><br>#{ERB::Util.html_escape(display_value)}<br>"
|
||||
end
|
||||
|
||||
html
|
||||
end
|
||||
|
||||
def sanitize_phone_number(raw_phone)
|
||||
return nil unless raw_phone.present?
|
||||
|
||||
# Remove all non-digit characters except leading +
|
||||
cleaned = raw_phone.to_s.strip
|
||||
has_plus = cleaned.start_with?('+')
|
||||
digits_only = cleaned.gsub(/\D/, '')
|
||||
|
||||
return nil if digits_only.empty?
|
||||
|
||||
# Add + prefix for E.164 format
|
||||
if has_plus
|
||||
"+#{digits_only}"
|
||||
elsif digits_only.length == 10
|
||||
# Assume US number
|
||||
"+1#{digits_only}"
|
||||
elsif digits_only.length == 11 && digits_only.start_with?('1')
|
||||
"+#{digits_only}"
|
||||
else
|
||||
"+#{digits_only}"
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Formstack: Invalid phone number format #{raw_phone}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def find_or_create_customer(phone:, email:, first_name:, last_name:)
|
||||
customer = nil
|
||||
|
||||
# Try phone first
|
||||
if phone.present?
|
||||
customer = User.find_by(phone: phone)
|
||||
customer ||= User.find_by(mobile: phone)
|
||||
end
|
||||
|
||||
# Try email
|
||||
if customer.nil? && email.present?
|
||||
email_regex = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/
|
||||
if email.match?(email_regex)
|
||||
customer = User.find_by(email: email)
|
||||
end
|
||||
end
|
||||
|
||||
# Create new customer if not found
|
||||
unless customer
|
||||
user_data = {
|
||||
firstname: first_name,
|
||||
lastname: last_name,
|
||||
role_ids: Role.signup_role_ids,
|
||||
active: true,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
}
|
||||
user_data[:email] = email if email.present?
|
||||
user_data[:phone] = phone if phone.present?
|
||||
|
||||
customer = User.create!(user_data)
|
||||
Rails.logger.info "Formstack: Created new customer #{customer.id}"
|
||||
end
|
||||
|
||||
customer
|
||||
end
|
||||
|
||||
def get_zammad_field_values(form_data, mapping)
|
||||
result = {}
|
||||
zammad_fields = mapping['zammadFields'] || {}
|
||||
|
||||
zammad_fields.each do |zammad_field_name, source_key|
|
||||
value = get_field_value(form_data, source_key, mapping)
|
||||
formatted = format_field_value(value)
|
||||
result[zammad_field_name] = formatted if formatted.present?
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SignalNotificationJob < ApplicationJob
|
||||
retry_on StandardError, attempts: 3, wait: lambda { |executions|
|
||||
executions * 60.seconds
|
||||
}
|
||||
|
||||
def perform(ticket_id:, article_id:, user_id:, type:, changes:)
|
||||
ticket = Ticket.find_by(id: ticket_id)
|
||||
return if !ticket
|
||||
|
||||
user = User.find_by(id: user_id)
|
||||
return if !user
|
||||
|
||||
signal_uid = user.preferences.dig('notification_config', 'signal_uid').presence
|
||||
return if signal_uid.blank?
|
||||
|
||||
article = article_id ? Ticket::Article.find_by(id: article_id) : nil
|
||||
|
||||
channel = signal_channel
|
||||
return if !channel
|
||||
|
||||
message = SignalNotificationSender.build_message(
|
||||
ticket: ticket,
|
||||
article: article,
|
||||
user: user,
|
||||
type: type,
|
||||
changes: changes
|
||||
)
|
||||
|
||||
return if message.blank?
|
||||
|
||||
SignalNotificationSender.send_message(
|
||||
channel: channel,
|
||||
recipient: signal_uid,
|
||||
message: message
|
||||
)
|
||||
|
||||
add_history(ticket, user, signal_uid, type)
|
||||
|
||||
Rails.logger.info "Sent Signal notification to #{signal_uid} for ticket ##{ticket.number} (#{type})"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def signal_channel
|
||||
channel_id = Setting.get('signal_notification_channel_id')
|
||||
return unless channel_id
|
||||
|
||||
Channel.find_by(id: channel_id, area: 'Signal::Number', active: true)
|
||||
end
|
||||
|
||||
def add_history(ticket, user, signal_uid, type)
|
||||
identifier = signal_uid.presence || user.login
|
||||
recipient_list = "#{identifier}(#{type}:signal)"
|
||||
|
||||
History.add(
|
||||
o_id: ticket.id,
|
||||
history_type: 'notification',
|
||||
history_object: 'Ticket',
|
||||
value_to: recipient_list,
|
||||
created_by_id: 1
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Channel
|
||||
class Driver
|
||||
class CdrSignal
|
||||
def fetchable?(_channel)
|
||||
false
|
||||
end
|
||||
|
||||
def disconnect; end
|
||||
|
||||
#
|
||||
# instance = Channel::Driver::CdrSignal.new
|
||||
# instance.send(
|
||||
# {
|
||||
# adapter: 'cdrsignal',
|
||||
# auth: {
|
||||
# api_key: api_key
|
||||
# },
|
||||
# },
|
||||
# signal_attributes,
|
||||
# notification
|
||||
# )
|
||||
#
|
||||
|
||||
def deliver(options, article, _notification = false)
|
||||
# return if we run import mode
|
||||
return if Setting.get('import_mode')
|
||||
|
||||
options = check_external_credential(options)
|
||||
|
||||
Rails.logger.debug { 'signal send started' }
|
||||
Rails.logger.debug { options.inspect }
|
||||
@signal = ::CdrSignal.new(options[:bot_endpoint], options[:bot_token])
|
||||
@signal.from_article(article)
|
||||
end
|
||||
|
||||
def self.streamable?
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_external_credential(options)
|
||||
if options[:auth] && options[:auth][:external_credential_id]
|
||||
external_credential = ExternalCredential.find_by(id: options[:auth][:external_credential_id])
|
||||
raise "No such ExternalCredential.find(#{options[:auth][:external_credential_id]})" unless external_credential
|
||||
|
||||
options[:auth][:api_key] = external_credential.credentials['api_key']
|
||||
end
|
||||
options
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Channel::Driver::CdrWhatsapp
|
||||
def fetchable?(_channel)
|
||||
false
|
||||
end
|
||||
|
||||
def disconnect; end
|
||||
|
||||
#
|
||||
# instance = Channel::Driver::CdrWhatsapp.new
|
||||
# instance.send(
|
||||
# {
|
||||
# adapter: 'cdr_whatsapp',
|
||||
# auth: {
|
||||
# api_key: api_key
|
||||
# },
|
||||
# },
|
||||
# whatsapp_attributes,
|
||||
# notification
|
||||
# )
|
||||
#
|
||||
|
||||
def deliver(options, article, _notification = false)
|
||||
# return if we run import mode
|
||||
return if Setting.get('import_mode')
|
||||
|
||||
options = check_external_credential(options)
|
||||
|
||||
Rails.logger.debug { 'whatsapp send started' }
|
||||
Rails.logger.debug { options.inspect }
|
||||
@whatsapp = ::CdrWhatsapp.new(options[:bot_endpoint], options[:bot_token])
|
||||
@whatsapp.from_article(article)
|
||||
end
|
||||
|
||||
def self.streamable?
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_external_credential(options)
|
||||
if options[:auth] && options[:auth][:external_credential_id]
|
||||
external_credential = ExternalCredential.find_by(id: options[:auth][:external_credential_id])
|
||||
raise "No such ExternalCredential.find(#{options[:auth][:external_credential_id]})" unless external_credential
|
||||
|
||||
options[:auth][:api_key] = external_credential.credentials['api_key']
|
||||
end
|
||||
options
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Link::SetupSplitSignalGroup
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create :setup_signal_group_for_split_ticket
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_signal_group_for_split_ticket
|
||||
# Only if auto-groups enabled
|
||||
return unless ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
|
||||
|
||||
# Only child links (splits create child->parent links)
|
||||
return unless link_type_id == Link::Type.find_by(name: 'child')&.id
|
||||
|
||||
# Only Ticket-to-Ticket links
|
||||
ticket_object_id = Link::Object.find_by(name: 'Ticket')&.id
|
||||
return unless link_object_source_id == ticket_object_id
|
||||
return unless link_object_target_id == ticket_object_id
|
||||
|
||||
child_ticket = Ticket.find_by(id: link_object_source_value)
|
||||
parent_ticket = Ticket.find_by(id: link_object_target_value)
|
||||
return unless child_ticket && parent_ticket
|
||||
|
||||
# Only if parent has Signal group (chat_id starts with "group.")
|
||||
parent_signal_prefs = parent_ticket.preferences&.dig('cdr_signal')
|
||||
return unless parent_signal_prefs.present?
|
||||
return unless parent_signal_prefs['chat_id']&.start_with?('group.')
|
||||
|
||||
original_recipient = parent_signal_prefs['original_recipient']
|
||||
return unless original_recipient.present?
|
||||
|
||||
# Set up child for lazy group creation:
|
||||
# chat_id = phone number triggers new group on first message
|
||||
child_ticket.preferences ||= {}
|
||||
child_ticket.preferences['channel_id'] = parent_ticket.preferences['channel_id']
|
||||
child_ticket.preferences['cdr_signal'] = {
|
||||
'bot_token' => parent_signal_prefs['bot_token'],
|
||||
'chat_id' => original_recipient, # Phone number, NOT group ID
|
||||
'original_recipient' => original_recipient
|
||||
}
|
||||
# Set article type so Zammad shows Signal reply option
|
||||
child_ticket.create_article_type_id = Ticket::Article::Type.find_by(name: 'cdr_signal')&.id
|
||||
child_ticket.save!
|
||||
|
||||
Rails.logger.info "Signal split: Ticket ##{child_ticket.number} set up for new group (recipient: #{original_recipient})"
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ticket::Article::EnqueueCommunicateCdrSignalJob
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create :ticket_article_enqueue_communicate_cdr_signal_job
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ticket_article_enqueue_communicate_cdr_signal_job
|
||||
# return if we run import mode
|
||||
return true if Setting.get('import_mode')
|
||||
|
||||
# if sender is customer, do not communicate
|
||||
return true unless sender_id
|
||||
|
||||
sender = Ticket::Article::Sender.lookup(id: sender_id)
|
||||
return true if sender.nil?
|
||||
return true if sender.name == 'Customer'
|
||||
|
||||
# only apply on cdr signal messages
|
||||
return true unless type_id
|
||||
|
||||
type = Ticket::Article::Type.lookup(id: type_id)
|
||||
return true unless type.name.match?(/\Acdr_signal/i)
|
||||
|
||||
CommunicateCdrSignalJob.perform_later(id)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ticket::Article::EnqueueCommunicateCdrWhatsappJob
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
after_create :ticket_article_enqueue_communicate_cdr_whatsapp_job
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ticket_article_enqueue_communicate_cdr_whatsapp_job
|
||||
# return if we run import mode
|
||||
return true if Setting.get('import_mode')
|
||||
|
||||
# if sender is customer, do not communicate
|
||||
return true unless sender_id
|
||||
|
||||
sender = Ticket::Article::Sender.lookup(id: sender_id)
|
||||
return true if sender.nil?
|
||||
return true if sender.name == 'Customer'
|
||||
|
||||
# only apply on cdr whatsapp messages
|
||||
return true unless type_id
|
||||
|
||||
type = Ticket::Article::Type.lookup(id: type_id)
|
||||
return true unless type.name.match?(/\Acdr_whatsapp/i)
|
||||
|
||||
CommunicateCdrWhatsappJob.perform_later(id)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -0,0 +1,143 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Transaction::SignalNotification
|
||||
include ChecksHumanChanges
|
||||
|
||||
def initialize(item, params = {})
|
||||
@item = item
|
||||
@params = params
|
||||
end
|
||||
|
||||
def perform
|
||||
return if Setting.get('import_mode')
|
||||
return if %w[Ticket Ticket::Article].exclude?(@item[:object])
|
||||
return if @params[:disable_notification]
|
||||
return if !ticket
|
||||
return if !signal_notifications_enabled?
|
||||
return if !signal_channel
|
||||
|
||||
collect_signal_recipients.each do |user|
|
||||
SignalNotificationJob.perform_later(
|
||||
ticket_id: ticket.id,
|
||||
article_id: @item[:article_id],
|
||||
user_id: user.id,
|
||||
type: @item[:type],
|
||||
changes: human_changes(@item[:changes], ticket, user)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ticket
|
||||
@ticket ||= Ticket.find_by(id: @item[:object_id])
|
||||
end
|
||||
|
||||
def article
|
||||
return if !@item[:article_id]
|
||||
|
||||
@article ||= begin
|
||||
art = Ticket::Article.find_by(id: @item[:article_id])
|
||||
return unless art
|
||||
|
||||
sender = Ticket::Article::Sender.lookup(id: art.sender_id)
|
||||
if sender&.name == 'System'
|
||||
return if @item[:changes].blank? && art.preferences[:notification] != true
|
||||
return if art.preferences[:notification] != true
|
||||
end
|
||||
|
||||
art
|
||||
end
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.lookup(id: @item[:user_id]) || User.lookup(id: 1)
|
||||
end
|
||||
|
||||
def signal_notifications_enabled?
|
||||
Setting.get('signal_notification_enabled') == true
|
||||
end
|
||||
|
||||
def signal_channel
|
||||
@signal_channel ||= begin
|
||||
channel_id = Setting.get('signal_notification_channel_id')
|
||||
return unless channel_id
|
||||
|
||||
Channel.find_by(id: channel_id, area: 'Signal::Number', active: true)
|
||||
end
|
||||
end
|
||||
|
||||
def collect_signal_recipients
|
||||
recipients = []
|
||||
|
||||
possible_recipients = possible_recipients_of_group(ticket.group_id)
|
||||
|
||||
mention_users = Mention.where(mentionable_type: @item[:object], mentionable_id: @item[:object_id]).map(&:user)
|
||||
mention_users.each do |user|
|
||||
next if !user.group_access?(ticket.group_id, 'read')
|
||||
|
||||
possible_recipients.push(user)
|
||||
end
|
||||
|
||||
if ticket.owner_id != 1
|
||||
possible_recipients.push(ticket.owner)
|
||||
end
|
||||
|
||||
possible_recipients_with_ooo = Set.new(possible_recipients)
|
||||
possible_recipients.each do |user|
|
||||
add_out_of_office_replacement(user, possible_recipients_with_ooo)
|
||||
end
|
||||
|
||||
possible_recipients_with_ooo.each do |user|
|
||||
next if recipient_is_current_user?(user)
|
||||
next if !user.active?
|
||||
next if user_signal_uid(user).blank?
|
||||
next if !user_wants_signal_for_event?(user)
|
||||
|
||||
recipients.push(user)
|
||||
end
|
||||
|
||||
recipients.uniq(&:id)
|
||||
end
|
||||
|
||||
def possible_recipients_of_group(group_id)
|
||||
Rails.cache.fetch("User/signal_notification/possible_recipients_of_group/#{group_id}/#{User.latest_change}", expires_in: 20.seconds) do
|
||||
User.group_access(group_id, 'full').sort_by(&:login)
|
||||
end
|
||||
end
|
||||
|
||||
def add_out_of_office_replacement(user, recipients)
|
||||
replacement = user.out_of_office_agent
|
||||
return unless replacement
|
||||
return unless TicketPolicy.new(replacement, ticket).agent_read_access?
|
||||
|
||||
recipients.add(replacement)
|
||||
end
|
||||
|
||||
def recipient_is_current_user?(user)
|
||||
return false if @params[:interface_handle] != 'application_server'
|
||||
return true if article&.updated_by_id == user.id
|
||||
return true if !article && @item[:user_id] == user.id
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def user_signal_uid(user)
|
||||
user.preferences.dig('notification_config', 'signal_uid').presence
|
||||
end
|
||||
|
||||
def user_wants_signal_for_event?(user)
|
||||
event_type = @item[:type]
|
||||
return false if event_type.blank?
|
||||
|
||||
event_key = case event_type
|
||||
when 'create' then 'create'
|
||||
when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction' then 'update'
|
||||
when 'reminder_reached' then 'reminder_reached'
|
||||
when 'escalation', 'escalation_warning' then 'escalation'
|
||||
else return false
|
||||
end
|
||||
|
||||
user.preferences.dig('notification_config', 'matrix', event_key, 'channel', 'signal') == true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Controllers
|
||||
class CdrSignalChannelsControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
def index?
|
||||
user.permissions?('admin.channel')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Controllers
|
||||
class CdrTicketArticleTypesControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
def index?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Controllers
|
||||
class ChannelsCdrSignalControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
default_permit!('admin.channel_cdr_signal')
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Controllers
|
||||
class ChannelsCdrVoiceControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
default_permit!('admin.channel_cdr_voice')
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Controllers
|
||||
class ChannelsCdrWhatsappControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
default_permit!('admin.channel_cdr_whatsapp')
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Controllers::FormstackControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
# Formstack webhooks are authenticated via handshake key, not user sessions
|
||||
def webhook?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Controllers::OpensearchControllerPolicy < Controllers::ApplicationControllerPolicy
|
||||
# Allow any authenticated user to get OpenSearch dashboard URL
|
||||
def index?
|
||||
true
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
[Ticket #<%= ticket.number %>] <%= ticket.title %>
|
||||
|
||||
NEW TICKET
|
||||
|
||||
Group: <%= ticket.group.name %>
|
||||
Owner: <%= ticket.owner.fullname %>
|
||||
State: <%= t(ticket.state.name) %>
|
||||
Priority: <%= t(ticket.priority.name) %>
|
||||
Customer: <%= ticket.customer.fullname %>
|
||||
Created by: <%= current_user.fullname %>
|
||||
<% if article -%>
|
||||
|
||||
<%= article_body_preview(500) %>
|
||||
<% end -%>
|
||||
|
||||
<%= ticket_url %>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
[Ticket #<%= ticket.number %>] <%= ticket.title %>
|
||||
|
||||
ESCALATION
|
||||
|
||||
Group: <%= ticket.group.name %>
|
||||
Owner: <%= ticket.owner.fullname %>
|
||||
State: <%= t(ticket.state.name) %>
|
||||
Priority: <%= t(ticket.priority.name) %>
|
||||
Customer: <%= ticket.customer.fullname %>
|
||||
|
||||
<%= ticket_url %>
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
[Ticket #<%= ticket.number %>] <%= ticket.title %>
|
||||
|
||||
REMINDER REACHED
|
||||
|
||||
Group: <%= ticket.group.name %>
|
||||
Owner: <%= ticket.owner.fullname %>
|
||||
State: <%= t(ticket.state.name) %>
|
||||
Pending till: <%= ticket.pending_time&.strftime('%Y-%m-%d %H:%M') %>
|
||||
|
||||
<%= ticket_url %>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
[Ticket #<%= ticket.number %>] <%= ticket.title %>
|
||||
|
||||
TICKET UPDATED by <%= current_user.fullname %>
|
||||
<% if changes.present? -%>
|
||||
|
||||
Changes:
|
||||
<%= changes_summary %>
|
||||
<% end -%>
|
||||
<% if article -%>
|
||||
|
||||
<%= article_body_preview(500) %>
|
||||
<% end -%>
|
||||
|
||||
<%= ticket_url_with_article %>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
class Ticket::Article
|
||||
include Ticket::Article::EnqueueCommunicateCdrSignalJob
|
||||
end
|
||||
|
||||
# Handle Signal group setup for split tickets
|
||||
class Link
|
||||
include Link::SetupSplitSignalGroup
|
||||
end
|
||||
|
||||
icon = File.read('public/assets/images/icons/cdr_signal.svg')
|
||||
doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) }
|
||||
if !doc.at_css('#icon-cdr-signal')
|
||||
doc.at('svg').add_child(icon)
|
||||
Rails.logger.debug 'signal icon added to icon set'
|
||||
else
|
||||
Rails.logger.debug 'signal icon already in icon set'
|
||||
end
|
||||
File.write('public/assets/images/icons.svg', doc.to_xml)
|
||||
end
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
|
||||
class Ticket::Article
|
||||
include Ticket::Article::EnqueueCommunicateCdrWhatsappJob
|
||||
end
|
||||
|
||||
icon = File.read('public/assets/images/icons/cdr_whatsapp.svg')
|
||||
doc = File.open('public/assets/images/icons.svg') { |f| Nokogiri::XML(f) }
|
||||
if !doc.at_css('#icon-cdr-whatsapp')
|
||||
doc.at('svg').add_child(icon)
|
||||
Rails.logger.debug 'whatsapp icon added to icon set'
|
||||
else
|
||||
Rails.logger.debug 'whatsapp icon already in icon set'
|
||||
end
|
||||
File.write('public/assets/images/icons.svg', doc.to_xml)
|
||||
end
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# Configuration for direct Signal CLI REST API access
|
||||
# The SIGNAL_CLI_URL environment variable points to the signal-cli-rest-api container
|
||||
# Default: http://signal-cli-rest-api:8080
|
||||
#
|
||||
# This enables Zammad to poll for Signal messages directly without going through bridge-worker
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
signal_cli_url = ENV.fetch('SIGNAL_CLI_URL', 'http://signal-cli-rest-api:8080')
|
||||
Rails.logger.info "Signal CLI API URL configured: #{signal_cli_url}"
|
||||
end
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match api_path + '/cdr_signal_channels', to: 'cdr_signal_channels#index', via: :get
|
||||
end
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match api_path + '/ticket_article_types', to: 'cdr_ticket_article_types#index', via: :get
|
||||
end
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#index', via: :get
|
||||
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#add', via: :post
|
||||
match "#{api_path}/channels_cdr_signal/:id", to: 'channels_cdr_signal#update', via: :put
|
||||
match "#{api_path}/channels_cdr_signal_webhook/:token", to: 'channels_cdr_signal#webhook', via: :post
|
||||
match "#{api_path}/channels_cdr_signal_webhook/:token/update_group", to: 'channels_cdr_signal#update_group', via: :post
|
||||
match "#{api_path}/channels_cdr_signal_disable", to: 'channels_cdr_signal#disable', via: :post
|
||||
match "#{api_path}/channels_cdr_signal_enable", to: 'channels_cdr_signal#enable', via: :post
|
||||
match "#{api_path}/channels_cdr_signal", to: 'channels_cdr_signal#destroy', via: :delete
|
||||
match "#{api_path}/channels_cdr_signal_rotate_token", to: 'channels_cdr_signal#rotate_token', via: :post
|
||||
match "#{api_path}/channels_cdr_signal_set_notification", to: 'channels_cdr_signal#set_notification_channel', via: :post
|
||||
match "#{api_path}/channels_cdr_signal_unset_notification", to: 'channels_cdr_signal#unset_notification_channel', via: :post
|
||||
end
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match "#{api_path}/channels_cdr_voice", to: 'channels_cdr_voice#index', via: :get
|
||||
match "#{api_path}/channels_cdr_voice", to: 'channels_cdr_voice#add', via: :post
|
||||
match "#{api_path}/channels_cdr_voice/:id", to: 'channels_cdr_voice#update', via: :put
|
||||
match "#{api_path}/channels_cdr_voice_webhook/:token", to: 'channels_cdr_voice#webhook', via: :post
|
||||
match "#{api_path}/channels_cdr_voice_disable", to: 'channels_cdr_voice#disable', via: :post
|
||||
match "#{api_path}/channels_cdr_voice_enable", to: 'channels_cdr_voice#enable', via: :post
|
||||
match "#{api_path}/channels_cdr_voice", to: 'channels_cdr_voice#destroy', via: :delete
|
||||
match "#{api_path}/channels_cdr_voice_rotate_token", to: 'channels_cdr_voice#rotate_token', via: :post
|
||||
end
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Zammad::Application.routes.draw do
|
||||
api_path = Rails.configuration.api_path
|
||||
|
||||
match "#{api_path}/channels_cdr_whatsapp", to: 'channels_cdr_whatsapp#index', via: :get
|
||||
match "#{api_path}/channels_cdr_whatsapp", to: 'channels_cdr_whatsapp#add', via: :post
|
||||
match "#{api_path}/channels_cdr_whatsapp/:id", to: 'channels_cdr_whatsapp#update', via: :put
|
||||
match "#{api_path}/channels_cdr_whatsapp_webhook/:token", to: 'channels_cdr_whatsapp#webhook', via: :post
|
||||
match "#{api_path}/channels_cdr_whatsapp_bot_webhook/:bot_token", to: 'channels_cdr_whatsapp#bot_webhook', via: :post
|
||||
match "#{api_path}/channels_cdr_whatsapp_disable", to: 'channels_cdr_whatsapp#disable', via: :post
|
||||
match "#{api_path}/channels_cdr_whatsapp_enable", to: 'channels_cdr_whatsapp#enable', via: :post
|
||||
match "#{api_path}/channels_cdr_whatsapp", to: 'channels_cdr_whatsapp#destroy', via: :delete
|
||||
match "#{api_path}/channels_cdr_whatsapp_rotate_token", to: 'channels_cdr_whatsapp#rotate_token', via: :post
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Zammad::Application.routes.draw do
|
||||
scope Rails.configuration.api_path do
|
||||
match '/formstack/webhook', to: 'formstack#webhook', via: :post
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
Zammad::Application.routes.draw do
|
||||
scope Rails.configuration.api_path do
|
||||
match '/opensearch', to: 'opensearch#index', via: :get
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
class HardeningHardenSettings < ActiveRecord::Migration[5.2]
|
||||
def self.restore_setting(name)
|
||||
s = Setting.find_by(name: name)
|
||||
return if s.nil?
|
||||
|
||||
s.state_current = s.state_initial
|
||||
s.save!
|
||||
end
|
||||
|
||||
def self.set_setting(name, value)
|
||||
s = Setting.find_by(name: name)
|
||||
return if s.nil?
|
||||
|
||||
s.state_current = { 'value' => value }
|
||||
s.save!
|
||||
end
|
||||
|
||||
def self.up
|
||||
%w[ui_send_client_stats geo_ip_backend geo_location_backend image_backend
|
||||
geo_calendar_backend].each do |n|
|
||||
set_setting(n, '')
|
||||
end
|
||||
|
||||
# disable customer ticket creation
|
||||
set_setting('customer_ticket_create', false)
|
||||
|
||||
# disable user account registration
|
||||
set_setting('user_create_account', false)
|
||||
|
||||
# bump up min password length
|
||||
set_setting('password_min_size', 10)
|
||||
|
||||
# delete default zammad user
|
||||
nicole = User.find_by(email: 'nicole.braun@zammad.org')
|
||||
return if nicole.nil?
|
||||
|
||||
Ticket.where(customer: nicole).destroy_all
|
||||
nicole.destroy
|
||||
end
|
||||
|
||||
def self.down
|
||||
%w[ui_send_client_stats geo_ip_backend geo_location_backend image_backend
|
||||
geo_calendar_backend].each do |n|
|
||||
restore_setting(n)
|
||||
end
|
||||
%w[customer_ticket_create user_create_account password_min_size].each do |n|
|
||||
restore_setting(n)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CdrSignalChannel < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
Ticket::Article::Type.create_if_not_exists(
|
||||
name: 'cdr_signal',
|
||||
communication: true,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
Permission.create_if_not_exists(
|
||||
name: 'admin.channel_cdr_signal',
|
||||
description: 'Manage %s',
|
||||
preferences: {
|
||||
translations: ['Channel - Signal']
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
t = Ticket::Article::Type.find_by(name: 'cdr_signal')
|
||||
t&.destroy
|
||||
|
||||
p = Permission.find_by(name: 'admin.channel_cdr_signal')
|
||||
p&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CdrVoiceChannel < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
Ticket::Article::Type.create_if_not_exists(
|
||||
name: 'cdr_voice',
|
||||
communication: false,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
Permission.create_if_not_exists(
|
||||
name: 'admin.channel_cdr_voice',
|
||||
description: 'Manage %s',
|
||||
preferences: {
|
||||
translations: ['Channel - Voice']
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
t = Ticket::Article::Type.find_by(name: 'cdr_voice')
|
||||
|
||||
t&.destroy
|
||||
|
||||
p = Permission.find_by(name: 'admin.channel_cdr_voice')
|
||||
p&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CdrWhatsappChannel < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
Ticket::Article::Type.create_if_not_exists(
|
||||
name: 'cdr_whatsapp',
|
||||
communication: true,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
Permission.create_if_not_exists(
|
||||
name: 'admin.channel_cdr_whatsapp',
|
||||
description: 'Manage %s',
|
||||
preferences: {
|
||||
translations: ['Channel - Whatsapp']
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
t = Ticket::Article::Type.find_by(name: 'cdr_whatsapp')
|
||||
t&.destroy
|
||||
|
||||
p = Permission.find_by(name: 'admin.channel_cdr_whatsapp')
|
||||
p&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddChannelRestrictionSetting < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
Setting.create_if_not_exists(
|
||||
title: 'CDR Link - Allowed Reply Channels',
|
||||
name: 'cdr_link_allowed_channels',
|
||||
area: 'Integration::CDRLink',
|
||||
description: 'Comma-separated whitelist of allowed reply channels (e.g., "note,signal message,email"). Leave empty to allow all channels.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: 'Allowed Channels',
|
||||
null: true,
|
||||
name: 'cdr_link_allowed_channels',
|
||||
tag: 'input',
|
||||
}
|
||||
],
|
||||
},
|
||||
state: '', # Empty by default (allows all)
|
||||
frontend: true, # Available to frontend
|
||||
preferences: {
|
||||
permission: ['admin'],
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
Setting.find_by(name: 'cdr_link_allowed_channels')&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
class EnableDesktopBetaSwitch < ActiveRecord::Migration[6.1]
|
||||
def change
|
||||
# Enable the desktop beta switch to allow users to toggle between old and new UI
|
||||
Setting.set('ui_desktop_beta_switch', true)
|
||||
|
||||
# Also ensure the beta UI switch permission exists and is active
|
||||
permission = Permission.find_by(name: 'user_preferences.beta_ui_switch')
|
||||
if permission
|
||||
permission.update(active: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddCdrLinkConfig < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
Setting.create_if_not_exists(
|
||||
title: 'CDR Link',
|
||||
name: 'cdr_link_config',
|
||||
area: 'Integration::CDRLink',
|
||||
description: 'Defines the CDR Link integration config.',
|
||||
options: {},
|
||||
state: { items: [] },
|
||||
frontend: false,
|
||||
preferences: {
|
||||
prio: 2,
|
||||
permission: ['admin.integration'],
|
||||
}
|
||||
)
|
||||
|
||||
# Update the existing allowed_channels setting to use admin.integration permission
|
||||
setting = Setting.find_by(name: 'cdr_link_allowed_channels')
|
||||
if setting
|
||||
setting.preferences = {
|
||||
permission: ['admin.integration'],
|
||||
}
|
||||
setting.save!
|
||||
end
|
||||
end
|
||||
|
||||
def self.down
|
||||
Setting.find_by(name: 'cdr_link_config')&.destroy
|
||||
|
||||
# Restore original permission if rolling back
|
||||
setting = Setting.find_by(name: 'cdr_link_allowed_channels')
|
||||
if setting
|
||||
setting.preferences = {
|
||||
permission: ['admin'],
|
||||
}
|
||||
setting.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
class AddMessagingUserIds < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
# Add WhatsApp UID column
|
||||
unless column_exists?(:users, :whatsapp_uid)
|
||||
add_column :users, :whatsapp_uid, :string, limit: 50
|
||||
add_index :users, :whatsapp_uid
|
||||
end
|
||||
User.reset_column_information
|
||||
|
||||
# Add Signal UID column
|
||||
unless column_exists?(:users, :signal_uid)
|
||||
add_column :users, :signal_uid, :string, limit: 50
|
||||
add_index :users, :signal_uid
|
||||
end
|
||||
User.reset_column_information
|
||||
|
||||
# Register WhatsApp UID with ObjectManager for UI
|
||||
# Column name: whatsapp_uid, Display name: "WhatsApp User ID"
|
||||
ObjectManager::Attribute.add(
|
||||
force: true,
|
||||
object: 'User',
|
||||
name: 'whatsapp_uid',
|
||||
display: 'WhatsApp User ID',
|
||||
data_type: 'input',
|
||||
data_option: {
|
||||
type: 'text',
|
||||
maxlength: 50,
|
||||
null: true,
|
||||
item_class: 'formGroup--halfSize',
|
||||
},
|
||||
editable: false,
|
||||
active: true,
|
||||
screens: {
|
||||
signup: {},
|
||||
invite_agent: {},
|
||||
invite_customer: {},
|
||||
edit: {
|
||||
'-all-' => {
|
||||
null: true,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
'-all-' => {
|
||||
null: true,
|
||||
},
|
||||
},
|
||||
view: {
|
||||
'-all-' => {
|
||||
shown: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
to_create: false,
|
||||
to_migrate: false,
|
||||
to_delete: false,
|
||||
position: 710,
|
||||
created_by_id: 1,
|
||||
updated_by_id: 1,
|
||||
)
|
||||
|
||||
# Register Signal UID with ObjectManager for UI
|
||||
# Column name: signal_uid, Display name: "Signal User ID"
|
||||
ObjectManager::Attribute.add(
|
||||
force: true,
|
||||
object: 'User',
|
||||
name: 'signal_uid',
|
||||
display: 'Signal User ID',
|
||||
data_type: 'input',
|
||||
data_option: {
|
||||
type: 'text',
|
||||
maxlength: 50,
|
||||
null: true,
|
||||
item_class: 'formGroup--halfSize',
|
||||
},
|
||||
editable: false,
|
||||
active: true,
|
||||
screens: {
|
||||
signup: {},
|
||||
invite_agent: {},
|
||||
invite_customer: {},
|
||||
edit: {
|
||||
'-all-' => {
|
||||
null: true,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
'-all-' => {
|
||||
null: true,
|
||||
},
|
||||
},
|
||||
view: {
|
||||
'-all-' => {
|
||||
shown: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
to_create: false,
|
||||
to_migrate: false,
|
||||
to_delete: false,
|
||||
position: 720,
|
||||
created_by_id: 1,
|
||||
updated_by_id: 1,
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
ObjectManager::Attribute.remove(
|
||||
object: 'User',
|
||||
name: 'whatsapp_uid',
|
||||
)
|
||||
|
||||
ObjectManager::Attribute.remove(
|
||||
object: 'User',
|
||||
name: 'signal_uid',
|
||||
)
|
||||
|
||||
remove_index :users, :whatsapp_uid if index_exists?(:users, :whatsapp_uid)
|
||||
remove_column :users, :whatsapp_uid if column_exists?(:users, :whatsapp_uid)
|
||||
|
||||
remove_index :users, :signal_uid if index_exists?(:users, :signal_uid)
|
||||
remove_column :users, :signal_uid if column_exists?(:users, :signal_uid)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddSignalNotificationSettings < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
# Register Signal notification transaction backend
|
||||
# Using 0105 to run after email notifications (0100)
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Defines transaction backend.',
|
||||
name: '0105_signal_notification',
|
||||
area: 'Transaction::Backend::Async',
|
||||
description: 'Defines the transaction backend to send Signal notifications.',
|
||||
options: {},
|
||||
state: 'Transaction::SignalNotification',
|
||||
frontend: false
|
||||
)
|
||||
|
||||
# Global enable/disable for Signal notifications
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Signal Notifications',
|
||||
name: 'signal_notification_enabled',
|
||||
area: 'Integration::Switch',
|
||||
description: 'Enable or disable Signal notifications for agents.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'signal_notification_enabled',
|
||||
tag: 'boolean',
|
||||
options: {
|
||||
true => 'yes',
|
||||
false => 'no',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
state: false,
|
||||
preferences: {
|
||||
prio: 1,
|
||||
permission: ['admin.integration'],
|
||||
},
|
||||
frontend: true
|
||||
)
|
||||
|
||||
# Which Signal channel/bot to use for sending notifications
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Signal Notification Channel',
|
||||
name: 'signal_notification_channel_id',
|
||||
area: 'Integration::SignalNotification',
|
||||
description: 'The Signal channel (bot) used to send notifications to agents.',
|
||||
options: {},
|
||||
state: nil,
|
||||
preferences: {
|
||||
prio: 2,
|
||||
permission: ['admin.integration'],
|
||||
},
|
||||
frontend: false
|
||||
)
|
||||
|
||||
end
|
||||
|
||||
def self.down
|
||||
# Only destroy the transaction backend registration.
|
||||
# Preserve signal_notification_enabled and signal_notification_channel_id
|
||||
# so admin configuration survives addon reinstalls (setup.rb runs
|
||||
# uninstall + install on every container start).
|
||||
Setting.find_by(name: '0105_signal_notification')&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddSignalSchedulers < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
# Scheduler to fetch Signal messages every 30 seconds
|
||||
# Replaces bridge-worker fetch-signal-messages task
|
||||
Scheduler.create_if_not_exists(
|
||||
name: 'Fetch Signal messages',
|
||||
method: 'CdrSignalPoller.fetch_messages',
|
||||
period: 30.seconds,
|
||||
prio: 1,
|
||||
active: true,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
|
||||
# Scheduler to check Signal group membership every 2 minutes
|
||||
# Replaces bridge-worker check-group-membership task
|
||||
Scheduler.create_if_not_exists(
|
||||
name: 'Check Signal group membership',
|
||||
method: 'CdrSignalPoller.check_group_membership',
|
||||
period: 2.minutes,
|
||||
prio: 2,
|
||||
active: true,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1,
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
Scheduler.find_by(name: 'Fetch Signal messages')&.destroy
|
||||
Scheduler.find_by(name: 'Check Signal group membership')&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddFormstackSettings < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
# Handshake key for webhook authentication
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Formstack Handshake Key',
|
||||
name: 'formstack_handshake_key',
|
||||
area: 'Integration::Formstack',
|
||||
description: 'Shared secret key for authenticating Formstack webhook requests.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'formstack_handshake_key',
|
||||
tag: 'input',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
state: '',
|
||||
preferences: {
|
||||
prio: 1,
|
||||
permission: ['admin.integration'],
|
||||
},
|
||||
frontend: false
|
||||
)
|
||||
|
||||
# Default group for Formstack tickets (override via FORMSTACK_FIELD_MAPPING)
|
||||
Setting.create_if_not_exists(
|
||||
title: 'Formstack Default Group',
|
||||
name: 'formstack_default_group',
|
||||
area: 'Integration::Formstack',
|
||||
description: 'Default Zammad group for tickets created from Formstack forms.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'formstack_default_group',
|
||||
tag: 'input',
|
||||
type: 'text',
|
||||
},
|
||||
],
|
||||
},
|
||||
state: '',
|
||||
preferences: {
|
||||
prio: 2,
|
||||
permission: ['admin.integration'],
|
||||
},
|
||||
frontend: false
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
Setting.find_by(name: 'formstack_handshake_key')&.destroy
|
||||
Setting.find_by(name: 'formstack_default_group')&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddOpensearchSettings < ActiveRecord::Migration[5.2]
|
||||
def self.up
|
||||
# OpenSearch Dashboards URL
|
||||
Setting.create_if_not_exists(
|
||||
title: 'OpenSearch Dashboards URL',
|
||||
name: 'opensearch_dashboards_url',
|
||||
area: 'Integration::OpenSearch',
|
||||
description: 'URL to OpenSearch Dashboards for analytics access.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'opensearch_dashboards_url',
|
||||
tag: 'input',
|
||||
type: 'url',
|
||||
},
|
||||
],
|
||||
},
|
||||
state: '',
|
||||
preferences: {
|
||||
prio: 1,
|
||||
permission: ['admin.integration'],
|
||||
},
|
||||
frontend: false
|
||||
)
|
||||
|
||||
# Enable/disable OpenSearch Dashboards access
|
||||
Setting.create_if_not_exists(
|
||||
title: 'OpenSearch Dashboards Enabled',
|
||||
name: 'opensearch_dashboards_enabled',
|
||||
area: 'Integration::OpenSearch',
|
||||
description: 'Enable or disable OpenSearch Dashboards integration.',
|
||||
options: {
|
||||
form: [
|
||||
{
|
||||
display: '',
|
||||
null: true,
|
||||
name: 'opensearch_dashboards_enabled',
|
||||
tag: 'boolean',
|
||||
options: {
|
||||
true => 'yes',
|
||||
false => 'no',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
state: false,
|
||||
preferences: {
|
||||
prio: 2,
|
||||
permission: ['admin.integration'],
|
||||
},
|
||||
frontend: true
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
Setting.find_by(name: 'opensearch_dashboards_url')&.destroy
|
||||
Setting.find_by(name: 'opensearch_dashboards_enabled')&.destroy
|
||||
end
|
||||
end
|
||||
408
packages/zammad-addon-link/src/lib/cdr_signal.rb
Normal file
408
packages/zammad-addon-link/src/lib/cdr_signal.rb
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'cdr_signal_api'
|
||||
|
||||
class CdrSignal
|
||||
attr_accessor :client
|
||||
|
||||
#
|
||||
# check token and return bot attributes of token
|
||||
#
|
||||
# bot = CdrSignal.check_token('token')
|
||||
#
|
||||
|
||||
def self.check_token(phone_number)
|
||||
api = CdrSignalApi.new
|
||||
unless api.check_number(phone_number)
|
||||
raise "Phone number #{phone_number} is not registered with Signal CLI"
|
||||
end
|
||||
{ 'id' => phone_number, 'number' => phone_number }
|
||||
end
|
||||
|
||||
#
|
||||
# create or update channel, store bot attributes and verify token
|
||||
#
|
||||
# channel = CdrSignal.create_or_update_channel('token', params)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# channel # instance of Channel
|
||||
#
|
||||
|
||||
def self.create_or_update_channel(phone_number, params, channel = nil)
|
||||
# verify phone number is registered with Signal CLI
|
||||
bot = CdrSignal.check_token(phone_number)
|
||||
|
||||
raise 'Bot already exists!' unless channel && CdrSignal.bot_duplicate?(bot['id'])
|
||||
|
||||
raise 'Group needed!' if params[:group_id].blank?
|
||||
|
||||
group = Group.find_by(id: params[:group_id])
|
||||
raise 'Group invalid!' unless group
|
||||
|
||||
unless channel
|
||||
channel = CdrSignal.bot_by_bot_id(bot['id'])
|
||||
channel ||= Channel.new
|
||||
end
|
||||
channel.area = 'Signal::Account'
|
||||
channel.options = {
|
||||
adapter: 'cdr_signal',
|
||||
phone_number: phone_number,
|
||||
welcome: params[:welcome]
|
||||
}
|
||||
channel.group_id = group.id
|
||||
channel.active = true
|
||||
channel.save!
|
||||
channel
|
||||
end
|
||||
|
||||
#
|
||||
# check if bot already exists as channel
|
||||
#
|
||||
# success = CdrSignal.bot_duplicate?(bot_id)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# channel # instance of Channel
|
||||
#
|
||||
|
||||
def self.bot_duplicate?(bot_id, channel_id = nil)
|
||||
Channel.where(area: 'Signal::Account').each do |channel|
|
||||
next unless channel.options
|
||||
next unless channel.options[:bot]
|
||||
next unless channel.options[:bot][:id]
|
||||
next if channel.options[:bot][:id] != bot_id
|
||||
next if channel.id.to_s == channel_id.to_s
|
||||
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
#
|
||||
# get channel by bot_id
|
||||
#
|
||||
# channel = CdrSignal.bot_by_bot_id(bot_id)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# true|false
|
||||
#
|
||||
|
||||
def self.bot_by_bot_token(bot_token)
|
||||
Channel.where(area: 'Signal::Account').each do |channel|
|
||||
next unless channel.options
|
||||
next unless channel.options[:bot_token]
|
||||
return channel if channel.options[:bot_token].to_s == bot_token.to_s
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
#
|
||||
# date = CdrSignal.timestamp_to_date('1543414973285')
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# 2018-11-28T14:22:53.285Z
|
||||
#
|
||||
|
||||
def self.timestamp_to_date(timestamp_str)
|
||||
Time.at(timestamp_str.to_i).utc.to_datetime
|
||||
end
|
||||
|
||||
def self.message_id(message_raw)
|
||||
format('%<from>s@%<timestamp>s', from: message_raw['from'], timestamp: message_raw['timestamp'])
|
||||
end
|
||||
|
||||
#
|
||||
# client = CdrSignal.new('token')
|
||||
#
|
||||
|
||||
def initialize(phone_number)
|
||||
@phone_number = phone_number
|
||||
@api = CdrSignalApi.new
|
||||
end
|
||||
|
||||
#
|
||||
# client.send_message(chat_id, 'some message')
|
||||
#
|
||||
|
||||
def send_message(recipient, message)
|
||||
return if Rails.env.test?
|
||||
|
||||
@api.send_message(@phone_number, [recipient], message)
|
||||
end
|
||||
|
||||
def user(number)
|
||||
{
|
||||
# id: params[:message][:from][:id],
|
||||
id: number,
|
||||
username: number
|
||||
# first_name: params[:message][:from][:first_name],
|
||||
# last_name: params[:message][:from][:last_name]
|
||||
}
|
||||
end
|
||||
|
||||
def to_user(message)
|
||||
Rails.logger.debug { 'Create user from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
|
||||
# do message_user lookup
|
||||
message_user = user(message[:source])
|
||||
|
||||
# create or update user
|
||||
login = message_user[:username] || message_user[:id]
|
||||
|
||||
auth = Authorization.find_by(uid: message[:source], provider: 'cdr_signal')
|
||||
|
||||
user_data = {
|
||||
login: login,
|
||||
mobile: message[:source]
|
||||
}
|
||||
|
||||
user = if auth
|
||||
User.find(auth.user_id)
|
||||
else
|
||||
User.where(mobile: message[:source]).order(:updated_at).first
|
||||
end
|
||||
if user
|
||||
user.update!(user_data)
|
||||
else
|
||||
user = User.create!(
|
||||
firstname: message[:source],
|
||||
mobile: message[:source],
|
||||
note: "Signal #{message_user[:username]}",
|
||||
active: true,
|
||||
role_ids: Role.signup_role_ids
|
||||
)
|
||||
end
|
||||
|
||||
# create or update authorization
|
||||
auth_data = {
|
||||
uid: message_user[:id],
|
||||
username: login,
|
||||
user_id: user.id,
|
||||
provider: 'cdr_signal'
|
||||
}
|
||||
if auth
|
||||
auth.update!(auth_data)
|
||||
else
|
||||
Authorization.create(auth_data)
|
||||
end
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def to_ticket(message, user, group_id, channel)
|
||||
UserInfo.current_user_id = user.id
|
||||
|
||||
Rails.logger.debug { 'Create ticket from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
Rails.logger.debug { user.inspect }
|
||||
Rails.logger.debug { group_id.inspect }
|
||||
|
||||
# prepare title
|
||||
title = '-'
|
||||
title = message[:message][:body] unless message[:message][:body].nil?
|
||||
title = "#{title[0, 60]}..." if title.length > 60
|
||||
|
||||
# find ticket or create one
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
ticket = Ticket.where(customer_id: user.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||
if ticket
|
||||
|
||||
# check if title need to be updated
|
||||
ticket.title = title if ticket.title == '-'
|
||||
new_state = Ticket::State.find_by(default_create: true)
|
||||
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||
ticket.save!
|
||||
return ticket
|
||||
end
|
||||
|
||||
ticket = Ticket.new(
|
||||
group_id: group_id,
|
||||
title: title,
|
||||
state_id: Ticket::State.find_by(default_create: true).id,
|
||||
priority_id: Ticket::Priority.find_by(default_create: true).id,
|
||||
customer_id: user.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_signal: {
|
||||
bot_token: channel.options[:bot_token],
|
||||
chat_id: message[:source]
|
||||
}
|
||||
}
|
||||
)
|
||||
ticket.save!
|
||||
ticket
|
||||
end
|
||||
|
||||
def to_article(message, user, ticket, channel)
|
||||
Rails.logger.debug { 'Create article from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
Rails.logger.debug { user.inspect }
|
||||
Rails.logger.debug { ticket.inspect }
|
||||
|
||||
UserInfo.current_user_id = user.id
|
||||
|
||||
article = Ticket::Article.new(
|
||||
from: message[:source],
|
||||
to: channel[:options][:bot][:number],
|
||||
body: message[:message][:body],
|
||||
content_type: 'text/plain',
|
||||
message_id: "cdr_signal.#{message[:id]}",
|
||||
ticket_id: ticket.id,
|
||||
type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
internal: false,
|
||||
preferences: {
|
||||
cdr_signal: {
|
||||
timestamp: message[:timestamp],
|
||||
message_id: message[:id],
|
||||
from: message[:source]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# TODO: attachments
|
||||
# TODO voice
|
||||
# TODO emojis
|
||||
#
|
||||
if message[:message][:body]
|
||||
Rails.logger.debug { article.inspect }
|
||||
article.save!
|
||||
|
||||
Store.remove(
|
||||
object: 'Ticket::Article',
|
||||
o_id: article.id
|
||||
)
|
||||
|
||||
return article
|
||||
end
|
||||
raise 'invalid action'
|
||||
end
|
||||
|
||||
def to_group(message, group_id, channel)
|
||||
# begin import
|
||||
Rails.logger.debug { 'signal import message' }
|
||||
|
||||
# TODO: handle messages in group chats
|
||||
|
||||
return if Ticket::Article.find_by(message_id: message[:id])
|
||||
|
||||
ticket = nil
|
||||
# use transaction
|
||||
Transaction.execute(reset_user_id: true) do
|
||||
user = to_user(message)
|
||||
ticket = to_ticket(message, user, group_id, channel)
|
||||
to_article(message, user, ticket, channel)
|
||||
end
|
||||
|
||||
ticket
|
||||
end
|
||||
|
||||
def from_article(article)
|
||||
# sends a message from a zammad article using direct Signal CLI API
|
||||
|
||||
Rails.logger.debug { "Create signal message from article..." }
|
||||
|
||||
# Get the recipient from ticket preferences
|
||||
ticket = Ticket.find_by(id: article.ticket_id)
|
||||
raise "No ticket found for article #{article.id}" unless ticket
|
||||
|
||||
# Get channel to find the bot phone number
|
||||
channel = Channel.find_by(id: ticket.preferences[:channel_id])
|
||||
raise "No channel found for ticket #{ticket.id}" unless channel
|
||||
|
||||
bot_phone_number = channel.options[:phone_number]
|
||||
raise "No phone number configured for channel #{channel.id}" unless bot_phone_number
|
||||
|
||||
recipient = ticket.preferences.dig('cdr_signal', 'chat_id')
|
||||
enable_auto_groups = ENV['BRIDGE_SIGNAL_AUTO_GROUPS'].to_s.downcase == 'true'
|
||||
|
||||
# If auto-groups is enabled and no chat_id, use original_recipient
|
||||
if recipient.blank? && enable_auto_groups
|
||||
recipient = ticket.preferences.dig('cdr_signal', 'original_recipient')
|
||||
raise "No Signal chat_id or original_recipient found in ticket preferences" unless recipient
|
||||
elsif recipient.blank?
|
||||
raise "No Signal chat_id found in ticket preferences"
|
||||
end
|
||||
|
||||
Rails.logger.debug { "Sending to recipient: '#{recipient}'" }
|
||||
|
||||
# Use Signal CLI API
|
||||
api = CdrSignalApi.new
|
||||
|
||||
# Check if we need to create a group (auto-groups enabled, recipient is a phone number)
|
||||
is_group_id = recipient.start_with?('group.')
|
||||
final_recipient = recipient
|
||||
|
||||
if enable_auto_groups && !is_group_id && recipient.present?
|
||||
# Create a group for this conversation
|
||||
begin
|
||||
group_name = "Support Request: #{ticket.number}"
|
||||
|
||||
Rails.logger.info "Creating Signal group '#{group_name}' for ticket ##{ticket.number}"
|
||||
|
||||
create_result = api.create_group(
|
||||
bot_phone_number,
|
||||
name: group_name,
|
||||
members: [recipient],
|
||||
description: 'Private support conversation'
|
||||
)
|
||||
|
||||
if create_result['id'].present?
|
||||
final_recipient = create_result['id']
|
||||
|
||||
# Update ticket preferences with the new group ID
|
||||
ticket.preferences[:cdr_signal] ||= {}
|
||||
ticket.preferences[:cdr_signal][:chat_id] = final_recipient
|
||||
ticket.preferences[:cdr_signal][:original_recipient] = recipient
|
||||
ticket.preferences[:cdr_signal][:group_joined] = false
|
||||
ticket.preferences[:cdr_signal][:group_created_at] = Time.current.iso8601
|
||||
ticket.save!
|
||||
|
||||
Rails.logger.info "Created Signal group #{final_recipient} for ticket ##{ticket.number}"
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to create Signal group: #{e.message}"
|
||||
# Continue with original recipient if group creation fails
|
||||
end
|
||||
end
|
||||
|
||||
# Get attachments from the article
|
||||
options = {}
|
||||
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
|
||||
if attachments.any?
|
||||
attachment_data = attachments.map do |attachment|
|
||||
{
|
||||
data: Base64.strict_encode64(attachment.content),
|
||||
filename: attachment.filename,
|
||||
mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream'
|
||||
}
|
||||
end
|
||||
options[:attachments] = attachment_data
|
||||
Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" }
|
||||
end
|
||||
|
||||
# Send the message via direct Signal CLI API
|
||||
result = api.send_message(bot_phone_number, [final_recipient], article[:body], options)
|
||||
|
||||
Rails.logger.info "Sent Signal message to #{final_recipient}"
|
||||
|
||||
# Update group name if needed (for consistency)
|
||||
if final_recipient.start_with?('group.')
|
||||
expected_name = "Support Request: #{ticket.number}"
|
||||
api.update_group(bot_phone_number, final_recipient, name: expected_name)
|
||||
end
|
||||
|
||||
# Return result in expected format
|
||||
{
|
||||
'result' => {
|
||||
'to' => final_recipient,
|
||||
'from' => bot_phone_number,
|
||||
'timestamp' => result['timestamp'] || Time.current.to_i * 1000
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
156
packages/zammad-addon-link/src/lib/cdr_signal_api.rb
Normal file
156
packages/zammad-addon-link/src/lib/cdr_signal_api.rb
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'net/https'
|
||||
require 'uri'
|
||||
|
||||
# Direct Signal CLI API client for communicating with signal-cli-rest-api
|
||||
# All Signal operations go through this single class
|
||||
class CdrSignalApi
|
||||
def initialize(base_url = nil)
|
||||
@base_url = base_url || ENV.fetch('SIGNAL_CLI_URL', 'http://signal-cli-rest-api:8080')
|
||||
end
|
||||
|
||||
# Fetch pending messages for a phone number
|
||||
# GET /v1/receive/{number}
|
||||
def fetch_messages(phone_number)
|
||||
url = "#{@base_url}/v1/receive/#{CGI.escape(phone_number)}"
|
||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||
return [] unless response.success?
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError, Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to fetch messages for #{phone_number}: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
# Fetch an attachment by ID
|
||||
# GET /v1/attachments/{id}
|
||||
def fetch_attachment(attachment_id)
|
||||
url = "#{@base_url}/v1/attachments/#{CGI.escape(attachment_id)}"
|
||||
response = Faraday.get(url)
|
||||
return nil unless response.success?
|
||||
|
||||
response.body
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to fetch attachment #{attachment_id}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
# List all groups for a phone number
|
||||
# GET /v1/groups/{number}
|
||||
def list_groups(phone_number)
|
||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||
return [] unless response.success?
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError, Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to list groups for #{phone_number}: #{e.message}"
|
||||
[]
|
||||
end
|
||||
|
||||
# Check if a phone number is registered with signal-cli
|
||||
# GET /v1/about
|
||||
def check_number(phone_number)
|
||||
# Verify we can connect to signal-cli-rest-api
|
||||
url = "#{@base_url}/v1/about"
|
||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||
return false unless response.success?
|
||||
|
||||
# Try to list groups for this number to verify it's registered
|
||||
groups_url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
||||
groups_response = Faraday.get(groups_url, nil, { 'Accept' => 'application/json' })
|
||||
groups_response.success?
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to check number #{phone_number}: #{e.message}"
|
||||
false
|
||||
end
|
||||
|
||||
# Send a message via Signal CLI
|
||||
# POST /v2/send
|
||||
def send_message(from_number, recipients, message, options = {})
|
||||
url = "#{@base_url}/v2/send"
|
||||
|
||||
data = {
|
||||
number: from_number,
|
||||
recipients: Array(recipients),
|
||||
message: message
|
||||
}
|
||||
|
||||
# Add base64 attachments if provided
|
||||
if options[:attachments].present?
|
||||
data[:base64Attachments] = options[:attachments].map { |a| a[:data] }
|
||||
end
|
||||
|
||||
# Add quote parameters if provided
|
||||
if options[:quote_timestamp] && options[:quote_author] && options[:quote_message]
|
||||
data[:quoteTimestamp] = options[:quote_timestamp]
|
||||
data[:quoteAuthor] = options[:quote_author]
|
||||
data[:quoteMessage] = options[:quote_message]
|
||||
end
|
||||
|
||||
response = Faraday.post(url, data.to_json, {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
})
|
||||
|
||||
unless response.success?
|
||||
Rails.logger.error "CdrSignalApi: Failed to send message: #{response.status} #{response.body}"
|
||||
raise "Failed to send Signal message: #{response.status}"
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to send message: #{e.message}"
|
||||
raise "Failed to send Signal message: #{e.message}"
|
||||
end
|
||||
|
||||
# Create a new Signal group
|
||||
# POST /v1/groups/{number}
|
||||
def create_group(phone_number, name:, members:, description: nil)
|
||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}"
|
||||
|
||||
data = {
|
||||
name: name,
|
||||
members: Array(members),
|
||||
description: description
|
||||
}.compact
|
||||
|
||||
response = Faraday.post(url, data.to_json, {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
})
|
||||
|
||||
unless response.success?
|
||||
Rails.logger.error "CdrSignalApi: Failed to create group: #{response.status} #{response.body}"
|
||||
raise "Failed to create Signal group: #{response.status}"
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to create group: #{e.message}"
|
||||
raise "Failed to create Signal group: #{e.message}"
|
||||
end
|
||||
|
||||
# Update a Signal group
|
||||
# PUT /v1/groups/{number}/{groupId}
|
||||
def update_group(phone_number, group_id, name: nil, description: nil)
|
||||
url = "#{@base_url}/v1/groups/#{CGI.escape(phone_number)}/#{CGI.escape(group_id)}"
|
||||
|
||||
data = {}
|
||||
data[:name] = name if name.present?
|
||||
data[:description] = description if description.present?
|
||||
|
||||
response = Faraday.put(url, data.to_json, {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
})
|
||||
|
||||
response.success?
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrSignalApi: Failed to update group: #{e.message}"
|
||||
false
|
||||
end
|
||||
end
|
||||
428
packages/zammad-addon-link/src/lib/cdr_signal_poller.rb
Normal file
428
packages/zammad-addon-link/src/lib/cdr_signal_poller.rb
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# CdrSignalPoller handles polling Signal CLI for incoming messages and group membership changes.
|
||||
# This replaces the bridge-worker tasks:
|
||||
# - fetch-signal-messages.ts
|
||||
# - check-group-membership.ts
|
||||
#
|
||||
# It runs via Zammad schedulers to poll at regular intervals.
|
||||
|
||||
class CdrSignalPoller
|
||||
class << self
|
||||
# Fetch messages from all active Signal channels
|
||||
# This is called by the scheduler every 30 seconds
|
||||
def fetch_messages
|
||||
api = CdrSignalApi.new
|
||||
channels = Channel.where(area: 'Signal::Number', active: true)
|
||||
|
||||
channels.each do |channel|
|
||||
phone_number = channel.options[:phone_number]
|
||||
bot_token = channel.options[:bot_token]
|
||||
next unless phone_number.present?
|
||||
|
||||
Rails.logger.debug { "CdrSignalPoller: Fetching messages for #{phone_number}" }
|
||||
|
||||
messages = api.fetch_messages(phone_number)
|
||||
process_messages(channel, messages, api)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "CdrSignalPoller: Error fetching messages for #{phone_number}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
# Check group membership for all active Signal channels
|
||||
# This is called by the scheduler every 2 minutes
|
||||
def check_group_membership
|
||||
api = CdrSignalApi.new
|
||||
channels = Channel.where(area: 'Signal::Number', active: true)
|
||||
|
||||
channels.each do |channel|
|
||||
phone_number = channel.options[:phone_number]
|
||||
next unless phone_number.present?
|
||||
|
||||
Rails.logger.debug { "CdrSignalPoller: Checking groups for #{phone_number}" }
|
||||
|
||||
groups = api.list_groups(phone_number)
|
||||
process_group_membership(channel, groups)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "CdrSignalPoller: Error checking groups for #{phone_number}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_messages(channel, messages, api)
|
||||
messages.each do |msg|
|
||||
envelope = msg['envelope']
|
||||
next unless envelope
|
||||
|
||||
source = envelope['source']
|
||||
source_uuid = envelope['sourceUuid']
|
||||
data_message = envelope['dataMessage']
|
||||
sync_message = envelope['syncMessage']
|
||||
|
||||
# Log envelope types for debugging
|
||||
Rails.logger.debug do
|
||||
"CdrSignalPoller: Received envelope - source: #{source}, uuid: #{source_uuid}, " \
|
||||
"dataMessage: #{data_message.present?}, syncMessage: #{sync_message.present?}"
|
||||
end
|
||||
|
||||
# Handle group join events from groupInfo
|
||||
if data_message && data_message['groupInfo']
|
||||
handle_group_info_event(channel, data_message['groupInfo'], source)
|
||||
end
|
||||
|
||||
# Process data messages with content
|
||||
next unless data_message
|
||||
|
||||
process_data_message(channel, data_message, source, source_uuid, api)
|
||||
end
|
||||
end
|
||||
|
||||
def handle_group_info_event(channel, group_info, source)
|
||||
type = group_info['type']
|
||||
return unless %w[JOIN JOINED].include?(type)
|
||||
|
||||
group_id_raw = group_info['groupId']
|
||||
return unless group_id_raw
|
||||
|
||||
group_id = "group.#{Base64.strict_encode64(group_id_raw.pack('c*'))}"
|
||||
|
||||
Rails.logger.info "CdrSignalPoller: User #{source} joined group #{group_id}"
|
||||
notify_group_member_joined(channel, group_id, source)
|
||||
end
|
||||
|
||||
def process_data_message(channel, data_message, source, source_uuid, api)
|
||||
# Determine if this is a group message
|
||||
is_group = data_message['groupV2'].present? ||
|
||||
data_message['groupContext'].present? ||
|
||||
data_message['groupInfo'].present?
|
||||
|
||||
# Get group ID if applicable
|
||||
group_id_raw = data_message.dig('groupV2', 'id') ||
|
||||
data_message.dig('groupContext', 'id') ||
|
||||
data_message.dig('groupInfo', 'groupId')
|
||||
|
||||
phone_number = channel.options[:phone_number]
|
||||
to_recipient = if group_id_raw
|
||||
"group.#{Base64.strict_encode64(group_id_raw.is_a?(Array) ? group_id_raw.pack('c*') : group_id_raw)}"
|
||||
else
|
||||
phone_number
|
||||
end
|
||||
|
||||
# Skip if message is from self
|
||||
return if source == phone_number
|
||||
|
||||
message_text = data_message['message']
|
||||
raw_timestamp = data_message['timestamp']
|
||||
attachments = data_message['attachments']
|
||||
|
||||
# Generate unique message ID
|
||||
message_id = "#{source_uuid}-#{raw_timestamp}"
|
||||
|
||||
# Check for duplicate
|
||||
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
||||
|
||||
# Fetch and encode attachments
|
||||
attachment_data = fetch_attachments(attachments, api)
|
||||
|
||||
# Process the message through the webhook handler
|
||||
process_incoming_message(
|
||||
channel: channel,
|
||||
to: to_recipient,
|
||||
from: source,
|
||||
user_id: source_uuid,
|
||||
message_id: message_id,
|
||||
message: message_text,
|
||||
sent_at: raw_timestamp ? Time.at(raw_timestamp / 1000).iso8601 : Time.current.iso8601,
|
||||
attachments: attachment_data,
|
||||
is_group: is_group
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_attachments(attachments, api)
|
||||
return [] unless attachments.is_a?(Array)
|
||||
|
||||
attachments.filter_map do |att|
|
||||
id = att['id']
|
||||
content_type = att['contentType']
|
||||
filename = att['filename']
|
||||
|
||||
blob = api.fetch_attachment(id)
|
||||
next unless blob
|
||||
|
||||
# Generate filename if not provided
|
||||
default_filename = filename
|
||||
unless default_filename
|
||||
extension = content_type&.split('/')&.last || 'bin'
|
||||
default_filename = id.include?('.') ? id : "#{id}.#{extension}"
|
||||
end
|
||||
|
||||
{
|
||||
filename: default_filename,
|
||||
mime_type: content_type,
|
||||
data: Base64.strict_encode64(blob)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def process_incoming_message(channel:, to:, from:, user_id:, message_id:, message:, sent_at:, attachments:, is_group:)
|
||||
# Find or create customer
|
||||
customer = find_or_create_customer(from, user_id)
|
||||
return unless customer
|
||||
|
||||
# Set current user context
|
||||
UserInfo.current_user_id = customer.id
|
||||
|
||||
# Find or create ticket
|
||||
ticket = find_or_create_ticket(
|
||||
channel: channel,
|
||||
customer: customer,
|
||||
to: to,
|
||||
from: from,
|
||||
user_id: user_id,
|
||||
is_group: is_group,
|
||||
sent_at: sent_at
|
||||
)
|
||||
|
||||
# Create article
|
||||
create_article(
|
||||
ticket: ticket,
|
||||
from: from,
|
||||
to: to,
|
||||
user_id: user_id,
|
||||
message_id: message_id,
|
||||
message: message || 'No text content',
|
||||
sent_at: sent_at,
|
||||
attachments: attachments
|
||||
)
|
||||
|
||||
Rails.logger.info "CdrSignalPoller: Created article for ticket ##{ticket.number} from #{from}"
|
||||
end
|
||||
|
||||
def find_or_create_customer(phone_number, user_id)
|
||||
# Try phone number first
|
||||
customer = User.find_by(phone: phone_number) if phone_number.present?
|
||||
customer ||= User.find_by(mobile: phone_number) if phone_number.present?
|
||||
|
||||
# Try user ID
|
||||
if customer.nil? && user_id.present?
|
||||
customer = User.find_by(signal_uid: user_id)
|
||||
customer ||= User.find_by(phone: user_id)
|
||||
customer ||= User.find_by(mobile: user_id)
|
||||
end
|
||||
|
||||
# Create new customer if not found
|
||||
unless customer
|
||||
role_ids = Role.signup_role_ids
|
||||
customer = User.create!(
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phone: phone_number.presence || user_id,
|
||||
signal_uid: user_id,
|
||||
note: 'CDR Signal',
|
||||
active: true,
|
||||
role_ids: role_ids,
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
end
|
||||
|
||||
# Update signal_uid if needed
|
||||
customer.update!(signal_uid: user_id) if user_id.present? && customer.signal_uid.blank?
|
||||
|
||||
# Update phone if customer only has user_id
|
||||
customer.update!(phone: phone_number) if phone_number.present? && customer.phone == user_id
|
||||
|
||||
customer
|
||||
end
|
||||
|
||||
def find_or_create_ticket(channel:, customer:, to:, from:, user_id:, is_group:, sent_at:)
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
sender_display = from.presence || user_id
|
||||
|
||||
if is_group
|
||||
# Find ticket by group ID
|
||||
ticket = Ticket.where.not(state_id: state_ids)
|
||||
.where('preferences LIKE ?', "%channel_id: #{channel.id}%")
|
||||
.where('preferences LIKE ?', "%chat_id: #{to}%")
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
else
|
||||
# Find ticket by customer
|
||||
ticket = Ticket.where(customer_id: customer.id)
|
||||
.where.not(state_id: state_ids)
|
||||
.order(:updated_at)
|
||||
.first
|
||||
end
|
||||
|
||||
if ticket
|
||||
ticket.title = "Message from #{sender_display} at #{sent_at}" if ticket.title == '-'
|
||||
new_state = Ticket::State.find_by(default_create: true)
|
||||
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||
else
|
||||
chat_id = is_group ? to : (user_id.presence || from)
|
||||
|
||||
cdr_signal_prefs = {
|
||||
bot_token: channel.options[:bot_token],
|
||||
chat_id: chat_id,
|
||||
user_id: user_id
|
||||
}
|
||||
cdr_signal_prefs[:original_recipient] = from if is_group
|
||||
|
||||
ticket = Ticket.new(
|
||||
group_id: channel.group_id,
|
||||
title: "Message from #{sender_display} at #{sent_at}",
|
||||
customer_id: customer.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_signal: cdr_signal_prefs
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
ticket.save!
|
||||
ticket.update!(create_article_type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id)
|
||||
ticket
|
||||
end
|
||||
|
||||
def create_article(ticket:, from:, to:, user_id:, message_id:, message:, sent_at:, attachments:)
|
||||
sender_display = from.presence || user_id
|
||||
|
||||
article_params = {
|
||||
ticket_id: ticket.id,
|
||||
type_id: Ticket::Article::Type.find_by(name: 'cdr_signal').id,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
from: sender_display,
|
||||
to: to,
|
||||
subject: "Message from #{sender_display} at #{sent_at}",
|
||||
body: message,
|
||||
content_type: 'text/plain',
|
||||
message_id: "cdr_signal.#{message_id}",
|
||||
internal: false,
|
||||
preferences: {
|
||||
cdr_signal: {
|
||||
timestamp: sent_at,
|
||||
message_id: message_id,
|
||||
from: from,
|
||||
user_id: user_id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Add primary attachment if present
|
||||
if attachments.present? && attachments.first
|
||||
primary = attachments.first
|
||||
article_params[:attachments] = [{
|
||||
'filename' => primary[:filename],
|
||||
filename: primary[:filename],
|
||||
data: primary[:data],
|
||||
'data' => primary[:data],
|
||||
'mime-type' => primary[:mime_type]
|
||||
}]
|
||||
end
|
||||
|
||||
ticket.with_lock do
|
||||
article = Ticket::Article.create!(article_params)
|
||||
|
||||
# Create additional articles for extra attachments
|
||||
attachments[1..].each_with_index do |att, index|
|
||||
Ticket::Article.create!(
|
||||
article_params.merge(
|
||||
message_id: "cdr_signal.#{message_id}-#{index + 1}",
|
||||
subject: att[:filename],
|
||||
body: att[:filename],
|
||||
attachments: [{
|
||||
'filename' => att[:filename],
|
||||
filename: att[:filename],
|
||||
data: att[:data],
|
||||
'data' => att[:data],
|
||||
'mime-type' => att[:mime_type]
|
||||
}]
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
article
|
||||
end
|
||||
end
|
||||
|
||||
def process_group_membership(channel, groups)
|
||||
groups.each do |group|
|
||||
group_id = group['id']
|
||||
internal_id = group['internalId']
|
||||
members = group['members'] || []
|
||||
next unless group_id && internal_id
|
||||
|
||||
Rails.logger.debug do
|
||||
"CdrSignalPoller: Group #{group['name']} - #{members.length} members, " \
|
||||
"#{(group['pendingInvites'] || []).length} pending"
|
||||
end
|
||||
|
||||
members.each do |member_phone|
|
||||
notify_group_member_joined(channel, group_id, member_phone)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def notify_group_member_joined(channel, group_id, member_phone)
|
||||
# Find ticket with this group_id
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
|
||||
ticket = Ticket.where.not(state_id: state_ids)
|
||||
.where('preferences LIKE ?', "%chat_id: #{group_id}%")
|
||||
.order(updated_at: :desc)
|
||||
.first
|
||||
|
||||
return unless ticket
|
||||
|
||||
# Idempotency check
|
||||
return if ticket.preferences.dig('cdr_signal', 'group_joined') == true
|
||||
|
||||
# Update group_joined flag
|
||||
ticket.preferences[:cdr_signal] ||= {}
|
||||
ticket.preferences[:cdr_signal][:group_joined] = true
|
||||
ticket.preferences[:cdr_signal][:group_joined_at] = Time.current.iso8601
|
||||
ticket.preferences[:cdr_signal][:group_joined_by] = member_phone
|
||||
ticket.save!
|
||||
|
||||
Rails.logger.info "CdrSignalPoller: Member #{member_phone} joined group #{group_id} for ticket ##{ticket.number}"
|
||||
|
||||
# Add resolution note if there were pending notifications
|
||||
add_group_join_resolution_note(ticket)
|
||||
end
|
||||
|
||||
def add_group_join_resolution_note(ticket)
|
||||
# Check if any articles had a group_not_joined notification
|
||||
articles_with_pending = Ticket::Article.where(ticket_id: ticket.id)
|
||||
.where('preferences LIKE ?', '%group_not_joined_note_added: true%')
|
||||
|
||||
return unless articles_with_pending.exists?
|
||||
|
||||
# Check if resolution note already exists
|
||||
resolution_exists = Ticket::Article.where(ticket_id: ticket.id)
|
||||
.where('preferences LIKE ?', '%group_joined_resolution: true%')
|
||||
.exists?
|
||||
|
||||
return if resolution_exists
|
||||
|
||||
Ticket::Article.create!(
|
||||
ticket_id: ticket.id,
|
||||
content_type: 'text/plain',
|
||||
body: 'Recipient has now joined the Signal group. Pending messages will be delivered shortly.',
|
||||
internal: true,
|
||||
sender: Ticket::Article::Sender.find_by(name: 'System'),
|
||||
type: Ticket::Article::Type.find_by(name: 'note'),
|
||||
preferences: {
|
||||
delivery_message: true,
|
||||
group_joined_resolution: true
|
||||
},
|
||||
updated_by_id: 1,
|
||||
created_by_id: 1
|
||||
)
|
||||
|
||||
Rails.logger.info "CdrSignalPoller: Added resolution note for ticket ##{ticket.number}"
|
||||
end
|
||||
end
|
||||
end
|
||||
344
packages/zammad-addon-link/src/lib/cdr_whatsapp.rb
Normal file
344
packages/zammad-addon-link/src/lib/cdr_whatsapp.rb
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'cdr_whatsapp_api'
|
||||
|
||||
class CdrWhatsapp
|
||||
attr_accessor :client
|
||||
|
||||
#
|
||||
# check token and return bot attributes of token
|
||||
#
|
||||
# bot = CdrWhatsapp.check_token('token')
|
||||
#
|
||||
|
||||
def self.check_token(api_url, token)
|
||||
api = CdrWhatsappApi.new(api_url, token)
|
||||
begin
|
||||
bot = api.fetch_self
|
||||
rescue StandardError => e
|
||||
raise "invalid api token: #{e.message}"
|
||||
end
|
||||
bot
|
||||
end
|
||||
|
||||
#
|
||||
# create or update channel, store bot attributes and verify token
|
||||
#
|
||||
# channel = CdrWhatsapp.create_or_update_channel('token', params)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# channel # instance of Channel
|
||||
#
|
||||
|
||||
def self.create_or_update_channel(api_url, token, params, channel = nil)
|
||||
# verify token
|
||||
bot = CdrWhatsapp.check_token(api_url, token)
|
||||
|
||||
raise 'Bot already exists!' unless channel && CdrWhatsapp.bot_duplicate?(bot['id'])
|
||||
|
||||
raise 'Group needed!' if params[:group_id].blank?
|
||||
|
||||
group = Group.find_by(id: params[:group_id])
|
||||
raise 'Group invalid!' unless group
|
||||
|
||||
unless channel
|
||||
channel = CdrWhatsapp.bot_by_bot_id(bot['id'])
|
||||
channel ||= Channel.new
|
||||
end
|
||||
channel.area = 'Whatsapp::Account'
|
||||
channel.options = {
|
||||
adapter: 'cdr_whatsapp',
|
||||
bot: {
|
||||
id: bot['id'],
|
||||
number: bot['number']
|
||||
},
|
||||
api_token: token,
|
||||
api_url: api_url,
|
||||
welcome: params[:welcome]
|
||||
}
|
||||
channel.group_id = group.id
|
||||
channel.active = true
|
||||
channel.save!
|
||||
channel
|
||||
end
|
||||
|
||||
#
|
||||
# check if bot already exists as channel
|
||||
#
|
||||
# success = CdrWhatsapp.bot_duplicate?(bot_id)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# channel # instance of Channel
|
||||
#
|
||||
|
||||
def self.bot_duplicate?(bot_id, channel_id = nil)
|
||||
Channel.where(area: 'Whatsapp::Account').each do |channel|
|
||||
next unless channel.options
|
||||
next unless channel.options[:bot]
|
||||
next unless channel.options[:bot][:id]
|
||||
next if channel.options[:bot][:id] != bot_id
|
||||
next if channel.id.to_s == channel_id.to_s
|
||||
|
||||
return true
|
||||
end
|
||||
false
|
||||
end
|
||||
|
||||
#
|
||||
# get channel by bot_id
|
||||
#
|
||||
# channel = CdrWhatsapp.bot_by_bot_id(bot_id)
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# true|false
|
||||
#
|
||||
|
||||
def self.bot_by_bot_token(bot_token)
|
||||
Channel.where(area: 'Whatsapp::Account').each do |channel|
|
||||
next unless channel.options
|
||||
next unless channel.options[:bot_token]
|
||||
return channel if channel.options[:bot_token].to_s == bot_token.to_s
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
#
|
||||
# date = CdrWhatsapp.timestamp_to_date('1543414973285')
|
||||
#
|
||||
# returns
|
||||
#
|
||||
# 2018-11-28T14:22:53.285Z
|
||||
#
|
||||
|
||||
def self.timestamp_to_date(timestamp_str)
|
||||
Time.at(timestamp_str.to_i).utc.to_datetime
|
||||
end
|
||||
|
||||
def self.message_id(message_raw)
|
||||
format('%<from>s@%<timestamp>s', from: message_raw['from'], timestamp: message_raw['timestamp'])
|
||||
end
|
||||
|
||||
#
|
||||
# client = CdrWhatsapp.new('token')
|
||||
#
|
||||
|
||||
def initialize(api_url, token)
|
||||
@token = token
|
||||
@api_url = api_url
|
||||
@api = CdrWhatsappApi.new(api_url, token)
|
||||
end
|
||||
|
||||
#
|
||||
# client.send_message(chat_id, 'some message')
|
||||
#
|
||||
|
||||
def send_message(recipient, message)
|
||||
return if Rails.env.test?
|
||||
|
||||
@api.send_message(recipient, message)
|
||||
end
|
||||
|
||||
def user(number)
|
||||
{
|
||||
# id: params[:message][:from][:id],
|
||||
id: number,
|
||||
username: number
|
||||
# first_name: params[:message][:from][:first_name],
|
||||
# last_name: params[:message][:from][:last_name]
|
||||
}
|
||||
end
|
||||
|
||||
def to_user(message)
|
||||
Rails.logger.debug { 'Create user from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
|
||||
# do message_user lookup
|
||||
message_user = user(message[:source])
|
||||
|
||||
# create or update user
|
||||
login = message_user[:username] || message_user[:id]
|
||||
|
||||
auth = Authorization.find_by(uid: message[:source], provider: 'whatsapp')
|
||||
|
||||
user_data = {
|
||||
login: login,
|
||||
mobile: message[:source]
|
||||
}
|
||||
|
||||
user = if auth
|
||||
User.find(auth.user_id)
|
||||
else
|
||||
User.where(mobile: message[:source]).order(:updated_at).first
|
||||
end
|
||||
if user
|
||||
user.update!(user_data)
|
||||
else
|
||||
user = User.create!(
|
||||
firstname: message[:source],
|
||||
mobile: message[:source],
|
||||
note: "Whatsapp #{message_user[:username]}",
|
||||
active: true,
|
||||
role_ids: Role.signup_role_ids
|
||||
)
|
||||
end
|
||||
|
||||
# create or update authorization
|
||||
auth_data = {
|
||||
uid: message_user[:id],
|
||||
username: login,
|
||||
user_id: user.id,
|
||||
provider: 'cdr_whatsapp'
|
||||
}
|
||||
if auth
|
||||
auth.update!(auth_data)
|
||||
else
|
||||
Authorization.create(auth_data)
|
||||
end
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def to_ticket(message, user, group_id, channel)
|
||||
UserInfo.current_user_id = user.id
|
||||
|
||||
Rails.logger.debug { 'Create ticket from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
Rails.logger.debug { user.inspect }
|
||||
Rails.logger.debug { group_id.inspect }
|
||||
|
||||
# prepare title
|
||||
title = '-'
|
||||
title = message[:message][:body] unless message[:message][:body].nil?
|
||||
title = "#{title[0, 60]}..." if title.length > 60
|
||||
|
||||
# find ticket or create one
|
||||
state_ids = Ticket::State.where(name: %w[closed merged removed]).pluck(:id)
|
||||
ticket = Ticket.where(customer_id: user.id).where.not(state_id: state_ids).order(:updated_at).first
|
||||
if ticket
|
||||
|
||||
# check if title need to be updated
|
||||
ticket.title = title if ticket.title == '-'
|
||||
new_state = Ticket::State.find_by(default_create: true)
|
||||
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||
ticket.save!
|
||||
return ticket
|
||||
end
|
||||
|
||||
ticket = Ticket.new(
|
||||
group_id: group_id,
|
||||
title: title,
|
||||
state_id: Ticket::State.find_by(default_create: true).id,
|
||||
priority_id: Ticket::Priority.find_by(default_create: true).id,
|
||||
customer_id: user.id,
|
||||
preferences: {
|
||||
channel_id: channel.id,
|
||||
cdr_whatsapp: {
|
||||
bot_id: channel.options[:bot][:id],
|
||||
chat_id: message[:source]
|
||||
}
|
||||
}
|
||||
)
|
||||
ticket.save!
|
||||
ticket
|
||||
end
|
||||
|
||||
def to_article(message, user, ticket, channel)
|
||||
Rails.logger.debug { 'Create article from message...' }
|
||||
Rails.logger.debug { message.inspect }
|
||||
Rails.logger.debug { user.inspect }
|
||||
Rails.logger.debug { ticket.inspect }
|
||||
|
||||
UserInfo.current_user_id = user.id
|
||||
|
||||
article = Ticket::Article.new(
|
||||
from: message[:source],
|
||||
to: channel[:options][:bot][:number],
|
||||
body: message[:message][:body],
|
||||
content_type: 'text/plain',
|
||||
message_id: "cdr_whatsapp.#{message[:id]}",
|
||||
ticket_id: ticket.id,
|
||||
type_id: Ticket::Article::Type.find_by(name: 'cdr_whatsapp').id,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
internal: false,
|
||||
preferences: {
|
||||
cdr_whatsapp: {
|
||||
timestamp: message[:timestamp],
|
||||
message_id: message[:id],
|
||||
from: message[:source]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
# TODO: attachments
|
||||
# TODO voice
|
||||
# TODO emojis
|
||||
#
|
||||
if message[:message][:body]
|
||||
Rails.logger.debug { article.inspect }
|
||||
article.save!
|
||||
|
||||
Store.remove(
|
||||
object: 'Ticket::Article',
|
||||
o_id: article.id
|
||||
)
|
||||
|
||||
return article
|
||||
end
|
||||
raise 'invalid action'
|
||||
end
|
||||
|
||||
def to_group(message, group_id, channel)
|
||||
# begin import
|
||||
Rails.logger.debug { 'whatsapp import message' }
|
||||
|
||||
# TODO: handle messages in group chats
|
||||
|
||||
return if Ticket::Article.find_by(message_id: message[:id])
|
||||
|
||||
ticket = nil
|
||||
# use transaction
|
||||
Transaction.execute(reset_user_id: true) do
|
||||
user = to_user(message)
|
||||
ticket = to_ticket(message, user, group_id, channel)
|
||||
to_article(message, user, ticket, channel)
|
||||
end
|
||||
|
||||
ticket
|
||||
end
|
||||
|
||||
def from_article(article)
|
||||
# sends a message from a zammad article
|
||||
|
||||
Rails.logger.debug { "Create whatsapp message from article..." }
|
||||
|
||||
# Get the recipient from ticket preferences
|
||||
ticket = Ticket.find_by(id: article.ticket_id)
|
||||
raise "No ticket found for article #{article.id}" unless ticket
|
||||
|
||||
recipient = ticket.preferences.dig('cdr_whatsapp', 'chat_id')
|
||||
raise "No WhatsApp chat_id found in ticket preferences" unless recipient
|
||||
|
||||
Rails.logger.debug { "Sending to recipient: '#{recipient}'" }
|
||||
|
||||
options = {}
|
||||
|
||||
# Get attachments from the article
|
||||
attachments = Store.list(object: 'Ticket::Article', o_id: article.id)
|
||||
if attachments.any?
|
||||
attachment_data = attachments.map do |attachment|
|
||||
{
|
||||
data: Base64.strict_encode64(attachment.content),
|
||||
filename: attachment.filename,
|
||||
mime_type: attachment.preferences['Mime-Type'] || attachment.preferences['Content-Type'] || 'application/octet-stream'
|
||||
}
|
||||
end
|
||||
options[:attachments] = attachment_data
|
||||
Rails.logger.debug { "Sending #{attachment_data.length} attachment(s) with message" }
|
||||
end
|
||||
|
||||
@api.send_message(recipient, article[:body], options)
|
||||
end
|
||||
end
|
||||
84
packages/zammad-addon-link/src/lib/cdr_whatsapp_api.rb
Normal file
84
packages/zammad-addon-link/src/lib/cdr_whatsapp_api.rb
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'json'
|
||||
require 'net/http'
|
||||
require 'net/https'
|
||||
require 'uri'
|
||||
|
||||
# Direct WhatsApp API client that calls bridge-whatsapp container directly
|
||||
# Bypasses bridge-worker to communicate with bridge-whatsapp at BRIDGE_WHATSAPP_URL
|
||||
class CdrWhatsappApi
|
||||
def initialize(api_url, token)
|
||||
@token = token
|
||||
@last_update = 0
|
||||
# Use direct bridge-whatsapp URL instead of bridge-worker
|
||||
@api_url = ENV.fetch('BRIDGE_WHATSAPP_URL', api_url || 'http://bridge-whatsapp:5000')
|
||||
end
|
||||
|
||||
def get(api)
|
||||
url = "#{@api_url}/api/bots/#{@token}/#{api}"
|
||||
response = Faraday.get(url, nil, { 'Accept' => 'application/json' })
|
||||
return {} unless response.success?
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError, Faraday::Error => e
|
||||
Rails.logger.error "CdrWhatsappApi: GET #{api} failed: #{e.message}"
|
||||
{}
|
||||
end
|
||||
|
||||
def post(api, params = {})
|
||||
url = "#{@api_url}/api/bots/#{@token}/#{api}"
|
||||
response = Faraday.post(url, params.to_json, {
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json'
|
||||
})
|
||||
|
||||
unless response.success?
|
||||
Rails.logger.error "CdrWhatsappApi: POST #{api} failed: #{response.status} #{response.body}"
|
||||
raise "Failed to call WhatsApp API: #{response.status}"
|
||||
end
|
||||
|
||||
JSON.parse(response.body)
|
||||
rescue JSON::ParserError => e
|
||||
Rails.logger.error "CdrWhatsappApi: Failed to parse response: #{e.message}"
|
||||
{}
|
||||
rescue Faraday::Error => e
|
||||
Rails.logger.error "CdrWhatsappApi: POST #{api} failed: #{e.message}"
|
||||
raise "Failed to call WhatsApp API: #{e.message}"
|
||||
end
|
||||
|
||||
def fetch_self
|
||||
get('')
|
||||
end
|
||||
|
||||
# Send a message via bridge-whatsapp
|
||||
# POST /api/bots/{id}/send with { phoneNumber, message, attachments }
|
||||
def send_message(recipient, text, options = {})
|
||||
params = {
|
||||
phoneNumber: recipient.to_s,
|
||||
message: text
|
||||
}
|
||||
|
||||
# Convert attachments to bridge-whatsapp format
|
||||
if options[:attachments].present?
|
||||
params[:attachments] = options[:attachments].map do |att|
|
||||
{
|
||||
data: att[:data],
|
||||
filename: att[:filename],
|
||||
mime_type: att[:mime_type]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
result = post('send', params)
|
||||
|
||||
# Return in expected format for compatibility
|
||||
{
|
||||
'result' => {
|
||||
'to' => result.dig('result', 'recipient') || recipient,
|
||||
'from' => result.dig('result', 'source') || @token,
|
||||
'timestamp' => result.dig('result', 'timestamp') || Time.current.iso8601
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
142
packages/zammad-addon-link/src/lib/signal_notification_sender.rb
Normal file
142
packages/zammad-addon-link/src/lib/signal_notification_sender.rb
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'erb'
|
||||
require 'cdr_signal_api'
|
||||
|
||||
class SignalNotificationSender
|
||||
TEMPLATE_DIR = Rails.root.join('app', 'views', 'signal_notification')
|
||||
|
||||
class << self
|
||||
def build_message(ticket:, article:, user:, type:, changes:)
|
||||
template_name = template_for_type(type)
|
||||
return if template_name.blank?
|
||||
|
||||
locale = user.locale || Setting.get('locale_default') || 'en'
|
||||
template_path = find_template(template_name, locale)
|
||||
return if template_path.blank?
|
||||
|
||||
render_template(template_path, binding_for(ticket, article, user, changes))
|
||||
end
|
||||
|
||||
def send_message(channel:, recipient:, message:)
|
||||
return if Rails.env.test?
|
||||
return if channel.blank?
|
||||
return if recipient.blank?
|
||||
return if message.blank?
|
||||
|
||||
# Get the phone number from channel options
|
||||
phone_number = channel.options['phone_number'] || channel.options[:phone_number] ||
|
||||
channel.options.dig('bot', 'number') || channel.options.dig(:bot, :number)
|
||||
|
||||
return if phone_number.blank?
|
||||
|
||||
# Use direct Signal CLI API
|
||||
api = CdrSignalApi.new
|
||||
api.send_message(phone_number, [recipient], message)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def template_for_type(type)
|
||||
case type
|
||||
when 'create'
|
||||
'ticket_create'
|
||||
when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction'
|
||||
'ticket_update'
|
||||
when 'reminder_reached'
|
||||
'ticket_reminder_reached'
|
||||
when 'escalation', 'escalation_warning'
|
||||
'ticket_escalation'
|
||||
end
|
||||
end
|
||||
|
||||
def find_template(template_name, locale)
|
||||
base_locale = locale.split('-').first
|
||||
|
||||
[locale, base_locale, 'en'].uniq.each do |try_locale|
|
||||
path = TEMPLATE_DIR.join(template_name, "#{try_locale}.txt.erb")
|
||||
return path if File.exist?(path)
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def binding_for(ticket, article, user, changes)
|
||||
TemplateContext.new(
|
||||
ticket: ticket,
|
||||
article: article,
|
||||
user: user,
|
||||
changes: changes,
|
||||
config: {
|
||||
http_type: Setting.get('http_type'),
|
||||
fqdn: Setting.get('fqdn'),
|
||||
product_name: Setting.get('product_name')
|
||||
}
|
||||
).get_binding
|
||||
end
|
||||
|
||||
def render_template(template_path, binding)
|
||||
template = File.read(template_path)
|
||||
erb = ERB.new(template, trim_mode: '-')
|
||||
erb.result(binding).strip
|
||||
end
|
||||
end
|
||||
|
||||
class TemplateContext
|
||||
attr_reader :ticket, :article, :recipient, :changes, :config
|
||||
|
||||
def initialize(ticket:, article:, user:, changes:, config:)
|
||||
@ticket = ticket
|
||||
@article = article
|
||||
@recipient = user
|
||||
@changes = changes
|
||||
@config = OpenStruct.new(config)
|
||||
end
|
||||
|
||||
def get_binding
|
||||
binding
|
||||
end
|
||||
|
||||
def ticket_url
|
||||
"#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}"
|
||||
end
|
||||
|
||||
def ticket_url_with_article
|
||||
if article
|
||||
"#{ticket_url}/#{article.id}"
|
||||
else
|
||||
ticket_url
|
||||
end
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.lookup(id: ticket.updated_by_id) || User.lookup(id: 1)
|
||||
end
|
||||
|
||||
def changes_summary
|
||||
return '' if changes.blank?
|
||||
|
||||
changes.map { |key, values| "#{key}: #{values[0]} -> #{values[1]}" }.join("\n")
|
||||
end
|
||||
|
||||
def article_body_preview(max_length = 500)
|
||||
return '' unless article
|
||||
return '' if article.body.blank?
|
||||
|
||||
body = article.body.to_s
|
||||
body = ActionController::Base.helpers.strip_tags(body) if article.content_type&.include?('html')
|
||||
body = body.gsub(/\s+/, ' ').strip
|
||||
|
||||
if body.length > max_length
|
||||
"#{body[0, max_length]}..."
|
||||
else
|
||||
body
|
||||
end
|
||||
end
|
||||
|
||||
def t(text)
|
||||
locale = recipient.locale || Setting.get('locale_default') || 'en'
|
||||
Translation.translate(locale, text)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<symbol id="icon-cdr-signal" viewBox="0 0 17 17"><title>signal</title>
|
||||
<defs><path id="a" d="M1 .41h.925v2.167H1z"/><path id="c" d="M.356 1h2.179v.745H.355z"/><path id="e" d="M.935.432h.921v2.167h-.92z"/><path id="g" d="M1 .202h.838V2.37H1z"/><path id="i" d="M1 .95h.856v2.165H.999z"/><path id="k" d="M.09.605H1.62V2H.09z"/></defs><g fill="none" fill-rule="evenodd"><path d="M15.255 7.872c0-3.422-2.988-6.196-6.675-6.196-3.686 0-6.717 2.774-6.717 6.196 0 1.925.59 3.806 2.18 4.913l.133 1.986a.327.327 0 0 0 .497.258c.703-.427 1.966-1.19 2.005-1.18 5.168 1.121 8.577-2.555 8.577-5.977" fill="#C6C7C8"/><g transform="translate(-1 5)"><mask id="b" fill="#fff"><use xlink:href="#a"/></mask><path d="M1.842.555l-.4-.145a7.466 7.466 0 0 0-.434 2.145l.426.022c.036-.69.174-1.37.408-2.022" fill="#C6C7C8" mask="url(#b)"/></g><path d="M6.791.59L6.708.17A8.732 8.732 0 0 0 4.64.858l.183.385A8.358 8.358 0 0 1 6.792.59M4.272 1.533l-.213-.37a8.43 8.43 0 0 0-1.736 1.322l.3.302a8.05 8.05 0 0 1 1.649-1.254M2.199 3.243l-.324-.278A7.886 7.886 0 0 0 .69 4.801l.388.18a7.477 7.477 0 0 1 1.12-1.738" fill="#C6C7C8"/><g transform="translate(7 -1)"><mask id="d" fill="#fff"><use xlink:href="#c"/></mask><path d="M2.484 1.489l.05-.424a9.026 9.026 0 0 0-2.178.002l.052.423a8.634 8.634 0 0 1 2.076-.001" fill="#C6C7C8" mask="url(#d)"/></g><path d="M16.18 4.822a7.854 7.854 0 0 0-1.171-1.845l-.325.276a7.45 7.45 0 0 1 1.107 1.745l.389-.176z" fill="#C6C7C8"/><g transform="translate(15 5)"><mask id="f" fill="#fff"><use xlink:href="#e"/></mask><path d="M1.42 2.599l.427-.021A7.568 7.568 0 0 0 1.425.432l-.402.143c.23.652.364 1.333.397 2.024" fill="#C6C7C8" mask="url(#f)"/></g><path d="M14.564 2.493a8.426 8.426 0 0 0-1.731-1.33l-.214.37c.604.35 1.157.774 1.643 1.262l.302-.302zM12.252.857a8.653 8.653 0 0 0-2.07-.688L10.1.587a8.23 8.23 0 0 1 1.966.654l.186-.384zM12.98 14.214l.229.361a7.813 7.813 0 0 0 1.668-1.41l-.318-.284a7.376 7.376 0 0 1-1.578 1.333" fill="#C6C7C8"/><g transform="translate(-1 8)"><mask id="h" fill="#fff"><use xlink:href="#g"/></mask><path d="M1.426.202L1 .21c.016.773.104 1.499.261 2.16l.415-.1a9.917 9.917 0 0 1-.25-2.068" fill="#C6C7C8" mask="url(#h)"/></g><path d="M10.495 15.233l.095.415a8.604 8.604 0 0 0 2.049-.746l-.198-.378c-.6.312-1.257.55-1.947.709M8.314 15.47h-.001c-.17 0-.343-.004-.516-.012l-.02.426a10.49 10.49 0 0 0 2.169-.112l-.066-.422c-.503.08-1.03.12-1.566.12" fill="#C6C7C8"/><g transform="translate(15 7)"><mask id="j" fill="#fff"><use xlink:href="#i"/></mask><path d="M1.43.95c0 .692-.098 1.38-.288 2.048l.41.117A7.892 7.892 0 0 0 1.856.95H1.43z" fill="#C6C7C8" mask="url(#j)"/></g><path d="M14.953 12.4l.34.255a7.809 7.809 0 0 0 1.053-1.916l-.4-.152a7.374 7.374 0 0 1-.993 1.813M.44 11.002a6.393 6.393 0 0 0 1.016 1.946l.337-.26a5.979 5.979 0 0 1-.948-1.819l-.405.133zM6.653 15.353c-.058 0-.082 0-1.604.624l.163.395a48.799 48.799 0 0 1 1.46-.588c.152.021.306.038.457.053l.042-.424a13.84 13.84 0 0 1-.49-.058c-.008-.002-.018-.002-.028-.002M2.94 13.828a.215.215 0 0 0-.078-.109 5.416 5.416 0 0 1-.665-.57l-.304.3c.204.206.426.4.66.573l.346 1.1.407-.129-.366-1.165z" fill="#C6C7C8"/><g transform="translate(3 15)"><mask id="l" fill="#fff"><use xlink:href="#k"/></mask><path d="M.779 1.5L.498.606.09.732.44 1.85A.219.219 0 0 0 .645 2a.203.203 0 0 0 .08-.017l.894-.368-.163-.395-.677.28" fill="#C6C7C8" mask="url(#l)"/></g></g>
|
||||
</symbol>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<symbol id="icon-cdr-whatsapp" viewBox="0 0 17 17"><title>whatsapp</title>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 1000 1000" enable-background="new 0 0 1000 1000" xml:space="preserve">
|
||||
<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
|
||||
<g><g transform="translate(0.000000,462.000000) scale(0.100000,-0.100000)"><path d="M4472.9,4492.4C2377.1,4206.7,746.1,2682.3,347.4,637.7c-198.3-1025.5-51.2-2070.2,428.5-3036l172.7-343.2L520.1-4005.9c-236.7-692.9-424.3-1266.4-420-1270.7c4.3-4.3,601.2,179.1,1324,409.4l1317.6,420l251.6-119.4c1029.8-481.8,2172.6-599.1,3259.9-330.5c1669.4,413.6,3012.6,1709.9,3475.3,3355.8c194,695,226,1522.3,83.2,2228c-328.3,1618.2-1483.9,2963.6-3048.8,3545.6c-236.7,89.6-622.6,189.8-914.7,240.9C5568.8,4520.1,4760.7,4532.9,4472.9,4492.4z M5666.9,3722.8c1571.3-213.2,2906-1383.7,3326-2918.8c179.1-654.6,187.6-1385.8,23.4-2034c-336.9-1326.1-1341-2402.8-2645.9-2835.6c-1115.1-371-2351.7-243-3343.1,347.5c-91.7,53.3-174.8,98.1-183.4,98.1c-6.4,0-356.1-108.8-776.1-243.1c-417.9-132.2-761.1-234.5-761.1-223.9c0,8.5,110.9,343.3,247.3,742l247.3,724.9l-110.9,166.3c-138.6,208.9-362.5,663.1-445.6,904C594.7,334.9,1453.9,2420.1,3257.6,3332.6C4001.7,3707.8,4809.8,3837.9,5666.9,3722.8z"/><path d="M3272.6,1878.5c-168.4-83.1-400.8-407.2-486.1-678c-17.1-53.3-38.4-194-44.8-311.3c-14.9-272.9,38.4-509.6,185.5-816.6c108.7-226,360.3-605.5,609.8-916.8c437.1-547.9,976.5-995.7,1488.2-1240.9c398.7-189.8,1012.7-392.3,1272.8-420c356.1-36.3,897.6,200.4,1087.4,477.6c123.7,179.1,191.9,618.3,108.7,695c-42.6,38.4-850.7,432.8-1010.6,494.6c-55.4,21.3-123.7,32-151.4,23.4c-32-6.4-115.1-91.7-209-208.9C5892.9-1313.1,5779.9-1424,5718-1424c-83.1,0-496.8,200.4-729.1,353.9c-296.4,196.2-678,579.9-884.8,889.1c-85.3,125.8-153.5,253.7-153.5,281.4c0,29.9,55.4,113,149.2,221.7c136.4,159.9,255.8,349.6,255.8,409.4c0,12.8-98.1,260.1-217.5,550.1c-140.7,336.9-238.8,545.8-275,584.2c-55.4,55.4-66.1,57.6-279.3,57.6C3402.6,1923.3,3347.2,1914.8,3272.6,1878.5z"/></g></g>
|
||||
</svg>
|
||||
</symbol>
|
||||
Loading…
Add table
Add a link
Reference in a new issue