Add channel filtering

This commit is contained in:
Darren Clarke 2025-09-05 12:23:06 +02:00
parent 38de035571
commit d9130fbaa2
5 changed files with 527 additions and 42 deletions

View file

@ -1,63 +1,150 @@
# zammad-addon-bridge # CDR Bridge Zammad Addon
An addon that adds [bridge](https://gitlab.com/digiresilience/link/link-stack) channels to Zammad. ## Overview
## Channels The CDR Bridge addon integrates external communication channels (Signal, WhatsApp, Voice) into Zammad, supporting both the classic UI and the new Vue-based desktop/mobile interfaces.
This channel creates a three channels: "Voice", "Signal" and "Whatsapp". ## Features
To submit a ticket: make a POST to the Submission Endpoint with the header ### Signal Channel Integration
`Authorization: SUBMISSION_TOKEN`.
The payload for the Voice channel must be a json object with the keys: - 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
- `startTime` - string containing ISO date ### WhatsApp Channel Integration
- `endTime` - string containing ISO date
- `to` - fully qualified phone number
- `from` - fully qualified phone number
- `duration` - string containing the recording duration
- `callSid` - the unique identifier for the call
- `recording` - string base64 encoded binary of the recording
- `mimeType` - string of the binary mime-type
The payload for the Signal channel must be a json object with the keys: - 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
- TBD ### Voice Channel Support
The payload for the Whatsapp channel must be a json object with the keys: - Classic UI implementation maintained
- New UI support ready for future implementation
- TBD ### 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-bridge
npm 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 ## Development
1. Edit the files in `src/` ### Adding New Channels
Migration files should go in `src/db/addon/CHANNEL_NAME` ([see this post](https://community.zammad.org/t/automating-creation-of-custom-object-attributes/3831/2?u=abelxluck)) 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/bridge/`
2. Update version and changelog in `bridge-skeleton.szpm` ### Building the Package
3. Build a new package `make`
This outputs `dist/bridge-vXXX.szpm`
4. Install the szpm using the zammad package manager.
5. Repeat
### Create a new migration
Included is a helper script to create new migrations. You must have the python
`inflection` library installed.
- debian/ubuntu: `apt install python3-inflection`
- pip: `pip install --user inflection`
- or create your own venv
To make a new migration simply run:
```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 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 ## License
[![License GNU AGPL v3.0](https://img.shields.io/badge/License-AGPL%203.0-lightgrey.svg)](https://gitlab.com/digiresilience/link/zamamd-addon-bridge/blob/master/LICENSE.md) [![License GNU AGPL v3.0](https://img.shields.io/badge/License-AGPL%203.0-lightgrey.svg)](https://gitlab.com/digiresilience/link/zamamd-addon-bridge/blob/master/LICENSE.md)
@ -66,5 +153,3 @@ This is a free software project licensed under the GNU Affero General
Public License v3.0 (GNU AGPLv3) by [The Center for Digital Public License v3.0 (GNU AGPLv3) by [The Center for Digital
Resilience](https://digiresilience.org) and [Guardian Resilience](https://digiresilience.org) and [Guardian
Project](https://guardianproject.info). Project](https://guardianproject.info).
🐻

View file

@ -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>

View file

@ -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",
};

View file

@ -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",
};

View file

@ -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