Compare commits
No commits in common. "feature/signal-notifications" and "main" have entirely different histories.
feature/si
...
main
44 changed files with 900 additions and 1846 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -31,5 +31,3 @@ project.org
|
|||
apps/bridge-worker/scripts/*
|
||||
ENVIRONMENT_VARIABLES_MIGRATION.md
|
||||
local-scripts/*
|
||||
docs/
|
||||
packages/zammad-addon-bridge/test/
|
||||
|
|
|
|||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
|||
v24
|
||||
v22.18.0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-frontend",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-migrations",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"migrate:up:all": "tsx migrate.ts up:all",
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-whatsapp",
|
||||
"version": "3.4.0-beta.6",
|
||||
"type": "module",
|
||||
"version": "3.3.5",
|
||||
"main": "build/main/index.js",
|
||||
"author": "Darren Clarke <darren@redaranj.com>",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@adiwajshing/keyed-db": "0.2.4",
|
||||
"@hapi/hapi": "^21.4.3",
|
||||
"@hapipal/schmervice": "^3.0.0",
|
||||
"@hapipal/toys": "^4.0.0",
|
||||
"@link-stack/bridge-common": "workspace:*",
|
||||
"@link-stack/logger": "workspace:*",
|
||||
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||
"@whiskeysockets/baileys": "6.7.21",
|
||||
"hapi-pino": "^13.0.0",
|
||||
"link-preview-js": "^3.1.0"
|
||||
},
|
||||
|
|
@ -19,12 +19,15 @@
|
|||
"@link-stack/eslint-config": "workspace:*",
|
||||
"@link-stack/jest-config": "workspace:*",
|
||||
"@link-stack/typescript-config": "workspace:*",
|
||||
"@types/long": "^5",
|
||||
"@types/node": "*",
|
||||
"dotenv-cli": "^10.0.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "node --env-file=.env --experimental-transform-types src/index.ts",
|
||||
"dev": "dotenv -- tsx src/index.ts",
|
||||
"start": "node build/main/index.js"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import * as Hapi from "@hapi/hapi";
|
||||
import hapiPino from "hapi-pino";
|
||||
import Schmervice from "@hapipal/schmervice";
|
||||
import WhatsappService from "./service.ts";
|
||||
import WhatsappService from "./service.js";
|
||||
import {
|
||||
RegisterBotRoute,
|
||||
UnverifyBotRoute,
|
||||
GetBotRoute,
|
||||
SendMessageRoute,
|
||||
ReceiveMessageRoute,
|
||||
} from "./routes.ts";
|
||||
} from "./routes.js";
|
||||
import { createLogger } from "@link-stack/logger";
|
||||
|
||||
const logger = createLogger("bridge-whatsapp-index");
|
||||
const logger = createLogger('bridge-whatsapp-index');
|
||||
|
||||
const server = Hapi.server({ port: 5000 });
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as Hapi from "@hapi/hapi";
|
||||
import Toys from "@hapipal/toys";
|
||||
import WhatsappService from "./service.ts";
|
||||
import WhatsappService from "./service";
|
||||
|
||||
const withDefaults = Toys.withRouteDefaults({
|
||||
options: {
|
||||
|
|
@ -27,9 +27,15 @@ export const SendMessageRoute = withDefaults({
|
|||
description: "Send a message",
|
||||
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
||||
const { id } = request.params;
|
||||
const { phoneNumber, message, attachments } = request.payload as MessageRequest;
|
||||
const { phoneNumber, message, attachments } =
|
||||
request.payload as MessageRequest;
|
||||
const whatsappService = getService(request);
|
||||
await whatsappService.send(id, phoneNumber, message as string, attachments);
|
||||
await whatsappService.send(
|
||||
id,
|
||||
phoneNumber,
|
||||
message as string,
|
||||
attachments,
|
||||
);
|
||||
request.logger.info(
|
||||
{
|
||||
id,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@ import makeWASocket, {
|
|||
DisconnectReason,
|
||||
proto,
|
||||
downloadContentFromMessage,
|
||||
MediaType,
|
||||
fetchLatestBaileysVersion,
|
||||
isJidBroadcast,
|
||||
isJidStatusBroadcast,
|
||||
useMultiFileAuthState,
|
||||
} from "@whiskeysockets/baileys";
|
||||
|
||||
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
|
||||
import fs from "fs";
|
||||
import { createLogger } from "@link-stack/logger";
|
||||
import {
|
||||
|
|
@ -98,7 +97,6 @@ export default class WhatsappService extends Service {
|
|||
...options,
|
||||
auth: state,
|
||||
generateHighQualityLinkPreview: false,
|
||||
syncFullHistory: true,
|
||||
msgRetryCounterMap,
|
||||
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
|
||||
});
|
||||
|
|
@ -149,17 +147,6 @@ export default class WhatsappService extends Service {
|
|||
await this.queueUnreadMessages(botID, messages);
|
||||
}
|
||||
}
|
||||
|
||||
if (events["messaging-history.set"]) {
|
||||
const { messages, isLatest } = events["messaging-history.set"];
|
||||
logger.info(
|
||||
{ messageCount: messages.length, isLatest },
|
||||
"received message history on connection",
|
||||
);
|
||||
if (messages.length > 0) {
|
||||
await this.queueUnreadMessages(botID, messages);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.connections[botID] = { socket, msgRetryCounterMap };
|
||||
|
|
@ -180,6 +167,7 @@ export default class WhatsappService extends Service {
|
|||
|
||||
await this.createConnection(botID, this.server, {
|
||||
browser: WhatsappService.browserDescription,
|
||||
printQRInTerminal: true,
|
||||
version,
|
||||
});
|
||||
}
|
||||
|
|
@ -187,20 +175,18 @@ export default class WhatsappService extends Service {
|
|||
}
|
||||
|
||||
private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) {
|
||||
const { key, message, messageTimestamp } = webMessageInfo;
|
||||
if (!key) {
|
||||
logger.warn("Message missing key, skipping");
|
||||
return;
|
||||
const {
|
||||
key: { id, fromMe, remoteJid },
|
||||
message,
|
||||
messageTimestamp,
|
||||
} = webMessageInfo;
|
||||
logger.info("Message type debug");
|
||||
for (const key in message) {
|
||||
logger.info(
|
||||
{ key, exists: !!message[key as keyof proto.IMessage] },
|
||||
"Message field",
|
||||
);
|
||||
}
|
||||
const { id, fromMe, remoteJid } = key;
|
||||
// Baileys 7 uses LIDs (Linked IDs) instead of phone numbers in some cases.
|
||||
// senderPn contains the actual phone number when available.
|
||||
const senderPn = (key as any).senderPn as string | undefined;
|
||||
const participantPn = (key as any).participantPn as string | undefined;
|
||||
logger.info(
|
||||
{ remoteJid, senderPn, participantPn, fromMe },
|
||||
"Processing incoming message",
|
||||
);
|
||||
const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe;
|
||||
if (isValidMessage) {
|
||||
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
|
||||
|
|
@ -258,27 +244,9 @@ export default class WhatsappService extends Service {
|
|||
videoMessage,
|
||||
].find((text) => text && text !== "");
|
||||
|
||||
// Extract phone number and user ID (LID) separately
|
||||
// remoteJid may contain LIDs (Baileys 7+) which are not phone numbers
|
||||
const jidValue = remoteJid?.split("@")[0];
|
||||
const isLidJid = remoteJid?.endsWith("@lid");
|
||||
|
||||
// Phone number: prefer senderPn/participantPn, fall back to remoteJid only if it's not a LID
|
||||
const senderPhone = senderPn?.split("@")[0] || participantPn?.split("@")[0] || (!isLidJid ? jidValue : undefined);
|
||||
|
||||
// User ID (LID): extract from remoteJid if it's a LID format
|
||||
const senderUserId = isLidJid ? jidValue : undefined;
|
||||
|
||||
// Must have at least one identifier
|
||||
if (!senderPhone && !senderUserId) {
|
||||
logger.warn({ remoteJid, senderPn, participantPn }, "Could not determine sender identity, skipping message");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
to: botID,
|
||||
from: senderPhone,
|
||||
userId: senderUserId,
|
||||
from: remoteJid?.split("@")[0],
|
||||
messageId: id,
|
||||
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
|
||||
message: messageText,
|
||||
|
|
@ -442,17 +410,12 @@ export default class WhatsappService extends Service {
|
|||
}
|
||||
|
||||
async receive(
|
||||
_botID: string,
|
||||
botID: string,
|
||||
_lastReceivedDate: Date,
|
||||
): Promise<proto.IWebMessageInfo[]> {
|
||||
// loadAllUnreadMessages() was removed in Baileys 7.x
|
||||
// Messages are now delivered via events (messages.upsert, messaging-history.set)
|
||||
// and forwarded to webhooks automatically.
|
||||
// See: https://baileys.wiki/docs/migration/to-v7.0.0/
|
||||
throw new Error(
|
||||
"Message polling is no longer supported in Baileys 7.x. " +
|
||||
"Please configure a webhook to receive messages instead. " +
|
||||
"Messages are automatically forwarded to BRIDGE_FRONTEND_URL/api/whatsapp/bots/{id}/receive"
|
||||
);
|
||||
const connection = this.connections[botID]?.socket;
|
||||
const messages = await connection.loadAllUnreadMessages();
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type WhatsappService from "./service.ts";
|
||||
import type WhatsappService from "./service.js";
|
||||
|
||||
declare module "@hapipal/schmervice" {
|
||||
interface SchmerviceDecorator {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,16 @@
|
|||
{
|
||||
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"target": "es2018",
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "NodeNext",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "build/main",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true,
|
||||
"types": ["node"],
|
||||
"lib": ["es2022", "DOM"],
|
||||
"composite": true,
|
||||
"rewriteRelativeImportExtensions": true
|
||||
"lib": ["es2020", "DOM"],
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
||||
"exclude": ["node_modules/**"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-worker",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"type": "module",
|
||||
"main": "build/main/index.js",
|
||||
"author": "Darren Clarke <darren@redaranj.com>",
|
||||
|
|
|
|||
|
|
@ -168,7 +168,6 @@ const processMessage = async ({
|
|||
token: id,
|
||||
to: toRecipient,
|
||||
from: source,
|
||||
userId: sourceUuid, // Signal user UUID for user identification
|
||||
messageId: `${sourceUuid}-${rawTimestamp}`,
|
||||
message: dataMessage?.message,
|
||||
sentAt: timestamp.toISOString(),
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ interface ReceiveSignalMessageTaskOptions {
|
|||
token: string;
|
||||
to: string;
|
||||
from: string;
|
||||
userId?: string; // Signal user UUID for user identification
|
||||
messageId: string;
|
||||
sentAt: string;
|
||||
message: string;
|
||||
|
|
@ -23,7 +22,6 @@ const receiveSignalMessageTask = async ({
|
|||
token,
|
||||
to,
|
||||
from,
|
||||
userId,
|
||||
messageId,
|
||||
sentAt,
|
||||
message,
|
||||
|
|
@ -214,7 +212,6 @@ const receiveSignalMessageTask = async ({
|
|||
const payload = {
|
||||
to: finalTo,
|
||||
from,
|
||||
user_id: userId, // Signal user UUID for user identification
|
||||
message_id: messageId,
|
||||
sent_at: sentAt,
|
||||
message,
|
||||
|
|
|
|||
|
|
@ -64,14 +64,13 @@ const sendSignalMessageTask = async ({
|
|||
let groupCreated = false;
|
||||
|
||||
try {
|
||||
// Check if 'to' is a group ID (group.base64 format or base64 internal ID) vs individual recipient
|
||||
// Signal group IDs are 32 bytes = 44 chars base64 (or 43 without padding)
|
||||
// Signal user UUIDs (ACIs) are 36 chars with hyphens: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
// Phone numbers start with +, usernames with u:, PNIs with PNI:
|
||||
// Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number
|
||||
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
to,
|
||||
);
|
||||
const isGroupPrefix = to.startsWith("group.");
|
||||
const isBase64GroupId =
|
||||
/^[A-Za-z0-9+/]+=*$/.test(to) && to.length >= 43 && to.length <= 44;
|
||||
const isGroupId = isGroupPrefix || isBase64GroupId;
|
||||
const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(to) && to.length > 20; // Base64 internal_id
|
||||
const isGroupId = isUUID || isGroupPrefix || isBase64;
|
||||
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
|
||||
|
||||
logger.debug(
|
||||
|
|
|
|||
|
|
@ -3,8 +3,7 @@ import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
|||
interface ReceiveWhatsappMessageTaskOptions {
|
||||
token: string;
|
||||
to: string;
|
||||
from?: string;
|
||||
userId?: string;
|
||||
from: string;
|
||||
messageId: string;
|
||||
sentAt: string;
|
||||
message: string;
|
||||
|
|
@ -17,7 +16,6 @@ const receiveWhatsappMessageTask = async ({
|
|||
token,
|
||||
to,
|
||||
from,
|
||||
userId,
|
||||
messageId,
|
||||
sentAt,
|
||||
message,
|
||||
|
|
@ -35,7 +33,6 @@ const receiveWhatsappMessageTask = async ({
|
|||
const payload = {
|
||||
to,
|
||||
from,
|
||||
user_id: userId,
|
||||
message_id: messageId,
|
||||
sent_at: sentAt,
|
||||
message,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/link",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ COPY --from=node /usr/local/lib /usr/local/lib
|
|||
COPY --from=node /usr/local/include /usr/local/include
|
||||
COPY --from=node /usr/local/bin /usr/local/bin
|
||||
|
||||
# Prepare pnpm (corepack symlinks already copied from node image)
|
||||
# Prepare pnpm (corepack is already enabled in node:22-alpine)
|
||||
RUN corepack prepare pnpm@9.15.4 --activate
|
||||
|
||||
# Set up pnpm home
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ const app = process.argv[2];
|
|||
const command = process.argv[3];
|
||||
|
||||
const files = {
|
||||
all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||
all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api"],
|
||||
linkDev: ["zammad", "postgresql", "opensearch"],
|
||||
link: ["zammad", "postgresql", "opensearch", "link"],
|
||||
linkOnly: ["link"],
|
||||
opensearch: ["opensearch"],
|
||||
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api"],
|
||||
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api"],
|
||||
zammad: ["zammad", "postgresql", "opensearch"],
|
||||
};
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ const finalFiles = files[app]
|
|||
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
|
||||
|
||||
// Add bridge-dev.yml for dev commands that include zammad
|
||||
const devAppsWithZammad = ['linkDev', 'bridgeDev'];
|
||||
const devAppsWithZammad = ['linkDev', 'bridgeDev', 'all'];
|
||||
if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
|
||||
finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "Link from the Center for Digital Resilience",
|
||||
"scripts": {
|
||||
"dev": "dotenv -- turbo dev",
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"turbo": "^2.6.0",
|
||||
"turbo": "^2.5.8",
|
||||
"typescript": "latest"
|
||||
},
|
||||
"pnpm": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-common",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"main": "build/main/index.js",
|
||||
"type": "module",
|
||||
"author": "Darren Clarke <darren@redaranj.com>",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/bridge-ui",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/eslint-config",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "amigo's eslint config",
|
||||
"main": "index.js",
|
||||
"author": "Abel Luck <abel@guardianproject.info>",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/jest-config",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"author": "Abel Luck <abel@guardianproject.info>",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/logger",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "Shared logging utility for Link Stack monorepo",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/signal-api",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/typescript-config",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "Shared TypeScript config",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"author": "Abel Luck <abel@guardianproject.info>",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/ui",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@link-stack/zammad-addon-bridge",
|
||||
"displayName": "Bridge",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "An addon that adds CDR Bridge channels to Zammad.",
|
||||
"scripts": {
|
||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
class ProfileSignalNotifications extends App.ControllerSubContent
|
||||
@requiredPermission: 'user_preferences.signal_notifications+ticket.agent'
|
||||
header: __('Signal Notifications')
|
||||
events:
|
||||
'submit form': 'update'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
App.User.full(App.Session.get().id, @render, true, true)
|
||||
|
||||
render: =>
|
||||
config =
|
||||
enabled: false
|
||||
events:
|
||||
create: true
|
||||
update: true
|
||||
escalation: true
|
||||
reminder_reached: true
|
||||
|
||||
user = App.User.find(App.Session.get().id)
|
||||
user_config = user.preferences?.signal_notifications
|
||||
if user_config
|
||||
config = $.extend(true, {}, config, user_config)
|
||||
|
||||
@html App.view('profile/signal_notifications')
|
||||
config: config
|
||||
signal_uid: user.signal_uid || ''
|
||||
signal_notification_enabled: App.Config.get('signal_notification_enabled')
|
||||
|
||||
update: (e) =>
|
||||
e.preventDefault()
|
||||
params = @formParam(e.target)
|
||||
|
||||
preferences = {}
|
||||
preferences.signal_notifications =
|
||||
enabled: params.enabled == 'true'
|
||||
events:
|
||||
create: params.event_create == 'true'
|
||||
update: params.event_update == 'true'
|
||||
escalation: params.event_escalation == 'true'
|
||||
reminder_reached: params.event_reminder_reached == 'true'
|
||||
|
||||
@formDisable(e)
|
||||
|
||||
@ajax(
|
||||
id: 'preferences_signal_notifications'
|
||||
type: 'PUT'
|
||||
url: @apiPath + '/users/preferences'
|
||||
data: JSON.stringify(preferences)
|
||||
processData: true
|
||||
success: @successPreferences
|
||||
error: @error
|
||||
)
|
||||
|
||||
if params.signal_uid?
|
||||
user = App.User.find(App.Session.get().id)
|
||||
user.signal_uid = params.signal_uid
|
||||
user.save(
|
||||
done: =>
|
||||
# User saved successfully
|
||||
fail: (settings, details) =>
|
||||
@notify(
|
||||
type: 'error'
|
||||
msg: details.error || __('Failed to save Signal phone number')
|
||||
)
|
||||
)
|
||||
|
||||
successPreferences: (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
|
||||
)
|
||||
|
||||
App.Config.set('SignalNotifications', { prio: 2650, name: __('Signal Notifications'), parent: '#profile', target: '#profile/signal_notifications', permission: ['user_preferences.signal_notifications+ticket.agent'], controller: ProfileSignalNotifications }, 'NavBarProfile')
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<div class="page-header">
|
||||
<div class="page-header-title"><h1><%- @T('Signal Notifications') %></h1></div>
|
||||
</div>
|
||||
|
||||
<% if !@signal_notification_enabled: %>
|
||||
<div class="alert alert--warning" role="alert">
|
||||
<%- @T('Signal notifications are currently disabled by the administrator.') %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<form class="page-content form--flexibleWidth">
|
||||
<h2><%- @T('Signal Phone Number') %></h2>
|
||||
<p class="help-text">
|
||||
<%- @T('Enter your Signal phone number to receive ticket notifications via Signal.') %>
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="signal-uid"><%- @T('Signal Phone Number') %></label>
|
||||
<input type="text" id="signal-uid" 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>
|
||||
|
||||
<h2><%- @T('Notification Settings') %></h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="inline-label">
|
||||
<span class="checkbox-replacement checkbox-replacement--inline">
|
||||
<input type="checkbox" name="enabled" value="true" <% if @config.enabled: %> checked<% end %>>
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</span>
|
||||
<%- @T('Enable Signal notifications') %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<h3><%- @T('Notification Events') %></h3>
|
||||
<p class="help-text">
|
||||
<%- @T('Select which events should trigger Signal notifications.') %>
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="inline-label">
|
||||
<span class="checkbox-replacement checkbox-replacement--inline">
|
||||
<input type="checkbox" name="event_create" value="true" <% if @config.events?.create: %> checked<% end %>>
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</span>
|
||||
<%- @T('New Ticket') %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="inline-label">
|
||||
<span class="checkbox-replacement checkbox-replacement--inline">
|
||||
<input type="checkbox" name="event_update" value="true" <% if @config.events?.update: %> checked<% end %>>
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</span>
|
||||
<%- @T('Ticket update') %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="inline-label">
|
||||
<span class="checkbox-replacement checkbox-replacement--inline">
|
||||
<input type="checkbox" name="event_escalation" value="true" <% if @config.events?.escalation: %> checked<% end %>>
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</span>
|
||||
<%- @T('Ticket escalation') %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="inline-label">
|
||||
<span class="checkbox-replacement checkbox-replacement--inline">
|
||||
<input type="checkbox" name="event_reminder_reached" value="true" <% if @config.events?.reminder_reached: %> checked<% end %>>
|
||||
<%- @Icon('checkbox', 'icon-unchecked') %>
|
||||
<%- @Icon('checkbox-checked', 'icon-checked') %>
|
||||
</span>
|
||||
<%- @T('Ticket reminder reached') %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn--primary"><%- @T('Submit') %></button>
|
||||
</form>
|
||||
|
|
@ -154,31 +154,16 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
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
|
||||
sender_phone_number = params[:from].strip
|
||||
|
||||
# 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
|
||||
is_group_message = params[:is_group].to_s == 'true' || params[:is_group].to_s == 'true'
|
||||
|
||||
customer = User.find_by(phone: sender_phone_number)
|
||||
customer ||= User.find_by(mobile: sender_phone_number)
|
||||
unless customer
|
||||
role_ids = Role.signup_role_ids
|
||||
customer = User.create(
|
||||
|
|
@ -186,8 +171,7 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
lastname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phone: sender_phone_number.presence || sender_user_id,
|
||||
signal_uid: sender_user_id,
|
||||
phone: sender_phone_number,
|
||||
note: 'CDR Signal',
|
||||
active: true,
|
||||
role_ids: role_ids,
|
||||
|
|
@ -196,15 +180,6 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
)
|
||||
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')
|
||||
|
|
@ -233,8 +208,7 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
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}"
|
||||
title = "Message from #{sender_phone_number} at #{sent_at}"
|
||||
body = message
|
||||
|
||||
# find ticket or create one
|
||||
|
|
@ -244,7 +218,7 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
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 "Customer Phone: #{sender_phone_number}"
|
||||
Rails.logger.info "Channel ID: #{channel.id}"
|
||||
|
||||
begin
|
||||
|
|
@ -282,14 +256,12 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
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)
|
||||
chat_id = is_group_message ? receiver_phone_number : 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
|
||||
bot_token: channel.options[:bot_token], # change to bot id
|
||||
chat_id: chat_id
|
||||
}
|
||||
|
||||
Rails.logger.info "=== CREATING NEW TICKET ==="
|
||||
|
|
@ -311,7 +283,7 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
ticket.save!
|
||||
|
||||
article_params = {
|
||||
from: sender_display,
|
||||
from: sender_phone_number,
|
||||
to: receiver_phone_number,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
subject: title,
|
||||
|
|
@ -324,8 +296,7 @@ class ChannelsCdrSignalController < ApplicationController
|
|||
cdr_signal: {
|
||||
timestamp: sent_at,
|
||||
message_id: message_id,
|
||||
from: sender_phone_number,
|
||||
user_id: sender_user_id
|
||||
from: sender_phone_number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,16 +123,12 @@ class ChannelsCdrWhatsappController < ApplicationController
|
|||
errors = {}
|
||||
|
||||
%i[to
|
||||
from
|
||||
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
|
||||
|
|
@ -145,25 +141,9 @@ class ChannelsCdrWhatsappController < ApplicationController
|
|||
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
|
||||
|
||||
sender_phone_number = params[:from].strip
|
||||
customer = User.find_by(phone: sender_phone_number)
|
||||
customer ||= User.find_by(mobile: sender_phone_number)
|
||||
unless customer
|
||||
role_ids = Role.signup_role_ids
|
||||
customer = User.create(
|
||||
|
|
@ -171,8 +151,7 @@ class ChannelsCdrWhatsappController < ApplicationController
|
|||
lastname: '',
|
||||
email: '',
|
||||
password: '',
|
||||
phone: sender_phone_number.presence || sender_user_id,
|
||||
whatsapp_uid: sender_user_id,
|
||||
phone: sender_phone_number,
|
||||
note: 'CDR Whatsapp',
|
||||
active: true,
|
||||
role_ids: role_ids,
|
||||
|
|
@ -181,15 +160,6 @@ class ChannelsCdrWhatsappController < ApplicationController
|
|||
)
|
||||
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')
|
||||
|
|
@ -218,8 +188,7 @@ class ChannelsCdrWhatsappController < ApplicationController
|
|||
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}"
|
||||
title = "Message from #{sender_phone_number} at #{sent_at}"
|
||||
body = message
|
||||
|
||||
# find ticket or create one
|
||||
|
|
@ -238,9 +207,8 @@ class ChannelsCdrWhatsappController < ApplicationController
|
|||
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
|
||||
bot_token: channel.options[:bot_token], # change to bot id
|
||||
chat_id: sender_phone_number
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
@ -249,7 +217,7 @@ class ChannelsCdrWhatsappController < ApplicationController
|
|||
ticket.save!
|
||||
|
||||
article_params = {
|
||||
from: sender_display,
|
||||
from: sender_phone_number,
|
||||
to: receiver_phone_number,
|
||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||
subject: title,
|
||||
|
|
@ -262,8 +230,7 @@ class ChannelsCdrWhatsappController < ApplicationController
|
|||
cdr_whatsapp: {
|
||||
timestamp: sent_at,
|
||||
message_id: message_id,
|
||||
from: sender_phone_number,
|
||||
user_id: sender_user_id
|
||||
from: sender_phone_number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
# 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
|
||||
return if user.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: user.signal_uid,
|
||||
message: message
|
||||
)
|
||||
|
||||
add_history(ticket, user, type)
|
||||
|
||||
Rails.logger.info "Sent Signal notification to #{user.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::Account', active: true)
|
||||
end
|
||||
|
||||
def add_history(ticket, user, type)
|
||||
identifier = user.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
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
# 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::Account', 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_has_signal_notifications_enabled?(user)
|
||||
next if user.signal_uid.blank?
|
||||
next if !should_notify_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_has_signal_notifications_enabled?(user)
|
||||
user.preferences.dig('signal_notifications', 'enabled') == true
|
||||
end
|
||||
|
||||
def should_notify_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('signal_notifications', 'events', event_key) == true
|
||||
end
|
||||
end
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
[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 %>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
[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 %>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
[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 %>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
[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 %>
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
# 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
|
||||
)
|
||||
|
||||
# Permission for Signal notifications profile page
|
||||
Permission.create_if_not_exists(
|
||||
name: 'user_preferences.signal_notifications',
|
||||
description: 'Manage Signal notification preferences',
|
||||
preferences: {
|
||||
translations: ['Profile - Signal Notifications']
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def self.down
|
||||
Setting.find_by(name: '0105_signal_notification')&.destroy
|
||||
Setting.find_by(name: 'signal_notification_enabled')&.destroy
|
||||
Setting.find_by(name: 'signal_notification_channel_id')&.destroy
|
||||
Permission.find_by(name: 'user_preferences.signal_notifications')&.destroy
|
||||
end
|
||||
end
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'erb'
|
||||
|
||||
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?
|
||||
|
||||
api_url = channel.options[:api_url]
|
||||
api_token = channel.options[:api_token]
|
||||
|
||||
return if api_url.blank? || api_token.blank?
|
||||
|
||||
api = CdrSignalApi.new(api_url, api_token)
|
||||
api.send_message(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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@link-stack/zammad-addon-common",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "",
|
||||
"bin": {
|
||||
"zpm-build": "./dist/build.js",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@link-stack/zammad-addon-hardening",
|
||||
"displayName": "Hardening",
|
||||
"version": "3.4.0-beta.6",
|
||||
"version": "3.3.5",
|
||||
"description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.",
|
||||
"scripts": {
|
||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
||||
|
|
|
|||
1682
pnpm-lock.yaml
generated
1682
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue