Compare commits
13 commits
main
...
feature/ke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72b52463a2 | ||
|
|
eea56dd50b | ||
|
|
e8f2cc4c50 | ||
|
|
ac42d7df78 | ||
|
|
87bb05fdd5 | ||
|
|
3d8f794cab | ||
|
|
57d7173485 | ||
|
|
8688efc5af | ||
|
|
d6dab5fb1f | ||
|
|
7a6e7d0748 | ||
|
|
57f3ccbaeb | ||
|
|
e202eeb9d2 | ||
|
|
e952973f7f |
36 changed files with 1237 additions and 911 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -31,3 +31,5 @@ project.org
|
||||||
apps/bridge-worker/scripts/*
|
apps/bridge-worker/scripts/*
|
||||||
ENVIRONMENT_VARIABLES_MIGRATION.md
|
ENVIRONMENT_VARIABLES_MIGRATION.md
|
||||||
local-scripts/*
|
local-scripts/*
|
||||||
|
docs/
|
||||||
|
packages/zammad-addon-bridge/test/
|
||||||
|
|
|
||||||
2
.nvmrc
2
.nvmrc
|
|
@ -1 +1 @@
|
||||||
v22.18.0
|
v24
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-frontend",
|
"name": "@link-stack/bridge-frontend",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-migrations",
|
"name": "@link-stack/bridge-migrations",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"migrate:up:all": "tsx migrate.ts up:all",
|
"migrate:up:all": "tsx migrate.ts up:all",
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-whatsapp",
|
"name": "@link-stack/bridge-whatsapp",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
|
"type": "module",
|
||||||
"main": "build/main/index.js",
|
"main": "build/main/index.js",
|
||||||
"author": "Darren Clarke <darren@redaranj.com>",
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@adiwajshing/keyed-db": "0.2.4",
|
|
||||||
"@hapi/hapi": "^21.4.3",
|
"@hapi/hapi": "^21.4.3",
|
||||||
"@hapipal/schmervice": "^3.0.0",
|
"@hapipal/schmervice": "^3.0.0",
|
||||||
"@hapipal/toys": "^4.0.0",
|
"@hapipal/toys": "^4.0.0",
|
||||||
"@link-stack/bridge-common": "workspace:*",
|
"@link-stack/bridge-common": "workspace:*",
|
||||||
"@link-stack/logger": "workspace:*",
|
"@link-stack/logger": "workspace:*",
|
||||||
"@whiskeysockets/baileys": "6.7.21",
|
"@whiskeysockets/baileys": "7.0.0-rc.9",
|
||||||
"hapi-pino": "^13.0.0",
|
"hapi-pino": "^13.0.0",
|
||||||
"link-preview-js": "^3.1.0"
|
"link-preview-js": "^3.1.0"
|
||||||
},
|
},
|
||||||
|
|
@ -19,15 +19,12 @@
|
||||||
"@link-stack/eslint-config": "workspace:*",
|
"@link-stack/eslint-config": "workspace:*",
|
||||||
"@link-stack/jest-config": "workspace:*",
|
"@link-stack/jest-config": "workspace:*",
|
||||||
"@link-stack/typescript-config": "workspace:*",
|
"@link-stack/typescript-config": "workspace:*",
|
||||||
"@types/long": "^5",
|
|
||||||
"@types/node": "*",
|
"@types/node": "*",
|
||||||
"dotenv-cli": "^10.0.0",
|
|
||||||
"tsx": "^4.20.6",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"dev": "dotenv -- tsx src/index.ts",
|
"dev": "node --env-file=.env --experimental-transform-types src/index.ts",
|
||||||
"start": "node build/main/index.js"
|
"start": "node build/main/index.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import * as Hapi from "@hapi/hapi";
|
import * as Hapi from "@hapi/hapi";
|
||||||
import hapiPino from "hapi-pino";
|
import hapiPino from "hapi-pino";
|
||||||
import Schmervice from "@hapipal/schmervice";
|
import Schmervice from "@hapipal/schmervice";
|
||||||
import WhatsappService from "./service.js";
|
import WhatsappService from "./service.ts";
|
||||||
import {
|
import {
|
||||||
RegisterBotRoute,
|
RegisterBotRoute,
|
||||||
UnverifyBotRoute,
|
UnverifyBotRoute,
|
||||||
GetBotRoute,
|
GetBotRoute,
|
||||||
SendMessageRoute,
|
SendMessageRoute,
|
||||||
ReceiveMessageRoute,
|
ReceiveMessageRoute,
|
||||||
} from "./routes.js";
|
} from "./routes.ts";
|
||||||
import { createLogger } from "@link-stack/logger";
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
const logger = createLogger('bridge-whatsapp-index');
|
const logger = createLogger("bridge-whatsapp-index");
|
||||||
|
|
||||||
const server = Hapi.server({ port: 5000 });
|
const server = Hapi.server({ port: 5000 });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import * as Hapi from "@hapi/hapi";
|
import * as Hapi from "@hapi/hapi";
|
||||||
import Toys from "@hapipal/toys";
|
import Toys from "@hapipal/toys";
|
||||||
import WhatsappService from "./service";
|
import WhatsappService from "./service.ts";
|
||||||
|
|
||||||
const withDefaults = Toys.withRouteDefaults({
|
const withDefaults = Toys.withRouteDefaults({
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -27,15 +27,9 @@ export const SendMessageRoute = withDefaults({
|
||||||
description: "Send a message",
|
description: "Send a message",
|
||||||
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
async handler(request: Hapi.Request, _h: Hapi.ResponseToolkit) {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { phoneNumber, message, attachments } =
|
const { phoneNumber, message, attachments } = request.payload as MessageRequest;
|
||||||
request.payload as MessageRequest;
|
|
||||||
const whatsappService = getService(request);
|
const whatsappService = getService(request);
|
||||||
await whatsappService.send(
|
await whatsappService.send(id, phoneNumber, message as string, attachments);
|
||||||
id,
|
|
||||||
phoneNumber,
|
|
||||||
message as string,
|
|
||||||
attachments,
|
|
||||||
);
|
|
||||||
request.logger.info(
|
request.logger.info(
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import makeWASocket, {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
proto,
|
proto,
|
||||||
downloadContentFromMessage,
|
downloadContentFromMessage,
|
||||||
MediaType,
|
|
||||||
fetchLatestBaileysVersion,
|
fetchLatestBaileysVersion,
|
||||||
isJidBroadcast,
|
isJidBroadcast,
|
||||||
isJidStatusBroadcast,
|
isJidStatusBroadcast,
|
||||||
useMultiFileAuthState,
|
useMultiFileAuthState,
|
||||||
} from "@whiskeysockets/baileys";
|
} from "@whiskeysockets/baileys";
|
||||||
|
|
||||||
|
type MediaType = "audio" | "document" | "image" | "video" | "sticker";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
import { createLogger } from "@link-stack/logger";
|
import { createLogger } from "@link-stack/logger";
|
||||||
import {
|
import {
|
||||||
|
|
@ -97,6 +98,7 @@ export default class WhatsappService extends Service {
|
||||||
...options,
|
...options,
|
||||||
auth: state,
|
auth: state,
|
||||||
generateHighQualityLinkPreview: false,
|
generateHighQualityLinkPreview: false,
|
||||||
|
syncFullHistory: true,
|
||||||
msgRetryCounterMap,
|
msgRetryCounterMap,
|
||||||
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
|
shouldIgnoreJid: (jid) => isJidBroadcast(jid) || isJidStatusBroadcast(jid),
|
||||||
});
|
});
|
||||||
|
|
@ -147,6 +149,17 @@ export default class WhatsappService extends Service {
|
||||||
await this.queueUnreadMessages(botID, messages);
|
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 };
|
this.connections[botID] = { socket, msgRetryCounterMap };
|
||||||
|
|
@ -167,7 +180,6 @@ export default class WhatsappService extends Service {
|
||||||
|
|
||||||
await this.createConnection(botID, this.server, {
|
await this.createConnection(botID, this.server, {
|
||||||
browser: WhatsappService.browserDescription,
|
browser: WhatsappService.browserDescription,
|
||||||
printQRInTerminal: true,
|
|
||||||
version,
|
version,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -175,18 +187,20 @@ export default class WhatsappService extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) {
|
private async queueMessage(botID: string, webMessageInfo: proto.IWebMessageInfo) {
|
||||||
const {
|
const { key, message, messageTimestamp } = webMessageInfo;
|
||||||
key: { id, fromMe, remoteJid },
|
if (!key) {
|
||||||
message,
|
logger.warn("Message missing key, skipping");
|
||||||
messageTimestamp,
|
return;
|
||||||
} = 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;
|
const isValidMessage = message && remoteJid !== "status@broadcast" && !fromMe;
|
||||||
if (isValidMessage) {
|
if (isValidMessage) {
|
||||||
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
|
const { audioMessage, documentMessage, imageMessage, videoMessage } = message;
|
||||||
|
|
@ -244,9 +258,27 @@ export default class WhatsappService extends Service {
|
||||||
videoMessage,
|
videoMessage,
|
||||||
].find((text) => text && text !== "");
|
].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 = {
|
const payload = {
|
||||||
to: botID,
|
to: botID,
|
||||||
from: remoteJid?.split("@")[0],
|
from: senderPhone,
|
||||||
|
userId: senderUserId,
|
||||||
messageId: id,
|
messageId: id,
|
||||||
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
|
sentAt: new Date((messageTimestamp as number) * 1000).toISOString(),
|
||||||
message: messageText,
|
message: messageText,
|
||||||
|
|
@ -410,12 +442,17 @@ export default class WhatsappService extends Service {
|
||||||
}
|
}
|
||||||
|
|
||||||
async receive(
|
async receive(
|
||||||
botID: string,
|
_botID: string,
|
||||||
_lastReceivedDate: Date,
|
_lastReceivedDate: Date,
|
||||||
): Promise<proto.IWebMessageInfo[]> {
|
): Promise<proto.IWebMessageInfo[]> {
|
||||||
const connection = this.connections[botID]?.socket;
|
// loadAllUnreadMessages() was removed in Baileys 7.x
|
||||||
const messages = await connection.loadAllUnreadMessages();
|
// Messages are now delivered via events (messages.upsert, messaging-history.set)
|
||||||
|
// and forwarded to webhooks automatically.
|
||||||
return messages;
|
// 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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type WhatsappService from "./service.js";
|
import type WhatsappService from "./service.ts";
|
||||||
|
|
||||||
declare module "@hapipal/schmervice" {
|
declare module "@hapipal/schmervice" {
|
||||||
interface SchmerviceDecorator {
|
interface SchmerviceDecorator {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
{
|
{
|
||||||
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
"extends": "@link-stack/typescript-config/tsconfig.node.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "commonjs",
|
"module": "NodeNext",
|
||||||
"target": "es2018",
|
"target": "es2022",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "NodeNext",
|
||||||
"outDir": "build/main",
|
"outDir": "build/main",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"lib": ["es2020", "DOM"],
|
"lib": ["es2022", "DOM"],
|
||||||
"composite": true
|
"composite": true,
|
||||||
|
"rewriteRelativeImportExtensions": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
"include": ["src/**/*.ts", "src/**/.*.ts"],
|
||||||
"exclude": ["node_modules/**"]
|
"exclude": ["node_modules/**"]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-worker",
|
"name": "@link-stack/bridge-worker",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "build/main/index.js",
|
"main": "build/main/index.js",
|
||||||
"author": "Darren Clarke <darren@redaranj.com>",
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
|
|
|
||||||
|
|
@ -168,6 +168,7 @@ const processMessage = async ({
|
||||||
token: id,
|
token: id,
|
||||||
to: toRecipient,
|
to: toRecipient,
|
||||||
from: source,
|
from: source,
|
||||||
|
userId: sourceUuid, // Signal user UUID for user identification
|
||||||
messageId: `${sourceUuid}-${rawTimestamp}`,
|
messageId: `${sourceUuid}-${rawTimestamp}`,
|
||||||
message: dataMessage?.message,
|
message: dataMessage?.message,
|
||||||
sentAt: timestamp.toISOString(),
|
sentAt: timestamp.toISOString(),
|
||||||
|
|
@ -204,17 +205,16 @@ const fetchSignalMessagesTask = async ({
|
||||||
|
|
||||||
if (scheduleTasks === "true") {
|
if (scheduleTasks === "true") {
|
||||||
// because cron only has minimum 1 minute resolution
|
// because cron only has minimum 1 minute resolution
|
||||||
for (const offset of [15000, 30000, 45000]) {
|
// schedule one additional job at 30s to achieve 30-second polling
|
||||||
await worker.addJob(
|
await worker.addJob(
|
||||||
"fetch-signal-messages",
|
"fetch-signal-messages",
|
||||||
{ scheduleTasks: "false" },
|
{ scheduleTasks: "false" },
|
||||||
{
|
{
|
||||||
maxAttempts: 1,
|
maxAttempts: 1,
|
||||||
runAt: new Date(Date.now() + offset),
|
runAt: new Date(Date.now() + 30000),
|
||||||
jobKey: `fetchSignalMessages-${offset}`,
|
jobKey: "fetchSignalMessages-30000",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesClient = new MessagesApi(config);
|
const messagesClient = new MessagesApi(config);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ interface ReceiveSignalMessageTaskOptions {
|
||||||
token: string;
|
token: string;
|
||||||
to: string;
|
to: string;
|
||||||
from: string;
|
from: string;
|
||||||
|
userId?: string; // Signal user UUID for user identification
|
||||||
messageId: string;
|
messageId: string;
|
||||||
sentAt: string;
|
sentAt: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -22,6 +23,7 @@ const receiveSignalMessageTask = async ({
|
||||||
token,
|
token,
|
||||||
to,
|
to,
|
||||||
from,
|
from,
|
||||||
|
userId,
|
||||||
messageId,
|
messageId,
|
||||||
sentAt,
|
sentAt,
|
||||||
message,
|
message,
|
||||||
|
|
@ -212,6 +214,7 @@ const receiveSignalMessageTask = async ({
|
||||||
const payload = {
|
const payload = {
|
||||||
to: finalTo,
|
to: finalTo,
|
||||||
from,
|
from,
|
||||||
|
user_id: userId, // Signal user UUID for user identification
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
sent_at: sentAt,
|
sent_at: sentAt,
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -64,13 +64,14 @@ const sendSignalMessageTask = async ({
|
||||||
let groupCreated = false;
|
let groupCreated = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if 'to' is a group ID (UUID format, group.base64 format, or base64) vs phone number
|
// Check if 'to' is a group ID (group.base64 format or base64 internal ID) vs individual recipient
|
||||||
const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
// Signal group IDs are 32 bytes = 44 chars base64 (or 43 without padding)
|
||||||
to,
|
// Signal user UUIDs (ACIs) are 36 chars with hyphens: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||||
);
|
// Phone numbers start with +, usernames with u:, PNIs with PNI:
|
||||||
const isGroupPrefix = to.startsWith("group.");
|
const isGroupPrefix = to.startsWith("group.");
|
||||||
const isBase64 = /^[A-Za-z0-9+/]+=*$/.test(to) && to.length > 20; // Base64 internal_id
|
const isBase64GroupId =
|
||||||
const isGroupId = isUUID || isGroupPrefix || isBase64;
|
/^[A-Za-z0-9+/]+=*$/.test(to) && to.length >= 43 && to.length <= 44;
|
||||||
|
const isGroupId = isGroupPrefix || isBase64GroupId;
|
||||||
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
|
const enableAutoGroups = process.env.BRIDGE_SIGNAL_AUTO_GROUPS === "true";
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@ import { db, getWorkerUtils } from "@link-stack/bridge-common";
|
||||||
interface ReceiveWhatsappMessageTaskOptions {
|
interface ReceiveWhatsappMessageTaskOptions {
|
||||||
token: string;
|
token: string;
|
||||||
to: string;
|
to: string;
|
||||||
from: string;
|
from?: string;
|
||||||
|
userId?: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
sentAt: string;
|
sentAt: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
|
@ -16,6 +17,7 @@ const receiveWhatsappMessageTask = async ({
|
||||||
token,
|
token,
|
||||||
to,
|
to,
|
||||||
from,
|
from,
|
||||||
|
userId,
|
||||||
messageId,
|
messageId,
|
||||||
sentAt,
|
sentAt,
|
||||||
message,
|
message,
|
||||||
|
|
@ -33,6 +35,7 @@ const receiveWhatsappMessageTask = async ({
|
||||||
const payload = {
|
const payload = {
|
||||||
to,
|
to,
|
||||||
from,
|
from,
|
||||||
|
user_id: userId,
|
||||||
message_id: messageId,
|
message_id: messageId,
|
||||||
sent_at: sentAt,
|
sent_at: sentAt,
|
||||||
message,
|
message,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
Google as GoogleIcon,
|
Google as GoogleIcon,
|
||||||
Microsoft as MicrosoftIcon,
|
Microsoft as MicrosoftIcon,
|
||||||
Key as KeyIcon,
|
Key as KeyIcon,
|
||||||
|
VpnKey as KeycloakIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { signIn, getProviders } from "next-auth/react";
|
import { signIn, getProviders } from "next-auth/react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
|
@ -200,6 +201,21 @@ export const Login: FC<LoginProps> = ({ session, baseURL }) => {
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Grid>
|
</Grid>
|
||||||
)}
|
)}
|
||||||
|
{provider === "keycloak" && (
|
||||||
|
<Grid item sx={{ width: "100%" }}>
|
||||||
|
<IconButton
|
||||||
|
sx={buttonStyles}
|
||||||
|
onClick={() =>
|
||||||
|
signIn("keycloak", {
|
||||||
|
callbackUrl,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<KeycloakIcon sx={{ mr: 1 }} />
|
||||||
|
Sign in with Keycloak
|
||||||
|
</IconButton>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
{provider === "credentials" && (
|
{provider === "credentials" && (
|
||||||
<Grid item container spacing={3}>
|
<Grid item container spacing={3}>
|
||||||
<Grid item sx={{ width: "100%" }}>
|
<Grid item sx={{ width: "100%" }}>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import Google from "next-auth/providers/google";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
import Apple from "next-auth/providers/apple";
|
import Apple from "next-auth/providers/apple";
|
||||||
import AzureADProvider from "next-auth/providers/azure-ad";
|
import AzureADProvider from "next-auth/providers/azure-ad";
|
||||||
|
import Keycloak from "next-auth/providers/keycloak";
|
||||||
import { createLogger } from "@link-stack/logger";
|
import { createLogger } from "@link-stack/logger";
|
||||||
|
|
||||||
const logger = createLogger('link-authentication');
|
const logger = createLogger('link-authentication');
|
||||||
|
|
@ -101,6 +102,18 @@ if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||||
tenantId: process.env.AZURE_AD_TENANT_ID,
|
tenantId: process.env.AZURE_AD_TENANT_ID,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
process.env.KEYCLOAK_CLIENT_ID &&
|
||||||
|
process.env.KEYCLOAK_CLIENT_SECRET &&
|
||||||
|
process.env.KEYCLOAK_ISSUER
|
||||||
|
) {
|
||||||
|
providers.push(
|
||||||
|
Keycloak({
|
||||||
|
clientId: process.env.KEYCLOAK_CLIENT_ID,
|
||||||
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET,
|
||||||
|
issuer: process.env.KEYCLOAK_ISSUER,
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
providers.push(
|
providers.push(
|
||||||
Credentials({
|
Credentials({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/link",
|
"name": "@link-stack/link",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev -H 0.0.0.0",
|
"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/include /usr/local/include
|
||||||
COPY --from=node /usr/local/bin /usr/local/bin
|
COPY --from=node /usr/local/bin /usr/local/bin
|
||||||
|
|
||||||
# Prepare pnpm (corepack is already enabled in node:22-alpine)
|
# Prepare pnpm (corepack symlinks already copied from node image)
|
||||||
RUN corepack prepare pnpm@9.15.4 --activate
|
RUN corepack prepare pnpm@9.15.4 --activate
|
||||||
|
|
||||||
# Set up pnpm home
|
# Set up pnpm home
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,13 @@ const app = process.argv[2];
|
||||||
const command = process.argv[3];
|
const command = process.argv[3];
|
||||||
|
|
||||||
const files = {
|
const files = {
|
||||||
all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api"],
|
all: ["zammad", "postgresql", "bridge", "opensearch", "link", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||||
linkDev: ["zammad", "postgresql", "opensearch"],
|
linkDev: ["zammad", "postgresql", "opensearch"],
|
||||||
link: ["zammad", "postgresql", "opensearch", "link"],
|
link: ["zammad", "postgresql", "opensearch", "link"],
|
||||||
linkOnly: ["link"],
|
linkOnly: ["link"],
|
||||||
opensearch: ["opensearch"],
|
opensearch: ["opensearch"],
|
||||||
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api"],
|
bridgeDev: ["zammad", "postgresql", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||||
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api"],
|
bridge: ["zammad", "postgresql", "bridge", "signal-cli-rest-api", "bridge-whatsapp"],
|
||||||
zammad: ["zammad", "postgresql", "opensearch"],
|
zammad: ["zammad", "postgresql", "opensearch"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -21,7 +21,7 @@ const finalFiles = files[app]
|
||||||
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
|
.map((file) => ['-f', `docker/compose/${file}.yml`]).flat();
|
||||||
|
|
||||||
// Add bridge-dev.yml for dev commands that include zammad
|
// Add bridge-dev.yml for dev commands that include zammad
|
||||||
const devAppsWithZammad = ['linkDev', 'bridgeDev', 'all'];
|
const devAppsWithZammad = ['linkDev', 'bridgeDev'];
|
||||||
if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
|
if (devAppsWithZammad.includes(app) && files[app].includes('zammad')) {
|
||||||
finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
|
finalFiles.push('-f', 'docker-compose.bridge-dev.yml');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack",
|
"name": "@link-stack",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "Link from the Center for Digital Resilience",
|
"description": "Link from the Center for Digital Resilience",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "dotenv -- turbo dev",
|
"dev": "dotenv -- turbo dev",
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"turbo": "^2.5.8",
|
"turbo": "^2.6.0",
|
||||||
"typescript": "latest"
|
"typescript": "latest"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-common",
|
"name": "@link-stack/bridge-common",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"main": "build/main/index.js",
|
"main": "build/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"author": "Darren Clarke <darren@redaranj.com>",
|
"author": "Darren Clarke <darren@redaranj.com>",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/bridge-ui",
|
"name": "@link-stack/bridge-ui",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json"
|
"build": "tsc -p tsconfig.json"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/eslint-config",
|
"name": "@link-stack/eslint-config",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "amigo's eslint config",
|
"description": "amigo's eslint config",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"author": "Abel Luck <abel@guardianproject.info>",
|
"author": "Abel Luck <abel@guardianproject.info>",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/jest-config",
|
"name": "@link-stack/jest-config",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"author": "Abel Luck <abel@guardianproject.info>",
|
"author": "Abel Luck <abel@guardianproject.info>",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/logger",
|
"name": "@link-stack/logger",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "Shared logging utility for Link Stack monorepo",
|
"description": "Shared logging utility for Link Stack monorepo",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/signal-api",
|
"name": "@link-stack/signal-api",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "build/index.js",
|
"main": "build/index.js",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/typescript-config",
|
"name": "@link-stack/typescript-config",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "Shared TypeScript config",
|
"description": "Shared TypeScript config",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"author": "Abel Luck <abel@guardianproject.info>",
|
"author": "Abel Luck <abel@guardianproject.info>",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/ui",
|
"name": "@link-stack/ui",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "",
|
"description": "",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -p tsconfig.json"
|
"build": "tsc -p tsconfig.json"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/zammad-addon-bridge",
|
"name": "@link-stack/zammad-addon-bridge",
|
||||||
"displayName": "Bridge",
|
"displayName": "Bridge",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "An addon that adds CDR Bridge channels to Zammad.",
|
"description": "An addon that adds CDR Bridge channels to Zammad.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
"build": "node '../zammad-addon-common/dist/build.js'",
|
||||||
|
|
|
||||||
|
|
@ -154,16 +154,31 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
return if Ticket::Article.exists?(message_id: "cdr_signal.#{message_id}")
|
||||||
|
|
||||||
receiver_phone_number = params[:to].strip
|
receiver_phone_number = params[:to].strip
|
||||||
sender_phone_number = params[:from].strip
|
sender_phone_number = params[:from].present? ? params[:from].strip : nil
|
||||||
|
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
|
||||||
|
|
||||||
# Check if this is a group message using the is_group flag from bridge-worker
|
# Check if this is a group message using the is_group flag from bridge-worker
|
||||||
# This flag is set when:
|
# This flag is set when:
|
||||||
# 1. The original message came from a Signal group
|
# 1. The original message came from a Signal group
|
||||||
# 2. Bridge-worker created a new group for the conversation
|
# 2. Bridge-worker created a new group for the conversation
|
||||||
is_group_message = params[:is_group].to_s == 'true' || params[:is_group].to_s == 'true'
|
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
|
||||||
|
|
||||||
customer = User.find_by(phone: sender_phone_number)
|
|
||||||
customer ||= User.find_by(mobile: sender_phone_number)
|
|
||||||
unless customer
|
unless customer
|
||||||
role_ids = Role.signup_role_ids
|
role_ids = Role.signup_role_ids
|
||||||
customer = User.create(
|
customer = User.create(
|
||||||
|
|
@ -171,7 +186,8 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
lastname: '',
|
lastname: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
phone: sender_phone_number,
|
phone: sender_phone_number.presence || sender_user_id,
|
||||||
|
signal_uid: sender_user_id,
|
||||||
note: 'CDR Signal',
|
note: 'CDR Signal',
|
||||||
active: true,
|
active: true,
|
||||||
role_ids: role_ids,
|
role_ids: role_ids,
|
||||||
|
|
@ -180,6 +196,15 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
)
|
)
|
||||||
end
|
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
|
# set current user
|
||||||
UserInfo.current_user_id = customer.id
|
UserInfo.current_user_id = customer.id
|
||||||
current_user_set(customer, 'token_auth')
|
current_user_set(customer, 'token_auth')
|
||||||
|
|
@ -208,7 +233,8 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
attachment_data_base64 = params[:attachment]
|
attachment_data_base64 = params[:attachment]
|
||||||
attachment_filename = params[:filename]
|
attachment_filename = params[:filename]
|
||||||
attachment_mimetype = params[:mime_type]
|
attachment_mimetype = params[:mime_type]
|
||||||
title = "Message from #{sender_phone_number} at #{sent_at}"
|
sender_display = sender_phone_number.presence || sender_user_id
|
||||||
|
title = "Message from #{sender_display} at #{sent_at}"
|
||||||
body = message
|
body = message
|
||||||
|
|
||||||
# find ticket or create one
|
# find ticket or create one
|
||||||
|
|
@ -218,7 +244,7 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
|
Rails.logger.info "=== SIGNAL GROUP TICKET LOOKUP ==="
|
||||||
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
|
Rails.logger.info "Looking for ticket with group_id: #{receiver_phone_number}"
|
||||||
Rails.logger.info "Customer ID: #{customer.id}"
|
Rails.logger.info "Customer ID: #{customer.id}"
|
||||||
Rails.logger.info "Customer Phone: #{sender_phone_number}"
|
Rails.logger.info "Customer Phone: #{sender_display}"
|
||||||
Rails.logger.info "Channel ID: #{channel.id}"
|
Rails.logger.info "Channel ID: #{channel.id}"
|
||||||
|
|
||||||
begin
|
begin
|
||||||
|
|
@ -256,12 +282,14 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
ticket.state = Ticket::State.find_by(default_follow_up: true) if ticket.state_id != new_state.id
|
||||||
else
|
else
|
||||||
# Set up chat_id based on whether this is a group message
|
# Set up chat_id based on whether this is a group message
|
||||||
chat_id = is_group_message ? receiver_phone_number : sender_phone_number
|
# For direct messages, prefer UUID (more stable than phone numbers which can change)
|
||||||
|
chat_id = is_group_message ? receiver_phone_number : (sender_user_id.presence || sender_phone_number)
|
||||||
|
|
||||||
# Build preferences with group_id included if needed
|
# Build preferences with group_id included if needed
|
||||||
cdr_signal_prefs = {
|
cdr_signal_prefs = {
|
||||||
bot_token: channel.options[:bot_token], # change to bot id
|
bot_token: channel.options[:bot_token],
|
||||||
chat_id: chat_id
|
chat_id: chat_id,
|
||||||
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
|
|
||||||
Rails.logger.info "=== CREATING NEW TICKET ==="
|
Rails.logger.info "=== CREATING NEW TICKET ==="
|
||||||
|
|
@ -283,7 +311,7 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
ticket.save!
|
ticket.save!
|
||||||
|
|
||||||
article_params = {
|
article_params = {
|
||||||
from: sender_phone_number,
|
from: sender_display,
|
||||||
to: receiver_phone_number,
|
to: receiver_phone_number,
|
||||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||||
subject: title,
|
subject: title,
|
||||||
|
|
@ -296,7 +324,8 @@ class ChannelsCdrSignalController < ApplicationController
|
||||||
cdr_signal: {
|
cdr_signal: {
|
||||||
timestamp: sent_at,
|
timestamp: sent_at,
|
||||||
message_id: message_id,
|
message_id: message_id,
|
||||||
from: sender_phone_number
|
from: sender_phone_number,
|
||||||
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,16 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
errors = {}
|
errors = {}
|
||||||
|
|
||||||
%i[to
|
%i[to
|
||||||
from
|
|
||||||
message_id
|
message_id
|
||||||
sent_at].each do |field|
|
sent_at].each do |field|
|
||||||
errors[field] = 'required' if params[field].blank?
|
errors[field] = 'required' if params[field].blank?
|
||||||
end
|
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?
|
if errors.present?
|
||||||
render json: {
|
render json: {
|
||||||
errors: errors
|
errors: errors
|
||||||
|
|
@ -141,9 +145,25 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}")
|
return if Ticket::Article.exists?(message_id: "cdr_whatsapp.#{message_id}")
|
||||||
|
|
||||||
receiver_phone_number = params[:to].strip
|
receiver_phone_number = params[:to].strip
|
||||||
sender_phone_number = params[:from].strip
|
sender_phone_number = params[:from].present? ? params[:from].strip : nil
|
||||||
customer = User.find_by(phone: sender_phone_number)
|
sender_user_id = params[:user_id].present? ? params[:user_id].strip : nil
|
||||||
customer ||= User.find_by(mobile: sender_phone_number)
|
|
||||||
|
# Lookup customer with fallback chain:
|
||||||
|
# 1. Phone number in phone/mobile fields (preferred)
|
||||||
|
# 2. WhatsApp user ID in whatsapp_uid field
|
||||||
|
# 3. User ID in phone/mobile fields (legacy - we used to store LIDs there)
|
||||||
|
customer = nil
|
||||||
|
if sender_phone_number.present?
|
||||||
|
customer = User.find_by(phone: sender_phone_number)
|
||||||
|
customer ||= User.find_by(mobile: sender_phone_number)
|
||||||
|
end
|
||||||
|
if customer.nil? && sender_user_id.present?
|
||||||
|
customer = User.find_by(whatsapp_uid: sender_user_id)
|
||||||
|
# Legacy fallback: user ID might be stored in phone field
|
||||||
|
customer ||= User.find_by(phone: sender_user_id)
|
||||||
|
customer ||= User.find_by(mobile: sender_user_id)
|
||||||
|
end
|
||||||
|
|
||||||
unless customer
|
unless customer
|
||||||
role_ids = Role.signup_role_ids
|
role_ids = Role.signup_role_ids
|
||||||
customer = User.create(
|
customer = User.create(
|
||||||
|
|
@ -151,7 +171,8 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
lastname: '',
|
lastname: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
phone: sender_phone_number,
|
phone: sender_phone_number.presence || sender_user_id,
|
||||||
|
whatsapp_uid: sender_user_id,
|
||||||
note: 'CDR Whatsapp',
|
note: 'CDR Whatsapp',
|
||||||
active: true,
|
active: true,
|
||||||
role_ids: role_ids,
|
role_ids: role_ids,
|
||||||
|
|
@ -160,6 +181,15 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
)
|
)
|
||||||
end
|
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
|
# set current user
|
||||||
UserInfo.current_user_id = customer.id
|
UserInfo.current_user_id = customer.id
|
||||||
current_user_set(customer, 'token_auth')
|
current_user_set(customer, 'token_auth')
|
||||||
|
|
@ -188,7 +218,8 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
attachment_data_base64 = params[:attachment]
|
attachment_data_base64 = params[:attachment]
|
||||||
attachment_filename = params[:filename]
|
attachment_filename = params[:filename]
|
||||||
attachment_mimetype = params[:mime_type]
|
attachment_mimetype = params[:mime_type]
|
||||||
title = "Message from #{sender_phone_number} at #{sent_at}"
|
sender_display = sender_phone_number.presence || sender_user_id
|
||||||
|
title = "Message from #{sender_display} at #{sent_at}"
|
||||||
body = message
|
body = message
|
||||||
|
|
||||||
# find ticket or create one
|
# find ticket or create one
|
||||||
|
|
@ -207,8 +238,9 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
preferences: {
|
preferences: {
|
||||||
channel_id: channel.id,
|
channel_id: channel.id,
|
||||||
cdr_whatsapp: {
|
cdr_whatsapp: {
|
||||||
bot_token: channel.options[:bot_token], # change to bot id
|
bot_token: channel.options[:bot_token],
|
||||||
chat_id: sender_phone_number
|
chat_id: sender_phone_number.presence || sender_user_id,
|
||||||
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -217,7 +249,7 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
ticket.save!
|
ticket.save!
|
||||||
|
|
||||||
article_params = {
|
article_params = {
|
||||||
from: sender_phone_number,
|
from: sender_display,
|
||||||
to: receiver_phone_number,
|
to: receiver_phone_number,
|
||||||
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
sender_id: Ticket::Article::Sender.find_by(name: 'Customer').id,
|
||||||
subject: title,
|
subject: title,
|
||||||
|
|
@ -230,7 +262,8 @@ class ChannelsCdrWhatsappController < ApplicationController
|
||||||
cdr_whatsapp: {
|
cdr_whatsapp: {
|
||||||
timestamp: sent_at,
|
timestamp: sent_at,
|
||||||
message_id: message_id,
|
message_id: message_id,
|
||||||
from: sender_phone_number
|
from: sender_phone_number,
|
||||||
|
user_id: sender_user_id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
class AddMessagingUserIds < ActiveRecord::Migration[5.2]
|
||||||
|
def self.up
|
||||||
|
# Add WhatsApp UID column
|
||||||
|
unless column_exists?(:users, :whatsapp_uid)
|
||||||
|
add_column :users, :whatsapp_uid, :string, limit: 50
|
||||||
|
add_index :users, :whatsapp_uid
|
||||||
|
end
|
||||||
|
User.reset_column_information
|
||||||
|
|
||||||
|
# Add Signal UID column
|
||||||
|
unless column_exists?(:users, :signal_uid)
|
||||||
|
add_column :users, :signal_uid, :string, limit: 50
|
||||||
|
add_index :users, :signal_uid
|
||||||
|
end
|
||||||
|
User.reset_column_information
|
||||||
|
|
||||||
|
# Register WhatsApp UID with ObjectManager for UI
|
||||||
|
# Column name: whatsapp_uid, Display name: "WhatsApp User ID"
|
||||||
|
ObjectManager::Attribute.add(
|
||||||
|
force: true,
|
||||||
|
object: 'User',
|
||||||
|
name: 'whatsapp_uid',
|
||||||
|
display: 'WhatsApp User ID',
|
||||||
|
data_type: 'input',
|
||||||
|
data_option: {
|
||||||
|
type: 'text',
|
||||||
|
maxlength: 50,
|
||||||
|
null: true,
|
||||||
|
item_class: 'formGroup--halfSize',
|
||||||
|
},
|
||||||
|
editable: false,
|
||||||
|
active: true,
|
||||||
|
screens: {
|
||||||
|
signup: {},
|
||||||
|
invite_agent: {},
|
||||||
|
invite_customer: {},
|
||||||
|
edit: {
|
||||||
|
'-all-' => {
|
||||||
|
null: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
'-all-' => {
|
||||||
|
null: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
'-all-' => {
|
||||||
|
shown: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
to_create: false,
|
||||||
|
to_migrate: false,
|
||||||
|
to_delete: false,
|
||||||
|
position: 710,
|
||||||
|
created_by_id: 1,
|
||||||
|
updated_by_id: 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Register Signal UID with ObjectManager for UI
|
||||||
|
# Column name: signal_uid, Display name: "Signal User ID"
|
||||||
|
ObjectManager::Attribute.add(
|
||||||
|
force: true,
|
||||||
|
object: 'User',
|
||||||
|
name: 'signal_uid',
|
||||||
|
display: 'Signal User ID',
|
||||||
|
data_type: 'input',
|
||||||
|
data_option: {
|
||||||
|
type: 'text',
|
||||||
|
maxlength: 50,
|
||||||
|
null: true,
|
||||||
|
item_class: 'formGroup--halfSize',
|
||||||
|
},
|
||||||
|
editable: false,
|
||||||
|
active: true,
|
||||||
|
screens: {
|
||||||
|
signup: {},
|
||||||
|
invite_agent: {},
|
||||||
|
invite_customer: {},
|
||||||
|
edit: {
|
||||||
|
'-all-' => {
|
||||||
|
null: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
'-all-' => {
|
||||||
|
null: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
view: {
|
||||||
|
'-all-' => {
|
||||||
|
shown: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
to_create: false,
|
||||||
|
to_migrate: false,
|
||||||
|
to_delete: false,
|
||||||
|
position: 720,
|
||||||
|
created_by_id: 1,
|
||||||
|
updated_by_id: 1,
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.down
|
||||||
|
ObjectManager::Attribute.remove(
|
||||||
|
object: 'User',
|
||||||
|
name: 'whatsapp_uid',
|
||||||
|
)
|
||||||
|
|
||||||
|
ObjectManager::Attribute.remove(
|
||||||
|
object: 'User',
|
||||||
|
name: 'signal_uid',
|
||||||
|
)
|
||||||
|
|
||||||
|
remove_index :users, :whatsapp_uid if index_exists?(:users, :whatsapp_uid)
|
||||||
|
remove_column :users, :whatsapp_uid if column_exists?(:users, :whatsapp_uid)
|
||||||
|
|
||||||
|
remove_index :users, :signal_uid if index_exists?(:users, :signal_uid)
|
||||||
|
remove_column :users, :signal_uid if column_exists?(:users, :signal_uid)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/zammad-addon-common",
|
"name": "@link-stack/zammad-addon-common",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "",
|
"description": "",
|
||||||
"bin": {
|
"bin": {
|
||||||
"zpm-build": "./dist/build.js",
|
"zpm-build": "./dist/build.js",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "@link-stack/zammad-addon-hardening",
|
"name": "@link-stack/zammad-addon-hardening",
|
||||||
"displayName": "Hardening",
|
"displayName": "Hardening",
|
||||||
"version": "3.3.5",
|
"version": "3.4.0-beta.7",
|
||||||
"description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.",
|
"description": "A Zammad addon that hardens a Zammad instance according to CDR's needs.",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node '../zammad-addon-common/dist/build.js'",
|
"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