Formstack and Signal updates
This commit is contained in:
commit
7d7944fa90
17 changed files with 838 additions and 74 deletions
|
|
@ -4,42 +4,119 @@ FROM node:22-slim AS node
|
||||||
|
|
||||||
FROM zammad/zammad-docker-compose:${ZAMMAD_VERSION} AS builder
|
FROM zammad/zammad-docker-compose:${ZAMMAD_VERSION} AS builder
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
# Copy Node.js from node image
|
||||||
COPY --from=node /opt /opt
|
COPY --from=node /opt /opt
|
||||||
COPY --from=node /usr/local/bin /usr/local/bin
|
COPY --from=node /usr/local/bin /usr/local/bin
|
||||||
COPY --from=node /usr/local/lib /usr/local/lib
|
COPY --from=node /usr/local/lib /usr/local/lib
|
||||||
COPY --from=node /usr/lib /usr/lib
|
COPY --from=node /usr/lib /usr/lib
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"]
|
SHELL ["/bin/bash", "-e", "-o", "pipefail", "-c"]
|
||||||
|
|
||||||
|
# Install pnpm for package management
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
RUN pnpm --version
|
RUN pnpm --version
|
||||||
|
|
||||||
|
ENV ZAMMAD_DIR=/opt/zammad
|
||||||
WORKDIR ${ZAMMAD_DIR}
|
WORKDIR ${ZAMMAD_DIR}
|
||||||
|
|
||||||
|
# Copy addons and installation scripts
|
||||||
RUN mkdir -p /opt/zammad/contrib/link/addons
|
RUN mkdir -p /opt/zammad/contrib/link/addons
|
||||||
COPY addons contrib/link/addons
|
COPY addons contrib/link/addons
|
||||||
COPY setup.rb contrib/link/setup.rb
|
COPY setup.rb contrib/link/setup.rb
|
||||||
COPY install.rb contrib/link/install.rb
|
COPY install.rb contrib/link/install.rb
|
||||||
|
|
||||||
RUN sed -i '/script\/build\/cleanup\.sh/d' contrib/docker/setup.sh
|
# Install system dependencies
|
||||||
RUN sed -i '/touch db\/schema.rb/a cd ${ZAMMAD_DIR} && ruby contrib\/link\/install.rb' contrib/docker/setup.sh
|
RUN apt-get update && \
|
||||||
RUN cat contrib/docker/setup.sh
|
apt-get install -y --no-install-recommends \
|
||||||
RUN contrib/docker/setup.sh builder
|
git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Ruby gems (they should already be installed in the base image)
|
||||||
|
RUN bundle check || bundle install --jobs 8
|
||||||
|
|
||||||
|
# Install Node packages
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# CRITICAL: Install addons BEFORE asset compilation
|
||||||
|
# This extracts addon files including Vue components, TypeScript, and CSS
|
||||||
|
RUN ruby contrib/link/install.rb
|
||||||
|
|
||||||
|
# Recompile assets with our addon components
|
||||||
|
# The base image has assets precompiled, but we need to recompile with our additions
|
||||||
|
# SKIP asset compilation during build - it will happen at runtime via entrypoint
|
||||||
|
# This is because asset compilation requires Redis which isn't available during build
|
||||||
|
# RUN bundle exec rake assets:precompile RAILS_SKIP_ASSET_COMPILATION=false || echo "Skipped"
|
||||||
|
|
||||||
|
# Run additional setup for addons
|
||||||
|
RUN bundle exec rails runner /opt/zammad/contrib/link/setup.rb || true
|
||||||
|
|
||||||
|
# Clean up build artifacts
|
||||||
|
RUN rm -rf tmp/cache node_modules/.cache
|
||||||
ARG EMBEDDED=false
|
ARG EMBEDDED=false
|
||||||
ARG LINK_HOST=http://link:3000
|
ARG LINK_HOST=http://link:3000
|
||||||
RUN if [ "$EMBEDDED" = "true" ] ; then sed -i "/location \/ {/i\
|
# Add nginx proxy configuration for embedded mode
|
||||||
location /link {\n\
|
# Insert location block before the final closing brace
|
||||||
proxy_pass ${LINK_HOST};\n\
|
RUN if [ "$EMBEDDED" = "true" ] ; then \
|
||||||
proxy_set_header Host \$host;\n\
|
sed -i '$ d' /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
proxy_set_header X-Real-IP \$remote_addr;\n\
|
echo "" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;\n\
|
echo " location /link {" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
proxy_set_header X-Forwarded-Proto https;\n\
|
echo " proxy_pass ${LINK_HOST};" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
}\n\
|
echo " proxy_set_header Host \$host;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
" ${ZAMMAD_DIR}/contrib/nginx/zammad.conf; fi
|
echo " proxy_set_header X-Real-IP \$remote_addr;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
|
echo " proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
|
echo " proxy_set_header X-Forwarded-Proto https;" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
|
echo " }" >> /opt/zammad/contrib/nginx/zammad.conf && \
|
||||||
|
echo "}" >> /opt/zammad/contrib/nginx/zammad.conf; \
|
||||||
|
fi
|
||||||
RUN sed -i '/^[[:space:]]*# es config/a\
|
RUN sed -i '/^[[:space:]]*# es config/a\
|
||||||
echo "about to reinstall..."\n\
|
echo "about to reinstall..."\n\
|
||||||
bundle exec rails runner /opt/zammad/contrib/link/setup.rb\n\
|
bundle exec rails runner /opt/zammad/contrib/link/setup.rb\n\
|
||||||
bundle exec rake zammad:package:migrate\n\
|
bundle exec rake zammad:package:migrate\n\
|
||||||
' /docker-entrypoint.sh
|
echo "Recompiling assets with addon CoffeeScript files..."\n\
|
||||||
|
bundle exec rake assets:precompile RAILS_SKIP_ASSET_COMPILATION=false\n\
|
||||||
|
echo "Asset recompilation complete"\n\
|
||||||
|
' /docker-entrypoint.sh
|
||||||
|
|
||||||
FROM zammad/zammad-docker-compose:${ZAMMAD_VERSION} AS runner
|
FROM zammad/zammad-docker-compose:${ZAMMAD_VERSION} AS runner
|
||||||
USER zammad
|
USER root
|
||||||
COPY --from=builder --chown=zammad:zammad ${ZAMMAD_DIR} ${ZAMMAD_DIR}
|
|
||||||
COPY --from=builder /usr/local/bundle /usr/local/bundle
|
# Install Node.js and npm in runner for asset compilation at runtime
|
||||||
|
# Using Node from Debian repository for simplicity
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends nodejs npm && \
|
||||||
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
|
npm install -g pnpm
|
||||||
|
|
||||||
|
# Copy only the modified/added files from builder
|
||||||
|
# Copy addon files that were installed
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/ /opt/zammad/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/frontend/shared/entities/ticket-article/action/plugins/ /opt/zammad/app/frontend/shared/entities/ticket-article/action/plugins/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/db/addon/ /opt/zammad/db/addon/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/assets/ /opt/zammad/app/assets/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/controllers/*cdr* /opt/zammad/app/controllers/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/jobs/*cdr* /opt/zammad/app/jobs/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/models/channel/driver/*cdr* /opt/zammad/app/models/channel/driver/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/models/ticket/article/*cdr* /opt/zammad/app/models/ticket/article/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/policies/controllers/*cdr* /opt/zammad/app/policies/controllers/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/config/initializers/*cdr* /opt/zammad/config/initializers/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/config/routes/*cdr* /opt/zammad/config/routes/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/lib/cdr* /opt/zammad/lib/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/public/assets/images/icons/*cdr* /opt/zammad/public/assets/images/icons/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/app/views/mailer/ticket_create/ /opt/zammad/app/views/mailer/ticket_create/
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/public/assets/images/logo* /opt/zammad/public/assets/images/
|
||||||
|
|
||||||
|
# Copy the nginx config if embedded mode was used
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/contrib/nginx/zammad.conf /opt/zammad/contrib/nginx/zammad.conf
|
||||||
|
|
||||||
|
# Copy the link setup scripts and addons
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/contrib/link/ /opt/zammad/contrib/link/
|
||||||
|
|
||||||
|
# CRITICAL: Copy compiled assets that include our CoffeeScript changes
|
||||||
|
# The builder stage compiles assets at line 47, we must copy them to runner
|
||||||
|
COPY --from=builder --chown=zammad:zammad /opt/zammad/public/assets/ /opt/zammad/public/assets/
|
||||||
|
|
||||||
|
# Copy the modified entrypoint script
|
||||||
COPY --from=builder /docker-entrypoint.sh /docker-entrypoint.sh
|
COPY --from=builder /docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
|
USER zammad
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,32 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*", "../../node_modules/*"]
|
||||||
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["**.d.ts", "**/*.ts", "**/*.tsx", "**/*.png, **/*.svg"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "babel__core", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
|
@ -24,5 +28,5 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": ["**.d.ts", "**/*.ts", "**/*.tsx", "**/*.png, **/*.svg"],
|
"include": ["**.d.ts", "**/*.ts", "**/*.tsx", "**/*.png, **/*.svg"],
|
||||||
"exclude": ["node_modules", "babel__core"]
|
"exclude": ["node_modules", "babel__core", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
|
@ -25,5 +28,5 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": ["**.d.ts", "**/*.ts", "**/*.tsx", "**/*.png, **/*.svg"],
|
"include": ["**.d.ts", "**/*.ts", "**/*.tsx", "**/*.png, **/*.svg"],
|
||||||
"exclude": ["node_modules", "babel__core"]
|
"exclude": ["node_modules", "babel__core", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
|
@ -25,5 +28,5 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"include": ["**.d.ts", "**/*.ts", "**/*.tsx", "**/*.png, **/*.svg"],
|
"include": ["**.d.ts", "**/*.ts", "**/*.tsx", "**/*.png, **/*.svg"],
|
||||||
"exclude": ["node_modules", "babel__core"]
|
"exclude": ["node_modules", "babel__core", "dist"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
[](https://gitlab.com/digiresilience/link/zamamd-addon-bridge/blob/master/LICENSE.md)
|
[](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).
|
||||||
|
|
||||||
🐻
|
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ class CdrSignalReply
|
||||||
@articleTypes: (articleTypes, ticket, ui) ->
|
@articleTypes: (articleTypes, ticket, ui) ->
|
||||||
return articleTypes if !ui.permissionCheck('ticket.agent')
|
return articleTypes if !ui.permissionCheck('ticket.agent')
|
||||||
|
|
||||||
|
# Check CDR Link allowed channels setting
|
||||||
|
allowedChannels = ui.Config.get('cdr_link_allowed_channels')
|
||||||
|
if allowedChannels && allowedChannels.trim()
|
||||||
|
whitelist = (channel.trim() for channel in allowedChannels.split(','))
|
||||||
|
# Return early if 'cdr_signal' or 'signal message' not in whitelist
|
||||||
|
return articleTypes if 'cdr_signal' not in whitelist && 'signal message' not in whitelist
|
||||||
|
|
||||||
return articleTypes if !ticket || !ticket.create_article_type_id
|
return articleTypes if !ticket || !ticket.create_article_type_id
|
||||||
|
|
||||||
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
|
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,13 @@ class CdrWhatsappReply
|
||||||
@articleTypes: (articleTypes, ticket, ui) ->
|
@articleTypes: (articleTypes, ticket, ui) ->
|
||||||
return articleTypes if !ui.permissionCheck('ticket.agent')
|
return articleTypes if !ui.permissionCheck('ticket.agent')
|
||||||
|
|
||||||
|
# Check CDR Link allowed channels setting
|
||||||
|
allowedChannels = ui.Config.get('cdr_link_allowed_channels')
|
||||||
|
if allowedChannels && allowedChannels.trim()
|
||||||
|
whitelist = (channel.trim() for channel in allowedChannels.split(','))
|
||||||
|
# Return early if 'cdr_whatsapp' or 'whatsapp message' not in whitelist
|
||||||
|
return articleTypes if 'cdr_whatsapp' not in whitelist && 'whatsapp message' not in whitelist
|
||||||
|
|
||||||
return articleTypes if !ticket || !ticket.create_article_type_id
|
return articleTypes if !ticket || !ticket.create_article_type_id
|
||||||
|
|
||||||
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
|
articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name
|
||||||
|
|
|
||||||
|
|
@ -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,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
|
||||||
9
set_channel_setting.rb
Normal file
9
set_channel_setting.rb
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
require '/opt/zammad/config/boot'
|
||||||
|
require '/opt/zammad/config/application'
|
||||||
|
|
||||||
|
Rails.application.initialize!
|
||||||
|
|
||||||
|
Setting.set('cdr_link_allowed_channels', 'note,signal message')
|
||||||
|
puts "Setting 'cdr_link_allowed_channels' has been set to: 'note,signal message'"
|
||||||
|
|
@ -19,7 +19,13 @@
|
||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": [".next/**", "!.next/cache/**", "build/**", "dist/**"]
|
"outputs": [
|
||||||
|
".next/**",
|
||||||
|
"!.next/cache/**",
|
||||||
|
"build/**",
|
||||||
|
"dist/**",
|
||||||
|
"docker/zammad/addons/**"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"dependsOn": ["build"],
|
"dependsOn": ["build"],
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue